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"), contentFields: document.querySelector("#content-fields"), 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 getByPath(source, path) { return path.split(".").reduce((value, key) => value?.[key], source); } function setByPath(source, path, nextValue) { const keys = path.split("."); const lastKey = keys.pop(); let target = source; for (const key of keys) { if (target[key] == null) { target[key] = /^\d+$/.test(keys[0]) ? [] : {}; } target = target[key]; } target[lastKey] = nextValue; } 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; renderContentFields(block); el.json.value = JSON.stringify(block, null, 2); } function renderAll() { renderRegions(); renderEditor(); } function syncBlockFromFields() { const block = activeBlock(); if (!block) return; syncContentFields(); 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 renderContentFields(block) { el.contentFields.innerHTML = ""; if (!block.editableFields?.length) { const note = document.createElement("div"); note.className = "empty-note"; note.textContent = "Для этого блока typed-поля ещё не выделены. Пока доступно редактирование через JSON."; el.contentFields.append(note); return; } for (const editableField of block.editableFields) { const label = document.createElement("label"); const value = getByPath(block, editableField.path) ?? ""; const isLong = editableField.kind === "html" || editableField.kind === "textarea"; const input = isLong ? document.createElement("textarea") : document.createElement("input"); label.className = isLong ? "wide" : ""; label.textContent = editableField.label || editableField.path; input.dataset.path = editableField.path; input.value = value; if (!isLong) { input.type = editableField.kind === "media" || editableField.kind === "url" ? "text" : "text"; input.autocomplete = "off"; } input.addEventListener("input", () => { const active = activeBlock(); if (!active) return; setByPath(active, editableField.path, input.value); el.json.value = JSON.stringify(active, null, 2); }); label.append(input); el.contentFields.append(label); } } function syncContentFields() { const block = activeBlock(); if (!block) return; for (const input of el.contentFields.querySelectorAll("[data-path]")) { setByPath(block, input.dataset.path, input.value); } } 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));