Extend admin content templates

This commit is contained in:
dcconstructions
2026-06-26 11:10:18 +03:00
parent 931ac628b4
commit e4bfd93a67
13 changed files with 4268 additions and 45 deletions
+151
View File
@@ -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
View File
@@ -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]) {
+1
View File
@@ -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>