Compare commits

...

2 Commits

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

View File

@ -923,8 +923,103 @@ body[data-file-picker-active="true"] .editor {
align-content: start;
}
.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;

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 = {
@ -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();

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

File diff suppressed because one or more lines are too long

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@ -48,12 +48,16 @@ function sendJson(res, status, payload) {
send(res, status, `${JSON.stringify(payload, null, 2)}\n`, "application/json; charset=utf-8");
}
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"));
@ -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)) {