Refine admin static collections and drag sorting
This commit is contained in:
parent
e4bfd93a67
commit
7230e8a319
105
admin/admin.css
105
admin/admin.css
|
|
@ -28,6 +28,15 @@
|
|||
--surface-strong: rgba(8, 8, 11, 0.9);
|
||||
--surface-soft: rgba(255, 255, 255, 0.08);
|
||||
--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-secondary: rgba(247, 248, 244, 0.72);
|
||||
--text-muted: rgba(247, 248, 244, 0.48);
|
||||
|
|
@ -333,7 +342,9 @@ p {
|
|||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
|
|
@ -484,13 +495,48 @@ p {
|
|||
}
|
||||
|
||||
.block-btn.dragging {
|
||||
opacity: 0.46;
|
||||
opacity: 0.16;
|
||||
}
|
||||
|
||||
.sorting-source {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.block-btn.drag-over {
|
||||
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 {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
|
|
@ -896,13 +942,18 @@ p {
|
|||
}
|
||||
|
||||
.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);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: rgba(8, 8, 10, 0.96);
|
||||
padding: 0 0.95rem;
|
||||
font-size: 0.76rem;
|
||||
padding: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 850;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.collection-list {
|
||||
|
|
@ -914,12 +965,20 @@ p {
|
|||
overflow: hidden;
|
||||
border-radius: var(--launcher-radius-control);
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
transition:
|
||||
background 160ms ease,
|
||||
opacity 160ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.collection-item-card[open] {
|
||||
background: rgba(255, 255, 255, 0.065);
|
||||
}
|
||||
|
||||
.collection-item-card.dragging {
|
||||
opacity: 0.16;
|
||||
}
|
||||
|
||||
.collection-summary {
|
||||
min-height: 3rem;
|
||||
cursor: pointer;
|
||||
|
|
@ -946,6 +1005,7 @@ p {
|
|||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.collection-drag-handle,
|
||||
.collection-icon-btn {
|
||||
display: grid;
|
||||
width: 2.2rem;
|
||||
|
|
@ -957,6 +1017,18 @@ p {
|
|||
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 {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: var(--text-primary);
|
||||
|
|
@ -1210,10 +1282,10 @@ textarea {
|
|||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.34);
|
||||
background: rgba(0, 0, 0, 0.38);
|
||||
padding: 1.4rem;
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
backdrop-filter: blur(22px) saturate(1.08);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(1.08);
|
||||
}
|
||||
|
||||
.template-modal {
|
||||
|
|
@ -1222,20 +1294,23 @@ textarea {
|
|||
width: min(58rem, calc(100vw - 2.8rem));
|
||||
max-height: min(46rem, calc(100vh - 2.8rem));
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--nodedc-glass-outline);
|
||||
border-radius: var(--launcher-radius-modal);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.02)),
|
||||
rgba(8, 8, 11, 0.8);
|
||||
box-shadow: 0 44px 130px rgba(0, 0, 0, 0.62);
|
||||
backdrop-filter: blur(40px) saturate(1.12);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(1.12);
|
||||
background: var(--nodedc-glass-panel-surface-strong);
|
||||
box-shadow:
|
||||
var(--nodedc-glass-panel-shadow),
|
||||
0 46px 140px rgba(0, 0, 0, 0.62);
|
||||
backdrop-filter: var(--nodedc-glass-filter);
|
||||
-webkit-backdrop-filter: var(--nodedc-glass-filter);
|
||||
}
|
||||
|
||||
.template-modal::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
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: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
|
|
|||
283
admin/admin.js
283
admin/admin.js
|
|
@ -46,6 +46,7 @@ const STATIC_FALLBACK = {
|
|||
enabled: true,
|
||||
content: {
|
||||
navigation: {
|
||||
logoSrc: "./assets/webflow/images/69ad964d951f9d17cc5a919e_logo-app.svg",
|
||||
logoAlt: "главная",
|
||||
brandLabel: "NODE.DC",
|
||||
links: [
|
||||
|
|
@ -58,16 +59,19 @@ const STATIC_FALLBACK = {
|
|||
notifications: [
|
||||
{
|
||||
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> — ИЮЛЬ 2026',
|
||||
eyebrowClass: "red-txt gray",
|
||||
title: "Открытая бета",
|
||||
body: "Открываем доступ всем. Будем собирать и отлаживать вместе.",
|
||||
},
|
||||
{
|
||||
eyebrowHtml: '<span class="darker-70">ДАЛЕЕ</span> —<span class="darker-70"> </span>ИЮЛЬ 2026',
|
||||
eyebrowClass: "red-txt gray",
|
||||
title: "Альфа для Founder-доступа",
|
||||
body: "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа.",
|
||||
},
|
||||
{
|
||||
eyebrowHtml: "СЕЙЧАС",
|
||||
eyebrowClass: "red-txt",
|
||||
title: "Инвайты в закрытую бету уже отправлены.",
|
||||
body: "Сейчас тестируем базовую функциональность и ИИ-интеграцию. ",
|
||||
},
|
||||
|
|
@ -88,26 +92,37 @@ const STATIC_FALLBACK = {
|
|||
},
|
||||
},
|
||||
editableFields: [
|
||||
field("content.navigation.logoAlt", "Логотип в шапке: alt", "text", "text", "Шапка"),
|
||||
field("content.navigation.brandLabel", "Бренд в шапке", "text", "text", "Шапка"),
|
||||
field("content.navigation.links.0.label", "Ссылка 1: текст", "text", "text", "Шапка"),
|
||||
field("content.navigation.links.0.href", "Ссылка 1: адрес", "url", "attr", "Шапка"),
|
||||
field("content.navigation.links.1.label", "Ссылка 2: текст", "text", "text", "Шапка"),
|
||||
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", "Шапка"),
|
||||
field("content.navigation.links.2.target", "Ссылка 3: target", "text", "attr", "Шапка"),
|
||||
field("content.navigation.links.3.label", "Ссылка 4: текст", "text", "text", "Шапка"),
|
||||
field("content.navigation.links.3.href", "Ссылка 4: адрес", "url", "attr", "Шапка"),
|
||||
field("content.notifications.0.eyebrowHtml", "Уведомление 1: дата/статус", "html", "html", "Уведомления"),
|
||||
field("content.notifications.0.title", "Уведомление 1: заголовок", "text", "text", "Уведомления"),
|
||||
field("content.notifications.0.body", "Уведомление 1: текст", "textarea", "text", "Уведомления"),
|
||||
field("content.notifications.1.eyebrowHtml", "Уведомление 2: дата/статус", "html", "html", "Уведомления"),
|
||||
field("content.notifications.1.title", "Уведомление 2: заголовок", "text", "text", "Уведомления"),
|
||||
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", "Уведомления"),
|
||||
field("content.navigation.logoSrc", "Логотип в шапке", "media", "attr", "Шапка: логотип"),
|
||||
field("content.navigation.logoAlt", "Логотип в шапке: alt", "text", "text", "Шапка: логотип"),
|
||||
field("content.navigation.brandLabel", "Бренд в шапке", "text", "text", "Шапка: логотип"),
|
||||
collection(
|
||||
"content.navigation.links",
|
||||
"Кнопки шапки",
|
||||
"Шапка: ссылки",
|
||||
{ label: "Новая ссылка", href: "index.html#", target: "" },
|
||||
[
|
||||
field("label", "Текст кнопки", "text", "text", "Шапка: ссылки"),
|
||||
field("href", "Адрес", "url", "attr", "Шапка: ссылки"),
|
||||
field("target", "Target", "text", "attr", "Шапка: ссылки"),
|
||||
],
|
||||
),
|
||||
collection(
|
||||
"content.notifications",
|
||||
"Уведомления",
|
||||
"Уведомления",
|
||||
{
|
||||
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.trashAlt", "Dock: alt корзины", "text", "text", "Dock"),
|
||||
field("content.dock.demoFileLabel", "Dock: подпись демо-файла", "text", "text", "Dock"),
|
||||
|
|
@ -122,8 +137,9 @@ const STATIC_FALLBACK = {
|
|||
};
|
||||
|
||||
const GROUP_COPY = {
|
||||
Шапка: "Навигация верхней mac-панели: бренд, ссылки и целевые адреса.",
|
||||
Уведомления: "Карточки уведомлений справа сверху. Порядок соответствует текущему stack в shell.",
|
||||
"Шапка: логотип": "Статичный знак верхней mac-панели: файл логотипа, alt и брендовая подпись.",
|
||||
"Шапка: ссылки": "Пункты навигации верхней mac-панели. Каждый пункт можно добавить, удалить и переставить.",
|
||||
Уведомления: "Карточки уведомлений справа сверху. Порядок в админке соответствует порядку на сайте.",
|
||||
Dock: "Нижняя системная панель: связь кнопки приложения, корзина и подписи файлов.",
|
||||
"Модель и логотипы": "Базовые ассеты статичного слоя: 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 };
|
||||
}
|
||||
|
||||
function collection(path, label, group, itemTemplate, itemFields) {
|
||||
return {
|
||||
path,
|
||||
label,
|
||||
kind: "collection",
|
||||
renderAs: "collection",
|
||||
group,
|
||||
itemTemplate,
|
||||
itemFields,
|
||||
};
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
el.status.textContent = message;
|
||||
}
|
||||
|
|
@ -232,7 +260,16 @@ function ensureStaticElements() {
|
|||
state.page.staticElements.adminLabel ||= STATIC_FALLBACK.adminLabel;
|
||||
state.page.staticElements.enabled = true;
|
||||
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() {
|
||||
|
|
@ -377,23 +414,81 @@ function blockMeta(block) {
|
|||
function cleanupDragState() {
|
||||
state.drag = null;
|
||||
state.dragTarget = null;
|
||||
el.regions.querySelectorAll(".dragging, .drag-over").forEach((node) => {
|
||||
node.classList.remove("dragging", "drag-over");
|
||||
document.querySelectorAll(".dragging, .drag-over, .sorting-source, .sorting-active").forEach((node) => {
|
||||
node.classList.remove("dragging", "drag-over", "sorting-source", "sorting-active");
|
||||
});
|
||||
document.querySelectorAll(".sortable-ghost, .sortable-placeholder").forEach((node) => node.remove());
|
||||
}
|
||||
|
||||
function dragContextFromPoint(event) {
|
||||
const target = document.elementFromPoint(event.clientX, event.clientY);
|
||||
const button = target?.closest?.(".block-btn.has-drag-handle");
|
||||
if (!button || !el.regions.contains(button)) return null;
|
||||
function createSortableGhost(source, event) {
|
||||
const rect = source.getBoundingClientRect();
|
||||
const ghost = source.cloneNode(true);
|
||||
|
||||
return {
|
||||
node: button,
|
||||
context: {
|
||||
regionIndex: Number(button.dataset.regionIndex),
|
||||
blockIndex: Number(button.dataset.blockIndex),
|
||||
},
|
||||
};
|
||||
ghost.classList.add("sortable-ghost");
|
||||
ghost.style.width = `${rect.width}px`;
|
||||
ghost.style.height = `${rect.height}px`;
|
||||
ghost.dataset.dragOffsetX = String(event.clientX - rect.left);
|
||||
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) {
|
||||
|
|
@ -401,18 +496,27 @@ function startPointerDrag(event, button, dragContext) {
|
|||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const list = button.closest(".block-list");
|
||||
if (!list) return;
|
||||
|
||||
const placeholder = createSortablePlaceholder(button);
|
||||
const ghost = createSortableGhost(button, event);
|
||||
|
||||
state.drag = 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 target = dragContextFromPoint(moveEvent);
|
||||
el.regions.querySelectorAll(".drag-over").forEach((node) => node.classList.remove("drag-over"));
|
||||
|
||||
if (!target || target.context.regionIndex !== dragContext.regionIndex) return;
|
||||
|
||||
state.dragTarget = target.context;
|
||||
target.node.classList.add("drag-over");
|
||||
moveSortableGhost(ghost, moveEvent);
|
||||
movePlaceholderByPoint({
|
||||
event: moveEvent,
|
||||
list,
|
||||
source: button,
|
||||
placeholder,
|
||||
selector: ".block-btn.has-drag-handle",
|
||||
});
|
||||
};
|
||||
|
||||
const onFinish = () => {
|
||||
|
|
@ -420,12 +524,13 @@ function startPointerDrag(event, button, dragContext) {
|
|||
document.removeEventListener("pointerup", onFinish);
|
||||
document.removeEventListener("pointercancel", onCancel);
|
||||
|
||||
if (state.drag && state.dragTarget) {
|
||||
reorderBlockWithinRegion(state.drag, state.dragTarget);
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupDragState();
|
||||
const nextIndex = sortablePlaceholderIndex({
|
||||
list,
|
||||
placeholder,
|
||||
source: button,
|
||||
selector: ".block-btn.has-drag-handle",
|
||||
});
|
||||
moveBlockWithinRegionToIndex(dragContext.regionIndex, dragContext.blockIndex, nextIndex);
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
|
|
@ -493,7 +598,7 @@ function renderBlockButton({ list, block, meta, active, disabled, onClick, dragC
|
|||
if (canDrag) {
|
||||
const handle = document.createElement("span");
|
||||
handle.className = "block-drag-handle";
|
||||
handle.draggable = true;
|
||||
handle.draggable = false;
|
||||
handle.title = "Перетащить блок";
|
||||
handle.setAttribute("aria-label", "Перетащить блок");
|
||||
handle.textContent = "⋮⋮";
|
||||
|
|
@ -683,6 +788,7 @@ function renderEditor() {
|
|||
|
||||
el.empty.classList.add("hidden");
|
||||
el.form.classList.remove("hidden");
|
||||
el.form.dataset.static = String(isStatic);
|
||||
el.selectedRegion.textContent = `NODE.DC / ${region.label || region.id}`;
|
||||
el.selectedTitle.textContent = block.adminLabel || block.id;
|
||||
el.id.value = block.id || "";
|
||||
|
|
@ -693,6 +799,7 @@ function renderEditor() {
|
|||
el.id.readOnly = isStatic;
|
||||
el.anchor.disabled = isStatic;
|
||||
el.enabled.disabled = isStatic;
|
||||
el.enabled.closest(".toggle-row").hidden = isStatic;
|
||||
setStructuralControlsDisabled(isStatic);
|
||||
renderContentFields(block);
|
||||
el.json.value = JSON.stringify(block, null, 2);
|
||||
|
|
@ -1024,6 +1131,64 @@ function renderCollectionScalarField({ block, field, grid }) {
|
|||
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 }) {
|
||||
const items = collectionItems(block, editableField);
|
||||
const container = document.createElement("section");
|
||||
|
|
@ -1044,7 +1209,9 @@ function renderCollectionField({ block, editableField, grid }) {
|
|||
title.textContent = editableField.label || editableField.path;
|
||||
addButton.className = "collection-add-btn";
|
||||
addButton.type = "button";
|
||||
addButton.textContent = "Добавить";
|
||||
addButton.title = `Добавить элемент в «${editableField.label || editableField.path}»`;
|
||||
addButton.setAttribute("aria-label", addButton.title);
|
||||
addButton.textContent = "+";
|
||||
addButton.addEventListener("click", () => {
|
||||
items.push(prepareCollectionItemForInsert(items, collectionItemTemplate(editableField)));
|
||||
rerender(`Добавлен элемент в «${editableField.label || editableField.path}».`);
|
||||
|
|
@ -1058,6 +1225,7 @@ function renderCollectionField({ block, editableField, grid }) {
|
|||
const summary = document.createElement("summary");
|
||||
const summaryTitle = document.createElement("span");
|
||||
const controls = document.createElement("span");
|
||||
const dragHandle = document.createElement("button");
|
||||
const body = document.createElement("div");
|
||||
const itemGrid = document.createElement("div");
|
||||
|
||||
|
|
@ -1067,6 +1235,17 @@ function renderCollectionField({ block, editableField, grid }) {
|
|||
summaryTitle.className = "collection-summary-title";
|
||||
summaryTitle.textContent = collectionItemTitle(item, index);
|
||||
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);
|
||||
|
||||
[
|
||||
["↑", "Поднять", () => {
|
||||
|
|
|
|||
|
|
@ -33,23 +33,27 @@
|
|||
"href": "index.html#pricing-wrap",
|
||||
"target": ""
|
||||
}
|
||||
]
|
||||
],
|
||||
"logoSrc": "./assets/webflow/images/69ad964d951f9d17cc5a919e_logo-app.svg"
|
||||
},
|
||||
"notifications": [
|
||||
{
|
||||
"eyebrowHtml": "<span class=\"darker-70\">ДАЛЕЕ</span> — ИЮЛЬ 2026",
|
||||
"title": "Открытая бета",
|
||||
"body": "Открываем доступ всем. Будем собирать и отлаживать вместе."
|
||||
"body": "Открываем доступ всем. Будем собирать и отлаживать вместе.",
|
||||
"eyebrowClass": "red-txt gray"
|
||||
},
|
||||
{
|
||||
"eyebrowHtml": "<span class=\"darker-70\">ДАЛЕЕ</span> —<span class=\"darker-70\"> </span>ИЮЛЬ 2026",
|
||||
"title": "Альфа для Founder-доступа",
|
||||
"body": "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа."
|
||||
"body": "На второй фазе тестирования бета открыта для всех подписчиков Founder-тарифа.",
|
||||
"eyebrowClass": "red-txt gray"
|
||||
},
|
||||
{
|
||||
"eyebrowHtml": "СЕЙЧАС",
|
||||
"title": "Инвайты в закрытую бету уже отправлены.",
|
||||
"body": "Сейчас тестируем базовую функциональность и ИИ-интеграцию. "
|
||||
"body": "Сейчас тестируем базовую функциональность и ИИ-интеграцию. ",
|
||||
"eyebrowClass": "red-txt"
|
||||
}
|
||||
],
|
||||
"dock": {
|
||||
|
|
@ -68,145 +72,104 @@
|
|||
}
|
||||
},
|
||||
"editableFields": [
|
||||
{
|
||||
"path": "content.navigation.logoSrc",
|
||||
"label": "Логотип в шапке",
|
||||
"kind": "media",
|
||||
"renderAs": "attr",
|
||||
"group": "Шапка: логотип"
|
||||
},
|
||||
{
|
||||
"path": "content.navigation.logoAlt",
|
||||
"label": "Логотип в шапке: alt",
|
||||
"kind": "text",
|
||||
"renderAs": "text",
|
||||
"group": "Шапка"
|
||||
"group": "Шапка: логотип"
|
||||
},
|
||||
{
|
||||
"path": "content.navigation.brandLabel",
|
||||
"label": "Бренд в шапке",
|
||||
"kind": "text",
|
||||
"renderAs": "text",
|
||||
"group": "Шапка"
|
||||
"group": "Шапка: логотип"
|
||||
},
|
||||
{
|
||||
"path": "content.navigation.links.0.label",
|
||||
"label": "Ссылка 1: текст",
|
||||
"path": "content.navigation.links",
|
||||
"label": "Кнопки шапки",
|
||||
"kind": "collection",
|
||||
"renderAs": "collection",
|
||||
"group": "Шапка: ссылки",
|
||||
"itemTemplate": {
|
||||
"label": "Новая ссылка",
|
||||
"href": "index.html#",
|
||||
"target": ""
|
||||
},
|
||||
"itemFields": [
|
||||
{
|
||||
"path": "label",
|
||||
"label": "Текст кнопки",
|
||||
"kind": "text",
|
||||
"renderAs": "text",
|
||||
"group": "Шапка"
|
||||
"group": "Шапка: ссылки"
|
||||
},
|
||||
{
|
||||
"path": "content.navigation.links.0.href",
|
||||
"label": "Ссылка 1: адрес",
|
||||
"path": "href",
|
||||
"label": "Адрес",
|
||||
"kind": "url",
|
||||
"renderAs": "attr",
|
||||
"group": "Шапка"
|
||||
"group": "Шапка: ссылки"
|
||||
},
|
||||
{
|
||||
"path": "content.navigation.links.1.label",
|
||||
"label": "Ссылка 2: текст",
|
||||
"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",
|
||||
"path": "target",
|
||||
"label": "Target",
|
||||
"kind": "text",
|
||||
"renderAs": "attr",
|
||||
"group": "Шапка"
|
||||
"group": "Шапка: ссылки"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "content.navigation.links.3.label",
|
||||
"label": "Ссылка 4: текст",
|
||||
"kind": "text",
|
||||
"renderAs": "text",
|
||||
"group": "Шапка"
|
||||
"path": "content.notifications",
|
||||
"label": "Уведомления",
|
||||
"kind": "collection",
|
||||
"renderAs": "collection",
|
||||
"group": "Уведомления",
|
||||
"itemTemplate": {
|
||||
"eyebrowHtml": "СЕЙЧАС",
|
||||
"eyebrowClass": "red-txt gray",
|
||||
"title": "Новое уведомление",
|
||||
"body": "Текст уведомления."
|
||||
},
|
||||
"itemFields": [
|
||||
{
|
||||
"path": "content.navigation.links.3.href",
|
||||
"label": "Ссылка 4: адрес",
|
||||
"kind": "url",
|
||||
"renderAs": "attr",
|
||||
"group": "Шапка"
|
||||
},
|
||||
{
|
||||
"path": "content.notifications.0.eyebrowHtml",
|
||||
"label": "Уведомление 1: дата/статус",
|
||||
"path": "eyebrowHtml",
|
||||
"label": "Дата / статус",
|
||||
"kind": "html",
|
||||
"renderAs": "html",
|
||||
"group": "Уведомления"
|
||||
},
|
||||
{
|
||||
"path": "content.notifications.0.title",
|
||||
"label": "Уведомление 1: заголовок",
|
||||
"path": "eyebrowClass",
|
||||
"label": "CSS-класс статуса",
|
||||
"kind": "text",
|
||||
"renderAs": "attr",
|
||||
"group": "Уведомления"
|
||||
},
|
||||
{
|
||||
"path": "title",
|
||||
"label": "Заголовок",
|
||||
"kind": "text",
|
||||
"renderAs": "text",
|
||||
"group": "Уведомления"
|
||||
},
|
||||
{
|
||||
"path": "content.notifications.0.body",
|
||||
"label": "Уведомление 1: текст",
|
||||
"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: текст",
|
||||
"path": "body",
|
||||
"label": "Текст",
|
||||
"kind": "textarea",
|
||||
"renderAs": "text",
|
||||
"group": "Уведомления"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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
|
|
@ -144,7 +144,7 @@ async function renderRegion(region) {
|
|||
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 ?? []) {
|
||||
const token = REGION_TOKEN[region.id];
|
||||
if (!token) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue