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; 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
View File
@@ -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]) {
+1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
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
View File
@@ -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> &amp; <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
+6
View File
@@ -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
View File
@@ -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;