Refine admin static collections and drag sorting

This commit is contained in:
dcconstructions 2026-06-26 12:11:07 +03:00
parent e4bfd93a67
commit 7230e8a319
6 changed files with 415 additions and 198 deletions

View File

@ -28,6 +28,15 @@
--surface-strong: rgba(8, 8, 11, 0.9); --surface-strong: rgba(8, 8, 11, 0.9);
--surface-soft: rgba(255, 255, 255, 0.08); --surface-soft: rgba(255, 255, 255, 0.08);
--field: rgba(255, 255, 255, 0.06); --field: rgba(255, 255, 255, 0.06);
--nodedc-glass-panel-bg: rgba(28, 28, 30, 0.72);
--nodedc-glass-panel-bg-strong: rgba(28, 28, 30, 0.84);
--nodedc-glass-panel-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.055) 0%, rgba(255, 255, 255, 0.014) 100%),
var(--nodedc-glass-panel-bg);
--nodedc-glass-panel-surface-strong: linear-gradient(180deg, rgba(255, 255, 255, 0.068) 0%, rgba(255, 255, 255, 0.018) 100%),
var(--nodedc-glass-panel-bg-strong);
--nodedc-glass-outline: rgba(255, 255, 255, 0.09);
--nodedc-glass-panel-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.075), 0 22px 72px rgba(0, 0, 0, 0.42);
--nodedc-glass-filter: blur(30px) saturate(1.26) brightness(1.05);
--text-primary: #f7f8f4; --text-primary: #f7f8f4;
--text-secondary: rgba(247, 248, 244, 0.72); --text-secondary: rgba(247, 248, 244, 0.72);
--text-muted: rgba(247, 248, 244, 0.48); --text-muted: rgba(247, 248, 244, 0.48);
@ -333,7 +342,9 @@ p {
} }
.add-block-button { .add-block-button {
font-size: 1.55rem; width: calc(var(--admin-control-ring) * 0.85);
height: calc(var(--admin-control-ring) * 0.85);
font-size: 1.32rem;
font-weight: 520; font-weight: 520;
} }
@ -484,13 +495,48 @@ p {
} }
.block-btn.dragging { .block-btn.dragging {
opacity: 0.46; opacity: 0.16;
}
.sorting-source {
display: none !important;
} }
.block-btn.drag-over { .block-btn.drag-over {
background: rgba(255, 255, 255, 0.16); background: rgba(255, 255, 255, 0.16);
} }
.block-list.sorting-active .block-btn {
transition:
background 160ms ease,
color 160ms ease,
opacity 160ms ease,
transform 180ms ease,
box-shadow 160ms ease;
}
.sortable-placeholder {
border: 1px dashed rgba(255, 255, 255, 0.32);
border-radius: var(--launcher-radius-circle);
background: rgba(255, 255, 255, 0.07);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.06),
0 12px 34px rgba(0, 0, 0, 0.22);
}
.collection-list .sortable-placeholder {
border-radius: var(--launcher-radius-control);
}
.sortable-ghost {
position: fixed;
z-index: 300;
pointer-events: none;
opacity: 0.94;
transform: scale(1.015);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
}
.block-copy { .block-copy {
display: grid; display: grid;
min-width: 0; min-width: 0;
@ -896,13 +942,18 @@ p {
} }
.collection-add-btn { .collection-add-btn {
min-height: 2.4rem; display: grid;
width: 2.05rem;
height: 2.05rem;
min-height: 2.05rem;
place-items: center;
border-radius: var(--launcher-radius-circle); border-radius: var(--launcher-radius-circle);
background: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.9);
color: rgba(8, 8, 10, 0.96); color: rgba(8, 8, 10, 0.96);
padding: 0 0.95rem; padding: 0;
font-size: 0.76rem; font-size: 1.05rem;
font-weight: 850; font-weight: 850;
line-height: 1;
} }
.collection-list { .collection-list {
@ -914,12 +965,20 @@ p {
overflow: hidden; overflow: hidden;
border-radius: var(--launcher-radius-control); border-radius: var(--launcher-radius-control);
background: rgba(255, 255, 255, 0.045); background: rgba(255, 255, 255, 0.045);
transition:
background 160ms ease,
opacity 160ms ease,
transform 180ms ease;
} }
.collection-item-card[open] { .collection-item-card[open] {
background: rgba(255, 255, 255, 0.065); background: rgba(255, 255, 255, 0.065);
} }
.collection-item-card.dragging {
opacity: 0.16;
}
.collection-summary { .collection-summary {
min-height: 3rem; min-height: 3rem;
cursor: pointer; cursor: pointer;
@ -946,6 +1005,7 @@ p {
gap: 0.22rem; gap: 0.22rem;
} }
.collection-drag-handle,
.collection-icon-btn { .collection-icon-btn {
display: grid; display: grid;
width: 2.2rem; width: 2.2rem;
@ -957,6 +1017,18 @@ p {
font-size: 0.84rem; font-size: 0.84rem;
} }
.collection-drag-handle {
letter-spacing: -0.12em;
cursor: grab;
touch-action: none;
user-select: none;
}
.collection-drag-handle:active {
cursor: grabbing;
}
.collection-drag-handle:hover,
.collection-icon-btn:hover { .collection-icon-btn:hover {
background: rgba(255, 255, 255, 0.14); background: rgba(255, 255, 255, 0.14);
color: var(--text-primary); color: var(--text-primary);
@ -1210,10 +1282,10 @@ textarea {
inset: 0; inset: 0;
display: grid; display: grid;
place-items: center; place-items: center;
background: rgba(0, 0, 0, 0.34); background: rgba(0, 0, 0, 0.38);
padding: 1.4rem; padding: 1.4rem;
backdrop-filter: blur(14px); backdrop-filter: blur(22px) saturate(1.08);
-webkit-backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(22px) saturate(1.08);
} }
.template-modal { .template-modal {
@ -1222,20 +1294,23 @@ textarea {
width: min(58rem, calc(100vw - 2.8rem)); width: min(58rem, calc(100vw - 2.8rem));
max-height: min(46rem, calc(100vh - 2.8rem)); max-height: min(46rem, calc(100vh - 2.8rem));
overflow: hidden; overflow: hidden;
border: 1px solid var(--nodedc-glass-outline);
border-radius: var(--launcher-radius-modal); border-radius: var(--launcher-radius-modal);
background: background: var(--nodedc-glass-panel-surface-strong);
linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.02)), box-shadow:
rgba(8, 8, 11, 0.8); var(--nodedc-glass-panel-shadow),
box-shadow: 0 44px 130px rgba(0, 0, 0, 0.62); 0 46px 140px rgba(0, 0, 0, 0.62);
backdrop-filter: blur(40px) saturate(1.12); backdrop-filter: var(--nodedc-glass-filter);
-webkit-backdrop-filter: blur(40px) saturate(1.12); -webkit-backdrop-filter: var(--nodedc-glass-filter);
} }
.template-modal::before { .template-modal::before {
position: absolute; position: absolute;
inset: 0; inset: 0;
border-radius: inherit; border-radius: inherit;
background: rgba(255, 255, 255, 0.025); background:
radial-gradient(circle at 22% 0%, rgba(255, 255, 255, 0.14), transparent 32%),
linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.01));
content: ""; content: "";
pointer-events: none; pointer-events: none;
} }

View File

@ -46,6 +46,7 @@ const STATIC_FALLBACK = {
enabled: true, enabled: true,
content: { content: {
navigation: { navigation: {
logoSrc: "./assets/webflow/images/69ad964d951f9d17cc5a919e_logo-app.svg",
logoAlt: "главная", logoAlt: "главная",
brandLabel: "NODE.DC", brandLabel: "NODE.DC",
links: [ links: [
@ -58,16 +59,19 @@ const STATIC_FALLBACK = {
notifications: [ notifications: [
{ {
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> — ИЮЛЬ 2026', eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> — ИЮЛЬ 2026',
eyebrowClass: "red-txt gray",
title: "Открытая бета", title: "Открытая бета",
body: "Открываем доступ всем. Будем собирать и отлаживать вместе.", body: "Открываем доступ всем. Будем собирать и отлаживать вместе.",
}, },
{ {
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> —<span class="darker-70"> </span>ИЮЛЬ 2026', eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> —<span class="darker-70"> </span>ИЮЛЬ 2026',
eyebrowClass: "red-txt gray",
title: "Альфа для Founder-доступа", title: "Альфа для Founder-доступа",
body: "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа.", body: "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа.",
}, },
{ {
eyebrowHtml: "СЕЙЧАС", eyebrowHtml: "СЕЙЧАС",
eyebrowClass: "red-txt",
title: "Инвайты в закрытую бету уже отправлены.", title: "Инвайты в закрытую бету уже отправлены.",
body: "Сейчас тестируем базовую функциональность и ИИ-интеграцию. ", body: "Сейчас тестируем базовую функциональность и ИИ-интеграцию. ",
}, },
@ -88,26 +92,37 @@ const STATIC_FALLBACK = {
}, },
}, },
editableFields: [ editableFields: [
field("content.navigation.logoAlt", "Логотип в шапке: alt", "text", "text", "Шапка"), field("content.navigation.logoSrc", "Логотип в шапке", "media", "attr", "Шапка: логотип"),
field("content.navigation.brandLabel", "Бренд в шапке", "text", "text", "Шапка"), field("content.navigation.logoAlt", "Логотип в шапке: alt", "text", "text", "Шапка: логотип"),
field("content.navigation.links.0.label", "Ссылка 1: текст", "text", "text", "Шапка"), field("content.navigation.brandLabel", "Бренд в шапке", "text", "text", "Шапка: логотип"),
field("content.navigation.links.0.href", "Ссылка 1: адрес", "url", "attr", "Шапка"), collection(
field("content.navigation.links.1.label", "Ссылка 2: текст", "text", "text", "Шапка"), "content.navigation.links",
field("content.navigation.links.1.href", "Ссылка 2: адрес", "url", "attr", "Шапка"), "Кнопки шапки",
field("content.navigation.links.2.label", "Ссылка 3: текст", "text", "text", "Шапка"), "Шапка: ссылки",
field("content.navigation.links.2.href", "Ссылка 3: адрес", "url", "attr", "Шапка"), { label: "Новая ссылка", href: "index.html#", target: "" },
field("content.navigation.links.2.target", "Ссылка 3: target", "text", "attr", "Шапка"), [
field("content.navigation.links.3.label", "Ссылка 4: текст", "text", "text", "Шапка"), field("label", "Текст кнопки", "text", "text", "Шапка: ссылки"),
field("content.navigation.links.3.href", "Ссылка 4: адрес", "url", "attr", "Шапка"), field("href", "Адрес", "url", "attr", "Шапка: ссылки"),
field("content.notifications.0.eyebrowHtml", "Уведомление 1: дата/статус", "html", "html", "Уведомления"), field("target", "Target", "text", "attr", "Шапка: ссылки"),
field("content.notifications.0.title", "Уведомление 1: заголовок", "text", "text", "Уведомления"), ],
field("content.notifications.0.body", "Уведомление 1: текст", "textarea", "text", "Уведомления"), ),
field("content.notifications.1.eyebrowHtml", "Уведомление 2: дата/статус", "html", "html", "Уведомления"), collection(
field("content.notifications.1.title", "Уведомление 2: заголовок", "text", "text", "Уведомления"), "content.notifications",
field("content.notifications.1.body", "Уведомление 2: текст", "textarea", "text", "Уведомления"), "Уведомления",
field("content.notifications.2.eyebrowHtml", "Уведомление 3: дата/статус", "html", "html", "Уведомления"), "Уведомления",
field("content.notifications.2.title", "Уведомление 3: заголовок", "text", "text", "Уведомления"), {
field("content.notifications.2.body", "Уведомление 3: текст", "textarea", "text", "Уведомления"), eyebrowHtml: "СЕЙЧАС",
eyebrowClass: "red-txt gray",
title: "Новое уведомление",
body: "Текст уведомления.",
},
[
field("eyebrowHtml", "Дата / статус", "html", "html", "Уведомления"),
field("eyebrowClass", "CSS-класс статуса", "text", "attr", "Уведомления"),
field("title", "Заголовок", "text", "text", "Уведомления"),
field("body", "Текст", "textarea", "text", "Уведомления"),
],
),
field("content.dock.appTarget", "Dock: ID приложения", "text", "text", "Dock"), field("content.dock.appTarget", "Dock: ID приложения", "text", "text", "Dock"),
field("content.dock.trashAlt", "Dock: alt корзины", "text", "text", "Dock"), field("content.dock.trashAlt", "Dock: alt корзины", "text", "text", "Dock"),
field("content.dock.demoFileLabel", "Dock: подпись демо-файла", "text", "text", "Dock"), field("content.dock.demoFileLabel", "Dock: подпись демо-файла", "text", "text", "Dock"),
@ -122,8 +137,9 @@ const STATIC_FALLBACK = {
}; };
const GROUP_COPY = { const GROUP_COPY = {
Шапка: "Навигация верхней mac-панели: бренд, ссылки и целевые адреса.", "Шапка: логотип": "Статичный знак верхней mac-панели: файл логотипа, alt и брендовая подпись.",
Уведомления: "Карточки уведомлений справа сверху. Порядок соответствует текущему stack в shell.", "Шапка: ссылки": "Пункты навигации верхней mac-панели. Каждый пункт можно добавить, удалить и переставить.",
Уведомления: "Карточки уведомлений справа сверху. Порядок в админке соответствует порядку на сайте.",
Dock: "Нижняя системная панель: связь кнопки приложения, корзина и подписи файлов.", Dock: "Нижняя системная панель: связь кнопки приложения, корзина и подписи файлов.",
"Модель и логотипы": "Базовые ассеты статичного слоя: GLB-модель, PNG/SVG-логотипы и alt-тексты.", "Модель и логотипы": "Базовые ассеты статичного слоя: GLB-модель, PNG/SVG-логотипы и alt-тексты.",
Заголовок: "Главный заголовок и вводный текст выбранной секции.", Заголовок: "Главный заголовок и вводный текст выбранной секции.",
@ -152,6 +168,18 @@ function field(path, label, kind = "text", renderAs = kind === "html" ? "html" :
return { path, label, kind, renderAs, group: group || inferFieldGroup(path), ...options }; return { path, label, kind, renderAs, group: group || inferFieldGroup(path), ...options };
} }
function collection(path, label, group, itemTemplate, itemFields) {
return {
path,
label,
kind: "collection",
renderAs: "collection",
group,
itemTemplate,
itemFields,
};
}
function setStatus(message) { function setStatus(message) {
el.status.textContent = message; el.status.textContent = message;
} }
@ -232,7 +260,16 @@ function ensureStaticElements() {
state.page.staticElements.adminLabel ||= STATIC_FALLBACK.adminLabel; state.page.staticElements.adminLabel ||= STATIC_FALLBACK.adminLabel;
state.page.staticElements.enabled = true; state.page.staticElements.enabled = true;
state.page.staticElements.content ||= clone(STATIC_FALLBACK.content); state.page.staticElements.content ||= clone(STATIC_FALLBACK.content);
state.page.staticElements.editableFields ||= clone(STATIC_FALLBACK.editableFields); state.page.staticElements.content.navigation ||= clone(STATIC_FALLBACK.content.navigation);
state.page.staticElements.content.navigation.logoSrc ||= STATIC_FALLBACK.content.navigation.logoSrc;
state.page.staticElements.content.navigation.links ||= clone(STATIC_FALLBACK.content.navigation.links);
state.page.staticElements.content.notifications ||= clone(STATIC_FALLBACK.content.notifications);
state.page.staticElements.content.notifications.forEach((notification, index) => {
notification.eyebrowClass ||= STATIC_FALLBACK.content.notifications[index]?.eyebrowClass || "red-txt gray";
});
state.page.staticElements.content.dock ||= clone(STATIC_FALLBACK.content.dock);
state.page.staticElements.content.assets ||= clone(STATIC_FALLBACK.content.assets);
state.page.staticElements.editableFields = clone(STATIC_FALLBACK.editableFields);
} }
function isStaticSelection() { function isStaticSelection() {
@ -377,23 +414,81 @@ function blockMeta(block) {
function cleanupDragState() { function cleanupDragState() {
state.drag = null; state.drag = null;
state.dragTarget = null; state.dragTarget = null;
el.regions.querySelectorAll(".dragging, .drag-over").forEach((node) => { document.querySelectorAll(".dragging, .drag-over, .sorting-source, .sorting-active").forEach((node) => {
node.classList.remove("dragging", "drag-over"); node.classList.remove("dragging", "drag-over", "sorting-source", "sorting-active");
}); });
document.querySelectorAll(".sortable-ghost, .sortable-placeholder").forEach((node) => node.remove());
} }
function dragContextFromPoint(event) { function createSortableGhost(source, event) {
const target = document.elementFromPoint(event.clientX, event.clientY); const rect = source.getBoundingClientRect();
const button = target?.closest?.(".block-btn.has-drag-handle"); const ghost = source.cloneNode(true);
if (!button || !el.regions.contains(button)) return null;
return { ghost.classList.add("sortable-ghost");
node: button, ghost.style.width = `${rect.width}px`;
context: { ghost.style.height = `${rect.height}px`;
regionIndex: Number(button.dataset.regionIndex), ghost.dataset.dragOffsetX = String(event.clientX - rect.left);
blockIndex: Number(button.dataset.blockIndex), ghost.dataset.dragOffsetY = String(event.clientY - rect.top);
}, document.body.append(ghost);
}; moveSortableGhost(ghost, event);
return ghost;
}
function moveSortableGhost(ghost, event) {
ghost.style.left = `${event.clientX - Number(ghost.dataset.dragOffsetX || 0)}px`;
ghost.style.top = `${event.clientY - Number(ghost.dataset.dragOffsetY || 0)}px`;
}
function createSortablePlaceholder(source) {
const rect = source.getBoundingClientRect();
const placeholder = document.createElement("div");
placeholder.className = "sortable-placeholder";
placeholder.style.height = `${rect.height}px`;
return placeholder;
}
function pointInsideRect(event, rect) {
return event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom;
}
function movePlaceholderByPoint({ event, list, source, placeholder, selector }) {
const rect = list.getBoundingClientRect();
if (!pointInsideRect(event, rect)) return;
const candidates = [...list.querySelectorAll(selector)].filter((node) => node !== source && node !== placeholder);
for (const candidate of candidates) {
const candidateRect = candidate.getBoundingClientRect();
if (event.clientY < candidateRect.top + candidateRect.height / 2) {
list.insertBefore(placeholder, candidate);
return;
}
}
list.append(placeholder);
}
function sortablePlaceholderIndex({ list, placeholder, source, selector }) {
return [...list.querySelectorAll(`${selector}, .sortable-placeholder`)]
.filter((node) => node === placeholder || node !== source)
.indexOf(placeholder);
}
function moveBlockWithinRegionToIndex(regionIndex, fromIndex, toIndex) {
const region = state.page.regions[regionIndex];
if (!region || fromIndex === toIndex || toIndex < 0) {
cleanupDragState();
return;
}
const [block] = region.blocks.splice(fromIndex, 1);
region.blocks.splice(Math.min(toIndex, region.blocks.length), 0, block);
state.selected = { kind: "block", regionIndex, blockIndex: Math.min(toIndex, region.blocks.length - 1) };
cleanupDragState();
renderAll();
markDirty("Порядок блоков изменён локально. Нажмите «Сохранить модель» или «Обновить index.html».");
} }
function startPointerDrag(event, button, dragContext) { function startPointerDrag(event, button, dragContext) {
@ -401,18 +496,27 @@ function startPointerDrag(event, button, dragContext) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const list = button.closest(".block-list");
if (!list) return;
const placeholder = createSortablePlaceholder(button);
const ghost = createSortableGhost(button, event);
state.drag = dragContext; state.drag = dragContext;
state.dragTarget = dragContext; state.dragTarget = dragContext;
button.classList.add("dragging"); button.classList.add("dragging", "sorting-source");
list.classList.add("sorting-active");
button.after(placeholder);
const onMove = (moveEvent) => { const onMove = (moveEvent) => {
const target = dragContextFromPoint(moveEvent); moveSortableGhost(ghost, moveEvent);
el.regions.querySelectorAll(".drag-over").forEach((node) => node.classList.remove("drag-over")); movePlaceholderByPoint({
event: moveEvent,
if (!target || target.context.regionIndex !== dragContext.regionIndex) return; list,
source: button,
state.dragTarget = target.context; placeholder,
target.node.classList.add("drag-over"); selector: ".block-btn.has-drag-handle",
});
}; };
const onFinish = () => { const onFinish = () => {
@ -420,12 +524,13 @@ function startPointerDrag(event, button, dragContext) {
document.removeEventListener("pointerup", onFinish); document.removeEventListener("pointerup", onFinish);
document.removeEventListener("pointercancel", onCancel); document.removeEventListener("pointercancel", onCancel);
if (state.drag && state.dragTarget) { const nextIndex = sortablePlaceholderIndex({
reorderBlockWithinRegion(state.drag, state.dragTarget); list,
return; placeholder,
} source: button,
selector: ".block-btn.has-drag-handle",
cleanupDragState(); });
moveBlockWithinRegionToIndex(dragContext.regionIndex, dragContext.blockIndex, nextIndex);
}; };
const onCancel = () => { const onCancel = () => {
@ -493,7 +598,7 @@ function renderBlockButton({ list, block, meta, active, disabled, onClick, dragC
if (canDrag) { if (canDrag) {
const handle = document.createElement("span"); const handle = document.createElement("span");
handle.className = "block-drag-handle"; handle.className = "block-drag-handle";
handle.draggable = true; handle.draggable = false;
handle.title = "Перетащить блок"; handle.title = "Перетащить блок";
handle.setAttribute("aria-label", "Перетащить блок"); handle.setAttribute("aria-label", "Перетащить блок");
handle.textContent = "⋮⋮"; handle.textContent = "⋮⋮";
@ -683,6 +788,7 @@ function renderEditor() {
el.empty.classList.add("hidden"); el.empty.classList.add("hidden");
el.form.classList.remove("hidden"); el.form.classList.remove("hidden");
el.form.dataset.static = String(isStatic);
el.selectedRegion.textContent = `NODE.DC / ${region.label || region.id}`; el.selectedRegion.textContent = `NODE.DC / ${region.label || region.id}`;
el.selectedTitle.textContent = block.adminLabel || block.id; el.selectedTitle.textContent = block.adminLabel || block.id;
el.id.value = block.id || ""; el.id.value = block.id || "";
@ -693,6 +799,7 @@ function renderEditor() {
el.id.readOnly = isStatic; el.id.readOnly = isStatic;
el.anchor.disabled = isStatic; el.anchor.disabled = isStatic;
el.enabled.disabled = isStatic; el.enabled.disabled = isStatic;
el.enabled.closest(".toggle-row").hidden = isStatic;
setStructuralControlsDisabled(isStatic); setStructuralControlsDisabled(isStatic);
renderContentFields(block); renderContentFields(block);
el.json.value = JSON.stringify(block, null, 2); el.json.value = JSON.stringify(block, null, 2);
@ -1024,6 +1131,64 @@ function renderCollectionScalarField({ block, field, grid }) {
grid.append(label); grid.append(label);
} }
function startCollectionPointerDrag(event, card, items, index, rerender) {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
const list = card.closest(".collection-list");
if (!list) return;
const placeholder = createSortablePlaceholder(card);
const ghost = createSortableGhost(card, event);
card.classList.add("dragging", "sorting-source");
list.classList.add("sorting-active");
card.after(placeholder);
const onMove = (moveEvent) => {
moveSortableGhost(ghost, moveEvent);
movePlaceholderByPoint({
event: moveEvent,
list,
source: card,
placeholder,
selector: ".collection-item-card",
});
};
const onFinish = () => {
document.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerup", onFinish);
document.removeEventListener("pointercancel", onCancel);
const nextIndex = sortablePlaceholderIndex({
list,
placeholder,
source: card,
selector: ".collection-item-card",
});
cleanupDragState();
if (nextIndex < 0 || nextIndex === index) return;
const [moved] = items.splice(index, 1);
items.splice(Math.min(nextIndex, items.length), 0, moved);
rerender("Порядок элементов коллекции изменён локально.");
};
const onCancel = () => {
document.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerup", onFinish);
document.removeEventListener("pointercancel", onCancel);
cleanupDragState();
};
document.addEventListener("pointermove", onMove);
document.addEventListener("pointerup", onFinish);
document.addEventListener("pointercancel", onCancel);
}
function renderCollectionField({ block, editableField, grid }) { function renderCollectionField({ block, editableField, grid }) {
const items = collectionItems(block, editableField); const items = collectionItems(block, editableField);
const container = document.createElement("section"); const container = document.createElement("section");
@ -1044,7 +1209,9 @@ function renderCollectionField({ block, editableField, grid }) {
title.textContent = editableField.label || editableField.path; title.textContent = editableField.label || editableField.path;
addButton.className = "collection-add-btn"; addButton.className = "collection-add-btn";
addButton.type = "button"; addButton.type = "button";
addButton.textContent = "Добавить"; addButton.title = `Добавить элемент в «${editableField.label || editableField.path}»`;
addButton.setAttribute("aria-label", addButton.title);
addButton.textContent = "+";
addButton.addEventListener("click", () => { addButton.addEventListener("click", () => {
items.push(prepareCollectionItemForInsert(items, collectionItemTemplate(editableField))); items.push(prepareCollectionItemForInsert(items, collectionItemTemplate(editableField)));
rerender(`Добавлен элемент в «${editableField.label || editableField.path}».`); rerender(`Добавлен элемент в «${editableField.label || editableField.path}».`);
@ -1058,6 +1225,7 @@ function renderCollectionField({ block, editableField, grid }) {
const summary = document.createElement("summary"); const summary = document.createElement("summary");
const summaryTitle = document.createElement("span"); const summaryTitle = document.createElement("span");
const controls = document.createElement("span"); const controls = document.createElement("span");
const dragHandle = document.createElement("button");
const body = document.createElement("div"); const body = document.createElement("div");
const itemGrid = document.createElement("div"); const itemGrid = document.createElement("div");
@ -1067,6 +1235,17 @@ function renderCollectionField({ block, editableField, grid }) {
summaryTitle.className = "collection-summary-title"; summaryTitle.className = "collection-summary-title";
summaryTitle.textContent = collectionItemTitle(item, index); summaryTitle.textContent = collectionItemTitle(item, index);
controls.className = "collection-item-actions"; controls.className = "collection-item-actions";
dragHandle.className = "collection-drag-handle";
dragHandle.type = "button";
dragHandle.title = "Перетащить элемент";
dragHandle.setAttribute("aria-label", "Перетащить элемент");
dragHandle.textContent = "⋮⋮";
dragHandle.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
});
dragHandle.addEventListener("pointerdown", (event) => startCollectionPointerDrag(event, details, items, index, rerender));
controls.append(dragHandle);
[ [
["↑", "Поднять", () => { ["↑", "Поднять", () => {

View File

@ -33,23 +33,27 @@
"href": "index.html#pricing-wrap", "href": "index.html#pricing-wrap",
"target": "" "target": ""
} }
] ],
"logoSrc": "./assets/webflow/images/69ad964d951f9d17cc5a919e_logo-app.svg"
}, },
"notifications": [ "notifications": [
{ {
"eyebrowHtml": "<span class=\"darker-70\">ДАЛЕЕ</span> — ИЮЛЬ 2026", "eyebrowHtml": "<span class=\"darker-70\">ДАЛЕЕ</span> — ИЮЛЬ 2026",
"title": "Открытая бета", "title": "Открытая бета",
"body": "Открываем доступ всем. Будем собирать и отлаживать вместе." "body": "Открываем доступ всем. Будем собирать и отлаживать вместе.",
"eyebrowClass": "red-txt gray"
}, },
{ {
"eyebrowHtml": "<span class=\"darker-70\">ДАЛЕЕ</span> —<span class=\"darker-70\"> </span>ИЮЛЬ 2026", "eyebrowHtml": "<span class=\"darker-70\">ДАЛЕЕ</span> —<span class=\"darker-70\"> </span>ИЮЛЬ 2026",
"title": "Альфа для Founder-доступа", "title": "Альфа для Founder-доступа",
"body": "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа." "body": "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа.",
"eyebrowClass": "red-txt gray"
}, },
{ {
"eyebrowHtml": "СЕЙЧАС", "eyebrowHtml": "СЕЙЧАС",
"title": "Инвайты в закрытую бету уже отправлены.", "title": "Инвайты в закрытую бету уже отправлены.",
"body": "Сейчас тестируем базовую функциональность и ИИ-интеграцию. " "body": "Сейчас тестируем базовую функциональность и ИИ-интеграцию. ",
"eyebrowClass": "red-txt"
} }
], ],
"dock": { "dock": {
@ -68,145 +72,104 @@
} }
}, },
"editableFields": [ "editableFields": [
{
"path": "content.navigation.logoSrc",
"label": "Логотип в шапке",
"kind": "media",
"renderAs": "attr",
"group": "Шапка: логотип"
},
{ {
"path": "content.navigation.logoAlt", "path": "content.navigation.logoAlt",
"label": "Логотип в шапке: alt", "label": "Логотип в шапке: alt",
"kind": "text", "kind": "text",
"renderAs": "text", "renderAs": "text",
"group": "Шапка" "group": "Шапка: логотип"
}, },
{ {
"path": "content.navigation.brandLabel", "path": "content.navigation.brandLabel",
"label": "Бренд в шапке", "label": "Бренд в шапке",
"kind": "text", "kind": "text",
"renderAs": "text", "renderAs": "text",
"group": "Шапка" "group": "Шапка: логотип"
}, },
{ {
"path": "content.navigation.links.0.label", "path": "content.navigation.links",
"label": "Ссылка 1: текст", "label": "Кнопки шапки",
"kind": "collection",
"renderAs": "collection",
"group": "Шапка: ссылки",
"itemTemplate": {
"label": "Новая ссылка",
"href": "index.html#",
"target": ""
},
"itemFields": [
{
"path": "label",
"label": "Текст кнопки",
"kind": "text", "kind": "text",
"renderAs": "text", "renderAs": "text",
"group": "Шапка" "group": "Шапка: ссылки"
}, },
{ {
"path": "content.navigation.links.0.href", "path": "href",
"label": "Ссылка 1: адрес", "label": "Адрес",
"kind": "url", "kind": "url",
"renderAs": "attr", "renderAs": "attr",
"group": "Шапка" "group": "Шапка: ссылки"
}, },
{ {
"path": "content.navigation.links.1.label", "path": "target",
"label": "Ссылка 2: текст", "label": "Target",
"kind": "text",
"renderAs": "text",
"group": "Шапка"
},
{
"path": "content.navigation.links.1.href",
"label": "Ссылка 2: адрес",
"kind": "url",
"renderAs": "attr",
"group": "Шапка"
},
{
"path": "content.navigation.links.2.label",
"label": "Ссылка 3: текст",
"kind": "text",
"renderAs": "text",
"group": "Шапка"
},
{
"path": "content.navigation.links.2.href",
"label": "Ссылка 3: адрес",
"kind": "url",
"renderAs": "attr",
"group": "Шапка"
},
{
"path": "content.navigation.links.2.target",
"label": "Ссылка 3: target",
"kind": "text", "kind": "text",
"renderAs": "attr", "renderAs": "attr",
"group": "Шапка" "group": "Шапка: ссылки"
}
]
}, },
{ {
"path": "content.navigation.links.3.label", "path": "content.notifications",
"label": "Ссылка 4: текст", "label": "Уведомления",
"kind": "text", "kind": "collection",
"renderAs": "text", "renderAs": "collection",
"group": "Шапка" "group": "Уведомления",
"itemTemplate": {
"eyebrowHtml": "СЕЙЧАС",
"eyebrowClass": "red-txt gray",
"title": "Новое уведомление",
"body": "Текст уведомления."
}, },
"itemFields": [
{ {
"path": "content.navigation.links.3.href", "path": "eyebrowHtml",
"label": "Ссылка 4: адрес", "label": "Дата / статус",
"kind": "url",
"renderAs": "attr",
"group": "Шапка"
},
{
"path": "content.notifications.0.eyebrowHtml",
"label": "Уведомление 1: дата/статус",
"kind": "html", "kind": "html",
"renderAs": "html", "renderAs": "html",
"group": "Уведомления" "group": "Уведомления"
}, },
{ {
"path": "content.notifications.0.title", "path": "eyebrowClass",
"label": "Уведомление 1: заголовок", "label": "CSS-класс статуса",
"kind": "text",
"renderAs": "attr",
"group": "Уведомления"
},
{
"path": "title",
"label": "Заголовок",
"kind": "text", "kind": "text",
"renderAs": "text", "renderAs": "text",
"group": "Уведомления" "group": "Уведомления"
}, },
{ {
"path": "content.notifications.0.body", "path": "body",
"label": "Уведомление 1: текст", "label": "Текст",
"kind": "textarea",
"renderAs": "text",
"group": "Уведомления"
},
{
"path": "content.notifications.1.eyebrowHtml",
"label": "Уведомление 2: дата/статус",
"kind": "html",
"renderAs": "html",
"group": "Уведомления"
},
{
"path": "content.notifications.1.title",
"label": "Уведомление 2: заголовок",
"kind": "text",
"renderAs": "text",
"group": "Уведомления"
},
{
"path": "content.notifications.1.body",
"label": "Уведомление 2: текст",
"kind": "textarea",
"renderAs": "text",
"group": "Уведомления"
},
{
"path": "content.notifications.2.eyebrowHtml",
"label": "Уведомление 3: дата/статус",
"kind": "html",
"renderAs": "html",
"group": "Уведомления"
},
{
"path": "content.notifications.2.title",
"label": "Уведомление 3: заголовок",
"kind": "text",
"renderAs": "text",
"group": "Уведомления"
},
{
"path": "content.notifications.2.body",
"label": "Уведомление 3: текст",
"kind": "textarea", "kind": "textarea",
"renderAs": "text", "renderAs": "text",
"group": "Уведомления" "group": "Уведомления"
}
]
}, },
{ {
"path": "content.dock.appTarget", "path": "content.dock.appTarget",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -144,7 +144,7 @@ async function renderRegion(region) {
return blocks.join(""); return blocks.join("");
} }
let output = renderTemplateTokens(shell, { id: page.slug, ...page }); let output = renderTemplateTokens(renderEachBlocks(shell, { id: page.slug, ...page }), { id: page.slug, ...page });
for (const region of page.regions ?? []) { for (const region of page.regions ?? []) {
const token = REGION_TOKEN[region.id]; const token = REGION_TOKEN[region.id];
if (!token) { if (!token) {