Extend admin content templates
This commit is contained in:
+151
@@ -593,6 +593,24 @@ p {
|
|||||||
justify-content: flex-end;
|
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 {
|
.structure-card {
|
||||||
padding-block: 0.78rem;
|
padding-block: 0.78rem;
|
||||||
}
|
}
|
||||||
@@ -850,6 +868,104 @@ p {
|
|||||||
white-space: nowrap;
|
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,
|
input,
|
||||||
textarea {
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1291,6 +1407,26 @@ textarea {
|
|||||||
padding-inline: 0.75rem;
|
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 {
|
.admin-shell {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
min-height: calc(100vh - 5.25rem);
|
min-height: calc(100vh - 5.25rem);
|
||||||
@@ -1331,3 +1467,18 @@ textarea {
|
|||||||
display: none;
|
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 = {
|
const state = {
|
||||||
page: null,
|
page: null,
|
||||||
|
templates: [],
|
||||||
selected: null,
|
selected: null,
|
||||||
clipboard: null,
|
clipboard: null,
|
||||||
drag: null,
|
drag: null,
|
||||||
dragTarget: null,
|
dragTarget: null,
|
||||||
|
dirty: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const el = {
|
const el = {
|
||||||
@@ -12,6 +14,7 @@ const el = {
|
|||||||
reload: document.querySelector("#reload"),
|
reload: document.querySelector("#reload"),
|
||||||
save: document.querySelector("#save"),
|
save: document.querySelector("#save"),
|
||||||
render: document.querySelector("#render"),
|
render: document.querySelector("#render"),
|
||||||
|
dirtyState: document.querySelector("#dirty-state"),
|
||||||
status: document.querySelector("#status"),
|
status: document.querySelector("#status"),
|
||||||
empty: document.querySelector("#empty-state"),
|
empty: document.querySelector("#empty-state"),
|
||||||
form: document.querySelector("#block-form"),
|
form: document.querySelector("#block-form"),
|
||||||
@@ -136,6 +139,10 @@ const GROUP_COPY = {
|
|||||||
FAQ: "Список вопросов и тексты ответов в mail-интерфейсе.",
|
FAQ: "Список вопросов и тексты ответов в mail-интерфейсе.",
|
||||||
CTA: "Финальный призыв к действию: заголовок, пояснение, кнопка и ссылка.",
|
CTA: "Финальный призыв к действию: заголовок, пояснение, кнопка и ссылка.",
|
||||||
Тарифы: "Верхняя часть тарифной таблицы: заголовки, планы, цены, кнопки и примечания.",
|
Тарифы: "Верхняя часть тарифной таблицы: заголовки, планы, цены, кнопки и примечания.",
|
||||||
|
"Строки тарифов": "Повторяемые строки тарифной таблицы и значения по каждому плану.",
|
||||||
|
"Медиа-галерея": "Окна с изображениями, подписи файлов и связанные иконки.",
|
||||||
|
"TXT-файлы": "Список файлов листа ожидания: название окна, подпись и текст внутри файла.",
|
||||||
|
Футер: "Нижняя навигация, контактные ссылки, политики и брендовый блок.",
|
||||||
Контент: "Остальные typed-поля выбранного блока.",
|
Контент: "Остальные typed-поля выбранного блока.",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,6 +156,20 @@ function setStatus(message) {
|
|||||||
el.status.textContent = 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) {
|
function clone(value) {
|
||||||
return JSON.parse(JSON.stringify(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.faq.")) return "FAQ";
|
||||||
if (path.startsWith("content.cta.")) return "CTA";
|
if (path.startsWith("content.cta.")) return "CTA";
|
||||||
if (path.startsWith("content.plans.") || path.startsWith("content.pricing.")) return "Тарифы";
|
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 "Заголовок";
|
if (path.startsWith("content.heading.") || path === "content.introHtml") return "Заголовок";
|
||||||
return "Контент";
|
return "Контент";
|
||||||
}
|
}
|
||||||
@@ -434,7 +459,7 @@ function reorderBlockWithinRegion(from, to) {
|
|||||||
state.selected = { kind: "block", regionIndex: from.regionIndex, blockIndex: insertIndex };
|
state.selected = { kind: "block", regionIndex: from.regionIndex, blockIndex: insertIndex };
|
||||||
cleanupDragState();
|
cleanupDragState();
|
||||||
renderAll();
|
renderAll();
|
||||||
setStatus("Порядок блоков изменён локально. Нажмите «Сохранить модель» или «Обновить index.html».");
|
markDirty("Порядок блоков изменён локально. Нажмите «Сохранить модель» или «Обновить index.html».");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBlockButton({ list, block, meta, active, disabled, onClick, dragContext = null }) {
|
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.role = "button";
|
||||||
button.tabIndex = 0;
|
button.tabIndex = 0;
|
||||||
button.className = `block-btn${active ? " active" : ""}${disabled ? " disabled" : ""}${canDrag ? " has-drag-handle" : ""}`;
|
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) {
|
if (canDrag) {
|
||||||
button.dataset.regionIndex = String(dragContext.regionIndex);
|
button.dataset.regionIndex = String(dragContext.regionIndex);
|
||||||
button.dataset.blockIndex = String(dragContext.blockIndex);
|
button.dataset.blockIndex = String(dragContext.blockIndex);
|
||||||
@@ -565,25 +593,16 @@ function renderRegions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function blockTemplates() {
|
function blockTemplates() {
|
||||||
const seen = new Set();
|
return state.templates.map((template) => {
|
||||||
const templates = [];
|
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) => {
|
return {
|
||||||
region.blocks.forEach((block) => {
|
...template,
|
||||||
const fingerprint = `${block.template || block.type}:${block.type || ""}`;
|
regionIndex: regionIndex >= 0 ? regionIndex : 0,
|
||||||
if (seen.has(fingerprint)) return;
|
regionLabel: template.regionLabel || labelForRegion(region),
|
||||||
seen.add(fingerprint);
|
};
|
||||||
templates.push({
|
|
||||||
key: `${region.id}:${block.id}`,
|
|
||||||
regionId: region.id,
|
|
||||||
regionIndex,
|
|
||||||
regionLabel: labelForRegion(region),
|
|
||||||
block,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return templates;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTemplateModal() {
|
function renderTemplateModal() {
|
||||||
@@ -634,14 +653,13 @@ function addBlockFromTemplate(templateKey) {
|
|||||||
const block = clone(template.block);
|
const block = clone(template.block);
|
||||||
|
|
||||||
block.id = uniqueBlockId(block.id);
|
block.id = uniqueBlockId(block.id);
|
||||||
block.adminLabel = `${block.adminLabel || block.id} — копия`;
|
|
||||||
block.enabled = true;
|
block.enabled = true;
|
||||||
block.scopeIds = true;
|
block.scopeIds = true;
|
||||||
region.blocks.push(block);
|
region.blocks.push(block);
|
||||||
state.selected = { kind: "block", regionIndex: template.regionIndex, blockIndex: region.blocks.length - 1 };
|
state.selected = { kind: "block", regionIndex: template.regionIndex, blockIndex: region.blocks.length - 1 };
|
||||||
closeTemplateModal();
|
closeTemplateModal();
|
||||||
renderAll();
|
renderAll();
|
||||||
setStatus(`Добавлен блок «${block.adminLabel}». Нажмите «Сохранить модель» или «Обновить index.html».`);
|
markDirty(`Добавлен блок «${block.adminLabel || block.id}». Нажмите «Сохранить модель» или «Обновить index.html».`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStructuralControlsDisabled(disabled) {
|
function setStructuralControlsDisabled(disabled) {
|
||||||
@@ -700,6 +718,7 @@ function syncBlockFromFields() {
|
|||||||
|
|
||||||
el.json.value = JSON.stringify(block, null, 2);
|
el.json.value = JSON.stringify(block, null, 2);
|
||||||
renderRegions();
|
renderRegions();
|
||||||
|
markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupEditableFields(block) {
|
function groupEditableFields(block) {
|
||||||
@@ -763,6 +782,8 @@ function updateMediaFieldValue({ block, editableField, hiddenInput, urlInput, fi
|
|||||||
if (activeBlock() === block) {
|
if (activeBlock() === block) {
|
||||||
el.json.value = JSON.stringify(block, null, 2);
|
el.json.value = JSON.stringify(block, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMediaField({ block, editableField, grid }) {
|
function renderMediaField({ block, editableField, grid }) {
|
||||||
@@ -902,7 +923,7 @@ function renderMediaField({ block, editableField, grid }) {
|
|||||||
value: payload.url,
|
value: payload.url,
|
||||||
});
|
});
|
||||||
setSource("file");
|
setSource("file");
|
||||||
setStatus(`Файл сохранён: ${payload.url}. Нажмите «Сохранить JSON», чтобы записать путь в content/pages/home.json.`);
|
markDirty(`Файл сохранён: ${payload.url}. Нажмите «Сохранить модель», чтобы записать путь в content/pages/home.json.`);
|
||||||
} catch (uploadError) {
|
} catch (uploadError) {
|
||||||
error.textContent = uploadError.message;
|
error.textContent = uploadError.message;
|
||||||
fileName.textContent = truncateText(fileNameFromUrl(hiddenInput.value), 28);
|
fileName.textContent = truncateText(fileNameFromUrl(hiddenInput.value), 28);
|
||||||
@@ -918,6 +939,199 @@ function renderMediaField({ block, editableField, grid }) {
|
|||||||
grid.append(container);
|
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) {
|
function renderContentFields(block) {
|
||||||
el.contentFields.innerHTML = "";
|
el.contentFields.innerHTML = "";
|
||||||
|
|
||||||
@@ -947,6 +1161,11 @@ function renderContentFields(block) {
|
|||||||
grid.className = "group-grid";
|
grid.className = "group-grid";
|
||||||
|
|
||||||
for (const editableField of fields) {
|
for (const editableField of fields) {
|
||||||
|
if (editableField.kind === "collection") {
|
||||||
|
renderCollectionField({ block, editableField, grid });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (editableField.kind === "media") {
|
if (editableField.kind === "media") {
|
||||||
renderMediaField({ block, editableField, grid });
|
renderMediaField({ block, editableField, grid });
|
||||||
continue;
|
continue;
|
||||||
@@ -984,6 +1203,7 @@ function renderContentFields(block) {
|
|||||||
if (!active) return;
|
if (!active) return;
|
||||||
setByPath(active, editableField.path, input.value);
|
setByPath(active, editableField.path, input.value);
|
||||||
el.json.value = JSON.stringify(active, null, 2);
|
el.json.value = JSON.stringify(active, null, 2);
|
||||||
|
markDirty();
|
||||||
});
|
});
|
||||||
|
|
||||||
label.append(labelRow, input, path);
|
label.append(labelRow, input, path);
|
||||||
@@ -1017,7 +1237,7 @@ function syncBlockFromJson() {
|
|||||||
region.blocks[state.selected.blockIndex] = parsed;
|
region.blocks[state.selected.blockIndex] = parsed;
|
||||||
}
|
}
|
||||||
renderAll();
|
renderAll();
|
||||||
setStatus("JSON блока применён локально. Нажмите «Сохранить JSON», чтобы записать файл.");
|
markDirty("JSON блока применён локально. Нажмите «Сохранить модель», чтобы записать файл.");
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(`Ошибка JSON: ${error.message}`);
|
setStatus(`Ошибка JSON: ${error.message}`);
|
||||||
@@ -1026,12 +1246,20 @@ function syncBlockFromJson() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadPage() {
|
async function loadPage() {
|
||||||
const response = await fetch("/api/page/home");
|
const [pageResponse, templatesResponse] = await Promise.all([
|
||||||
if (!response.ok) throw new Error(await response.text());
|
fetch("/api/page/home"),
|
||||||
state.page = await response.json();
|
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();
|
ensureStaticElements();
|
||||||
state.selected = { kind: "static" };
|
state.selected = { kind: "static" };
|
||||||
renderAll();
|
renderAll();
|
||||||
|
setDirty(false);
|
||||||
setStatus("Модель home.json загружена.");
|
setStatus("Модель home.json загружена.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1049,6 +1277,7 @@ async function persistPage() {
|
|||||||
|
|
||||||
async function savePage() {
|
async function savePage() {
|
||||||
await persistPage();
|
await persistPage();
|
||||||
|
setDirty(false);
|
||||||
setStatus("Модель сохранена в content/pages/home.json. Для публичной страницы нажмите «Обновить index.html».");
|
setStatus("Модель сохранена в content/pages/home.json. Для публичной страницы нажмите «Обновить index.html».");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1061,6 +1290,7 @@ async function renderHome() {
|
|||||||
throw new Error(payload.error || "Render failed");
|
throw new Error(payload.error || "Render failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setDirty(false);
|
||||||
setStatus(payload.stdout ? `Модель сохранена. ${payload.stdout}` : "Модель сохранена, index.html пересобран.");
|
setStatus(payload.stdout ? `Модель сохранена. ${payload.stdout}` : "Модель сохранена, index.html пересобран.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1077,6 +1307,7 @@ function moveSelected(delta) {
|
|||||||
region.blocks.splice(nextIndex, 0, block);
|
region.blocks.splice(nextIndex, 0, block);
|
||||||
state.selected.blockIndex = nextIndex;
|
state.selected.blockIndex = nextIndex;
|
||||||
renderAll();
|
renderAll();
|
||||||
|
markDirty("Порядок блоков изменён локально. Нажмите «Сохранить модель» или «Обновить index.html».");
|
||||||
}
|
}
|
||||||
|
|
||||||
function duplicateSelected() {
|
function duplicateSelected() {
|
||||||
@@ -1092,6 +1323,7 @@ function duplicateSelected() {
|
|||||||
region.blocks.splice(state.selected.blockIndex + 1, 0, duplicate);
|
region.blocks.splice(state.selected.blockIndex + 1, 0, duplicate);
|
||||||
state.selected.blockIndex += 1;
|
state.selected.blockIndex += 1;
|
||||||
renderAll();
|
renderAll();
|
||||||
|
markDirty(`Блок «${duplicate.adminLabel || duplicate.id}» продублирован локально.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function copySelected() {
|
function copySelected() {
|
||||||
@@ -1115,6 +1347,7 @@ function pasteAfterSelected() {
|
|||||||
region.blocks.splice(state.selected.blockIndex + 1, 0, pasted);
|
region.blocks.splice(state.selected.blockIndex + 1, 0, pasted);
|
||||||
state.selected.blockIndex += 1;
|
state.selected.blockIndex += 1;
|
||||||
renderAll();
|
renderAll();
|
||||||
|
markDirty(`Блок «${pasted.adminLabel || pasted.id}» вставлен локально.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteSelected() {
|
function deleteSelected() {
|
||||||
@@ -1126,6 +1359,7 @@ function deleteSelected() {
|
|||||||
state.selected.blockIndex = Math.min(state.selected.blockIndex, region.blocks.length - 1);
|
state.selected.blockIndex = Math.min(state.selected.blockIndex, region.blocks.length - 1);
|
||||||
if (state.selected.blockIndex < 0) state.selected = { kind: "static" };
|
if (state.selected.blockIndex < 0) state.selected = { kind: "static" };
|
||||||
renderAll();
|
renderAll();
|
||||||
|
markDirty("Блок удалён локально. Нажмите «Сохранить модель» или «Обновить index.html».");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const input of [el.id, el.label, el.anchor, el.enabled]) {
|
for (const input of [el.id, el.label, el.anchor, el.enabled]) {
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
<h2 id="selected-title">Контентный слой</h2>
|
<h2 id="selected-title">Контентный слой</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<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="reload" class="icon-btn" type="button" title="Перезагрузить данные">↻</button>
|
||||||
<button id="save" class="primary-btn" type="button" title="Записать текущую модель в content/pages/home.json">Сохранить модель</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>
|
<button id="render" class="ghost-btn" type="button" title="Сохранить модель и пересобрать публичный index.html">Обновить index.html</button>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1112
-8
File diff suppressed because it is too large
Load Diff
+3
-3
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
<div data-module="folder-wrap" class="folder-w video-w"><div tabindex="0" data-target="demo-video" data-to="features" data-module="apple-icon" class="folder"><img src="./assets/webflow/images/69aab66f030013f2c7177d78_mov-file.avif" loading="eager" data-icon="" alt="Иконка MOV-файла." class="image"/><div class="folder-title-w"><div class="dot highlight purple"></div><div data-txt="" class="folder-title">демо-видео.txt</div></div></div></div>
|
<div data-module="folder-wrap" class="folder-w video-w"><div tabindex="0" data-target="{{attr:content.target}}" data-to="{{attr:content.to}}" data-module="apple-icon" class="folder"><img src="{{attr:content.iconSrc}}" loading="eager" data-icon="" alt="{{attr:content.iconAlt}}" class="image"/><div class="folder-title-w"><div class="dot highlight purple"></div><div data-txt="" class="folder-title">{{text:content.label}}</div></div></div></div>
|
||||||
@@ -1 +1 @@
|
|||||||
<footer class="s footer"><div class="c footer-col"><div class="div-block-9"><div class="div-block-2"><img data-module="mouse-follow" loading="lazy" alt="Иконка NODE.DC" src="./assets/webflow/images/69ad964d951f9d17cc5a919e_logo-app.svg" class="logo app footer"/><img loading="lazy" src="./assets/webflow/images/69ad96a28b06b7552a339137_logtype.svg?v=node-dc" alt="Логотип NODE.DC" class="logo-size"/></div><div class="footer-notice mt">© 2026<a href="https://federic.ooo/" target="_blank"></a> — <a href="https://federic.ooo/" target="_blank">NODE.DC</a></div></div><div class="footer-cols"><div class="div-block-3"><h2 class="footer-list-he">Навигация</h2><ul data-group="hero" data-module="list" role="list" class="w-list-unstyled"><li><a data-module="link" href="https://docs.ssscript.app/" target="_blank" class="ft-link w-inline-block"><div class="clip-12"><div>Документация</div></div></a></li><li><a data-module="link" href="index.html#waitlist" class="ft-link w-inline-block"><div class="clip-12"><div>Лист ожидания</div></div></a></li><li><a data-module="link" href="index.html#" class="ft-link w-inline-block"><div class="clip-12"><div>Тарифы</div></div></a></li><li><a data-module="link" href="index.html#features" class="ft-link w-inline-block"><div class="clip-12"><div>Возможности</div></div></a></li><li><a data-module="link" href="index.html#features" class="ft-link w-inline-block"><div class="clip-12"><div>Демо</div></div></a></li></ul></div><div class="div-block-3"><h2 data-module="text-wave" class="footer-list-he">Контакты</h2><ul data-group="hero" data-module="list" role="list" class="w-list-unstyled"><li><a data-module="link" href="https://federic.ooo/s/twitter" target="_blank" class="ft-link w-inline-block"><div class="clip-12"><div>Twitter</div></div></a></li><li><a data-module="link" href="https://federic.ooo/s/instagram" target="_blank" class="ft-link w-inline-block"><div class="clip-12"><div>Instagram</div></div></a></li><li><a data-module="link" href="https://www.federic.ooo/s/github" target="_blank" class="ft-link w-inline-block"><div class="clip-12"><div>GitHub</div></div></a></li><li><a data-module="link" href="https://federic.ooo/" target="_blank" class="ft-link w-inline-block"><div class="clip-12"><div>Портфолио</div></div></a></li></ul></div></div></div><div class="c footer-col"><div class="footer-notice mt"><a href="https://docs.google.com/document/d/1vME2w8ZB88HoH-JyvkkiL7exrcMfWhkERfFRlnIeZFA/edit?usp=sharing" target="_blank">Политика конфиденциальности</a> & <a href="https://docs.google.com/document/d/1vME2w8ZB88HoH-JyvkkiL7exrcMfWhkERfFRlnIeZFA/edit?usp=sharing" target="_blank">Условия использования</a></div></div></footer>
|
<footer class="s footer"><div class="c footer-col"><div class="div-block-9"><div class="div-block-2"><img data-module="mouse-follow" loading="lazy" alt="{{attr:content.footer.appIconAlt}}" src="{{attr:content.footer.appIconSrc}}" class="logo app footer"/><img loading="lazy" src="{{attr:content.footer.wordmarkSrc}}" alt="{{attr:content.footer.wordmarkAlt}}" class="logo-size"/></div><div class="footer-notice mt">{{html:content.footer.copyrightHtml}}</div></div><div class="footer-cols"><div class="div-block-3"><h2 class="footer-list-he">{{text:content.footer.navTitle}}</h2><ul data-group="hero" data-module="list" role="list" class="w-list-unstyled">{{#each content.footer.navLinks}}<li><a data-module="link" href="{{attr:href}}" target="{{attr:target}}" class="ft-link w-inline-block"><div class="clip-12"><div>{{text:label}}</div></div></a></li>{{/each}}</ul></div><div class="div-block-3"><h2 data-module="text-wave" class="footer-list-he">{{text:content.footer.contactTitle}}</h2><ul data-group="hero" data-module="list" role="list" class="w-list-unstyled">{{#each content.footer.contactLinks}}<li><a data-module="link" href="{{attr:href}}" target="{{attr:target}}" class="ft-link w-inline-block"><div class="clip-12"><div>{{text:label}}</div></div></a></li>{{/each}}</ul></div></div></div><div class="c footer-col"><div class="footer-notice mt">{{html:content.footer.policyHtml}}</div></div></footer>
|
||||||
@@ -1 +1 @@
|
|||||||
<section id="waitlist" class="s"><div class="c center ve"><hgroup data-module="hg" class="ce"><div data-module="text-wave" class="hg-par darker-70">Тарифы</div><h2 class="main-h h1 xl"><span class="darker-40">Код</span><span class="darker-70">для No-Code,</span><span>усиленный ИИ.</span></h2><div class="div-block-11"><div class="h-txt">Подписки на приложение с<br/>включённым объёмом в каждом тарифе. <br/></div><div class="h-txt"><span class="darker-70">Токенная</span> оплата сверх лимитов.</div><div class="h-txt darker-40"><span class="darker-70">200 Founder</span> мест с фиксированной ценой на первый год.</div></div></hgroup></div></section>
|
<section id="{{attr:anchor}}" class="s"><div class="c center ve"><hgroup data-module="hg" class="ce"><div data-module="text-wave" class="hg-par darker-70">{{text:content.eyebrow}}</div><h2 class="main-h h1 xl"><span class="darker-40">{{text:content.heading.muted}}</span><span class="darker-70">{{text:content.heading.middle}}</span><span>{{text:content.heading.main}}</span></h2><div class="div-block-11"><div class="h-txt">{{html:content.introHtml}}</div><div class="h-txt">{{html:content.tokenBillingHtml}}</div><div class="h-txt darker-40">{{html:content.founderLimitHtml}}</div></div></hgroup></div></section>
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -9,6 +9,7 @@ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|||||||
const port = Number(process.env.PORT || 8090);
|
const port = Number(process.env.PORT || 8090);
|
||||||
const host = process.env.HOST || "127.0.0.1";
|
const host = process.env.HOST || "127.0.0.1";
|
||||||
const pagePath = join(repoRoot, "content", "pages", "home.json");
|
const pagePath = join(repoRoot, "content", "pages", "home.json");
|
||||||
|
const blockTemplatesPath = join(repoRoot, "content", "block-templates", "home.json");
|
||||||
const uploadRoot = join(repoRoot, "assets", "uploads");
|
const uploadRoot = join(repoRoot, "assets", "uploads");
|
||||||
|
|
||||||
const MIME = {
|
const MIME = {
|
||||||
@@ -223,6 +224,11 @@ const server = createServer(async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && url.pathname === "/api/block-templates/home") {
|
||||||
|
send(res, 200, await readFile(blockTemplatesPath, "utf8"), "application/json; charset=utf-8");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "PUT" && url.pathname === "/api/page/home") {
|
if (req.method === "PUT" && url.pathname === "/api/page/home") {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
const parsed = JSON.parse(body);
|
const parsed = JSON.parse(body);
|
||||||
|
|||||||
+13
-1
@@ -83,6 +83,18 @@ function renderTemplateTokens(html, block) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderEachBlocks(html, block) {
|
||||||
|
return html.replace(/\{\{#each\s+([a-zA-Z0-9_.-]+)\s*\}\}([\s\S]*?)\{\{\/each\}\}/g, (_match, path, innerHtml) => {
|
||||||
|
const items = getByPath(block, path);
|
||||||
|
|
||||||
|
if (!Array.isArray(items)) {
|
||||||
|
throw new Error(`Each token "${path}" in block "${block.id}" must point to an array`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.map((item) => renderTemplateTokens(innerHtml, item ?? {})).join("");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function scopeDuplicateIds(html, scope) {
|
function scopeDuplicateIds(html, scope) {
|
||||||
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]);
|
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]);
|
||||||
if (!ids.length) {
|
if (!ids.length) {
|
||||||
@@ -112,7 +124,7 @@ async function renderBlock(block, renderCounts) {
|
|||||||
const renderCount = renderCounts.get(templateName) ?? 0;
|
const renderCount = renderCounts.get(templateName) ?? 0;
|
||||||
renderCounts.set(templateName, renderCount + 1);
|
renderCounts.set(templateName, renderCount + 1);
|
||||||
|
|
||||||
const html = renderTemplateTokens(await readTemplate(templateName), block);
|
const html = renderTemplateTokens(renderEachBlocks(await readTemplate(templateName), block), block);
|
||||||
|
|
||||||
if (renderCount === 0 && block.scopeIds !== true) {
|
if (renderCount === 0 && block.scopeIds !== true) {
|
||||||
return html;
|
return html;
|
||||||
|
|||||||
Reference in New Issue
Block a user