NODEDC_SITE/admin/admin.js

336 lines
10 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"),
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));