Update promo admin media blocks

This commit is contained in:
dcconstructions 2026-06-26 22:04:59 +03:00
parent fb1531f7c5
commit 82cdab07f2
43 changed files with 1901 additions and 1095 deletions

View File

@ -989,8 +989,37 @@ body[data-file-picker-active="true"] .editor {
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;
@ -1001,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;
@ -1012,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);
}
@ -1098,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;
@ -1110,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;
@ -1124,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;
@ -1589,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;

View File

@ -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 = {
@ -423,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";
}
@ -559,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;
}
@ -855,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,
};
});
}
@ -907,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;
@ -1142,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 || "Файл не удалось сохранить");
@ -1500,6 +1558,54 @@ function renderRangeField({ block, editableField, grid }) {
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");
@ -1532,6 +1638,11 @@ function renderCollectionScalarField({ block, 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");
@ -1666,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";
@ -1685,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;
@ -1702,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("Элемент коллекции удалён локально.");
}],
@ -1716,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);
});
@ -1802,6 +1944,11 @@ function renderContentFields(block) {
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");
@ -1886,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);
@ -1979,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" };
@ -2010,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();
}
});
@ -2023,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();

View File

@ -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>

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)}
.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}
.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

@ -8,6 +8,10 @@
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;
@ -227,14 +231,106 @@
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", bindInteractiveScroll, { once: true });
document.addEventListener(
"DOMContentLoaded",
() => {
hydrateMediaHandles();
bindInteractiveScroll();
bindAdaptiveVideos();
},
{ once: true },
);
} else {
hydrateMediaHandles();
bindInteractiveScroll();
bindAdaptiveVideos();
}
window.addEventListener("load", () => {
hydrateMediaHandles();
bindInteractiveScroll();
window.setTimeout(bindInteractiveScroll, 500);
bindAdaptiveVideos();
window.setTimeout(() => {
hydrateMediaHandles();
bindInteractiveScroll();
bindAdaptiveVideos();
}, 500);
});
})();

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

View File

@ -1,16 +1,16 @@
[
{
"key": "beforeCViz:heroWaitlist:hero-waitlist.html",
"key": "beforeCViz:heroWaitlist:theses-request-demo.html",
"regionId": "beforeCViz",
"regionLabel": "Главная",
"title": "Первый экран и заявка",
"description": "hero-waitlist.html",
"title": "ТЕЗИСЫ + ЗАПРОС ДЕМО",
"description": "theses-request-demo.html",
"block": {
"region": "beforeCViz",
"id": "hero-waitlist",
"type": "heroWaitlist",
"template": "hero-waitlist.html",
"adminLabel": "Первый экран и заявка",
"template": "theses-request-demo.html",
"adminLabel": "ТЕЗИСЫ + ЗАПРОС ДЕМО",
"anchor": "hero",
"content": {
"heading": {
@ -299,17 +299,145 @@
}
},
{
"key": "beforeCViz:featureVideo:feature-video.html",
"key": "cViz:aiModes:project-composition.html",
"regionId": "cViz",
"regionLabel": "Секции",
"title": "СОСТАВ ПРОЕКТА",
"description": "project-composition.html",
"block": {
"region": "cViz",
"id": "ai-modes",
"type": "aiModes",
"template": "project-composition.html",
"adminLabel": "СОСТАВ ПРОЕКТА",
"anchor": null,
"content": {
"heading": {
"muted": "Гибкие",
"main": "режимы ИИ."
},
"introHtml": "Разные <span class=\"darker-70\">режимы</span> и <span class=\"darker-70\">модели</span> для разных задач.",
"sliderItems": [
{
"enabled": true,
"videoSrc": "./assets/media/various-modes.mp4",
"title": "Переключение режимов ИИ"
},
{
"enabled": true,
"videoSrc": "./assets/media/project-flow.mp4",
"title": "Создание нового проекта"
},
{
"enabled": true,
"videoSrc": "./assets/media/ai-dock-mode.mp4",
"title": "ИИ-док"
},
{
"enabled": true,
"videoSrc": "./assets/media/account.mp4",
"title": "Аккаунт"
},
{
"enabled": true,
"videoSrc": "./assets/media/code-edits.mp4",
"title": "Редактирование кода с ИИ"
},
{
"enabled": true,
"videoSrc": "./assets/media/dev-server.mp4",
"title": "Dev-сервер с ИИ"
},
{
"enabled": true,
"videoSrc": "./assets/media/webflow-mcp.mp4",
"title": "Webflow MCP"
},
{
"enabled": true,
"videoSrc": "./assets/media/publish-flow.mp4",
"title": "Публикация"
}
],
"scroll": {
"speed": 0.55
}
},
"editableFields": [
{
"path": "content.scroll.speed",
"label": "Скорость скролла",
"kind": "range",
"renderAs": "number",
"group": "Скролл",
"min": 0.2,
"max": 2,
"step": 0.05,
"suffix": "x"
},
{
"path": "content.heading.muted",
"label": "Заголовок: приглушённая строка",
"kind": "text",
"renderAs": "text",
"group": "Заголовок"
},
{
"path": "content.heading.main",
"label": "Заголовок: основная строка",
"kind": "text",
"renderAs": "text",
"group": "Заголовок"
},
{
"path": "content.introHtml",
"label": "Вводный текст",
"kind": "html",
"renderAs": "html",
"group": "Заголовок"
},
{
"path": "content.sliderItems",
"label": "Слайды",
"kind": "collection",
"renderAs": "collection",
"group": "Слайдер",
"itemTemplate": {
"enabled": true,
"videoSrc": "./assets/media/publish-flow.mp4",
"title": "Новый слайд"
},
"itemFields": [
{
"path": "videoSrc",
"label": "Видео",
"kind": "media",
"renderAs": "attr"
},
{
"path": "title",
"label": "Подпись",
"kind": "text",
"renderAs": "text"
}
]
}
],
"enabled": true
}
},
{
"key": "beforeCViz:featureVideo:project-video-first.html",
"regionId": "beforeCViz",
"regionLabel": "Главная",
"title": "Демо и базовая логика",
"description": "feature-video.html",
"title": "PROJECT VIDEO - FIRST",
"description": "project-video-first.html",
"block": {
"region": "beforeCViz",
"id": "feature-video",
"type": "featureVideo",
"template": "feature-video.html",
"adminLabel": "Демо и базовая логика",
"template": "project-video-first.html",
"adminLabel": "PROJECT VIDEO - FIRST",
"anchor": "features",
"content": {
"windowTitle": "демо-видео.mp4",
@ -336,7 +464,8 @@
"eyebrow": "Деплой в один клик",
"bodyHtml": "<span class=\"darker-70\">Нажмите и публикуйте</span> кастомный код, написанный ИИ, чтобы он стал доступен всем посетителям сайта."
}
]
],
"videoHeightPx": 620
},
"editableFields": [
{
@ -353,6 +482,18 @@
"renderAs": "attr",
"group": "Видео-окно"
},
{
"path": "content.videoHeightPx",
"label": "Высота видео",
"kind": "number",
"renderAs": "number",
"group": "Видео-окно",
"min": 240,
"max": 900,
"step": 1,
"suffix": "px",
"defaultValue": 620
},
{
"path": "content.heading.muted",
"label": "Заголовок: приглушённая строка",
@ -435,17 +576,17 @@
}
},
{
"key": "cViz:componentLibrary:component-library.html",
"key": "cViz:componentLibrary:project-video-detail.html",
"regionId": "cViz",
"regionLabel": "Секции",
"title": "Компонентная библиотека и MCP",
"description": "component-library.html",
"title": "PROJECT VIDEO - DETAIL",
"description": "project-video-detail.html",
"block": {
"region": "cViz",
"id": "component-library",
"type": "componentLibrary",
"template": "component-library.html",
"adminLabel": "Компонентная библиотека и MCP",
"template": "project-video-detail.html",
"adminLabel": "PROJECT VIDEO - DETAIL",
"anchor": null,
"content": {
"heading": {
@ -457,69 +598,45 @@
"sliderItems": [
{
"videoSrc": "./assets/media/various-modes.mp4",
"title": "Переключение режимов ИИ"
"title": "Переключение режимов ИИ",
"enabled": true
},
{
"videoSrc": "./assets/media/project-flow.mp4",
"title": "Создание нового проекта"
"title": "Создание нового проекта",
"enabled": true
},
{
"videoSrc": "./assets/media/ai-dock-mode.mp4",
"title": "ИИ-док"
"title": "ИИ-док",
"enabled": true
},
{
"videoSrc": "./assets/media/account.mp4",
"title": "Аккаунт"
"title": "Аккаунт",
"enabled": true
},
{
"videoSrc": "./assets/media/code-edits.mp4",
"title": "Редактирование кода с ИИ"
"title": "Редактирование кода с ИИ",
"enabled": true
},
{
"videoSrc": "./assets/media/dev-server.mp4",
"title": "Dev-сервер с ИИ"
"title": "Dev-сервер с ИИ",
"enabled": true
},
{
"videoSrc": "./assets/media/webflow-mcp.mp4",
"title": "Webflow MCP"
"title": "Webflow MCP",
"enabled": true
},
{
"videoSrc": "./assets/media/publish-flow.mp4",
"title": "Публикация"
"title": "Публикация",
"enabled": true
}
],
"oneClick": {
"heading": {
"muted": "Один",
"middle": "клик",
"main": "добавления."
},
"introHtml": "С помощью <span class=\"darker-70\">расширения NODE.DC для Webflow.</span> ",
"items": [
{
"eyebrow": "Понимание компонентов",
"bodyHtml": "Используйте <span class=\"darker-70\">несколько агентов параллельно</span> чтобы добавлять кастомный код, анимации и функциональность."
},
{
"eyebrow": "Быстрый старт",
"bodyHtml": "Используйте быстрые <span class=\"darker-70\">Bun</span> сборки и автообновление для вашего <span class=\"darker-70\">кастомного кода.</span>"
}
]
},
"mcp": {
"heading": {
"muted": "Получите",
"middle": "более сильный",
"main": "MCP."
},
"introHtml": "Полностью переработанный и более быстрый MCP-сервер. ",
"items": [
{
"eyebrow": "Понимание Webflow",
"bodyHtml": "Используйте <span class=\"darker-70\">несколько агентов параллельно</span> чтобы добавлять кастомный код, анимации и функциональность."
}
]
},
"scroll": {
"speed": 0.55
}
@ -565,420 +682,30 @@
"group": "Заголовок"
},
{
"path": "content.sliderItems.0.videoSrc",
"label": "Слайд 1: видео",
"kind": "media",
"renderAs": "attr",
"group": "Слайдер"
},
{
"path": "content.sliderItems.0.title",
"label": "Слайд 1: подпись",
"kind": "text",
"renderAs": "text",
"group": "Слайдер"
},
{
"path": "content.sliderItems.1.videoSrc",
"label": "Слайд 2: видео",
"kind": "media",
"renderAs": "attr",
"group": "Слайдер"
},
{
"path": "content.sliderItems.1.title",
"label": "Слайд 2: подпись",
"kind": "text",
"renderAs": "text",
"group": "Слайдер"
},
{
"path": "content.sliderItems.2.videoSrc",
"label": "Слайд 3: видео",
"kind": "media",
"renderAs": "attr",
"group": "Слайдер"
},
{
"path": "content.sliderItems.2.title",
"label": "Слайд 3: подпись",
"kind": "text",
"renderAs": "text",
"group": "Слайдер"
},
{
"path": "content.sliderItems.3.videoSrc",
"label": "Слайд 4: видео",
"kind": "media",
"renderAs": "attr",
"group": "Слайдер"
},
{
"path": "content.sliderItems.3.title",
"label": "Слайд 4: подпись",
"kind": "text",
"renderAs": "text",
"group": "Слайдер"
},
{
"path": "content.sliderItems.4.videoSrc",
"label": "Слайд 5: видео",
"kind": "media",
"renderAs": "attr",
"group": "Слайдер"
},
{
"path": "content.sliderItems.4.title",
"label": "Слайд 5: подпись",
"kind": "text",
"renderAs": "text",
"group": "Слайдер"
},
{
"path": "content.sliderItems.5.videoSrc",
"label": "Слайд 6: видео",
"kind": "media",
"renderAs": "attr",
"group": "Слайдер"
},
{
"path": "content.sliderItems.5.title",
"label": "Слайд 6: подпись",
"kind": "text",
"renderAs": "text",
"group": "Слайдер"
},
{
"path": "content.sliderItems.6.videoSrc",
"label": "Слайд 7: видео",
"kind": "media",
"renderAs": "attr",
"group": "Слайдер"
},
{
"path": "content.sliderItems.6.title",
"label": "Слайд 7: подпись",
"kind": "text",
"renderAs": "text",
"group": "Слайдер"
},
{
"path": "content.sliderItems.7.videoSrc",
"label": "Слайд 8: видео",
"kind": "media",
"renderAs": "attr",
"group": "Слайдер"
},
{
"path": "content.sliderItems.7.title",
"label": "Слайд 8: подпись",
"kind": "text",
"renderAs": "text",
"group": "Слайдер"
},
{
"path": "content.oneClick.heading.muted",
"label": "Один клик: приглушённая строка",
"kind": "text",
"renderAs": "text",
"group": "Один клик"
},
{
"path": "content.oneClick.heading.middle",
"label": "Один клик: средняя строка",
"kind": "text",
"renderAs": "text",
"group": "Один клик"
},
{
"path": "content.oneClick.heading.main",
"label": "Один клик: основная строка",
"kind": "text",
"renderAs": "text",
"group": "Один клик"
},
{
"path": "content.oneClick.introHtml",
"label": "Один клик: вводный текст",
"kind": "html",
"renderAs": "html",
"group": "Один клик"
},
{
"path": "content.oneClick.items.0.eyebrow",
"label": "Один клик: пункт 1, подпись",
"kind": "text",
"renderAs": "text",
"group": "Один клик"
},
{
"path": "content.oneClick.items.0.bodyHtml",
"label": "Один клик: пункт 1, текст",
"kind": "html",
"renderAs": "html",
"group": "Один клик"
},
{
"path": "content.oneClick.items.1.eyebrow",
"label": "Один клик: пункт 2, подпись",
"kind": "text",
"renderAs": "text",
"group": "Один клик"
},
{
"path": "content.oneClick.items.1.bodyHtml",
"label": "Один клик: пункт 2, текст",
"kind": "html",
"renderAs": "html",
"group": "Один клик"
},
{
"path": "content.mcp.heading.muted",
"label": "MCP: приглушённая строка",
"kind": "text",
"renderAs": "text",
"group": "MCP"
},
{
"path": "content.mcp.heading.middle",
"label": "MCP: средняя строка",
"kind": "text",
"renderAs": "text",
"group": "MCP"
},
{
"path": "content.mcp.heading.main",
"label": "MCP: основная строка",
"kind": "text",
"renderAs": "text",
"group": "MCP"
},
{
"path": "content.mcp.introHtml",
"label": "MCP: вводный текст",
"kind": "html",
"renderAs": "html",
"group": "MCP"
},
{
"path": "content.mcp.items.0.eyebrow",
"label": "MCP: пункт 1, подпись",
"kind": "text",
"renderAs": "text",
"group": "MCP"
},
{
"path": "content.mcp.items.0.bodyHtml",
"label": "MCP: пункт 1, текст",
"kind": "html",
"renderAs": "html",
"group": "MCP"
}
],
"enabled": true
}
},
{
"key": "cViz:aiModes:ai-modes.html",
"regionId": "cViz",
"regionLabel": "Секции",
"title": "Гибкие режимы ИИ",
"description": "ai-modes.html",
"block": {
"region": "cViz",
"id": "ai-modes",
"type": "aiModes",
"template": "ai-modes.html",
"adminLabel": "Гибкие режимы ИИ",
"anchor": null,
"content": {
"heading": {
"muted": "Гибкие",
"main": "режимы ИИ."
},
"introHtml": "Разные <span class=\"darker-70\">режимы</span> и <span class=\"darker-70\">модели</span> для разных задач.",
"sliderItems": [
{
"videoSrc": "./assets/media/various-modes.mp4",
"title": "Переключение режимов ИИ"
},
{
"videoSrc": "./assets/media/project-flow.mp4",
"title": "Создание нового проекта"
},
{
"videoSrc": "./assets/media/ai-dock-mode.mp4",
"title": "ИИ-док"
},
{
"videoSrc": "./assets/media/account.mp4",
"title": "Аккаунт"
},
{
"videoSrc": "./assets/media/code-edits.mp4",
"title": "Редактирование кода с ИИ"
},
{
"videoSrc": "./assets/media/dev-server.mp4",
"title": "Dev-сервер с ИИ"
},
{
"videoSrc": "./assets/media/webflow-mcp.mp4",
"title": "Webflow MCP"
},
{
"path": "content.sliderItems",
"label": "Слайды",
"kind": "collection",
"renderAs": "collection",
"group": "Слайдер",
"itemTemplate": {
"enabled": true,
"videoSrc": "./assets/media/publish-flow.mp4",
"title": "Публикация"
}
],
"scroll": {
"speed": 0.55
}
},
"editableFields": [
{
"path": "content.scroll.speed",
"label": "Скорость скролла",
"kind": "range",
"renderAs": "number",
"group": "Скролл",
"min": 0.2,
"max": 2,
"step": 0.05,
"suffix": "x"
},
{
"path": "content.heading.muted",
"label": "Заголовок: приглушённая строка",
"kind": "text",
"renderAs": "text",
"group": "Заголовок"
},
{
"path": "content.heading.main",
"label": "Заголовок: основная строка",
"kind": "text",
"renderAs": "text",
"group": "Заголовок"
},
{
"path": "content.introHtml",
"label": "Вводный текст",
"kind": "html",
"renderAs": "html",
"group": "Заголовок"
},
{
"path": "content.sliderItems.0.videoSrc",
"label": "Слайд 1: видео",
"kind": "media",
"renderAs": "attr",
"group": "Слайдер"
},
{
"path": "content.sliderItems.0.title",
"label": "Слайд 1: подпись",
"kind": "text",
"renderAs": "text",
"group": "Слайдер"
},
{
"path": "content.sliderItems.1.videoSrc",
"label": "Слайд 2: видео",
"kind": "media",
"renderAs": "attr",
"group": "Слайдер"
},
{
"path": "content.sliderItems.1.title",
"label": "Слайд 2: подпись",
"kind": "text",
"renderAs": "text",
"group": "Слайдер"
},
{
"path": "content.sliderItems.2.videoSrc",
"label": "Слайд 3: видео",
"kind": "media",
"renderAs": "attr",
"group": "Слайдер"
},
{
"path": "content.sliderItems.2.title",
"label": "Слайд 3: подпись",
"kind": "text",
"renderAs": "text",
"group": "Слайдер"
},
{
"path": "content.sliderItems.3.videoSrc",
"label": "Слайд 4: видео",
"kind": "media",
"renderAs": "attr",
"group": "Слайдер"
},
{
"path": "content.sliderItems.3.title",
"label": "Слайд 4: подпись",
"kind": "text",
"renderAs": "text",
"group": "Слайдер"
},
{
"path": "content.sliderItems.4.videoSrc",
"label": "Слайд 5: видео",
"kind": "media",
"renderAs": "attr",
"group": "Слайдер"
},
{
"path": "content.sliderItems.4.title",
"label": "Слайд 5: подпись",
"kind": "text",
"renderAs": "text",
"group": "Слайдер"
},
{
"path": "content.sliderItems.5.videoSrc",
"label": "Слайд 6: видео",
"kind": "media",
"renderAs": "attr",
"group": "Слайдер"
},
{
"path": "content.sliderItems.5.title",
"label": "Слайд 6: подпись",
"kind": "text",
"renderAs": "text",
"group": "Слайдер"
},
{
"path": "content.sliderItems.6.videoSrc",
"label": "Слайд 7: видео",
"kind": "media",
"renderAs": "attr",
"group": "Слайдер"
},
{
"path": "content.sliderItems.6.title",
"label": "Слайд 7: подпись",
"kind": "text",
"renderAs": "text",
"group": "Слайдер"
},
{
"path": "content.sliderItems.7.videoSrc",
"label": "Слайд 8: видео",
"kind": "media",
"renderAs": "attr",
"group": "Слайдер"
},
{
"path": "content.sliderItems.7.title",
"label": "Слайд 8: подпись",
"kind": "text",
"renderAs": "text",
"group": "Слайдер"
"title": "Новый слайд"
},
"itemFields": [
{
"path": "videoSrc",
"label": "Видео / картинка",
"kind": "media",
"renderAs": "attr"
},
{
"path": "title",
"label": "Подпись",
"kind": "text",
"renderAs": "text"
}
]
}
],
"enabled": true

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" data-scroll-speed="{{attr:content.scroll.speed}}" 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" data-scroll-speed="{{attr:content.scroll.speed}}" 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>

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");
}
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;
}

View File

@ -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],
},

View File

@ -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"));
@ -176,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)) {