Add static home block renderer and local admin

This commit is contained in:
dcconstructions
2026-06-25 21:36:08 +03:00
parent 03e19601f3
commit aaae2da433
18 changed files with 1313 additions and 0 deletions
+268
View File
@@ -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;
}
}
+265
View File
@@ -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));
+85
View File
@@ -0,0 +1,85 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NODE.DC Admin</title>
<link rel="stylesheet" href="./admin.css" />
</head>
<body>
<main class="admin-shell">
<aside class="sidebar">
<div class="sidebar-head">
<div>
<div class="eyebrow">NODE.DC</div>
<h1>Блоки страницы</h1>
</div>
<button id="reload" class="icon-btn" type="button" title="Перезагрузить данные"></button>
</div>
<div id="regions" class="regions"></div>
</aside>
<section class="editor">
<header class="editor-head">
<div>
<div id="selected-region" class="eyebrow">Выберите блок</div>
<h2 id="selected-title">Контентный слой</h2>
</div>
<div class="actions">
<a class="ghost-btn" href="/" target="_blank">Открыть сайт</a>
<button id="render" class="ghost-btn" type="button">Собрать index.html</button>
<button id="save" class="primary-btn" type="button">Сохранить JSON</button>
</div>
</header>
<div id="empty-state" class="empty-state">
Слева выберите блок. Сейчас это первый этап: блоки можно включать, выключать,
дублировать и менять порядок внутри своего DOM-региона.
</div>
<form id="block-form" class="block-form hidden">
<div class="field-grid">
<label>
ID блока
<input id="block-id" name="id" type="text" autocomplete="off" />
</label>
<label>
Название в админке
<input id="block-label" name="adminLabel" type="text" autocomplete="off" />
</label>
<label>
Шаблон
<input id="block-template" name="template" type="text" autocomplete="off" readonly />
</label>
<label>
Anchor
<input id="block-anchor" name="anchor" type="text" autocomplete="off" />
</label>
</div>
<label class="toggle-row">
<input id="block-enabled" name="enabled" type="checkbox" />
<span>Блок включён в рендер</span>
</label>
<div class="button-row">
<button id="move-up" class="ghost-btn" type="button">Вверх</button>
<button id="move-down" class="ghost-btn" type="button">Вниз</button>
<button id="duplicate" class="ghost-btn" type="button">Дублировать</button>
<button id="copy" class="ghost-btn" type="button">Копировать</button>
<button id="paste-after" class="ghost-btn" type="button">Вставить после</button>
<button id="delete-block" class="danger-btn" type="button">Удалить</button>
</div>
<label class="json-field">
JSON выбранного блока
<textarea id="block-json" spellcheck="false"></textarea>
</label>
</form>
<pre id="status" class="status"></pre>
</section>
</main>
<script src="./admin.js" type="module"></script>
</body>
</html>