Compare commits
2 Commits
25e5afedf2
...
82cdab07f2
| Author | SHA1 | Date |
|---|---|---|
|
|
82cdab07f2 | |
|
|
fb1531f7c5 |
178
admin/admin.css
|
|
@ -923,8 +923,103 @@ body[data-file-picker-active="true"] .editor {
|
|||
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,
|
||||
.group-head-toggle input {
|
||||
.group-head-toggle input,
|
||||
.collection-visibility-toggle input {
|
||||
width: 2.42rem;
|
||||
height: 1.32rem;
|
||||
margin: 0;
|
||||
|
|
@ -935,7 +1030,8 @@ body[data-file-picker-active="true"] .editor {
|
|||
}
|
||||
|
||||
.boolean-field input::before,
|
||||
.group-head-toggle input::before {
|
||||
.group-head-toggle input::before,
|
||||
.collection-visibility-toggle input::before {
|
||||
display: block;
|
||||
width: 1rem;
|
||||
aspect-ratio: 1;
|
||||
|
|
@ -946,12 +1042,14 @@ body[data-file-picker-active="true"] .editor {
|
|||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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;
|
||||
transform: translateX(1.1rem);
|
||||
}
|
||||
|
|
@ -1032,6 +1130,10 @@ body[data-file-picker-active="true"] .editor {
|
|||
opacity: 0.16;
|
||||
}
|
||||
|
||||
.collection-item-card.is-hidden {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.collection-summary {
|
||||
min-height: 3rem;
|
||||
cursor: pointer;
|
||||
|
|
@ -1044,6 +1146,8 @@ body[data-file-picker-active="true"] .editor {
|
|||
}
|
||||
|
||||
.collection-summary-title {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
|
|
@ -1058,6 +1162,45 @@ body[data-file-picker-active="true"] .editor {
|
|||
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-icon-btn {
|
||||
display: grid;
|
||||
|
|
@ -1523,6 +1666,33 @@ textarea {
|
|||
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) {
|
||||
body {
|
||||
overflow: auto;
|
||||
|
|
|
|||
290
admin/admin.js
|
|
@ -9,8 +9,12 @@ const state = {
|
|||
filePickerActive: false,
|
||||
mediaUploadActive: false,
|
||||
filePickerResetTimer: null,
|
||||
deleteConfirmResolve: null,
|
||||
};
|
||||
|
||||
const SECTIONS_REGION_ID = "sections";
|
||||
const SECTIONS_REGION_LABEL = "Секции";
|
||||
|
||||
const el = {
|
||||
regions: document.querySelector("#regions"),
|
||||
addBlock: document.querySelector("#add-block"),
|
||||
|
|
@ -40,6 +44,11 @@ const el = {
|
|||
templateList: document.querySelector("#template-list"),
|
||||
templateModalClose: document.querySelector("#template-modal-close"),
|
||||
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 = {
|
||||
|
|
@ -202,9 +211,12 @@ const GROUP_COPY = {
|
|||
"Видео-окно": "Окно демо-видео и подключенный медиа-файл.",
|
||||
Пункты: "Текстовые пункты внутри секции.",
|
||||
Слайдер: "Видео-превью и подписи карточек внутри горизонтального слайдера.",
|
||||
Скролл: "Скорость локальной прокрутки внутри блока: меньше значение — спокойнее шаг, больше — быстрее.",
|
||||
"Один клик": "Подблок про быстрое добавление и настройку компонентов.",
|
||||
MCP: "Подблок про MCP-сервер и контекст Webflow.",
|
||||
FAQ: "Список вопросов и тексты ответов в mail-интерфейсе.",
|
||||
"Окно FAQ": "Название окна FAQ и служебные подписи mail-интерфейса.",
|
||||
Отправитель: "Профиль отправителя в FAQ: аватар, имя и подписи письма.",
|
||||
FAQ: "Вопросы и ответы mail-интерфейса. Каждый пункт можно добавить, удалить и переставить.",
|
||||
CTA: "Финальный призыв к действию: заголовок, пояснение, кнопка и ссылка.",
|
||||
Тарифы: "Верхняя часть тарифной таблицы: заголовки, планы, цены, кнопки и примечания.",
|
||||
"Строки тарифов": "Повторяемые строки тарифной таблицы и значения по каждому плану.",
|
||||
|
|
@ -324,6 +336,7 @@ function inferFieldGroup(path) {
|
|||
if (path === "content.legalHtml") return "Юридический блок";
|
||||
if (path === "content.windowTitle" || path === "content.videoSrc") return "Видео-окно";
|
||||
if (path.startsWith("content.features.")) return "Пункты";
|
||||
if (path.startsWith("content.scroll.")) return "Скролл";
|
||||
if (path.startsWith("content.sliderItems.")) return "Слайдер";
|
||||
if (path.startsWith("content.oneClick.")) return "Один клик";
|
||||
if (path.startsWith("content.mcp.")) return "MCP";
|
||||
|
|
@ -419,6 +432,32 @@ function ensureStaticElements() {
|
|||
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() {
|
||||
return state.selected?.kind === "static";
|
||||
}
|
||||
|
|
@ -555,8 +594,7 @@ function readFileAsDataUrl(file) {
|
|||
}
|
||||
|
||||
function labelForRegion(region) {
|
||||
if (region.id === "beforeCViz") return "Главная";
|
||||
if (region.id === "cViz") return "Секции";
|
||||
if (region.id === SECTIONS_REGION_ID || region.id === "beforeCViz" || region.id === "cViz") return SECTIONS_REGION_LABEL;
|
||||
return region.label || region.id;
|
||||
}
|
||||
|
||||
|
|
@ -851,14 +889,13 @@ function renderRegions() {
|
|||
}
|
||||
|
||||
function blockTemplates() {
|
||||
return state.templates.map((template) => {
|
||||
const regionIndex = state.page.regions.findIndex((region) => region.id === template.regionId);
|
||||
const region = state.page.regions[regionIndex] || state.page.regions[0];
|
||||
const regionIndex = sectionsRegionIndex();
|
||||
|
||||
return state.templates.map((template) => {
|
||||
return {
|
||||
...template,
|
||||
regionIndex: regionIndex >= 0 ? regionIndex : 0,
|
||||
regionLabel: template.regionLabel || labelForRegion(region),
|
||||
regionIndex,
|
||||
regionLabel: SECTIONS_REGION_LABEL,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -903,6 +940,29 @@ function closeTemplateModal() {
|
|||
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) {
|
||||
const template = blockTemplates().find((candidate) => candidate.key === templateKey);
|
||||
if (!template) return;
|
||||
|
|
@ -1138,18 +1198,20 @@ async function uploadMediaFile({ block, editableField, hiddenInput, urlInput, fi
|
|||
fileName.textContent = "Сохраняем в assets...";
|
||||
|
||||
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", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
fileName: file.name,
|
||||
mimeType: file.type || "application/octet-stream",
|
||||
bucket: uploadBucketForField(block, editableField),
|
||||
dataUrl,
|
||||
}),
|
||||
body: formData,
|
||||
});
|
||||
const payload = await response.json();
|
||||
const payload = await response.json().catch(() => ({
|
||||
ok: false,
|
||||
error: `Сервер вернул ${response.status}`,
|
||||
}));
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
throw new Error(payload.error || "Файл не удалось сохранить");
|
||||
|
|
@ -1393,8 +1455,19 @@ function prepareCollectionItemForInsert(items, item) {
|
|||
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) {
|
||||
setByPath(block, path, input.type === "checkbox" ? input.checked : input.value);
|
||||
setByPath(block, path, fieldInputValue(input));
|
||||
el.json.value = JSON.stringify(block, null, 2);
|
||||
markDirty();
|
||||
}
|
||||
|
|
@ -1431,6 +1504,108 @@ function renderBooleanField({ block, editableField, grid }) {
|
|||
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 }) {
|
||||
const label = document.createElement("label");
|
||||
const input = document.createElement("input");
|
||||
|
|
@ -1458,6 +1633,16 @@ function renderCollectionScalarField({ block, field, grid }) {
|
|||
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 labelRow = document.createElement("span");
|
||||
const labelText = document.createElement("span");
|
||||
|
|
@ -1592,8 +1777,14 @@ function renderCollectionField({ block, editableField, grid }) {
|
|||
const dragHandle = document.createElement("button");
|
||||
const body = 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.classList.toggle("is-hidden", item?.enabled === false);
|
||||
if (index === 0 && items.length <= 8) details.open = true;
|
||||
summary.className = "collection-summary";
|
||||
summaryTitle.className = "collection-summary-title";
|
||||
|
|
@ -1611,6 +1802,29 @@ function renderCollectionField({ block, editableField, grid }) {
|
|||
dragHandle.addEventListener("pointerdown", (event) => startCollectionPointerDrag(event, details, items, index, rerender));
|
||||
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;
|
||||
|
|
@ -1628,7 +1842,9 @@ function renderCollectionField({ block, editableField, grid }) {
|
|||
items.splice(index + 1, 0, prepareCollectionItemForInsert(items, item));
|
||||
rerender("Элемент коллекции продублирован локально.");
|
||||
}],
|
||||
["⌫", "Удалить", () => {
|
||||
["⌫", "Удалить", async () => {
|
||||
const confirmed = await requestDeleteConfirmation();
|
||||
if (!confirmed) return;
|
||||
items.splice(index, 1);
|
||||
rerender("Элемент коллекции удалён локально.");
|
||||
}],
|
||||
|
|
@ -1642,7 +1858,7 @@ function renderCollectionField({ block, editableField, grid }) {
|
|||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handler();
|
||||
Promise.resolve(handler()).catch((error) => setStatus(`Ошибка действия коллекции: ${error.message}`));
|
||||
});
|
||||
controls.append(button);
|
||||
});
|
||||
|
|
@ -1723,6 +1939,16 @@ function renderContentFields(block) {
|
|||
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 labelRow = document.createElement("span");
|
||||
const labelText = document.createElement("span");
|
||||
|
|
@ -1770,7 +1996,7 @@ function syncContentFields() {
|
|||
if (!block) return;
|
||||
|
||||
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.templates = await templatesResponse.json();
|
||||
ensureStaticElements();
|
||||
ensureSectionsRegion();
|
||||
state.selected = { kind: "static" };
|
||||
renderAll();
|
||||
setDirty(false);
|
||||
|
|
@ -1900,11 +2127,14 @@ function pasteAfterSelected() {
|
|||
markDirty(`Блок «${pasted.adminLabel || pasted.id}» вставлен локально.`);
|
||||
}
|
||||
|
||||
function deleteSelected() {
|
||||
async function deleteSelected() {
|
||||
if (isStaticSelection()) return;
|
||||
const region = activeRegion();
|
||||
if (!region || state.selected.blockIndex == null) return;
|
||||
|
||||
const confirmed = await requestDeleteConfirmation();
|
||||
if (!confirmed) return;
|
||||
|
||||
region.blocks.splice(state.selected.blockIndex, 1);
|
||||
state.selected.blockIndex = Math.min(state.selected.blockIndex, region.blocks.length - 1);
|
||||
if (state.selected.blockIndex < 0) state.selected = { kind: "static" };
|
||||
|
|
@ -1931,8 +2161,20 @@ el.templateModalCancel.addEventListener("click", closeTemplateModal);
|
|||
el.templateModal.addEventListener("click", (event) => {
|
||||
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) => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
|
@ -1944,7 +2186,7 @@ el.moveDown.addEventListener("click", () => moveSelected(1));
|
|||
el.duplicate.addEventListener("click", duplicateSelected);
|
||||
el.copy.addEventListener("click", copySelected);
|
||||
el.pasteAfter.addEventListener("click", pasteAfterSelected);
|
||||
el.deleteBlock.addEventListener("click", deleteSelected);
|
||||
el.deleteBlock.addEventListener("click", () => deleteSelected().catch((error) => setStatus(`Ошибка удаления: ${error.message}`)));
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
recoverEditorSurface();
|
||||
|
|
|
|||
|
|
@ -190,6 +190,21 @@
|
|||
</footer>
|
||||
</article>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
.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}}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
.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}}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
})();
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 544 KiB |
311
index.html
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -48,12 +48,16 @@ function sendJson(res, status, payload) {
|
|||
send(res, status, `${JSON.stringify(payload, null, 2)}\n`, "application/json; charset=utf-8");
|
||||
}
|
||||
|
||||
async function readBody(req) {
|
||||
async function readBodyBuffer(req) {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) {
|
||||
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) {
|
||||
|
|
@ -146,6 +150,29 @@ function buildStoredFileName(fileName, mimeType) {
|
|||
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) {
|
||||
if (!isUploadPayload(payload)) {
|
||||
throw new Error("Некорректный payload загрузки");
|
||||
|
|
@ -158,21 +185,99 @@ async function saveUploadedAsset(payload) {
|
|||
}
|
||||
|
||||
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 directory = join(uploadRoot, ...bucket.split("/"));
|
||||
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(join(directory, storedName), fileBuffer);
|
||||
return saveUploadedAssetBuffer({
|
||||
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 {
|
||||
ok: true,
|
||||
url: `./assets/uploads/${bucket}/${storedName}`,
|
||||
fileName: storedName,
|
||||
originalFileName: payload.fileName,
|
||||
mimeType,
|
||||
bucket,
|
||||
fileName: fields.fileName || filePart.fileName,
|
||||
mimeType: fields.mimeType || filePart.mimeType,
|
||||
bucket: fields.bucket || "media",
|
||||
fileBuffer: filePart.fileBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -244,8 +349,9 @@ const server = createServer(async (req, res) => {
|
|||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/upload/asset") {
|
||||
const body = await readBody(req);
|
||||
const result = await saveUploadedAsset(JSON.parse(body));
|
||||
const result = isMultipartRequest(req)
|
||||
? await saveUploadedAssetBuffer(parseMultipartUpload(req, await readBodyBuffer(req)))
|
||||
: await saveUploadedAsset(JSON.parse(await readBody(req)));
|
||||
sendJson(res, 200, result);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,8 +63,8 @@ const blocks = [
|
|||
region: "beforeCViz",
|
||||
id: "hero-waitlist",
|
||||
type: "staticHtml",
|
||||
template: "hero-waitlist.html",
|
||||
adminLabel: "Первый экран и заявка",
|
||||
template: "theses-request-demo.html",
|
||||
adminLabel: "ТЕЗИСЫ + ЗАПРОС ДЕМО",
|
||||
anchor: "hero",
|
||||
html: html.slice(heroStart, featuresStart),
|
||||
},
|
||||
|
|
@ -72,8 +72,8 @@ const blocks = [
|
|||
region: "beforeCViz",
|
||||
id: "feature-video",
|
||||
type: "staticHtml",
|
||||
template: "feature-video.html",
|
||||
adminLabel: "Демо и базовая логика",
|
||||
template: "project-video-first.html",
|
||||
adminLabel: "PROJECT VIDEO - FIRST",
|
||||
anchor: "features",
|
||||
html: html.slice(featuresStart, cVizStart),
|
||||
},
|
||||
|
|
@ -81,8 +81,8 @@ const blocks = [
|
|||
region: "cViz",
|
||||
id: "component-library",
|
||||
type: "staticHtml",
|
||||
template: "component-library.html",
|
||||
adminLabel: "Компонентная библиотека и MCP",
|
||||
template: "project-video-detail.html",
|
||||
adminLabel: "PROJECT VIDEO - DETAIL",
|
||||
anchor: null,
|
||||
html: productSections[0],
|
||||
},
|
||||
|
|
@ -90,8 +90,8 @@ const blocks = [
|
|||
region: "cViz",
|
||||
id: "ai-modes",
|
||||
type: "staticHtml",
|
||||
template: "ai-modes.html",
|
||||
adminLabel: "Гибкие режимы ИИ",
|
||||
template: "project-composition.html",
|
||||
adminLabel: "СОСТАВ ПРОЕКТА",
|
||||
anchor: null,
|
||||
html: productSections[1],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const blockRoot = join(templateRoot, "blocks");
|
|||
const REGION_TOKEN = {
|
||||
beforeCViz: "{{NODEDC_BLOCKS_BEFORE_CVIZ}}",
|
||||
cViz: "{{NODEDC_BLOCKS_CVIZ}}",
|
||||
sections: "{{NODEDC_BLOCKS_SECTIONS}}",
|
||||
};
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
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("");
|
||||
}
|
||||
|
||||
let output = renderTemplate(shell, { id: page.slug, ...page });
|
||||
for (const region of page.regions ?? []) {
|
||||
const token = REGION_TOKEN[region.id];
|
||||
if (!token) {
|
||||
throw new Error(`Unknown region: ${region.id}`);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output = output.replace(token, await renderRegion(region));
|
||||
flushCVizRun();
|
||||
return output.join("");
|
||||
}
|
||||
|
||||
let output = renderTemplate(shell, { id: page.slug, ...page });
|
||||
|
||||
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];
|
||||
if (!token) {
|
||||
throw new Error(`Unknown region: ${region.id}`);
|
||||
}
|
||||
|
||||
output = output.replace(token, await renderRegion(region));
|
||||
}
|
||||
}
|
||||
|
||||
for (const token of Object.values(REGION_TOKEN)) {
|
||||
|
|
|
|||