diff --git a/admin/admin.css b/admin/admin.css index a0bab39..613862e 100644 --- a/admin/admin.css +++ b/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; } diff --git a/admin/admin.js b/admin/admin.js index cedf892..7b6d88a 100644 --- a/admin/admin.js +++ b/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: 'ДАЛЕЕ — ИЮЛЬ 2026', + eyebrowClass: "red-txt gray", title: "Открытая бета", body: "Открываем доступ всем. Будем собирать и отлаживать вместе.", }, { eyebrowHtml: 'ДАЛЕЕ — ИЮЛЬ 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); [ ["↑", "Поднять", () => { diff --git a/content/pages/home.json b/content/pages/home.json index a58a654..f6339be 100644 --- a/content/pages/home.json +++ b/content/pages/home.json @@ -33,23 +33,27 @@ "href": "index.html#pricing-wrap", "target": "" } - ] + ], + "logoSrc": "./assets/webflow/images/69ad964d951f9d17cc5a919e_logo-app.svg" }, "notifications": [ { "eyebrowHtml": "ДАЛЕЕ — ИЮЛЬ 2026", "title": "Открытая бета", - "body": "Открываем доступ всем. Будем собирать и отлаживать вместе." + "body": "Открываем доступ всем. Будем собирать и отлаживать вместе.", + "eyebrowClass": "red-txt gray" }, { "eyebrowHtml": "ДАЛЕЕ — ИЮЛЬ 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: текст", - "kind": "text", - "renderAs": "text", - "group": "Шапка" + "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": "Шапка: ссылки" + }, + { + "path": "href", + "label": "Адрес", + "kind": "url", + "renderAs": "attr", + "group": "Шапка: ссылки" + }, + { + "path": "target", + "label": "Target", + "kind": "text", + "renderAs": "attr", + "group": "Шапка: ссылки" + } + ] }, { - "path": "content.navigation.links.0.href", - "label": "Ссылка 1: адрес", - "kind": "url", - "renderAs": "attr", - "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", - "kind": "text", - "renderAs": "attr", - "group": "Шапка" - }, - { - "path": "content.navigation.links.3.label", - "label": "Ссылка 4: текст", - "kind": "text", - "renderAs": "text", - "group": "Шапка" - }, - { - "path": "content.navigation.links.3.href", - "label": "Ссылка 4: адрес", - "kind": "url", - "renderAs": "attr", - "group": "Шапка" - }, - { - "path": "content.notifications.0.eyebrowHtml", - "label": "Уведомление 1: дата/статус", - "kind": "html", - "renderAs": "html", - "group": "Уведомления" - }, - { - "path": "content.notifications.0.title", - "label": "Уведомление 1: заголовок", - "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: текст", - "kind": "textarea", - "renderAs": "text", - "group": "Уведомления" + "path": "content.notifications", + "label": "Уведомления", + "kind": "collection", + "renderAs": "collection", + "group": "Уведомления", + "itemTemplate": { + "eyebrowHtml": "СЕЙЧАС", + "eyebrowClass": "red-txt gray", + "title": "Новое уведомление", + "body": "Текст уведомления." + }, + "itemFields": [ + { + "path": "eyebrowHtml", + "label": "Дата / статус", + "kind": "html", + "renderAs": "html", + "group": "Уведомления" + }, + { + "path": "eyebrowClass", + "label": "CSS-класс статуса", + "kind": "text", + "renderAs": "attr", + "group": "Уведомления" + }, + { + "path": "title", + "label": "Заголовок", + "kind": "text", + "renderAs": "text", + "group": "Уведомления" + }, + { + "path": "body", + "label": "Текст", + "kind": "textarea", + "renderAs": "text", + "group": "Уведомления" + } + ] }, { "path": "content.dock.appTarget", diff --git a/index.html b/index.html index 9e304e4..4e658f6 100644 --- a/index.html +++ b/index.html @@ -120,4 +120,4 @@ visibility: hidden;
Как и любой кастомный код, это добавляет только ту нагрузку, которую несёт сам код.
Все файлы собираются, минифицируются и сжимаются, чтобы минимально влиять на проект и не плодить внешние библиотеки и CDN-скрипты.
После сборки файлы загружаются в Cloudflare R2 и отдаются через CDN-ссылку на выделенном домене проекта, чтобы получить быстрый и совместимый с Webflow результат.
В панели деплоя есть мгновенный откат.
Нажмите откат, верните предыдущую рабочую версию, исправьте локально и опубликуйте заново.
Помимо @plan, @build и @explore стандартных режимов агентов мы готовим @webflow режим, специально заточенный под Webflow: структуру проекта, взаимодействие с ним и эффективную разработку.
Основано на многолетнем опыте написания кастомного кода для Webflow-анимаций.
WebGL, Three.js, GSAP и кастомная функциональность собраны в набор практик, пайплайнов и модульную систему, чтобы всё работало согласованно.
Также доступна библиотека компонентов: выберите компонент, добавьте в проект и попросите ИИ адаптировать его под задачу.
При запуске NODE.DC доступна отдельная вкладка для встраивания.
В зависимости от задачи можно получить скрипты для конкретной страницы или единый app.js для всего проекта.
Сгенерированная ссылка содержит и продакшен-версию, следующую за URL деплоя, и локальную версию для предпросмотра без влияния на посетителей сайта.
Мы используем основные доступные модели: OpenAI до Claude до Gemini.
Оркестратор выбирает модель под задачу автоматически, но вы можете вручную переключиться на нужную модель.
Во время работы NODE.DC выполняет две задачи параллельно.
Поднимает локальную среду разработки на Bun, синхронизирует JavaScript и TypeScript, включает HMR и держит webhook для автоматического обновления сайта при изменениях.
Одновременно работает оркестратор ИИ-агентов: обрабатывает запросы, меняет код, проверяет состояние сборки и при необходимости обновляет проект Webflow.
Процесс многоэтапный: сначала Bun собирает, сжимает, минифицирует и подготавливает проект вместе с библиотеками.
Затем файлы отправляются в CDN: cdn.node.dc/{USERNAME}/{PROJECT-NAME}. Ссылка уже содержит нужную структуру: вы видите локальную версию, а пользователи получают опубликованный скрипт.
Система изначально рассчитана на работу с Webflow MCP.
Подключите проект и авторизацию, чтобы ИИ-агенты получили доступ к Webflow-сборке. Подключение приложения в Webflow Designer даёт агентам доступ к контенту, элементам, стилям и CSS, а кастомный код получает полный контекст проекта.






































































