Добавить блок
+Выберите шаблон. Новый блок появится в конце соответствующего списка.
+diff --git a/admin/admin.css b/admin/admin.css index fc2bf6a..8c6883d 100644 --- a/admin/admin.css +++ b/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; } diff --git a/admin/admin.js b/admin/admin.js index e8e8824..069f488 100644 --- a/admin/admin.js +++ b/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))); diff --git a/admin/index.html b/admin/index.html index ccdbad0..c213813 100644 --- a/admin/index.html +++ b/admin/index.html @@ -55,7 +55,7 @@