266 lines
8.2 KiB
JavaScript
266 lines
8.2 KiB
JavaScript
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));
|