Extend admin content templates
This commit is contained in:
parent
931ac628b4
commit
e4bfd93a67
151
admin/admin.css
151
admin/admin.css
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
284
admin/admin.js
284
admin/admin.js
|
|
@ -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
File diff suppressed because it is too large
Load Diff
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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue