Compare commits

...

2 Commits

Author SHA1 Message Date
dcconstructions 82cdab07f2 Update promo admin media blocks 2026-06-26 22:04:59 +03:00
dcconstructions fb1531f7c5 Add admin scroll controls and site interaction fixes 2026-06-26 18:43:03 +03:00
50 changed files with 2596 additions and 1472 deletions

View File

@ -923,8 +923,103 @@ body[data-file-picker-active="true"] .editor {
align-content: start; align-content: start;
} }
.range-field {
gap: 0.46rem;
}
.range-control {
display: flex;
min-height: 2.72rem;
align-items: center;
gap: 0.85rem;
border-radius: var(--launcher-radius-control);
background: var(--field);
padding: 0 0.86rem;
}
.range-control input[type="range"] {
height: 1.5rem;
min-width: 0;
flex: 1 1 auto;
appearance: none;
border: 0;
border-radius: 0;
background: transparent;
padding: 0;
accent-color: #f7f8f4;
}
.range-control input[type="range"]::-webkit-slider-runnable-track {
height: 0.32rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
}
.range-control input[type="range"]::-webkit-slider-thumb {
width: 1.08rem;
height: 1.08rem;
margin-top: -0.38rem;
appearance: none;
border-radius: 50%;
background: #f7f8f4;
box-shadow: 0 0 0 0.28rem rgba(255, 255, 255, 0.08);
}
.range-control input[type="range"]::-moz-range-track {
height: 0.32rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
}
.range-control input[type="range"]::-moz-range-thumb {
width: 1.08rem;
height: 1.08rem;
border: 0;
border-radius: 50%;
background: #f7f8f4;
box-shadow: 0 0 0 0.28rem rgba(255, 255, 255, 0.08);
}
.range-value {
min-width: 3.4rem;
color: var(--text-secondary);
font-size: 0.78rem;
font-weight: 850;
text-align: right;
white-space: nowrap;
}
.number-control {
display: flex;
min-height: 2.72rem;
align-items: center;
gap: 0.52rem;
border-radius: var(--launcher-radius-control);
background: var(--field);
padding: 0 0.86rem;
}
.number-control input[type="number"] {
width: 100%;
min-width: 0;
border: 0;
background: transparent;
color: var(--text-primary);
font-size: 0.86rem;
font-weight: 820;
padding: 0;
}
.number-suffix {
flex: 0 0 auto;
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 850;
}
.boolean-field input, .boolean-field input,
.group-head-toggle input { .group-head-toggle input,
.collection-visibility-toggle input {
width: 2.42rem; width: 2.42rem;
height: 1.32rem; height: 1.32rem;
margin: 0; margin: 0;
@ -935,7 +1030,8 @@ body[data-file-picker-active="true"] .editor {
} }
.boolean-field input::before, .boolean-field input::before,
.group-head-toggle input::before { .group-head-toggle input::before,
.collection-visibility-toggle input::before {
display: block; display: block;
width: 1rem; width: 1rem;
aspect-ratio: 1; aspect-ratio: 1;
@ -946,12 +1042,14 @@ body[data-file-picker-active="true"] .editor {
} }
.boolean-field input:checked, .boolean-field input:checked,
.group-head-toggle input:checked { .group-head-toggle input:checked,
.collection-visibility-toggle input:checked {
background: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.9);
} }
.boolean-field input:checked::before, .boolean-field input:checked::before,
.group-head-toggle input:checked::before { .group-head-toggle input:checked::before,
.collection-visibility-toggle input:checked::before {
background: #050506; background: #050506;
transform: translateX(1.1rem); transform: translateX(1.1rem);
} }
@ -1032,6 +1130,10 @@ body[data-file-picker-active="true"] .editor {
opacity: 0.16; opacity: 0.16;
} }
.collection-item-card.is-hidden {
opacity: 0.55;
}
.collection-summary { .collection-summary {
min-height: 3rem; min-height: 3rem;
cursor: pointer; cursor: pointer;
@ -1044,6 +1146,8 @@ body[data-file-picker-active="true"] .editor {
} }
.collection-summary-title { .collection-summary-title {
min-width: 0;
flex: 1 1 auto;
overflow: hidden; overflow: hidden;
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.82rem; font-size: 0.82rem;
@ -1058,6 +1162,45 @@ body[data-file-picker-active="true"] .editor {
gap: 0.22rem; gap: 0.22rem;
} }
.collection-visibility-toggle {
display: inline-flex;
height: 2.2rem;
align-items: center;
gap: 0.38rem;
border-radius: var(--launcher-radius-circle);
background: rgba(255, 255, 255, 0.07);
padding: 0 0.52rem;
color: var(--text-secondary);
cursor: pointer;
}
.collection-visibility-toggle:hover {
background: rgba(255, 255, 255, 0.14);
color: var(--text-primary);
}
.collection-visibility-toggle span {
font-size: 0.68rem;
font-weight: 850;
line-height: 1;
text-transform: uppercase;
}
.collection-visibility-toggle input {
width: 2rem;
height: 1.12rem;
flex: 0 0 auto;
padding: 0.13rem;
}
.collection-visibility-toggle input::before {
width: 0.86rem;
}
.collection-visibility-toggle input:checked::before {
transform: translateX(0.88rem);
}
.collection-drag-handle, .collection-drag-handle,
.collection-icon-btn { .collection-icon-btn {
display: grid; display: grid;
@ -1523,6 +1666,33 @@ textarea {
padding-top: 0; padding-top: 0;
} }
.delete-modal-layer {
z-index: 240;
}
.delete-modal {
width: min(30rem, calc(100vw - 2.8rem));
}
.delete-modal-head {
padding-right: 1.25rem;
}
.delete-modal-foot {
justify-content: flex-end;
}
.delete-confirm-btn {
min-width: 4.4rem;
background: rgba(255, 255, 255, 0.92);
color: rgba(8, 8, 10, 0.96);
}
.delete-confirm-btn:hover {
background: #fff;
color: rgba(8, 8, 10, 1);
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
body { body {
overflow: auto; overflow: auto;

View File

@ -9,8 +9,12 @@ const state = {
filePickerActive: false, filePickerActive: false,
mediaUploadActive: false, mediaUploadActive: false,
filePickerResetTimer: null, filePickerResetTimer: null,
deleteConfirmResolve: null,
}; };
const SECTIONS_REGION_ID = "sections";
const SECTIONS_REGION_LABEL = "Секции";
const el = { const el = {
regions: document.querySelector("#regions"), regions: document.querySelector("#regions"),
addBlock: document.querySelector("#add-block"), addBlock: document.querySelector("#add-block"),
@ -40,6 +44,11 @@ const el = {
templateList: document.querySelector("#template-list"), templateList: document.querySelector("#template-list"),
templateModalClose: document.querySelector("#template-modal-close"), templateModalClose: document.querySelector("#template-modal-close"),
templateModalCancel: document.querySelector("#template-modal-cancel"), templateModalCancel: document.querySelector("#template-modal-cancel"),
deleteModal: document.querySelector("#delete-modal"),
deleteModalTitle: document.querySelector("#delete-modal-title"),
deleteModalBody: document.querySelector("#delete-modal-body"),
deleteModalCancel: document.querySelector("#delete-modal-cancel"),
deleteModalConfirm: document.querySelector("#delete-modal-confirm"),
}; };
const STATIC_FALLBACK = { const STATIC_FALLBACK = {
@ -202,9 +211,12 @@ const GROUP_COPY = {
"Видео-окно": "Окно демо-видео и подключенный медиа-файл.", "Видео-окно": "Окно демо-видео и подключенный медиа-файл.",
Пункты: "Текстовые пункты внутри секции.", Пункты: "Текстовые пункты внутри секции.",
Слайдер: "Видео-превью и подписи карточек внутри горизонтального слайдера.", Слайдер: "Видео-превью и подписи карточек внутри горизонтального слайдера.",
Скролл: "Скорость локальной прокрутки внутри блока: меньше значение — спокойнее шаг, больше — быстрее.",
"Один клик": "Подблок про быстрое добавление и настройку компонентов.", "Один клик": "Подблок про быстрое добавление и настройку компонентов.",
MCP: "Подблок про MCP-сервер и контекст Webflow.", MCP: "Подблок про MCP-сервер и контекст Webflow.",
FAQ: "Список вопросов и тексты ответов в mail-интерфейсе.", "Окно FAQ": "Название окна FAQ и служебные подписи mail-интерфейса.",
Отправитель: "Профиль отправителя в FAQ: аватар, имя и подписи письма.",
FAQ: "Вопросы и ответы mail-интерфейса. Каждый пункт можно добавить, удалить и переставить.",
CTA: "Финальный призыв к действию: заголовок, пояснение, кнопка и ссылка.", CTA: "Финальный призыв к действию: заголовок, пояснение, кнопка и ссылка.",
Тарифы: "Верхняя часть тарифной таблицы: заголовки, планы, цены, кнопки и примечания.", Тарифы: "Верхняя часть тарифной таблицы: заголовки, планы, цены, кнопки и примечания.",
"Строки тарифов": "Повторяемые строки тарифной таблицы и значения по каждому плану.", "Строки тарифов": "Повторяемые строки тарифной таблицы и значения по каждому плану.",
@ -324,6 +336,7 @@ function inferFieldGroup(path) {
if (path === "content.legalHtml") return "Юридический блок"; if (path === "content.legalHtml") return "Юридический блок";
if (path === "content.windowTitle" || path === "content.videoSrc") return "Видео-окно"; if (path === "content.windowTitle" || path === "content.videoSrc") return "Видео-окно";
if (path.startsWith("content.features.")) return "Пункты"; if (path.startsWith("content.features.")) return "Пункты";
if (path.startsWith("content.scroll.")) return "Скролл";
if (path.startsWith("content.sliderItems.")) return "Слайдер"; if (path.startsWith("content.sliderItems.")) return "Слайдер";
if (path.startsWith("content.oneClick.")) return "Один клик"; if (path.startsWith("content.oneClick.")) return "Один клик";
if (path.startsWith("content.mcp.")) return "MCP"; if (path.startsWith("content.mcp.")) return "MCP";
@ -419,6 +432,32 @@ function ensureStaticElements() {
state.page.staticElements.editableFields = clone(STATIC_FALLBACK.editableFields); state.page.staticElements.editableFields = clone(STATIC_FALLBACK.editableFields);
} }
function ensureSectionsRegion() {
const regions = Array.isArray(state.page.regions) ? state.page.regions : [];
const blocks = regions.flatMap((region) =>
(region.blocks ?? []).map((block) => {
block.region ||= region.id;
return block;
}),
);
state.page.regions = [
{
id: SECTIONS_REGION_ID,
label: SECTIONS_REGION_LABEL,
description: "Единый порядок секций главной страницы.",
blocks,
},
];
}
function sectionsRegionIndex() {
return Math.max(
0,
state.page.regions.findIndex((region) => region.id === SECTIONS_REGION_ID),
);
}
function isStaticSelection() { function isStaticSelection() {
return state.selected?.kind === "static"; return state.selected?.kind === "static";
} }
@ -555,8 +594,7 @@ function readFileAsDataUrl(file) {
} }
function labelForRegion(region) { function labelForRegion(region) {
if (region.id === "beforeCViz") return "Главная"; if (region.id === SECTIONS_REGION_ID || region.id === "beforeCViz" || region.id === "cViz") return SECTIONS_REGION_LABEL;
if (region.id === "cViz") return "Секции";
return region.label || region.id; return region.label || region.id;
} }
@ -851,14 +889,13 @@ function renderRegions() {
} }
function blockTemplates() { function blockTemplates() {
return state.templates.map((template) => { const regionIndex = sectionsRegionIndex();
const regionIndex = state.page.regions.findIndex((region) => region.id === template.regionId);
const region = state.page.regions[regionIndex] || state.page.regions[0];
return state.templates.map((template) => {
return { return {
...template, ...template,
regionIndex: regionIndex >= 0 ? regionIndex : 0, regionIndex,
regionLabel: template.regionLabel || labelForRegion(region), regionLabel: SECTIONS_REGION_LABEL,
}; };
}); });
} }
@ -903,6 +940,29 @@ function closeTemplateModal() {
el.addBlock.focus(); el.addBlock.focus();
} }
function closeDeleteModal(confirmed = false) {
const resolve = state.deleteConfirmResolve;
state.deleteConfirmResolve = null;
el.deleteModal.classList.add("hidden");
if (resolve) resolve(confirmed);
}
function requestDeleteConfirmation({
title = "Подтвердите действие",
body = "Вы действительно подтвердить удаление элемента?",
} = {}) {
if (state.deleteConfirmResolve) closeDeleteModal(false);
return new Promise((resolve) => {
state.deleteConfirmResolve = resolve;
el.deleteModalTitle.textContent = title;
el.deleteModalBody.textContent = body;
el.deleteModal.classList.remove("hidden");
el.deleteModalConfirm.focus();
});
}
function addBlockFromTemplate(templateKey) { function addBlockFromTemplate(templateKey) {
const template = blockTemplates().find((candidate) => candidate.key === templateKey); const template = blockTemplates().find((candidate) => candidate.key === templateKey);
if (!template) return; if (!template) return;
@ -1138,18 +1198,20 @@ async function uploadMediaFile({ block, editableField, hiddenInput, urlInput, fi
fileName.textContent = "Сохраняем в assets..."; fileName.textContent = "Сохраняем в assets...";
try { try {
const dataUrl = await readFileAsDataUrl(file); const formData = new FormData();
formData.append("file", file, file.name);
formData.append("fileName", file.name);
formData.append("mimeType", file.type || "application/octet-stream");
formData.append("bucket", uploadBucketForField(block, editableField));
const response = await fetch("/api/upload/asset", { const response = await fetch("/api/upload/asset", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, body: formData,
body: JSON.stringify({
fileName: file.name,
mimeType: file.type || "application/octet-stream",
bucket: uploadBucketForField(block, editableField),
dataUrl,
}),
}); });
const payload = await response.json(); const payload = await response.json().catch(() => ({
ok: false,
error: `Сервер вернул ${response.status}`,
}));
if (!response.ok || !payload.ok) { if (!response.ok || !payload.ok) {
throw new Error(payload.error || "Файл не удалось сохранить"); throw new Error(payload.error || "Файл не удалось сохранить");
@ -1393,8 +1455,19 @@ function prepareCollectionItemForInsert(items, item) {
return nextItem; return nextItem;
} }
function fieldInputValue(input) {
if (input.type === "checkbox") return input.checked;
if (input.dataset.valueType === "number" || input.type === "range" || input.type === "number") {
const value = Number.parseFloat(input.value);
return Number.isFinite(value) ? value : 0;
}
return input.value;
}
function syncFieldValue(block, path, input) { function syncFieldValue(block, path, input) {
setByPath(block, path, input.type === "checkbox" ? input.checked : input.value); setByPath(block, path, fieldInputValue(input));
el.json.value = JSON.stringify(block, null, 2); el.json.value = JSON.stringify(block, null, 2);
markDirty(); markDirty();
} }
@ -1431,6 +1504,108 @@ function renderBooleanField({ block, editableField, grid }) {
grid.append(label); grid.append(label);
} }
function formatRangeValue(value, editableField) {
const step = Number.parseFloat(editableField.step ?? 1);
const precision = Number.isFinite(step) && step > 0 && step < 1 ? String(step).split(".")[1]?.length || 0 : 0;
return `${Number(value).toFixed(precision)}${editableField.suffix || ""}`;
}
function renderRangeField({ block, editableField, 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 control = document.createElement("span");
const input = document.createElement("input");
const output = document.createElement("span");
const min = Number.parseFloat(editableField.min ?? 0);
const max = Number.parseFloat(editableField.max ?? 1);
const step = Number.parseFloat(editableField.step ?? 0.01);
const rawValue = Number.parseFloat(getByPath(block, editableField.path));
const value = Number.isFinite(rawValue) ? rawValue : min;
label.className = "field-control range-field wide";
labelRow.className = "field-label-row";
labelText.className = "field-label-text";
labelText.textContent = editableField.label || editableField.path;
kind.className = "field-kind";
kind.textContent = editableField.kind || "range";
path.className = "field-path";
path.textContent = editableField.path;
control.className = "range-control";
output.className = "range-value";
labelRow.append(labelText, kind);
input.dataset.path = editableField.path;
input.dataset.valueType = "number";
input.type = "range";
input.min = Number.isFinite(min) ? String(min) : "0";
input.max = Number.isFinite(max) ? String(max) : "1";
input.step = Number.isFinite(step) ? String(step) : "0.01";
input.value = String(value);
output.textContent = formatRangeValue(value, editableField);
input.addEventListener("input", () => {
const active = activeBlock();
if (!active) return;
output.textContent = formatRangeValue(input.value, editableField);
syncFieldValue(active, editableField.path, input);
});
control.append(input, output);
label.append(labelRow, control, path);
grid.append(label);
}
function renderNumberField({ block, editableField, 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 control = document.createElement("span");
const input = document.createElement("input");
const suffix = document.createElement("span");
const rawValue = Number.parseFloat(getByPath(block, editableField.path));
const fallback = Number.parseFloat(editableField.defaultValue ?? editableField.min ?? 0);
const value = Number.isFinite(rawValue) ? rawValue : fallback;
label.className = "field-control number-field";
labelRow.className = "field-label-row";
labelText.className = "field-label-text";
labelText.textContent = editableField.label || editableField.path;
kind.className = "field-kind";
kind.textContent = editableField.kind || "number";
path.className = "field-path";
path.textContent = editableField.path;
control.className = "number-control";
suffix.className = "number-suffix";
suffix.textContent = editableField.suffix || "";
labelRow.append(labelText, kind);
input.dataset.path = editableField.path;
input.dataset.valueType = "number";
input.type = "number";
input.value = Number.isFinite(value) ? String(value) : "0";
input.autocomplete = "off";
if (editableField.min != null) input.min = String(editableField.min);
if (editableField.max != null) input.max = String(editableField.max);
if (editableField.step != null) input.step = String(editableField.step);
input.addEventListener("input", () => {
const active = activeBlock();
if (!active) return;
syncFieldValue(active, editableField.path, input);
});
control.append(input);
if (suffix.textContent) control.append(suffix);
label.append(labelRow, control, path);
grid.append(label);
}
function renderHeaderBooleanToggle({ block, editableField, head }) { function renderHeaderBooleanToggle({ block, editableField, head }) {
const label = document.createElement("label"); const label = document.createElement("label");
const input = document.createElement("input"); const input = document.createElement("input");
@ -1458,6 +1633,16 @@ function renderCollectionScalarField({ block, field, grid }) {
return; return;
} }
if (field.kind === "range") {
renderRangeField({ block, editableField: field, grid });
return;
}
if (field.kind === "number") {
renderNumberField({ block, editableField: field, grid });
return;
}
const label = document.createElement("label"); const label = document.createElement("label");
const labelRow = document.createElement("span"); const labelRow = document.createElement("span");
const labelText = document.createElement("span"); const labelText = document.createElement("span");
@ -1592,8 +1777,14 @@ function renderCollectionField({ block, editableField, grid }) {
const dragHandle = document.createElement("button"); const dragHandle = document.createElement("button");
const body = document.createElement("div"); const body = document.createElement("div");
const itemGrid = document.createElement("div"); const itemGrid = document.createElement("div");
const hasVisibilityToggle =
item &&
typeof item === "object" &&
(Object.prototype.hasOwnProperty.call(item, "enabled") ||
Object.prototype.hasOwnProperty.call(editableField.itemTemplate ?? {}, "enabled"));
details.className = "collection-item-card"; details.className = "collection-item-card";
details.classList.toggle("is-hidden", item?.enabled === false);
if (index === 0 && items.length <= 8) details.open = true; if (index === 0 && items.length <= 8) details.open = true;
summary.className = "collection-summary"; summary.className = "collection-summary";
summaryTitle.className = "collection-summary-title"; summaryTitle.className = "collection-summary-title";
@ -1611,6 +1802,29 @@ function renderCollectionField({ block, editableField, grid }) {
dragHandle.addEventListener("pointerdown", (event) => startCollectionPointerDrag(event, details, items, index, rerender)); dragHandle.addEventListener("pointerdown", (event) => startCollectionPointerDrag(event, details, items, index, rerender));
controls.append(dragHandle); controls.append(dragHandle);
if (hasVisibilityToggle) {
if (!Object.prototype.hasOwnProperty.call(item, "enabled")) item.enabled = true;
const visibilityToggle = document.createElement("span");
const visibilityInput = document.createElement("input");
const visibilityText = document.createElement("span");
visibilityToggle.className = "collection-visibility-toggle";
visibilityToggle.title = "Скрыть/показать";
visibilityToggle.setAttribute("aria-label", "Скрыть/показать");
visibilityInput.type = "checkbox";
visibilityInput.checked = item.enabled !== false;
visibilityText.textContent = "Показать";
visibilityToggle.addEventListener("click", (event) => event.stopPropagation());
visibilityInput.addEventListener("click", (event) => event.stopPropagation());
visibilityInput.addEventListener("change", () => {
item.enabled = visibilityInput.checked;
rerender(visibilityInput.checked ? "Элемент коллекции показан локально." : "Элемент коллекции скрыт локально.");
});
visibilityToggle.append(visibilityInput, visibilityText);
controls.append(visibilityToggle);
}
[ [
["↑", "Поднять", () => { ["↑", "Поднять", () => {
if (index === 0) return; if (index === 0) return;
@ -1628,7 +1842,9 @@ function renderCollectionField({ block, editableField, grid }) {
items.splice(index + 1, 0, prepareCollectionItemForInsert(items, item)); items.splice(index + 1, 0, prepareCollectionItemForInsert(items, item));
rerender("Элемент коллекции продублирован локально."); rerender("Элемент коллекции продублирован локально.");
}], }],
["⌫", "Удалить", () => { ["⌫", "Удалить", async () => {
const confirmed = await requestDeleteConfirmation();
if (!confirmed) return;
items.splice(index, 1); items.splice(index, 1);
rerender("Элемент коллекции удалён локально."); rerender("Элемент коллекции удалён локально.");
}], }],
@ -1642,7 +1858,7 @@ function renderCollectionField({ block, editableField, grid }) {
button.addEventListener("click", (event) => { button.addEventListener("click", (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
handler(); Promise.resolve(handler()).catch((error) => setStatus(`Ошибка действия коллекции: ${error.message}`));
}); });
controls.append(button); controls.append(button);
}); });
@ -1723,6 +1939,16 @@ function renderContentFields(block) {
continue; continue;
} }
if (editableField.kind === "range") {
renderRangeField({ block, editableField, grid });
continue;
}
if (editableField.kind === "number") {
renderNumberField({ block, editableField, grid });
continue;
}
const label = document.createElement("label"); const label = document.createElement("label");
const labelRow = document.createElement("span"); const labelRow = document.createElement("span");
const labelText = document.createElement("span"); const labelText = document.createElement("span");
@ -1770,7 +1996,7 @@ function syncContentFields() {
if (!block) return; if (!block) return;
for (const input of el.contentFields.querySelectorAll("[data-path]")) { for (const input of el.contentFields.querySelectorAll("[data-path]")) {
setByPath(block, input.dataset.path, input.type === "checkbox" ? input.checked : input.value); setByPath(block, input.dataset.path, fieldInputValue(input));
} }
} }
@ -1807,6 +2033,7 @@ async function loadPage() {
state.page = await pageResponse.json(); state.page = await pageResponse.json();
state.templates = await templatesResponse.json(); state.templates = await templatesResponse.json();
ensureStaticElements(); ensureStaticElements();
ensureSectionsRegion();
state.selected = { kind: "static" }; state.selected = { kind: "static" };
renderAll(); renderAll();
setDirty(false); setDirty(false);
@ -1900,11 +2127,14 @@ function pasteAfterSelected() {
markDirty(`Блок «${pasted.adminLabel || pasted.id}» вставлен локально.`); markDirty(`Блок «${pasted.adminLabel || pasted.id}» вставлен локально.`);
} }
function deleteSelected() { async function deleteSelected() {
if (isStaticSelection()) return; if (isStaticSelection()) return;
const region = activeRegion(); const region = activeRegion();
if (!region || state.selected.blockIndex == null) return; if (!region || state.selected.blockIndex == null) return;
const confirmed = await requestDeleteConfirmation();
if (!confirmed) return;
region.blocks.splice(state.selected.blockIndex, 1); region.blocks.splice(state.selected.blockIndex, 1);
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" };
@ -1931,8 +2161,20 @@ el.templateModalCancel.addEventListener("click", closeTemplateModal);
el.templateModal.addEventListener("click", (event) => { el.templateModal.addEventListener("click", (event) => {
if (event.target === el.templateModal) closeTemplateModal(); if (event.target === el.templateModal) closeTemplateModal();
}); });
el.deleteModalCancel.addEventListener("click", () => closeDeleteModal(false));
el.deleteModalConfirm.addEventListener("click", () => closeDeleteModal(true));
el.deleteModal.addEventListener("click", (event) => {
if (event.target === el.deleteModal) closeDeleteModal(false);
});
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !el.templateModal.classList.contains("hidden")) { if (event.key !== "Escape") return;
if (!el.deleteModal.classList.contains("hidden")) {
closeDeleteModal(false);
return;
}
if (!el.templateModal.classList.contains("hidden")) {
closeTemplateModal(); closeTemplateModal();
} }
}); });
@ -1944,7 +2186,7 @@ el.moveDown.addEventListener("click", () => moveSelected(1));
el.duplicate.addEventListener("click", duplicateSelected); el.duplicate.addEventListener("click", duplicateSelected);
el.copy.addEventListener("click", copySelected); el.copy.addEventListener("click", copySelected);
el.pasteAfter.addEventListener("click", pasteAfterSelected); el.pasteAfter.addEventListener("click", pasteAfterSelected);
el.deleteBlock.addEventListener("click", deleteSelected); el.deleteBlock.addEventListener("click", () => deleteSelected().catch((error) => setStatus(`Ошибка удаления: ${error.message}`)));
window.addEventListener("error", (event) => { window.addEventListener("error", (event) => {
recoverEditorSurface(); recoverEditorSurface();

View File

@ -190,6 +190,21 @@
</footer> </footer>
</article> </article>
</div> </div>
<div id="delete-modal" class="template-modal-layer delete-modal-layer hidden" role="dialog" aria-modal="true" aria-labelledby="delete-modal-title" aria-describedby="delete-modal-body">
<article class="template-modal delete-modal">
<header class="template-modal-head delete-modal-head">
<div>
<div class="section-kicker">Удаление</div>
<h2 id="delete-modal-title">Подтвердите действие</h2>
<p id="delete-modal-body">Вы действительно подтвердить удаление элемента?</p>
</div>
</header>
<footer class="template-modal-foot delete-modal-foot">
<button id="delete-modal-cancel" class="ghost-btn" type="button">Отмена</button>
<button id="delete-modal-confirm" class="danger-btn delete-confirm-btn" type="button">Да</button>
</footer>
</article>
</div>
<script src="./admin.js" type="module"></script> <script src="./admin.js" type="module"></script>
</body> </body>
</html> </html>

View File

@ -1 +1,2 @@
*{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}img{pointer-events:none}.text-scrollable{scrollbar-width:none;-ms-overflow-style:none}.text-scrollable::-webkit-scrollbar{display:none}.circle .x-icon{opacity:0}.circle:hover .x-icon{opacity:1}[data-css=fade-tb]{mask-image:linear-gradient(#0000 0%,#000 15% 85%,#0000 100%);-webkit-mask-image:linear-gradient(#0000 0%,#000 15% 85%,#0000 100%);mask-size:100% 100%;mask-repeat:no-repeat;-webkit-mask-size:100% 100%;-webkit-mask-repeat:no-repeat}.current .mail-item{background-color:#3d3d3dc9}[data-module=""]{outline:10px solid red}[data-module=btn]>div>div>div>div{text-shadow:0 1.3em #fff;transition:transform .3s ease-in-out}[data-module=btn]:hover>div>div>div>div{transform:translateY(-1.3em)}[data-module=link]{transition:opacity .1s ease-in-out}[data-module=link] div>div{text-shadow:0 1.3em #fff;transition:transform .3s ease-in-out}[data-module=link]:hover{opacity:.6!important}[data-module=link]:hover div>div{transform:translateY(-.65em)}[data-module=apple-icon]:focus [data-txt]{position:relative}[data-module=apple-icon]:focus [data-txt]:before{content:"";position:absolute;z-index:-1;background-color:#0059d0;border-radius:.3em;width:120%;height:120%;top:-10%;left:-10%}[data-module=notification-w]{--notification-height:98%}[data-module=notification-w]>div:nth-child(2){transform:translateY(calc(-1*var(--notification-height)));z-index:2}[data-module=notification-w]>div:nth-child(3){transform:translateY(calc(-2*var(--notification-height)));z-index:3}[data-module=notification-w]:hover{--notification-height:98%}[data-module=notification-w]:hover>div:nth-child(2){z-index:2;transform:translateY(0)}[data-module=notification-w]:hover>div:nth-child(3){z-index:3;transform:translateY(0)}[data-module=notification]{transition:transform .3s cubic-bezier(.22,1,.36,1)} *{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}img{pointer-events:none}.text-scrollable{scrollbar-width:none;-ms-overflow-style:none}.text-scrollable::-webkit-scrollbar{display:none}.circle .x-icon{opacity:0}.circle:hover .x-icon{opacity:1}[data-css=fade-tb]{mask-image:linear-gradient(#0000 0%,#000 15% 85%,#0000 100%);-webkit-mask-image:linear-gradient(#0000 0%,#000 15% 85%,#0000 100%);mask-size:100% 100%;mask-repeat:no-repeat;-webkit-mask-size:100% 100%;-webkit-mask-repeat:no-repeat}.current .mail-item{background-color:#3d3d3dc9}[data-module=""]{outline:10px solid red}[data-module=btn]>div>div>div>div{text-shadow:0 1.3em #fff;transition:transform .3s ease-in-out}[data-module=btn]:hover>div>div>div>div{transform:translateY(-1.3em)}[data-module=link]{transition:opacity .1s ease-in-out}[data-module=link] div>div{text-shadow:0 1.3em #fff;transition:transform .3s ease-in-out}[data-module=link]:hover{opacity:.6!important}[data-module=link]:hover div>div{transform:translateY(-.65em)}[data-module=apple-icon]:focus [data-txt]{position:relative}[data-module=apple-icon]:focus [data-txt]:before{content:"";position:absolute;z-index:-1;background-color:#0059d0;border-radius:.3em;width:120%;height:120%;top:-10%;left:-10%}[data-module=notification-w]{--notification-height:98%}[data-module=notification-w]>div:nth-child(2){transform:translateY(calc(-1*var(--notification-height)));z-index:2}[data-module=notification-w]>div:nth-child(3){transform:translateY(calc(-2*var(--notification-height)));z-index:3}[data-module=notification-w]:hover{--notification-height:98%}[data-module=notification-w]:hover>div:nth-child(2){z-index:2;transform:translateY(0)}[data-module=notification-w]:hover>div:nth-child(3){z-index:3;transform:translateY(0)}[data-module=notification]{transition:transform .3s cubic-bezier(.22,1,.36,1)}
.app-item.video[data-adaptive-video-window]{--nodedc-video-source-aspect:1.777778;--nodedc-video-height:620px;--nodedc-video-max-height:72svh;--nodedc-video-max-width:70vw;--nodedc-video-render-height:min(var(--nodedc-video-height),var(--nodedc-video-max-height));aspect-ratio:auto;width:min(var(--nodedc-video-max-width),calc(var(--nodedc-video-render-height)*var(--nodedc-video-source-aspect)));height:auto}.app-item.video[data-adaptive-video-window] .video-size[data-adaptive-video]{aspect-ratio:var(--nodedc-video-source-aspect);display:block;width:100%;height:auto;max-height:var(--nodedc-video-render-height)}.app-item.video[data-adaptive-video-window] .video-size[data-adaptive-video]>video.video{display:block;width:100%;height:100%;object-fit:cover}.video-screen [data-media-handle]{display:block;width:100%;height:100%}.video-screen [data-media-handle]>img,.video-screen [data-media-handle]>video{display:block;width:100%;height:100%;object-fit:cover}@media screen and (max-width:767px){.app-item.video[data-adaptive-video-window]{--nodedc-video-max-height:58svh;--nodedc-video-max-width:95vw}}

View File

@ -1 +1,2 @@
*{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}img{pointer-events:none}.text-scrollable{scrollbar-width:none;-ms-overflow-style:none}.text-scrollable::-webkit-scrollbar{display:none}.circle .x-icon{opacity:0}.circle:hover .x-icon{opacity:1}[data-css=fade-tb]{mask-image:linear-gradient(#0000 0%,#000 15% 85%,#0000 100%);-webkit-mask-image:linear-gradient(#0000 0%,#000 15% 85%,#0000 100%);mask-size:100% 100%;mask-repeat:no-repeat;-webkit-mask-size:100% 100%;-webkit-mask-repeat:no-repeat}.current .mail-item{background-color:#3d3d3dc9}[data-module=""]{outline:10px solid red}[data-module=btn]>div>div>div>div{text-shadow:0 1.3em #fff;transition:transform .3s ease-in-out}[data-module=btn]:hover>div>div>div>div{transform:translateY(-1.3em)}[data-module=link]{transition:opacity .1s ease-in-out}[data-module=link] div>div{text-shadow:0 1.3em #fff;transition:transform .3s ease-in-out}[data-module=link]:hover{opacity:.6!important}[data-module=link]:hover div>div{transform:translateY(-.65em)}[data-module=apple-icon]:focus [data-txt]{position:relative}[data-module=apple-icon]:focus [data-txt]:before{content:"";position:absolute;z-index:-1;background-color:#0059d0;border-radius:.3em;width:120%;height:120%;top:-10%;left:-10%}[data-module=notification-w]{--notification-height:98%}[data-module=notification-w]>div:nth-child(2){transform:translateY(calc(-1*var(--notification-height)));z-index:2}[data-module=notification-w]>div:nth-child(3){transform:translateY(calc(-2*var(--notification-height)));z-index:3}[data-module=notification-w]:hover{--notification-height:98%}[data-module=notification-w]:hover>div:nth-child(2){z-index:2;transform:translateY(0)}[data-module=notification-w]:hover>div:nth-child(3){z-index:3;transform:translateY(0)}[data-module=notification]{transition:transform .3s cubic-bezier(.22,1,.36,1)}[data-start=closed]{pointer-events:none;visibility:hidden}[data-module=app]:not([data-start=closed]){transform:scale(0)translateY(200%)}[data-module=folder-wrap]>*{transform:scale(0)}[data-module=list] a{opacity:0}[data-success],[data-failure]{display:none}[data-module=alpha]{opacity:0} *{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}img{pointer-events:none}.text-scrollable{scrollbar-width:none;-ms-overflow-style:none}.text-scrollable::-webkit-scrollbar{display:none}.circle .x-icon{opacity:0}.circle:hover .x-icon{opacity:1}[data-css=fade-tb]{mask-image:linear-gradient(#0000 0%,#000 15% 85%,#0000 100%);-webkit-mask-image:linear-gradient(#0000 0%,#000 15% 85%,#0000 100%);mask-size:100% 100%;mask-repeat:no-repeat;-webkit-mask-size:100% 100%;-webkit-mask-repeat:no-repeat}.current .mail-item{background-color:#3d3d3dc9}[data-module=""]{outline:10px solid red}[data-module=btn]>div>div>div>div{text-shadow:0 1.3em #fff;transition:transform .3s ease-in-out}[data-module=btn]:hover>div>div>div>div{transform:translateY(-1.3em)}[data-module=link]{transition:opacity .1s ease-in-out}[data-module=link] div>div{text-shadow:0 1.3em #fff;transition:transform .3s ease-in-out}[data-module=link]:hover{opacity:.6!important}[data-module=link]:hover div>div{transform:translateY(-.65em)}[data-module=apple-icon]:focus [data-txt]{position:relative}[data-module=apple-icon]:focus [data-txt]:before{content:"";position:absolute;z-index:-1;background-color:#0059d0;border-radius:.3em;width:120%;height:120%;top:-10%;left:-10%}[data-module=notification-w]{--notification-height:98%}[data-module=notification-w]>div:nth-child(2){transform:translateY(calc(-1*var(--notification-height)));z-index:2}[data-module=notification-w]>div:nth-child(3){transform:translateY(calc(-2*var(--notification-height)));z-index:3}[data-module=notification-w]:hover{--notification-height:98%}[data-module=notification-w]:hover>div:nth-child(2){z-index:2;transform:translateY(0)}[data-module=notification-w]:hover>div:nth-child(3){z-index:3;transform:translateY(0)}[data-module=notification]{transition:transform .3s cubic-bezier(.22,1,.36,1)}[data-start=closed]{pointer-events:none;visibility:hidden}[data-module=app]:not([data-start=closed]){transform:scale(0)translateY(200%)}[data-module=folder-wrap]>*{transform:scale(0)}[data-module=list] a{opacity:0}[data-success],[data-failure]{display:none}[data-module=alpha]{opacity:0}
.app-item.video[data-adaptive-video-window]{--nodedc-video-source-aspect:1.777778;--nodedc-video-height:620px;--nodedc-video-max-height:72svh;--nodedc-video-max-width:70vw;--nodedc-video-render-height:min(var(--nodedc-video-height),var(--nodedc-video-max-height));aspect-ratio:auto;width:min(var(--nodedc-video-max-width),calc(var(--nodedc-video-render-height)*var(--nodedc-video-source-aspect)));height:auto}.app-item.video[data-adaptive-video-window] .video-size[data-adaptive-video]{aspect-ratio:var(--nodedc-video-source-aspect);display:block;width:100%;height:auto;max-height:var(--nodedc-video-render-height)}.app-item.video[data-adaptive-video-window] .video-size[data-adaptive-video]>video.video{display:block;width:100%;height:100%;object-fit:cover}.video-screen [data-media-handle]{display:block;width:100%;height:100%}.video-screen [data-media-handle]>img,.video-screen [data-media-handle]>video{display:block;width:100%;height:100%;object-fit:cover}@media screen and (max-width:767px){.app-item.video[data-adaptive-video-window]{--nodedc-video-max-height:58svh;--nodedc-video-max-width:95vw}}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,336 @@
(() => {
const BOUND_FLAG = "__nodedcWheelOverrideBound";
const GLOBAL_SLIDER_BOUND_FLAG = "__nodedcGlobalSliderWheelOverrideBound";
const SYNTHETIC_WHEEL_FLAG = "__nodedcSyntheticWheel";
const SLIDER_WHEEL_MULTIPLIER = 3;
const SLIDER_BASE_COOLDOWN = 220;
const MAIL_BASE_COOLDOWN = 280;
const MIN_SCROLL_SPEED = 0.2;
const MAX_SCROLL_SPEED = 2;
const DEFAULT_SCROLL_SPEED = 0.55;
const ADAPTIVE_VIDEO_BOUND_FLAG = "__nodedcAdaptiveVideoBound";
const MEDIA_HANDLE_BOUND_FLAG = "__nodedcMediaHandleBound";
const IMAGE_EXTENSIONS = new Set(["avif", "gif", "jpeg", "jpg", "png", "svg", "webp"]);
const VIDEO_EXTENSIONS = new Set(["m4v", "mov", "mp4", "webm"]);
function isDesktopPointer() {
return window.matchMedia?.("(pointer: fine)").matches ?? true;
}
function shouldIgnoreWheel(event) {
return event.ctrlKey || !isDesktopPointer();
}
function createSyntheticWheel(event, deltaX, deltaY) {
if (typeof WheelEvent !== "function") return null;
const syntheticEvent = new WheelEvent("wheel", {
bubbles: true,
cancelable: true,
view: window,
deltaX,
deltaY,
deltaZ: event.deltaZ || 0,
deltaMode: event.deltaMode,
altKey: event.altKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
});
Object.defineProperty(syntheticEvent, SYNTHETIC_WHEEL_FLAG, { value: true });
return syntheticEvent;
}
function createSyntheticHorizontalWheel(event) {
return createSyntheticWheel(event, event.deltaY * SLIDER_WHEEL_MULTIPLIER, 0);
}
function getPrimaryWheelDelta(event) {
return Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function getScrollSpeed(surface) {
const rawValue = surface?.dataset?.scrollSpeed ?? surface?.closest?.("[data-scroll-speed]")?.dataset?.scrollSpeed;
const value = Number.parseFloat(rawValue);
if (!Number.isFinite(value)) return DEFAULT_SCROLL_SPEED;
return clamp(value, MIN_SCROLL_SPEED, MAX_SCROLL_SPEED);
}
function getStepCooldown(baseCooldown, surface) {
return Math.round(baseCooldown / getScrollSpeed(surface));
}
function dispatchPointerStep(item) {
if (typeof PointerEvent !== "function") return false;
const rect = item.getBoundingClientRect();
const eventInit = {
bubbles: true,
cancelable: true,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
pointerId: 1,
pointerType: "mouse",
isPrimary: true,
};
item.dispatchEvent(new PointerEvent("pointerdown", eventInit));
document.dispatchEvent(new PointerEvent("pointerup", eventInit));
return true;
}
function stepMailList(surface, event) {
const now = Date.now();
const lastStep = Number(surface.__nodedcMailWheelLastStep || 0);
if (now - lastStep < getStepCooldown(MAIL_BASE_COOLDOWN, surface)) return;
const items = Array.from(surface.children);
if (!items.length) return;
const currentIndex = Math.max(
0,
items.findIndex((item) => item.classList.contains("current")),
);
const direction = event.deltaY > 0 ? 1 : -1;
const nextIndex = Math.max(0, Math.min(items.length - 1, currentIndex + direction));
if (nextIndex === currentIndex) return;
if (dispatchPointerStep(items[nextIndex])) {
surface.__nodedcMailWheelLastStep = now;
}
}
function stepSlider(surface, event) {
const delta = getPrimaryWheelDelta(event);
if (Math.abs(delta) < 1) return;
const now = Date.now();
const lastStep = Number(surface.__nodedcSliderWheelLastStep || 0);
if (now - lastStep < getStepCooldown(SLIDER_BASE_COOLDOWN, surface)) return;
const slider = surface.__nodedcSlider;
if (slider && typeof slider.goToNext === "function" && typeof slider.goToPrev === "function") {
if (delta > 0) slider.goToNext();
else slider.goToPrev();
surface.__nodedcSliderWheelLastStep = now;
return;
}
if (Math.abs(event.deltaY) >= Math.abs(event.deltaX)) {
const syntheticEvent = createSyntheticHorizontalWheel(event);
if (syntheticEvent) {
surface.dispatchEvent(syntheticEvent);
surface.__nodedcSliderWheelLastStep = now;
}
}
}
function findSliderAtPoint(event) {
if (!Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return null;
return Array.from(document.querySelectorAll('[data-module="slider"]')).find((surface) => {
const rect = surface.getBoundingClientRect();
const left = Math.max(rect.left, 0);
const right = Math.min(rect.right, window.innerWidth);
return (
event.clientX >= left &&
event.clientX <= right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom
);
});
}
function bindGlobalSliderWheel() {
if (document[GLOBAL_SLIDER_BOUND_FLAG]) return;
document[GLOBAL_SLIDER_BOUND_FLAG] = true;
document.addEventListener(
"wheel",
(event) => {
if (shouldIgnoreWheel(event) || event[SYNTHETIC_WHEEL_FLAG]) return;
if (event.target?.closest?.('[data-module="mail-list"]')) return;
const slider = findSliderAtPoint(event);
if (!slider) return;
event.preventDefault();
event.stopImmediatePropagation();
stepSlider(slider, event);
},
{ capture: true, passive: false },
);
}
function bindSliderWheel(surface) {
if (!surface || surface[BOUND_FLAG]) return;
surface[BOUND_FLAG] = true;
surface.addEventListener(
"wheel",
(event) => {
if (shouldIgnoreWheel(event) || event[SYNTHETIC_WHEEL_FLAG]) return;
event.preventDefault();
event.stopImmediatePropagation();
stepSlider(surface, event);
},
{ capture: true, passive: false },
);
surface.addEventListener(
"wheel",
(event) => {
if (shouldIgnoreWheel(event)) return;
event.stopPropagation();
},
{ passive: false },
);
}
function bindMailListWheel(surface) {
if (!surface || surface[BOUND_FLAG]) return;
surface[BOUND_FLAG] = true;
surface.addEventListener(
"wheel",
(event) => {
if (shouldIgnoreWheel(event)) return;
event.preventDefault();
if (Math.abs(event.deltaY) >= Math.abs(event.deltaX)) {
stepMailList(surface, event);
}
},
{ capture: true, passive: false },
);
surface.addEventListener(
"wheel",
(event) => {
if (shouldIgnoreWheel(event)) return;
event.stopPropagation();
},
{ passive: false },
);
}
function bindInteractiveScroll() {
bindGlobalSliderWheel();
document.querySelectorAll('[data-module="slider"]').forEach(bindSliderWheel);
document.querySelectorAll('[data-module="mail-list"]').forEach(bindMailListWheel);
}
function mediaExtension(src) {
const cleanSrc = String(src || "").split(/[?#]/)[0];
const match = /\.([a-z0-9]+)$/i.exec(cleanSrc);
return match ? match[1].toLowerCase() : "";
}
function createMediaElement(src) {
const extension = mediaExtension(src);
if (IMAGE_EXTENSIONS.has(extension)) {
const image = document.createElement("img");
image.className = "video-el";
image.src = src;
image.alt = "";
image.loading = "lazy";
image.decoding = "async";
return image;
}
if (!VIDEO_EXTENSIONS.has(extension)) return null;
const video = document.createElement("video");
video.className = "video-el";
video.dataset.module = "video-handle";
video.src = src;
video.muted = true;
video.loop = true;
video.playsInline = true;
video.preload = "none";
video.setAttribute("playsinline", "true");
return video;
}
function hydrateMediaHandle(handle) {
if (!handle || handle[MEDIA_HANDLE_BOUND_FLAG]) return;
const src = handle.dataset.mediaSrc;
const media = createMediaElement(src);
if (!media) return;
handle[MEDIA_HANDLE_BOUND_FLAG] = true;
handle.textContent = "";
handle.append(media);
}
function hydrateMediaHandles() {
document.querySelectorAll("[data-media-handle]").forEach(hydrateMediaHandle);
}
function applyAdaptiveVideoAspect(video) {
const surface = video.closest("[data-adaptive-video]");
if (!surface || !video.videoWidth || !video.videoHeight) return;
const windowEl = video.closest("[data-adaptive-video-window]");
const sourceAspect = video.videoWidth / video.videoHeight;
const targets = windowEl ? [surface, windowEl] : [surface];
targets.forEach((target) => {
target.style.setProperty("--nodedc-video-source-aspect", sourceAspect.toFixed(6));
});
}
function bindAdaptiveVideo(video) {
if (!video || video[ADAPTIVE_VIDEO_BOUND_FLAG]) return;
video[ADAPTIVE_VIDEO_BOUND_FLAG] = true;
video.addEventListener("loadedmetadata", () => applyAdaptiveVideoAspect(video));
applyAdaptiveVideoAspect(video);
}
function bindAdaptiveVideos() {
document.querySelectorAll("[data-adaptive-video] video").forEach(bindAdaptiveVideo);
}
hydrateMediaHandles();
if (document.readyState === "loading") {
document.addEventListener(
"DOMContentLoaded",
() => {
hydrateMediaHandles();
bindInteractiveScroll();
bindAdaptiveVideos();
},
{ once: true },
);
} else {
hydrateMediaHandles();
bindInteractiveScroll();
bindAdaptiveVideos();
}
window.addEventListener("load", () => {
hydrateMediaHandles();
bindInteractiveScroll();
bindAdaptiveVideos();
window.setTimeout(() => {
hydrateMediaHandles();
bindInteractiveScroll();
bindAdaptiveVideos();
}, 500);
});
})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

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

View File

@ -1 +0,0 @@
<section class="s"><div class="c _w-60"><hgroup data-module="hg" class="ho"><h2 class="main-h h2"><span class="darker-40">{{text:content.heading.muted}}</span><span>{{text:content.heading.main}}</span></h2><div class="h-txt">{{html:content.introHtml}}</div></hgroup></div><div class="c full"><div class="w-dyn-list"><div data-module="slider" role="list" class="slider w-dyn-items"><div role="listitem" class="slide-w w-dyn-item"><div class="video-cut"><div class="video-screen"><video src="{{attr:content.sliderItems.0.videoSrc}}" playsinline="true" muted="" loop="" preload="none" data-module="video-handle" class="video-el"></video></div></div><hgroup class="slide-he-w"><h3 data-title="" class="slide-he">{{text:content.sliderItems.0.title}}</h3></hgroup></div><div role="listitem" class="slide-w w-dyn-item"><div class="video-cut"><div class="video-screen"><video src="{{attr:content.sliderItems.1.videoSrc}}" playsinline="true" muted="" loop="" preload="none" data-module="video-handle" class="video-el"></video></div></div><hgroup class="slide-he-w"><h3 data-title="" class="slide-he">{{text:content.sliderItems.1.title}}</h3></hgroup></div><div role="listitem" class="slide-w w-dyn-item"><div class="video-cut"><div class="video-screen"><video src="{{attr:content.sliderItems.2.videoSrc}}" playsinline="true" muted="" loop="" preload="none" data-module="video-handle" class="video-el"></video></div></div><hgroup class="slide-he-w"><h3 data-title="" class="slide-he">{{text:content.sliderItems.2.title}}</h3></hgroup></div><div role="listitem" class="slide-w w-dyn-item"><div class="video-cut"><div class="video-screen"><video src="{{attr:content.sliderItems.3.videoSrc}}" playsinline="true" muted="" loop="" preload="none" data-module="video-handle" class="video-el"></video></div></div><hgroup class="slide-he-w"><h3 data-title="" class="slide-he">{{text:content.sliderItems.3.title}}</h3></hgroup></div><div role="listitem" class="slide-w w-dyn-item"><div class="video-cut"><div class="video-screen"><video src="{{attr:content.sliderItems.4.videoSrc}}" playsinline="true" muted="" loop="" preload="none" data-module="video-handle" class="video-el"></video></div></div><hgroup class="slide-he-w"><h3 data-title="" class="slide-he">{{text:content.sliderItems.4.title}}</h3></hgroup></div><div role="listitem" class="slide-w w-dyn-item"><div class="video-cut"><div class="video-screen"><video src="{{attr:content.sliderItems.5.videoSrc}}" playsinline="true" muted="" loop="" preload="none" data-module="video-handle" class="video-el"></video></div></div><hgroup class="slide-he-w"><h3 data-title="" class="slide-he">{{text:content.sliderItems.5.title}}</h3></hgroup></div><div role="listitem" class="slide-w w-dyn-item"><div class="video-cut"><div class="video-screen"><video src="{{attr:content.sliderItems.6.videoSrc}}" playsinline="true" muted="" loop="" preload="none" data-module="video-handle" class="video-el"></video></div></div><hgroup class="slide-he-w"><h3 data-title="" class="slide-he">{{text:content.sliderItems.6.title}}</h3></hgroup></div><div role="listitem" class="slide-w w-dyn-item"><div class="video-cut"><div class="video-screen"><video src="{{attr:content.sliderItems.7.videoSrc}}" playsinline="true" muted="" loop="" preload="none" data-module="video-handle" class="video-el"></video></div></div><hgroup class="slide-he-w"><h3 data-title="" class="slide-he">{{text:content.sliderItems.7.title}}</h3></hgroup></div></div></div></div><div class="webgl-target-w"><div data-scale="1, 0.6" data-module="webgl-target" class="webgl-target"></div></div></section>

View File

@ -1 +0,0 @@
<section class="s"><div class="c _w-60"><hgroup data-module="hg" class="ho flip"><h2 class="main-h h1"><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="h-txt">{{html:content.introHtml}}</div></hgroup></div><div class="c full"><div class="w-dyn-list"><div data-module="slider" role="list" class="slider w-dyn-items"><div role="listitem" class="slide-w w-dyn-item"><div class="video-cut"><div class="video-screen"><video src="{{attr:content.sliderItems.0.videoSrc}}" playsinline="true" muted="" loop="" preload="none" data-module="video-handle" class="video-el"></video></div></div><hgroup class="slide-he-w"><h3 data-title="" class="slide-he">{{text:content.sliderItems.0.title}}</h3></hgroup></div><div role="listitem" class="slide-w w-dyn-item"><div class="video-cut"><div class="video-screen"><video src="{{attr:content.sliderItems.1.videoSrc}}" playsinline="true" muted="" loop="" preload="none" data-module="video-handle" class="video-el"></video></div></div><hgroup class="slide-he-w"><h3 data-title="" class="slide-he">{{text:content.sliderItems.1.title}}</h3></hgroup></div><div role="listitem" class="slide-w w-dyn-item"><div class="video-cut"><div class="video-screen"><video src="{{attr:content.sliderItems.2.videoSrc}}" playsinline="true" muted="" loop="" preload="none" data-module="video-handle" class="video-el"></video></div></div><hgroup class="slide-he-w"><h3 data-title="" class="slide-he">{{text:content.sliderItems.2.title}}</h3></hgroup></div><div role="listitem" class="slide-w w-dyn-item"><div class="video-cut"><div class="video-screen"><video src="{{attr:content.sliderItems.3.videoSrc}}" playsinline="true" muted="" loop="" preload="none" data-module="video-handle" class="video-el"></video></div></div><hgroup class="slide-he-w"><h3 data-title="" class="slide-he">{{text:content.sliderItems.3.title}}</h3></hgroup></div><div role="listitem" class="slide-w w-dyn-item"><div class="video-cut"><div class="video-screen"><video src="{{attr:content.sliderItems.4.videoSrc}}" playsinline="true" muted="" loop="" preload="none" data-module="video-handle" class="video-el"></video></div></div><hgroup class="slide-he-w"><h3 data-title="" class="slide-he">{{text:content.sliderItems.4.title}}</h3></hgroup></div><div role="listitem" class="slide-w w-dyn-item"><div class="video-cut"><div class="video-screen"><video src="{{attr:content.sliderItems.5.videoSrc}}" playsinline="true" muted="" loop="" preload="none" data-module="video-handle" class="video-el"></video></div></div><hgroup class="slide-he-w"><h3 data-title="" class="slide-he">{{text:content.sliderItems.5.title}}</h3></hgroup></div><div role="listitem" class="slide-w w-dyn-item"><div class="video-cut"><div class="video-screen"><video src="{{attr:content.sliderItems.6.videoSrc}}" playsinline="true" muted="" loop="" preload="none" data-module="video-handle" class="video-el"></video></div></div><hgroup class="slide-he-w"><h3 data-title="" class="slide-he">{{text:content.sliderItems.6.title}}</h3></hgroup></div><div role="listitem" class="slide-w w-dyn-item"><div class="video-cut"><div class="video-screen"><video src="{{attr:content.sliderItems.7.videoSrc}}" playsinline="true" muted="" loop="" preload="none" data-module="video-handle" class="video-el"></video></div></div><hgroup class="slide-he-w"><h3 data-title="" class="slide-he">{{text:content.sliderItems.7.title}}</h3></hgroup></div></div></div></div><div class="gap-v"><div class="c _w-60"><hgroup data-module="hg" class="ho"><h2 class="main-h h2"><span class="darker-40">{{text:content.oneClick.heading.muted}}</span><span class="darker-70">{{text:content.oneClick.heading.middle}}</span><span>{{text:content.oneClick.heading.main}}</span></h2><div class="txt-w"><div class="h-txt">{{html:content.oneClick.introHtml}}</div><div class="hg-par darker-40">{{text:content.oneClick.items.0.eyebrow}}</div><div class="h-txt">{{html:content.oneClick.items.0.bodyHtml}}</div><div class="hg-par darker-40">{{text:content.oneClick.items.1.eyebrow}}</div><div class="h-txt">{{html:content.oneClick.items.1.bodyHtml}}</div></div></hgroup></div><div class="c _w-60"><hgroup data-module="hg" class="ho flip"><h2 class="main-h h2"><span class="darker-40">{{text:content.mcp.heading.muted}}</span><span class="darker-70">{{text:content.mcp.heading.middle}}</span><span>{{text:content.mcp.heading.main}}</span></h2><div class="txt-w"><div class="h-txt">{{html:content.mcp.introHtml}}</div><div class="hg-par darker-40">{{text:content.mcp.items.0.eyebrow}}</div><div class="h-txt">{{html:content.mcp.items.0.bodyHtml}}</div></div></hgroup></div></div></section>

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
<section id="features" class="s h-screen py mobile-flip"><div class="app-w align-right"><div id="demo-video" data-module="app" class="app-item video"><div data-draggable-area="" class="app-head preview"><div class="app-head-trigs"><button data-close="" aria-label="закрыть" class="circle opaque"><div class="x-icon"><div class="line full x-icon rot"></div><div class="line full x-icon"></div></div></button><div class="circle opaque"></div></div><div class="app-name-w"><div class="app-name-txt preview">{{text:content.windowTitle}}</div></div></div><div class="video-size"><video src="{{attr:content.videoSrc}}" autoplay="true" muted="" loop="" playsinline="true" class="video"></video></div></div></div><div class="c"><hgroup data-module="hg"><h2 class="main-h h1"><span class="darker-40">{{text:content.heading.muted}}</span><span>{{text:content.heading.main}}</span></h2><div class="h-txt">{{html:content.introHtml}}</div><div class="txt-w"><div class="hg-par darker-40">{{text:content.features.0.eyebrow}}</div><div class="h-txt">{{html:content.features.0.bodyHtml}}</div><div class="hg-par darker-40">{{text:content.features.1.eyebrow}}</div><div class="h-txt">{{html:content.features.1.bodyHtml}}</div><div class="hg-par darker-40">{{text:content.features.2.eyebrow}}</div><div class="h-txt">{{html:content.features.2.bodyHtml}}</div><div class="hg-par darker-40">{{text:content.features.3.eyebrow}}</div><div class="h-txt">{{html:content.features.3.bodyHtml}}</div></div></hgroup></div></section>

View File

@ -0,0 +1,34 @@
<section class="s">
<div class="c _w-60">
<hgroup data-module="hg" class="ho">
<h2 class="main-h h2">
<span class="darker-40">{{text:content.heading.muted}}</span>
<span>{{text:content.heading.main}}</span>
</h2>
<div class="h-txt">{{html:content.introHtml}}</div>
</hgroup>
</div>
<div class="c full">
<div class="w-dyn-list">
<div data-module="slider" data-scroll-speed="{{attr:content.scroll.speed}}" role="list" class="slider w-dyn-items">
{{#each content.sliderItems}}
{{#if enabled}}
<div role="listitem" class="slide-w w-dyn-item">
<div class="video-cut">
<div class="video-screen">
<video src="{{attr:videoSrc}}" playsinline="true" muted="" loop="" preload="none" data-module="video-handle" class="video-el"></video>
</div>
</div>
<hgroup class="slide-he-w">
<h3 data-title="" class="slide-he">{{text:title}}</h3>
</hgroup>
</div>
{{/if}}
{{/each}}
</div>
</div>
</div>
<div class="webgl-target-w">
<div data-scale="1, 0.6" data-module="webgl-target" class="webgl-target"></div>
</div>
</section>

View File

@ -0,0 +1,32 @@
<section class="s">
<div class="c _w-60">
<hgroup data-module="hg" class="ho flip">
<h2 class="main-h h1">
<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="h-txt">{{html:content.introHtml}}</div>
</hgroup>
</div>
<div class="c full">
<div class="w-dyn-list">
<div data-module="slider" data-scroll-speed="{{attr:content.scroll.speed}}" role="list" class="slider w-dyn-items">
{{#each content.sliderItems}}
{{#if enabled}}
<div role="listitem" class="slide-w w-dyn-item">
<div class="video-cut">
<div class="video-screen">
<div data-media-handle="" data-media-src="{{attr:videoSrc}}" class="video-el"></div>
</div>
</div>
<hgroup class="slide-he-w">
<h3 data-title="" class="slide-he">{{text:title}}</h3>
</hgroup>
</div>
{{/if}}
{{/each}}
</div>
</div>
</div>
</section>

View File

@ -0,0 +1 @@
<section id="features" class="s h-screen py mobile-flip"><div class="app-w align-right"><div id="demo-video" data-module="app" class="app-item video" data-adaptive-video-window style="--nodedc-video-height: {{attr:content.videoHeightPx}}px;"><div data-draggable-area="" class="app-head preview"><div class="app-head-trigs"><button data-close="" aria-label="закрыть" class="circle opaque"><div class="x-icon"><div class="line full x-icon rot"></div><div class="line full x-icon"></div></div></button><div class="circle opaque"></div></div><div class="app-name-w"><div class="app-name-txt preview">{{text:content.windowTitle}}</div></div></div><div class="video-size" data-adaptive-video><video src="{{attr:content.videoSrc}}" autoplay="true" muted="" loop="" playsinline="true" class="video"></video></div></div></div><div class="c"><hgroup data-module="hg"><h2 class="main-h h1"><span class="darker-40">{{text:content.heading.muted}}</span><span>{{text:content.heading.main}}</span></h2><div class="h-txt">{{html:content.introHtml}}</div><div class="txt-w"><div class="hg-par darker-40">{{text:content.features.0.eyebrow}}</div><div class="h-txt">{{html:content.features.0.bodyHtml}}</div><div class="hg-par darker-40">{{text:content.features.1.eyebrow}}</div><div class="h-txt">{{html:content.features.1.bodyHtml}}</div><div class="hg-par darker-40">{{text:content.features.2.eyebrow}}</div><div class="h-txt">{{html:content.features.2.bodyHtml}}</div><div class="hg-par darker-40">{{text:content.features.3.eyebrow}}</div><div class="h-txt">{{html:content.features.3.bodyHtml}}</div></div></hgroup></div></section>

File diff suppressed because one or more lines are too long

View File

@ -48,12 +48,16 @@ function sendJson(res, status, payload) {
send(res, status, `${JSON.stringify(payload, null, 2)}\n`, "application/json; charset=utf-8"); send(res, status, `${JSON.stringify(payload, null, 2)}\n`, "application/json; charset=utf-8");
} }
async function readBody(req) { async function readBodyBuffer(req) {
const chunks = []; const chunks = [];
for await (const chunk of req) { for await (const chunk of req) {
chunks.push(chunk); chunks.push(chunk);
} }
return Buffer.concat(chunks).toString("utf8"); return Buffer.concat(chunks);
}
async function readBody(req) {
return (await readBodyBuffer(req)).toString("utf8");
} }
function runNodeScript(scriptName) { function runNodeScript(scriptName) {
@ -146,6 +150,29 @@ function buildStoredFileName(fileName, mimeType) {
return `${safeBase}-${Date.now().toString(36)}${extension}`; return `${safeBase}-${Date.now().toString(36)}${extension}`;
} }
async function saveUploadedAssetBuffer({ fileName, mimeType, bucket, fileBuffer }) {
if (typeof fileName !== "string" || !fileName || !Buffer.isBuffer(fileBuffer)) {
throw new Error("Некорректный файл загрузки");
}
const resolvedMimeType = mimeType || "application/octet-stream";
const resolvedBucket = sanitizeUploadBucket(bucket || "media");
const storedName = buildStoredFileName(fileName, resolvedMimeType);
const directory = join(uploadRoot, ...resolvedBucket.split("/"));
await mkdir(directory, { recursive: true });
await writeFile(join(directory, storedName), fileBuffer);
return {
ok: true,
url: `./assets/uploads/${resolvedBucket}/${storedName}`,
fileName: storedName,
originalFileName: fileName,
mimeType: resolvedMimeType,
bucket: resolvedBucket,
};
}
async function saveUploadedAsset(payload) { async function saveUploadedAsset(payload) {
if (!isUploadPayload(payload)) { if (!isUploadPayload(payload)) {
throw new Error("Некорректный payload загрузки"); throw new Error("Некорректный payload загрузки");
@ -158,21 +185,99 @@ async function saveUploadedAsset(payload) {
} }
const mimeType = payload.mimeType || match[1] || "application/octet-stream"; const mimeType = payload.mimeType || match[1] || "application/octet-stream";
const bucket = sanitizeUploadBucket(payload.bucket || "media");
const storedName = buildStoredFileName(payload.fileName, mimeType);
const fileBuffer = Buffer.from(match[2], "base64"); const fileBuffer = Buffer.from(match[2], "base64");
const directory = join(uploadRoot, ...bucket.split("/"));
await mkdir(directory, { recursive: true }); return saveUploadedAssetBuffer({
await writeFile(join(directory, storedName), fileBuffer); fileName: payload.fileName,
mimeType,
bucket: payload.bucket || "media",
fileBuffer,
});
}
function isMultipartRequest(req) {
return String(req.headers["content-type"] || "").toLowerCase().startsWith("multipart/form-data");
}
function multipartBoundary(req) {
const contentType = String(req.headers["content-type"] || "");
const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType);
return match?.[1] || match?.[2] || "";
}
function parseHeaderParams(value) {
const params = {};
const parts = String(value || "").split(";");
for (const part of parts.slice(1)) {
const [rawKey, ...rawValueParts] = part.trim().split("=");
if (!rawKey || !rawValueParts.length) continue;
const rawValue = rawValueParts.join("=").trim();
params[rawKey.toLowerCase()] = rawValue.replace(/^"|"$/g, "");
}
return params;
}
function parseMultipartUpload(req, bodyBuffer) {
const boundary = multipartBoundary(req);
if (!boundary) {
throw new Error("Не найден multipart boundary");
}
const fields = {};
let filePart = null;
const body = bodyBuffer.toString("latin1");
const delimiter = `--${boundary}`;
for (const rawPart of body.split(delimiter)) {
let part = rawPart;
if (!part || part === "--" || part === "--\r\n") continue;
if (part.startsWith("\r\n")) part = part.slice(2);
if (part.endsWith("--")) part = part.slice(0, -2);
if (part.endsWith("\r\n")) part = part.slice(0, -2);
const headerEnd = part.indexOf("\r\n\r\n");
if (headerEnd < 0) continue;
const headerLines = part.slice(0, headerEnd).split("\r\n");
const headers = Object.fromEntries(
headerLines
.map((line) => {
const separator = line.indexOf(":");
if (separator < 0) return null;
return [line.slice(0, separator).trim().toLowerCase(), line.slice(separator + 1).trim()];
})
.filter(Boolean),
);
const disposition = headers["content-disposition"];
const params = parseHeaderParams(disposition);
const fieldName = params.name;
const value = part.slice(headerEnd + 4);
if (!fieldName) continue;
if (Object.prototype.hasOwnProperty.call(params, "filename")) {
filePart = {
fileName: params.filename || fields.fileName || "asset.bin",
mimeType: headers["content-type"] || fields.mimeType || "application/octet-stream",
fileBuffer: Buffer.from(value, "latin1"),
};
continue;
}
fields[fieldName] = Buffer.from(value, "latin1").toString("utf8");
}
if (!filePart) {
throw new Error("Файл не найден в multipart payload");
}
return { return {
ok: true, fileName: fields.fileName || filePart.fileName,
url: `./assets/uploads/${bucket}/${storedName}`, mimeType: fields.mimeType || filePart.mimeType,
fileName: storedName, bucket: fields.bucket || "media",
originalFileName: payload.fileName, fileBuffer: filePart.fileBuffer,
mimeType,
bucket,
}; };
} }
@ -244,8 +349,9 @@ const server = createServer(async (req, res) => {
} }
if (req.method === "POST" && url.pathname === "/api/upload/asset") { if (req.method === "POST" && url.pathname === "/api/upload/asset") {
const body = await readBody(req); const result = isMultipartRequest(req)
const result = await saveUploadedAsset(JSON.parse(body)); ? await saveUploadedAssetBuffer(parseMultipartUpload(req, await readBodyBuffer(req)))
: await saveUploadedAsset(JSON.parse(await readBody(req)));
sendJson(res, 200, result); sendJson(res, 200, result);
return; return;
} }

View File

@ -63,8 +63,8 @@ const blocks = [
region: "beforeCViz", region: "beforeCViz",
id: "hero-waitlist", id: "hero-waitlist",
type: "staticHtml", type: "staticHtml",
template: "hero-waitlist.html", template: "theses-request-demo.html",
adminLabel: "Первый экран и заявка", adminLabel: "ТЕЗИСЫ + ЗАПРОС ДЕМО",
anchor: "hero", anchor: "hero",
html: html.slice(heroStart, featuresStart), html: html.slice(heroStart, featuresStart),
}, },
@ -72,8 +72,8 @@ const blocks = [
region: "beforeCViz", region: "beforeCViz",
id: "feature-video", id: "feature-video",
type: "staticHtml", type: "staticHtml",
template: "feature-video.html", template: "project-video-first.html",
adminLabel: "Демо и базовая логика", adminLabel: "PROJECT VIDEO - FIRST",
anchor: "features", anchor: "features",
html: html.slice(featuresStart, cVizStart), html: html.slice(featuresStart, cVizStart),
}, },
@ -81,8 +81,8 @@ const blocks = [
region: "cViz", region: "cViz",
id: "component-library", id: "component-library",
type: "staticHtml", type: "staticHtml",
template: "component-library.html", template: "project-video-detail.html",
adminLabel: "Компонентная библиотека и MCP", adminLabel: "PROJECT VIDEO - DETAIL",
anchor: null, anchor: null,
html: productSections[0], html: productSections[0],
}, },
@ -90,8 +90,8 @@ const blocks = [
region: "cViz", region: "cViz",
id: "ai-modes", id: "ai-modes",
type: "staticHtml", type: "staticHtml",
template: "ai-modes.html", template: "project-composition.html",
adminLabel: "Гибкие режимы ИИ", adminLabel: "СОСТАВ ПРОЕКТА",
anchor: null, anchor: null,
html: productSections[1], html: productSections[1],
}, },

View File

@ -11,6 +11,7 @@ const blockRoot = join(templateRoot, "blocks");
const REGION_TOKEN = { const REGION_TOKEN = {
beforeCViz: "{{NODEDC_BLOCKS_BEFORE_CVIZ}}", beforeCViz: "{{NODEDC_BLOCKS_BEFORE_CVIZ}}",
cViz: "{{NODEDC_BLOCKS_CVIZ}}", cViz: "{{NODEDC_BLOCKS_CVIZ}}",
sections: "{{NODEDC_BLOCKS_SECTIONS}}",
}; };
const page = JSON.parse(await readFile(pagePath, "utf8")); const page = JSON.parse(await readFile(pagePath, "utf8"));
@ -114,7 +115,12 @@ function renderEachBlocks(html, block) {
throw new Error(`Each token "${path}" in block "${block.id}" must point to an array`); throw new Error(`Each token "${path}" in block "${block.id}" must point to an array`);
} }
return items.map((item) => renderTemplate(innerHtml, item ?? {})).join(""); return items
.map((item) => {
const itemScope = item && typeof item === "object" && !Array.isArray(item) ? item : { value: item };
return renderTemplate(innerHtml, { ...block, ...itemScope });
})
.join("");
}); });
} }
@ -171,14 +177,48 @@ async function renderRegion(region) {
return blocks.join(""); return blocks.join("");
} }
async function renderSections() {
const renderCounts = new Map();
const output = [];
let cVizRun = [];
const flushCVizRun = () => {
if (!cVizRun.length) return;
output.push(`<div class="c-viz">${cVizRun.join("")}</div>`);
cVizRun = [];
};
for (const region of page.regions ?? []) {
for (const block of region.blocks ?? []) {
const html = await renderBlock(block, renderCounts);
if (!html) continue;
if (block.region === "cViz") {
cVizRun.push(html);
} else {
flushCVizRun();
output.push(html);
}
}
}
flushCVizRun();
return output.join("");
}
let output = renderTemplate(shell, { id: page.slug, ...page }); let output = renderTemplate(shell, { id: page.slug, ...page });
for (const region of page.regions ?? []) {
if (output.includes(REGION_TOKEN.sections)) {
output = output.replace(REGION_TOKEN.sections, await renderSections());
} else {
for (const region of page.regions ?? []) {
const token = REGION_TOKEN[region.id]; const token = REGION_TOKEN[region.id];
if (!token) { if (!token) {
throw new Error(`Unknown region: ${region.id}`); throw new Error(`Unknown region: ${region.id}`);
} }
output = output.replace(token, await renderRegion(region)); output = output.replace(token, await renderRegion(region));
}
} }
for (const token of Object.values(REGION_TOKEN)) { for (const token of Object.values(REGION_TOKEN)) {