Extend admin content templates
This commit is contained in:
+151
@@ -593,6 +593,24 @@ p {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dirty-state {
|
||||
display: inline-flex;
|
||||
min-height: 2rem;
|
||||
align-items: center;
|
||||
border-radius: var(--launcher-radius-circle);
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
color: var(--text-muted);
|
||||
padding: 0 0.75rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 820;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dirty-state[data-dirty="true"] {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.structure-card {
|
||||
padding-block: 0.78rem;
|
||||
}
|
||||
@@ -850,6 +868,104 @@ p {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collection-field {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.collection-head,
|
||||
.collection-summary,
|
||||
.collection-item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.collection-head,
|
||||
.collection-summary {
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.collection-title {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 850;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.collection-add-btn {
|
||||
min-height: 2.4rem;
|
||||
border-radius: var(--launcher-radius-circle);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: rgba(8, 8, 10, 0.96);
|
||||
padding: 0 0.95rem;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.collection-list {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.collection-item-card {
|
||||
overflow: hidden;
|
||||
border-radius: var(--launcher-radius-control);
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
}
|
||||
|
||||
.collection-item-card[open] {
|
||||
background: rgba(255, 255, 255, 0.065);
|
||||
}
|
||||
|
||||
.collection-summary {
|
||||
min-height: 3rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
padding: 0.42rem 0.55rem 0.42rem 0.85rem;
|
||||
}
|
||||
|
||||
.collection-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.collection-summary-title {
|
||||
overflow: hidden;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 820;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collection-item-actions {
|
||||
flex: 0 0 auto;
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.collection-icon-btn {
|
||||
display: grid;
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
place-items: center;
|
||||
border-radius: var(--launcher-radius-circle);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.collection-icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.collection-item-body {
|
||||
padding: 0 0.75rem 0.85rem;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
@@ -1291,6 +1407,26 @@ textarea {
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
.nodedc-expanded-toolbar-center {
|
||||
display: grid;
|
||||
grid-template-columns: 3rem minmax(0, 1fr);
|
||||
gap: 0.55rem;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.nodedc-expanded-nav-group {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.nodedc-expanded-nav-button {
|
||||
min-width: 0;
|
||||
padding: 0.2rem 0.45rem;
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
width: 100vw;
|
||||
min-height: calc(100vh - 5.25rem);
|
||||
@@ -1331,3 +1467,18 @@ textarea {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.nodedc-expanded-toolbar-center {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.nodedc-expanded-workspace-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nodedc-expanded-nav-button {
|
||||
padding-inline: 0.24rem;
|
||||
font-size: 0.61rem;
|
||||
}
|
||||
}
|
||||
|
||||
+259
-25
@@ -1,9 +1,11 @@
|
||||
const state = {
|
||||
page: null,
|
||||
templates: [],
|
||||
selected: null,
|
||||
clipboard: null,
|
||||
drag: null,
|
||||
dragTarget: null,
|
||||
dirty: false,
|
||||
};
|
||||
|
||||
const el = {
|
||||
@@ -12,6 +14,7 @@ const el = {
|
||||
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"),
|
||||
@@ -136,6 +139,10 @@ const GROUP_COPY = {
|
||||
FAQ: "Список вопросов и тексты ответов в mail-интерфейсе.",
|
||||
CTA: "Финальный призыв к действию: заголовок, пояснение, кнопка и ссылка.",
|
||||
Тарифы: "Верхняя часть тарифной таблицы: заголовки, планы, цены, кнопки и примечания.",
|
||||
"Строки тарифов": "Повторяемые строки тарифной таблицы и значения по каждому плану.",
|
||||
"Медиа-галерея": "Окна с изображениями, подписи файлов и связанные иконки.",
|
||||
"TXT-файлы": "Список файлов листа ожидания: название окна, подпись и текст внутри файла.",
|
||||
Футер: "Нижняя навигация, контактные ссылки, политики и брендовый блок.",
|
||||
Контент: "Остальные typed-поля выбранного блока.",
|
||||
};
|
||||
|
||||
@@ -149,6 +156,20 @@ 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 clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
@@ -192,6 +213,10 @@ function inferFieldGroup(path) {
|
||||
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.startsWith("content.galleryItems.")) return "Медиа-галерея";
|
||||
if (path.startsWith("content.files.")) return "TXT-файлы";
|
||||
if (path.startsWith("content.footer.")) return "Футер";
|
||||
if (path.startsWith("content.heading.") || path === "content.introHtml") return "Заголовок";
|
||||
return "Контент";
|
||||
}
|
||||
@@ -434,7 +459,7 @@ function reorderBlockWithinRegion(from, to) {
|
||||
state.selected = { kind: "block", regionIndex: from.regionIndex, blockIndex: insertIndex };
|
||||
cleanupDragState();
|
||||
renderAll();
|
||||
setStatus("Порядок блоков изменён локально. Нажмите «Сохранить модель» или «Обновить index.html».");
|
||||
markDirty("Порядок блоков изменён локально. Нажмите «Сохранить модель» или «Обновить index.html».");
|
||||
}
|
||||
|
||||
function renderBlockButton({ list, block, meta, active, disabled, onClick, dragContext = null }) {
|
||||
@@ -448,6 +473,9 @@ function renderBlockButton({ list, block, meta, active, disabled, onClick, dragC
|
||||
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);
|
||||
@@ -565,25 +593,16 @@ function renderRegions() {
|
||||
}
|
||||
|
||||
function blockTemplates() {
|
||||
const seen = new Set();
|
||||
const templates = [];
|
||||
return state.templates.map((template) => {
|
||||
const regionIndex = state.page.regions.findIndex((region) => region.id === template.regionId);
|
||||
const region = state.page.regions[regionIndex] || state.page.regions[0];
|
||||
|
||||
state.page.regions.forEach((region, regionIndex) => {
|
||||
region.blocks.forEach((block) => {
|
||||
const fingerprint = `${block.template || block.type}:${block.type || ""}`;
|
||||
if (seen.has(fingerprint)) return;
|
||||
seen.add(fingerprint);
|
||||
templates.push({
|
||||
key: `${region.id}:${block.id}`,
|
||||
regionId: region.id,
|
||||
regionIndex,
|
||||
regionLabel: labelForRegion(region),
|
||||
block,
|
||||
});
|
||||
});
|
||||
return {
|
||||
...template,
|
||||
regionIndex: regionIndex >= 0 ? regionIndex : 0,
|
||||
regionLabel: template.regionLabel || labelForRegion(region),
|
||||
};
|
||||
});
|
||||
|
||||
return templates;
|
||||
}
|
||||
|
||||
function renderTemplateModal() {
|
||||
@@ -634,14 +653,13 @@ function addBlockFromTemplate(templateKey) {
|
||||
const block = clone(template.block);
|
||||
|
||||
block.id = uniqueBlockId(block.id);
|
||||
block.adminLabel = `${block.adminLabel || block.id} — копия`;
|
||||
block.enabled = true;
|
||||
block.scopeIds = true;
|
||||
region.blocks.push(block);
|
||||
state.selected = { kind: "block", regionIndex: template.regionIndex, blockIndex: region.blocks.length - 1 };
|
||||
closeTemplateModal();
|
||||
renderAll();
|
||||
setStatus(`Добавлен блок «${block.adminLabel}». Нажмите «Сохранить модель» или «Обновить index.html».`);
|
||||
markDirty(`Добавлен блок «${block.adminLabel || block.id}». Нажмите «Сохранить модель» или «Обновить index.html».`);
|
||||
}
|
||||
|
||||
function setStructuralControlsDisabled(disabled) {
|
||||
@@ -700,6 +718,7 @@ function syncBlockFromFields() {
|
||||
|
||||
el.json.value = JSON.stringify(block, null, 2);
|
||||
renderRegions();
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function groupEditableFields(block) {
|
||||
@@ -763,6 +782,8 @@ function updateMediaFieldValue({ block, editableField, hiddenInput, urlInput, fi
|
||||
if (activeBlock() === block) {
|
||||
el.json.value = JSON.stringify(block, null, 2);
|
||||
}
|
||||
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function renderMediaField({ block, editableField, grid }) {
|
||||
@@ -902,7 +923,7 @@ function renderMediaField({ block, editableField, grid }) {
|
||||
value: payload.url,
|
||||
});
|
||||
setSource("file");
|
||||
setStatus(`Файл сохранён: ${payload.url}. Нажмите «Сохранить JSON», чтобы записать путь в content/pages/home.json.`);
|
||||
markDirty(`Файл сохранён: ${payload.url}. Нажмите «Сохранить модель», чтобы записать путь в content/pages/home.json.`);
|
||||
} catch (uploadError) {
|
||||
error.textContent = uploadError.message;
|
||||
fileName.textContent = truncateText(fileNameFromUrl(hiddenInput.value), 28);
|
||||
@@ -918,6 +939,199 @@ function renderMediaField({ block, editableField, grid }) {
|
||||
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) return clone(editableField.itemTemplate);
|
||||
|
||||
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 renderCollectionScalarField({ block, field, 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 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;
|
||||
setByPath(active, field.path, input.value);
|
||||
el.json.value = JSON.stringify(active, null, 2);
|
||||
markDirty();
|
||||
});
|
||||
|
||||
label.append(labelRow, input, path);
|
||||
grid.append(label);
|
||||
}
|
||||
|
||||
function renderCollectionField({ block, editableField, grid }) {
|
||||
const items = collectionItems(block, editableField);
|
||||
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.textContent = "Добавить";
|
||||
addButton.addEventListener("click", () => {
|
||||
items.push(prepareCollectionItemForInsert(items, collectionItemTemplate(editableField)));
|
||||
rerender(`Добавлен элемент в «${editableField.label || editableField.path}».`);
|
||||
});
|
||||
head.append(title, addButton);
|
||||
|
||||
list.className = "collection-list";
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const details = document.createElement("details");
|
||||
const summary = document.createElement("summary");
|
||||
const summaryTitle = document.createElement("span");
|
||||
const controls = document.createElement("span");
|
||||
const body = document.createElement("div");
|
||||
const itemGrid = document.createElement("div");
|
||||
|
||||
details.className = "collection-item-card";
|
||||
if (index === 0 && items.length <= 8) details.open = true;
|
||||
summary.className = "collection-summary";
|
||||
summaryTitle.className = "collection-summary-title";
|
||||
summaryTitle.textContent = collectionItemTitle(item, index);
|
||||
controls.className = "collection-item-actions";
|
||||
|
||||
[
|
||||
["↑", "Поднять", () => {
|
||||
if (index === 0) return;
|
||||
const [moved] = items.splice(index, 1);
|
||||
items.splice(index - 1, 0, moved);
|
||||
rerender("Порядок элементов коллекции изменён локально.");
|
||||
}],
|
||||
["↓", "Опустить", () => {
|
||||
if (index >= items.length - 1) return;
|
||||
const [moved] = items.splice(index, 1);
|
||||
items.splice(index + 1, 0, moved);
|
||||
rerender("Порядок элементов коллекции изменён локально.");
|
||||
}],
|
||||
["⧉", "Дублировать", () => {
|
||||
items.splice(index + 1, 0, prepareCollectionItemForInsert(items, item));
|
||||
rerender("Элемент коллекции продублирован локально.");
|
||||
}],
|
||||
["⌫", "Удалить", () => {
|
||||
items.splice(index, 1);
|
||||
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);
|
||||
button.textContent = symbol;
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handler();
|
||||
});
|
||||
controls.append(button);
|
||||
});
|
||||
|
||||
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 = "";
|
||||
|
||||
@@ -947,6 +1161,11 @@ function renderContentFields(block) {
|
||||
grid.className = "group-grid";
|
||||
|
||||
for (const editableField of fields) {
|
||||
if (editableField.kind === "collection") {
|
||||
renderCollectionField({ block, editableField, grid });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (editableField.kind === "media") {
|
||||
renderMediaField({ block, editableField, grid });
|
||||
continue;
|
||||
@@ -984,6 +1203,7 @@ function renderContentFields(block) {
|
||||
if (!active) return;
|
||||
setByPath(active, editableField.path, input.value);
|
||||
el.json.value = JSON.stringify(active, null, 2);
|
||||
markDirty();
|
||||
});
|
||||
|
||||
label.append(labelRow, input, path);
|
||||
@@ -1017,7 +1237,7 @@ function syncBlockFromJson() {
|
||||
region.blocks[state.selected.blockIndex] = parsed;
|
||||
}
|
||||
renderAll();
|
||||
setStatus("JSON блока применён локально. Нажмите «Сохранить JSON», чтобы записать файл.");
|
||||
markDirty("JSON блока применён локально. Нажмите «Сохранить модель», чтобы записать файл.");
|
||||
return true;
|
||||
} catch (error) {
|
||||
setStatus(`Ошибка JSON: ${error.message}`);
|
||||
@@ -1026,12 +1246,20 @@ function syncBlockFromJson() {
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
const response = await fetch("/api/page/home");
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
state.page = await response.json();
|
||||
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();
|
||||
state.selected = { kind: "static" };
|
||||
renderAll();
|
||||
setDirty(false);
|
||||
setStatus("Модель home.json загружена.");
|
||||
}
|
||||
|
||||
@@ -1049,6 +1277,7 @@ async function persistPage() {
|
||||
|
||||
async function savePage() {
|
||||
await persistPage();
|
||||
setDirty(false);
|
||||
setStatus("Модель сохранена в content/pages/home.json. Для публичной страницы нажмите «Обновить index.html».");
|
||||
}
|
||||
|
||||
@@ -1061,6 +1290,7 @@ async function renderHome() {
|
||||
throw new Error(payload.error || "Render failed");
|
||||
}
|
||||
|
||||
setDirty(false);
|
||||
setStatus(payload.stdout ? `Модель сохранена. ${payload.stdout}` : "Модель сохранена, index.html пересобран.");
|
||||
}
|
||||
|
||||
@@ -1077,6 +1307,7 @@ function moveSelected(delta) {
|
||||
region.blocks.splice(nextIndex, 0, block);
|
||||
state.selected.blockIndex = nextIndex;
|
||||
renderAll();
|
||||
markDirty("Порядок блоков изменён локально. Нажмите «Сохранить модель» или «Обновить index.html».");
|
||||
}
|
||||
|
||||
function duplicateSelected() {
|
||||
@@ -1092,6 +1323,7 @@ function duplicateSelected() {
|
||||
region.blocks.splice(state.selected.blockIndex + 1, 0, duplicate);
|
||||
state.selected.blockIndex += 1;
|
||||
renderAll();
|
||||
markDirty(`Блок «${duplicate.adminLabel || duplicate.id}» продублирован локально.`);
|
||||
}
|
||||
|
||||
function copySelected() {
|
||||
@@ -1115,6 +1347,7 @@ function pasteAfterSelected() {
|
||||
region.blocks.splice(state.selected.blockIndex + 1, 0, pasted);
|
||||
state.selected.blockIndex += 1;
|
||||
renderAll();
|
||||
markDirty(`Блок «${pasted.adminLabel || pasted.id}» вставлен локально.`);
|
||||
}
|
||||
|
||||
function deleteSelected() {
|
||||
@@ -1126,6 +1359,7 @@ function deleteSelected() {
|
||||
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]) {
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
<h2 id="selected-title">Контентный слой</h2>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<span id="dirty-state" class="dirty-state" data-dirty="false">Синхронизировано</span>
|
||||
<button id="reload" class="icon-btn" type="button" title="Перезагрузить данные">↻</button>
|
||||
<button id="save" class="primary-btn" type="button" title="Записать текущую модель в content/pages/home.json">Сохранить модель</button>
|
||||
<button id="render" class="ghost-btn" type="button" title="Сохранить модель и пересобрать публичный index.html">Обновить index.html</button>
|
||||
|
||||
Reference in New Issue
Block a user