Add admin block templates and drag controls

This commit is contained in:
dcconstructions 2026-06-26 03:16:12 +03:00
parent 634eaff434
commit 931ac628b4
3 changed files with 524 additions and 22 deletions

View File

@ -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;
}

View File

@ -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)));

View File

@ -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,20 +122,32 @@
</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>
<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>
<section class="content-panel">
@ -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>