Add admin block templates and drag controls
This commit is contained in:
parent
634eaff434
commit
931ac628b4
233
admin/admin.css
233
admin/admin.css
|
|
@ -332,6 +332,11 @@ p {
|
|||
line-height: 1;
|
||||
}
|
||||
|
||||
.add-block-button {
|
||||
font-size: 1.55rem;
|
||||
font-weight: 520;
|
||||
}
|
||||
|
||||
.admin-panel-close:hover {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
|
|
@ -433,9 +438,11 @@ p {
|
|||
min-height: calc(var(--admin-control-ring) + (var(--admin-control-inset) * 2));
|
||||
align-items: center;
|
||||
gap: 0.86rem;
|
||||
border: 0;
|
||||
border-radius: var(--launcher-radius-circle);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.66);
|
||||
cursor: pointer;
|
||||
opacity: 0.66;
|
||||
padding: var(--admin-control-inset) 0.9rem var(--admin-control-inset) var(--admin-control-inset);
|
||||
text-align: left;
|
||||
|
|
@ -447,6 +454,10 @@ p {
|
|||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.block-btn.has-drag-handle {
|
||||
grid-template-columns: var(--admin-control-ring) minmax(0, 1fr) 1.35rem;
|
||||
}
|
||||
|
||||
.block-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.085);
|
||||
color: var(--text-primary);
|
||||
|
|
@ -472,6 +483,14 @@ p {
|
|||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.block-btn.dragging {
|
||||
opacity: 0.46;
|
||||
}
|
||||
|
||||
.block-btn.drag-over {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.block-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
|
|
@ -496,6 +515,30 @@ p {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.block-drag-handle {
|
||||
display: grid;
|
||||
width: 1.35rem;
|
||||
height: 2.2rem;
|
||||
place-items: center;
|
||||
border-radius: var(--launcher-radius-circle);
|
||||
color: rgba(255, 255, 255, 0.38);
|
||||
cursor: grab;
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.12em;
|
||||
line-height: 1;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.block-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.block-drag-handle:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.editor {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
|
|
@ -550,10 +593,23 @@ p {
|
|||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.structure-card {
|
||||
padding-block: 0.78rem;
|
||||
}
|
||||
|
||||
.structure-head {
|
||||
min-height: var(--nodedc-shell-control-height);
|
||||
}
|
||||
|
||||
.icon-action-row {
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
.primary-btn,
|
||||
.ghost-btn,
|
||||
.danger-btn,
|
||||
.icon-btn {
|
||||
.icon-btn,
|
||||
.icon-action-btn {
|
||||
display: inline-flex;
|
||||
min-height: var(--nodedc-shell-control-height);
|
||||
align-items: center;
|
||||
|
|
@ -570,6 +626,28 @@ p {
|
|||
opacity 160ms ease;
|
||||
}
|
||||
|
||||
.icon-action-btn {
|
||||
width: var(--nodedc-shell-control-height);
|
||||
min-width: var(--nodedc-shell-control-height);
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.075);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.icon-action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.13);
|
||||
}
|
||||
|
||||
.danger-action-btn {
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.operation-extra {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
min-width: 9rem;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
|
|
@ -1010,6 +1088,147 @@ textarea {
|
|||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.template-modal-layer {
|
||||
position: fixed;
|
||||
z-index: 200;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.34);
|
||||
padding: 1.4rem;
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.template-modal {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: min(58rem, calc(100vw - 2.8rem));
|
||||
max-height: min(46rem, calc(100vh - 2.8rem));
|
||||
overflow: hidden;
|
||||
border-radius: var(--launcher-radius-modal);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.02)),
|
||||
rgba(8, 8, 11, 0.8);
|
||||
box-shadow: 0 44px 130px rgba(0, 0, 0, 0.62);
|
||||
backdrop-filter: blur(40px) saturate(1.12);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(1.12);
|
||||
}
|
||||
|
||||
.template-modal::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.template-modal > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.template-modal-head,
|
||||
.template-modal-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.template-modal-head {
|
||||
padding-right: 4.5rem;
|
||||
}
|
||||
|
||||
.template-modal-head h2 {
|
||||
font-size: 1.18rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.template-modal-head p {
|
||||
margin-top: 0.4rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.38;
|
||||
}
|
||||
|
||||
.modal-close-button {
|
||||
top: 1.05rem;
|
||||
right: 1.05rem;
|
||||
}
|
||||
|
||||
.template-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
display: grid;
|
||||
grid-template-columns: var(--admin-control-ring) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.86rem;
|
||||
min-height: 4.8rem;
|
||||
border-radius: var(--launcher-radius-card);
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
padding: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.template-card:hover {
|
||||
background: rgba(255, 255, 255, 0.095);
|
||||
color: var(--text-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.template-card-icon {
|
||||
display: grid;
|
||||
width: var(--admin-control-ring);
|
||||
height: var(--admin-control-ring);
|
||||
place-items: center;
|
||||
border-radius: var(--launcher-radius-circle);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: rgba(8, 8, 10, 0.96);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.template-card-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.template-card-title {
|
||||
overflow: hidden;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 820;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.template-card-meta,
|
||||
.template-card-region {
|
||||
overflow: hidden;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.template-modal-foot {
|
||||
justify-content: flex-start;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
body {
|
||||
overflow: auto;
|
||||
|
|
@ -1060,6 +1279,10 @@ textarea {
|
|||
.actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.template-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
|
|
@ -1088,6 +1311,14 @@ textarea {
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
.structure-head {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.icon-action-row {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.panel-note {
|
||||
text-align: left;
|
||||
}
|
||||
|
|
|
|||
257
admin/admin.js
257
admin/admin.js
|
|
@ -2,10 +2,13 @@ const state = {
|
|||
page: null,
|
||||
selected: null,
|
||||
clipboard: null,
|
||||
drag: null,
|
||||
dragTarget: null,
|
||||
};
|
||||
|
||||
const el = {
|
||||
regions: document.querySelector("#regions"),
|
||||
addBlock: document.querySelector("#add-block"),
|
||||
reload: document.querySelector("#reload"),
|
||||
save: document.querySelector("#save"),
|
||||
render: document.querySelector("#render"),
|
||||
|
|
@ -27,6 +30,10 @@ const el = {
|
|||
copy: document.querySelector("#copy"),
|
||||
pasteAfter: document.querySelector("#paste-after"),
|
||||
deleteBlock: document.querySelector("#delete-block"),
|
||||
templateModal: document.querySelector("#template-modal"),
|
||||
templateList: document.querySelector("#template-list"),
|
||||
templateModalClose: document.querySelector("#template-modal-close"),
|
||||
templateModalCancel: document.querySelector("#template-modal-cancel"),
|
||||
};
|
||||
|
||||
const STATIC_FALLBACK = {
|
||||
|
|
@ -249,6 +256,10 @@ function uniqueId(base) {
|
|||
return candidate;
|
||||
}
|
||||
|
||||
function uniqueBlockId(base) {
|
||||
return uniqueId(safeBucketSegment(base || "block").replace(/-copy(?:-\d+)?$/, ""));
|
||||
}
|
||||
|
||||
function truncateText(value, maxLength = 22) {
|
||||
if (!value) return "";
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
|
||||
|
|
@ -338,15 +349,109 @@ function blockMeta(block) {
|
|||
return block.template || block.type || block.id;
|
||||
}
|
||||
|
||||
function renderBlockButton({ list, block, meta, active, disabled, onClick }) {
|
||||
const button = document.createElement("button");
|
||||
function cleanupDragState() {
|
||||
state.drag = null;
|
||||
state.dragTarget = null;
|
||||
el.regions.querySelectorAll(".dragging, .drag-over").forEach((node) => {
|
||||
node.classList.remove("dragging", "drag-over");
|
||||
});
|
||||
}
|
||||
|
||||
function dragContextFromPoint(event) {
|
||||
const target = document.elementFromPoint(event.clientX, event.clientY);
|
||||
const button = target?.closest?.(".block-btn.has-drag-handle");
|
||||
if (!button || !el.regions.contains(button)) return null;
|
||||
|
||||
return {
|
||||
node: button,
|
||||
context: {
|
||||
regionIndex: Number(button.dataset.regionIndex),
|
||||
blockIndex: Number(button.dataset.blockIndex),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function startPointerDrag(event, button, dragContext) {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
state.drag = dragContext;
|
||||
state.dragTarget = dragContext;
|
||||
button.classList.add("dragging");
|
||||
|
||||
const onMove = (moveEvent) => {
|
||||
const target = dragContextFromPoint(moveEvent);
|
||||
el.regions.querySelectorAll(".drag-over").forEach((node) => node.classList.remove("drag-over"));
|
||||
|
||||
if (!target || target.context.regionIndex !== dragContext.regionIndex) return;
|
||||
|
||||
state.dragTarget = target.context;
|
||||
target.node.classList.add("drag-over");
|
||||
};
|
||||
|
||||
const onFinish = () => {
|
||||
document.removeEventListener("pointermove", onMove);
|
||||
document.removeEventListener("pointerup", onFinish);
|
||||
document.removeEventListener("pointercancel", onCancel);
|
||||
|
||||
if (state.drag && state.dragTarget) {
|
||||
reorderBlockWithinRegion(state.drag, state.dragTarget);
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupDragState();
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
document.removeEventListener("pointermove", onMove);
|
||||
document.removeEventListener("pointerup", onFinish);
|
||||
document.removeEventListener("pointercancel", onCancel);
|
||||
cleanupDragState();
|
||||
};
|
||||
|
||||
document.addEventListener("pointermove", onMove);
|
||||
document.addEventListener("pointerup", onFinish);
|
||||
document.addEventListener("pointercancel", onCancel);
|
||||
}
|
||||
|
||||
function reorderBlockWithinRegion(from, to) {
|
||||
if (!from || from.regionIndex !== to.regionIndex || from.blockIndex === to.blockIndex) {
|
||||
cleanupDragState();
|
||||
return;
|
||||
}
|
||||
|
||||
const region = state.page.regions[from.regionIndex];
|
||||
if (!region) {
|
||||
cleanupDragState();
|
||||
return;
|
||||
}
|
||||
|
||||
const [block] = region.blocks.splice(from.blockIndex, 1);
|
||||
let insertIndex = to.blockIndex;
|
||||
if (from.blockIndex < to.blockIndex) insertIndex -= 1;
|
||||
region.blocks.splice(insertIndex, 0, block);
|
||||
state.selected = { kind: "block", regionIndex: from.regionIndex, blockIndex: insertIndex };
|
||||
cleanupDragState();
|
||||
renderAll();
|
||||
setStatus("Порядок блоков изменён локально. Нажмите «Сохранить модель» или «Обновить index.html».");
|
||||
}
|
||||
|
||||
function renderBlockButton({ list, block, meta, active, disabled, onClick, dragContext = null }) {
|
||||
const button = document.createElement("div");
|
||||
const icon = document.createElement("span");
|
||||
const copy = document.createElement("span");
|
||||
const name = document.createElement("span");
|
||||
const description = document.createElement("span");
|
||||
const canDrag = Boolean(dragContext);
|
||||
|
||||
button.type = "button";
|
||||
button.className = `block-btn${active ? " active" : ""}${disabled ? " disabled" : ""}`;
|
||||
button.role = "button";
|
||||
button.tabIndex = 0;
|
||||
button.className = `block-btn${active ? " active" : ""}${disabled ? " disabled" : ""}${canDrag ? " has-drag-handle" : ""}`;
|
||||
if (canDrag) {
|
||||
button.dataset.regionIndex = String(dragContext.regionIndex);
|
||||
button.dataset.blockIndex = String(dragContext.blockIndex);
|
||||
}
|
||||
icon.className = "block-icon";
|
||||
icon.textContent = blockIcon(block);
|
||||
copy.className = "block-copy";
|
||||
|
|
@ -356,7 +461,48 @@ function renderBlockButton({ list, block, meta, active, disabled, onClick }) {
|
|||
description.textContent = meta;
|
||||
copy.append(name, description);
|
||||
button.append(icon, copy);
|
||||
|
||||
if (canDrag) {
|
||||
const handle = document.createElement("span");
|
||||
handle.className = "block-drag-handle";
|
||||
handle.draggable = true;
|
||||
handle.title = "Перетащить блок";
|
||||
handle.setAttribute("aria-label", "Перетащить блок");
|
||||
handle.textContent = "⋮⋮";
|
||||
handle.addEventListener("click", (event) => event.stopPropagation());
|
||||
handle.addEventListener("pointerdown", (event) => startPointerDrag(event, button, dragContext));
|
||||
handle.addEventListener("dragstart", (event) => {
|
||||
state.drag = dragContext;
|
||||
button.classList.add("dragging");
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", `${dragContext.regionIndex}:${dragContext.blockIndex}`);
|
||||
});
|
||||
handle.addEventListener("dragend", cleanupDragState);
|
||||
handle.addEventListener("keydown", (event) => event.stopPropagation());
|
||||
button.append(handle);
|
||||
}
|
||||
|
||||
if (canDrag) {
|
||||
button.addEventListener("dragover", (event) => {
|
||||
if (!state.drag || state.drag.regionIndex !== dragContext.regionIndex) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
button.classList.add("drag-over");
|
||||
});
|
||||
button.addEventListener("dragleave", () => button.classList.remove("drag-over"));
|
||||
button.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
button.classList.remove("drag-over");
|
||||
reorderBlockWithinRegion(state.drag, dragContext);
|
||||
});
|
||||
}
|
||||
|
||||
button.addEventListener("click", onClick);
|
||||
button.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
onClick(event);
|
||||
});
|
||||
list.append(button);
|
||||
}
|
||||
|
||||
|
|
@ -405,6 +551,7 @@ function renderRegions() {
|
|||
meta: blockMeta(block),
|
||||
active: isActive,
|
||||
disabled: block.enabled === false,
|
||||
dragContext: { regionIndex, blockIndex },
|
||||
onClick: () => {
|
||||
state.selected = { kind: "block", regionIndex, blockIndex };
|
||||
renderAll();
|
||||
|
|
@ -417,6 +564,86 @@ function renderRegions() {
|
|||
});
|
||||
}
|
||||
|
||||
function blockTemplates() {
|
||||
const seen = new Set();
|
||||
const templates = [];
|
||||
|
||||
state.page.regions.forEach((region, regionIndex) => {
|
||||
region.blocks.forEach((block) => {
|
||||
const fingerprint = `${block.template || block.type}:${block.type || ""}`;
|
||||
if (seen.has(fingerprint)) return;
|
||||
seen.add(fingerprint);
|
||||
templates.push({
|
||||
key: `${region.id}:${block.id}`,
|
||||
regionId: region.id,
|
||||
regionIndex,
|
||||
regionLabel: labelForRegion(region),
|
||||
block,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return templates;
|
||||
}
|
||||
|
||||
function renderTemplateModal() {
|
||||
el.templateList.innerHTML = "";
|
||||
|
||||
for (const template of blockTemplates()) {
|
||||
const card = document.createElement("button");
|
||||
const icon = document.createElement("span");
|
||||
const copy = document.createElement("span");
|
||||
const title = document.createElement("span");
|
||||
const meta = document.createElement("span");
|
||||
const region = document.createElement("span");
|
||||
|
||||
card.type = "button";
|
||||
card.className = "template-card";
|
||||
icon.className = "template-card-icon";
|
||||
icon.textContent = blockIcon(template.block);
|
||||
copy.className = "template-card-copy";
|
||||
title.className = "template-card-title";
|
||||
title.textContent = template.block.adminLabel || template.block.id;
|
||||
meta.className = "template-card-meta";
|
||||
meta.textContent = blockMeta(template.block);
|
||||
region.className = "template-card-region";
|
||||
region.textContent = `Добавится в: ${template.regionLabel}`;
|
||||
copy.append(title, meta, region);
|
||||
card.append(icon, copy);
|
||||
card.addEventListener("click", () => addBlockFromTemplate(template.key));
|
||||
el.templateList.append(card);
|
||||
}
|
||||
}
|
||||
|
||||
function openTemplateModal() {
|
||||
renderTemplateModal();
|
||||
el.templateModal.classList.remove("hidden");
|
||||
el.templateList.querySelector("button")?.focus();
|
||||
}
|
||||
|
||||
function closeTemplateModal() {
|
||||
el.templateModal.classList.add("hidden");
|
||||
el.addBlock.focus();
|
||||
}
|
||||
|
||||
function addBlockFromTemplate(templateKey) {
|
||||
const template = blockTemplates().find((candidate) => candidate.key === templateKey);
|
||||
if (!template) return;
|
||||
|
||||
const region = state.page.regions[template.regionIndex];
|
||||
const block = clone(template.block);
|
||||
|
||||
block.id = uniqueBlockId(block.id);
|
||||
block.adminLabel = `${block.adminLabel || block.id} — копия`;
|
||||
block.enabled = true;
|
||||
block.scopeIds = true;
|
||||
region.blocks.push(block);
|
||||
state.selected = { kind: "block", regionIndex: template.regionIndex, blockIndex: region.blocks.length - 1 };
|
||||
closeTemplateModal();
|
||||
renderAll();
|
||||
setStatus(`Добавлен блок «${block.adminLabel}». Нажмите «Сохранить модель» или «Обновить index.html».`);
|
||||
}
|
||||
|
||||
function setStructuralControlsDisabled(disabled) {
|
||||
for (const control of [el.moveUp, el.moveDown, el.duplicate, el.copy, el.pasteAfter, el.deleteBlock]) {
|
||||
control.disabled = disabled;
|
||||
|
|
@ -808,7 +1035,7 @@ async function loadPage() {
|
|||
setStatus("Модель home.json загружена.");
|
||||
}
|
||||
|
||||
async function savePage() {
|
||||
async function persistPage() {
|
||||
if (activeBlock()) syncBlockFromFields();
|
||||
|
||||
const response = await fetch("/api/page/home", {
|
||||
|
|
@ -818,10 +1045,15 @@ async function savePage() {
|
|||
});
|
||||
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
setStatus("content/pages/home.json сохранён.");
|
||||
}
|
||||
|
||||
async function savePage() {
|
||||
await persistPage();
|
||||
setStatus("Модель сохранена в content/pages/home.json. Для публичной страницы нажмите «Обновить index.html».");
|
||||
}
|
||||
|
||||
async function renderHome() {
|
||||
await persistPage();
|
||||
const response = await fetch("/api/render/home", { method: "POST" });
|
||||
const payload = await response.json();
|
||||
|
||||
|
|
@ -829,7 +1061,7 @@ async function renderHome() {
|
|||
throw new Error(payload.error || "Render failed");
|
||||
}
|
||||
|
||||
setStatus(payload.stdout || "index.html собран.");
|
||||
setStatus(payload.stdout ? `Модель сохранена. ${payload.stdout}` : "Модель сохранена, index.html пересобран.");
|
||||
}
|
||||
|
||||
function moveSelected(delta) {
|
||||
|
|
@ -902,6 +1134,17 @@ for (const input of [el.id, el.label, el.anchor, el.enabled]) {
|
|||
}
|
||||
|
||||
el.json.addEventListener("blur", syncBlockFromJson);
|
||||
el.addBlock.addEventListener("click", openTemplateModal);
|
||||
el.templateModalClose.addEventListener("click", closeTemplateModal);
|
||||
el.templateModalCancel.addEventListener("click", closeTemplateModal);
|
||||
el.templateModal.addEventListener("click", (event) => {
|
||||
if (event.target === el.templateModal) closeTemplateModal();
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && !el.templateModal.classList.contains("hidden")) {
|
||||
closeTemplateModal();
|
||||
}
|
||||
});
|
||||
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)));
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@
|
|||
<div class="section-kicker">NODE.DC</div>
|
||||
<h1>Администрирование</h1>
|
||||
</div>
|
||||
<a class="admin-panel-close" href="/" target="_blank" aria-label="Открыть сайт">×</a>
|
||||
<button id="add-block" class="admin-panel-close add-block-button" type="button" aria-label="Добавить блок">+</button>
|
||||
<div class="workspace-card admin-panel-client-select">
|
||||
<div class="workspace-logo admin-panel-client-select__icon">DC</div>
|
||||
<div>
|
||||
|
|
@ -76,8 +76,8 @@
|
|||
</div>
|
||||
<div class="actions">
|
||||
<button id="reload" class="icon-btn" type="button" title="Перезагрузить данные">↻</button>
|
||||
<button id="save" class="primary-btn" type="button">Сохранить JSON</button>
|
||||
<button id="render" class="ghost-btn" type="button">Собрать index.html</button>
|
||||
<button id="save" class="primary-btn" type="button" title="Записать текущую модель в content/pages/home.json">Сохранить модель</button>
|
||||
<button id="render" class="ghost-btn" type="button" title="Сохранить модель и пересобрать публичный index.html">Обновить index.html</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -98,7 +98,7 @@
|
|||
</div>
|
||||
<label class="toggle-row">
|
||||
<input id="block-enabled" name="enabled" type="checkbox" />
|
||||
<span>В рендере</span>
|
||||
<span>Отображение</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
|
|
@ -122,19 +122,31 @@
|
|||
</section>
|
||||
|
||||
<section class="settings-card structure-card">
|
||||
<div class="card-head">
|
||||
<div class="card-head structure-head">
|
||||
<div>
|
||||
<div class="section-kicker">Порядок</div>
|
||||
<div class="section-kicker">Порядок блока</div>
|
||||
<h3>Операции с блоком</h3>
|
||||
</div>
|
||||
<div class="button-row icon-action-row">
|
||||
<button id="move-up" class="icon-action-btn" type="button" title="Поднять блок" aria-label="Поднять блок">
|
||||
<span aria-hidden="true">↑</span>
|
||||
</button>
|
||||
<button id="move-down" class="icon-action-btn" type="button" title="Опустить блок" aria-label="Опустить блок">
|
||||
<span aria-hidden="true">↓</span>
|
||||
</button>
|
||||
<button id="duplicate" class="icon-action-btn" type="button" title="Дублировать блок" aria-label="Дублировать блок">
|
||||
<span aria-hidden="true">⧉</span>
|
||||
</button>
|
||||
<button id="copy" class="icon-action-btn operation-extra" type="button" title="Копировать блок" aria-label="Копировать блок">
|
||||
<span aria-hidden="true">□</span>
|
||||
</button>
|
||||
<button id="paste-after" class="icon-action-btn operation-extra" type="button" title="Вставить после" aria-label="Вставить после">
|
||||
<span aria-hidden="true">▣</span>
|
||||
</button>
|
||||
<button id="delete-block" class="icon-action-btn danger-action-btn" type="button" title="Удалить блок" aria-label="Удалить блок">
|
||||
<span aria-hidden="true">⌫</span>
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
|
|
@ -161,6 +173,22 @@
|
|||
<pre id="status" class="status"></pre>
|
||||
</section>
|
||||
</main>
|
||||
<div id="template-modal" class="template-modal-layer hidden" role="dialog" aria-modal="true" aria-labelledby="template-modal-title">
|
||||
<article class="template-modal">
|
||||
<header class="template-modal-head">
|
||||
<div>
|
||||
<div class="section-kicker">Библиотека блоков</div>
|
||||
<h2 id="template-modal-title">Добавить блок</h2>
|
||||
<p>Выберите шаблон. Новый блок появится в конце соответствующего списка.</p>
|
||||
</div>
|
||||
<button id="template-modal-close" class="admin-panel-close modal-close-button" type="button" aria-label="Закрыть">×</button>
|
||||
</header>
|
||||
<div id="template-list" class="template-list"></div>
|
||||
<footer class="template-modal-foot">
|
||||
<button id="template-modal-cancel" class="ghost-btn" type="button">Отмена</button>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
<script src="./admin.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Reference in New Issue