diff --git a/README.md b/README.md index b74e05b..71c0d4c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,37 @@ python3 -m http.server 8081 --bind 127.0.0.1 Open http://127.0.0.1:8081/ +## Admin And Render + +The first refactor step keeps the Webflow markup intact, but moves the home page into a block-rendered structure: + +- `content/pages/home.json` - ordered page regions and block instances. +- `templates/pages/home/shell.html` - original page shell with block slots. +- `templates/pages/home/blocks/` - exact HTML chunks extracted from the current page. +- `tools/render-home.mjs` - renders `index.html` from the content model and templates. +- `admin/` - local admin UI for block order, enable/disable, duplicate, copy/paste, and JSON edits. + +Run the local admin: + +```bash +cd /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_SITE +npm run admin +``` + +Open http://127.0.0.1:8090/admin/ + +Render the public page after JSON changes: + +```bash +npm run render:home +``` + +If the current `index.html` must be re-extracted into fresh templates: + +```bash +npm run extract:home +``` + ## Structure - `index.html`, `beta-apply.html`, `founders.html`, `enterprise.html` - editable local pages. @@ -22,6 +53,7 @@ Open http://127.0.0.1:8081/ - `assets/vendor` - third-party browser scripts. - `docs/` - refactor notes, block model draft, and admin plan. - `originals` - untouched files copied from the original wget download. +- `backbase/ssscript.app` - original downloaded app bundle backup. - `asset-manifest.json` - source URL to local file mapping. - `tools/build-local-copy.mjs` - rebuild script. It uses `NODEDC_SOURCE_DIR` when provided, otherwise the original local rescue folder. - `tools/localize-ru.mjs` - Russian localization and NODE.DC brand patch script. diff --git a/admin/admin.css b/admin/admin.css new file mode 100644 index 0000000..80d45e3 --- /dev/null +++ b/admin/admin.css @@ -0,0 +1,268 @@ +:root { + color-scheme: dark; + --bg: #08090b; + --panel: #121417; + --panel-2: #181b20; + --line: #2a2f36; + --text: #f4f5f6; + --muted: #9097a1; + --accent: #1976ff; + --danger: #cf3b3b; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +button, +input, +textarea { + font: inherit; +} + +.admin-shell { + display: grid; + grid-template-columns: 360px minmax(0, 1fr); + min-height: 100vh; +} + +.sidebar { + border-right: 1px solid var(--line); + background: #0d0f12; + padding: 20px; + overflow: auto; +} + +.sidebar-head, +.editor-head, +.actions, +.button-row, +.toggle-row { + display: flex; + align-items: center; +} + +.sidebar-head, +.editor-head { + justify-content: space-between; + gap: 16px; +} + +.eyebrow { + color: var(--muted); + font-size: 12px; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +h1, +h2 { + margin: 4px 0 0; + font-size: 24px; + line-height: 1.05; + letter-spacing: 0; +} + +.regions { + display: grid; + gap: 18px; + margin-top: 24px; +} + +.region-title { + margin: 0 0 8px; + color: var(--muted); + font-size: 12px; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.block-list { + display: grid; + gap: 8px; +} + +.block-btn { + width: 100%; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + color: var(--text); + cursor: pointer; + padding: 11px 12px; + text-align: left; +} + +.block-btn:hover, +.block-btn.active { + border-color: var(--accent); +} + +.block-btn.disabled { + color: #5f6670; +} + +.block-meta { + display: block; + margin-top: 4px; + color: var(--muted); + font-size: 12px; +} + +.editor { + min-width: 0; + padding: 24px; +} + +.editor-head { + border-bottom: 1px solid var(--line); + padding-bottom: 20px; +} + +.actions, +.button-row { + gap: 8px; + flex-wrap: wrap; +} + +.primary-btn, +.ghost-btn, +.danger-btn, +.icon-btn { + border: 1px solid var(--line); + border-radius: 8px; + color: var(--text); + cursor: pointer; + min-height: 36px; + padding: 8px 12px; + text-decoration: none; +} + +.primary-btn { + border-color: var(--accent); + background: var(--accent); +} + +.ghost-btn, +.icon-btn { + background: var(--panel-2); +} + +.danger-btn { + border-color: #5d2525; + background: #241010; + color: #ffb9b9; +} + +.icon-btn { + width: 36px; + padding: 0; +} + +.empty-state { + margin-top: 24px; + max-width: 680px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + color: var(--muted); + line-height: 1.45; + padding: 16px; +} + +.block-form { + display: grid; + gap: 18px; + margin-top: 24px; +} + +.hidden { + display: none; +} + +.field-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +label { + display: grid; + gap: 8px; + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +input, +textarea { + width: 100%; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + color: var(--text); + outline: none; + padding: 10px 12px; +} + +input:focus, +textarea:focus { + border-color: var(--accent); +} + +.toggle-row { + justify-content: flex-start; + gap: 10px; +} + +.toggle-row input { + width: 18px; + height: 18px; +} + +.json-field textarea { + min-height: 360px; + resize: vertical; + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 13px; + line-height: 1.45; +} + +.status { + min-height: 24px; + margin: 18px 0 0; + color: var(--muted); + white-space: pre-wrap; +} + +@media (max-width: 900px) { + .admin-shell { + grid-template-columns: 1fr; + } + + .sidebar { + border-right: 0; + border-bottom: 1px solid var(--line); + max-height: 48vh; + } + + .editor-head { + align-items: flex-start; + flex-direction: column; + } + + .field-grid { + grid-template-columns: 1fr; + } +} diff --git a/admin/admin.js b/admin/admin.js new file mode 100644 index 0000000..460d3a7 --- /dev/null +++ b/admin/admin.js @@ -0,0 +1,265 @@ +const state = { + page: null, + selected: null, + clipboard: null, +}; + +const el = { + regions: document.querySelector("#regions"), + reload: document.querySelector("#reload"), + save: document.querySelector("#save"), + render: document.querySelector("#render"), + status: document.querySelector("#status"), + empty: document.querySelector("#empty-state"), + form: document.querySelector("#block-form"), + selectedRegion: document.querySelector("#selected-region"), + selectedTitle: document.querySelector("#selected-title"), + id: document.querySelector("#block-id"), + label: document.querySelector("#block-label"), + template: document.querySelector("#block-template"), + anchor: document.querySelector("#block-anchor"), + enabled: document.querySelector("#block-enabled"), + json: document.querySelector("#block-json"), + moveUp: document.querySelector("#move-up"), + moveDown: document.querySelector("#move-down"), + duplicate: document.querySelector("#duplicate"), + copy: document.querySelector("#copy"), + pasteAfter: document.querySelector("#paste-after"), + deleteBlock: document.querySelector("#delete-block"), +}; + +function setStatus(message) { + el.status.textContent = message; +} + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function activeRegion() { + if (!state.selected) return null; + return state.page.regions[state.selected.regionIndex]; +} + +function activeBlock() { + const region = activeRegion(); + if (!region || state.selected.blockIndex == null) return null; + return region.blocks[state.selected.blockIndex]; +} + +function uniqueId(base) { + const ids = new Set(state.page.regions.flatMap((region) => region.blocks.map((block) => block.id))); + let candidate = `${base}-copy`; + let index = 2; + + while (ids.has(candidate)) { + candidate = `${base}-copy-${index}`; + index += 1; + } + + return candidate; +} + +function renderRegions() { + el.regions.innerHTML = ""; + + state.page.regions.forEach((region, regionIndex) => { + const regionNode = document.createElement("section"); + const title = document.createElement("h3"); + const list = document.createElement("div"); + + title.className = "region-title"; + title.textContent = region.label || region.id; + list.className = "block-list"; + + region.blocks.forEach((block, blockIndex) => { + const button = document.createElement("button"); + const meta = document.createElement("span"); + const isActive = + state.selected?.regionIndex === regionIndex && state.selected?.blockIndex === blockIndex; + + button.type = "button"; + button.className = `block-btn${isActive ? " active" : ""}${block.enabled === false ? " disabled" : ""}`; + button.textContent = block.adminLabel || block.id; + meta.className = "block-meta"; + meta.textContent = `${block.id} · ${block.template}`; + button.append(meta); + button.addEventListener("click", () => { + state.selected = { regionIndex, blockIndex }; + renderAll(); + }); + + list.append(button); + }); + + regionNode.append(title, list); + el.regions.append(regionNode); + }); +} + +function renderEditor() { + const region = activeRegion(); + const block = activeBlock(); + + if (!block) { + el.empty.classList.remove("hidden"); + el.form.classList.add("hidden"); + el.selectedRegion.textContent = "Выберите блок"; + el.selectedTitle.textContent = "Контентный слой"; + return; + } + + el.empty.classList.add("hidden"); + el.form.classList.remove("hidden"); + el.selectedRegion.textContent = region.label || region.id; + el.selectedTitle.textContent = block.adminLabel || block.id; + el.id.value = block.id || ""; + el.label.value = block.adminLabel || ""; + el.template.value = block.template || ""; + el.anchor.value = block.anchor || ""; + el.enabled.checked = block.enabled !== false; + el.json.value = JSON.stringify(block, null, 2); +} + +function renderAll() { + renderRegions(); + renderEditor(); +} + +function syncBlockFromFields() { + const block = activeBlock(); + if (!block) return; + + block.id = el.id.value.trim(); + block.adminLabel = el.label.value.trim(); + block.anchor = el.anchor.value.trim() || null; + block.enabled = el.enabled.checked; + el.json.value = JSON.stringify(block, null, 2); + renderRegions(); +} + +function syncBlockFromJson() { + const region = activeRegion(); + if (!region) return false; + + try { + const parsed = JSON.parse(el.json.value); + region.blocks[state.selected.blockIndex] = parsed; + renderAll(); + setStatus("JSON блока применён локально. Нажмите «Сохранить JSON», чтобы записать файл."); + return true; + } catch (error) { + setStatus(`Ошибка JSON: ${error.message}`); + return false; + } +} + +async function loadPage() { + const response = await fetch("/api/page/home"); + if (!response.ok) throw new Error(await response.text()); + state.page = await response.json(); + state.selected = null; + renderAll(); + setStatus("Модель home.json загружена."); +} + +async function savePage() { + if (activeBlock()) syncBlockFromFields(); + + const response = await fetch("/api/page/home", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(state.page), + }); + + if (!response.ok) throw new Error(await response.text()); + setStatus("content/pages/home.json сохранён."); +} + +async function renderHome() { + const response = await fetch("/api/render/home", { method: "POST" }); + const payload = await response.json(); + + if (!response.ok || !payload.ok) { + throw new Error(payload.error || "Render failed"); + } + + setStatus(payload.stdout || "index.html собран."); +} + +function moveSelected(delta) { + const region = activeRegion(); + const index = state.selected?.blockIndex; + if (!region || index == null) return; + + const nextIndex = index + delta; + if (nextIndex < 0 || nextIndex >= region.blocks.length) return; + + const [block] = region.blocks.splice(index, 1); + region.blocks.splice(nextIndex, 0, block); + state.selected.blockIndex = nextIndex; + renderAll(); +} + +function duplicateSelected() { + const region = activeRegion(); + const block = activeBlock(); + if (!region || !block) return; + + const duplicate = clone(block); + duplicate.id = uniqueId(block.id); + duplicate.adminLabel = `${block.adminLabel || block.id} — копия`; + duplicate.scopeIds = true; + region.blocks.splice(state.selected.blockIndex + 1, 0, duplicate); + state.selected.blockIndex += 1; + renderAll(); +} + +function copySelected() { + const block = activeBlock(); + if (!block) return; + + state.clipboard = clone(block); + setStatus(`Скопирован блок: ${block.adminLabel || block.id}`); +} + +function pasteAfterSelected() { + const region = activeRegion(); + if (!region || !state.clipboard) return; + + const pasted = clone(state.clipboard); + pasted.id = uniqueId(pasted.id); + pasted.adminLabel = `${pasted.adminLabel || pasted.id} — вставка`; + pasted.scopeIds = true; + region.blocks.splice(state.selected.blockIndex + 1, 0, pasted); + state.selected.blockIndex += 1; + renderAll(); +} + +function deleteSelected() { + const region = activeRegion(); + if (!region || state.selected.blockIndex == null) return; + + region.blocks.splice(state.selected.blockIndex, 1); + state.selected.blockIndex = Math.min(state.selected.blockIndex, region.blocks.length - 1); + if (state.selected.blockIndex < 0) state.selected = null; + renderAll(); +} + +for (const input of [el.id, el.label, el.anchor, el.enabled]) { + input.addEventListener("input", syncBlockFromFields); + input.addEventListener("change", syncBlockFromFields); +} + +el.json.addEventListener("blur", syncBlockFromJson); +el.reload.addEventListener("click", () => loadPage().catch((error) => setStatus(error.message))); +el.save.addEventListener("click", () => savePage().catch((error) => setStatus(error.message))); +el.render.addEventListener("click", () => renderHome().catch((error) => setStatus(error.message))); +el.moveUp.addEventListener("click", () => moveSelected(-1)); +el.moveDown.addEventListener("click", () => moveSelected(1)); +el.duplicate.addEventListener("click", duplicateSelected); +el.copy.addEventListener("click", copySelected); +el.pasteAfter.addEventListener("click", pasteAfterSelected); +el.deleteBlock.addEventListener("click", deleteSelected); + +loadPage().catch((error) => setStatus(error.message)); diff --git a/admin/index.html b/admin/index.html new file mode 100644 index 0000000..4bfc4da --- /dev/null +++ b/admin/index.html @@ -0,0 +1,85 @@ + + +
+ + +
Как и любой кастомный код, это добавляет только ту нагрузку, которую несёт сам код.
Все файлы собираются, минифицируются и сжимаются, чтобы минимально влиять на проект и не плодить внешние библиотеки и 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, а кастомный код получает полный контекст проекта.





































































