Подтвердите действие
+Вы действительно подтвердить удаление элемента?
+diff --git a/admin/admin.css b/admin/admin.css index bdc61ca..df0ed43 100644 --- a/admin/admin.css +++ b/admin/admin.css @@ -989,8 +989,37 @@ body[data-file-picker-active="true"] .editor { white-space: nowrap; } +.number-control { + display: flex; + min-height: 2.72rem; + align-items: center; + gap: 0.52rem; + border-radius: var(--launcher-radius-control); + background: var(--field); + padding: 0 0.86rem; +} + +.number-control input[type="number"] { + width: 100%; + min-width: 0; + border: 0; + background: transparent; + color: var(--text-primary); + font-size: 0.86rem; + font-weight: 820; + padding: 0; +} + +.number-suffix { + flex: 0 0 auto; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 850; +} + .boolean-field input, -.group-head-toggle input { +.group-head-toggle input, +.collection-visibility-toggle input { width: 2.42rem; height: 1.32rem; margin: 0; @@ -1001,7 +1030,8 @@ body[data-file-picker-active="true"] .editor { } .boolean-field input::before, -.group-head-toggle input::before { +.group-head-toggle input::before, +.collection-visibility-toggle input::before { display: block; width: 1rem; aspect-ratio: 1; @@ -1012,12 +1042,14 @@ body[data-file-picker-active="true"] .editor { } .boolean-field input:checked, -.group-head-toggle input:checked { +.group-head-toggle input:checked, +.collection-visibility-toggle input:checked { background: rgba(255, 255, 255, 0.9); } .boolean-field input:checked::before, -.group-head-toggle input:checked::before { +.group-head-toggle input:checked::before, +.collection-visibility-toggle input:checked::before { background: #050506; transform: translateX(1.1rem); } @@ -1098,6 +1130,10 @@ body[data-file-picker-active="true"] .editor { opacity: 0.16; } +.collection-item-card.is-hidden { + opacity: 0.55; +} + .collection-summary { min-height: 3rem; cursor: pointer; @@ -1110,6 +1146,8 @@ body[data-file-picker-active="true"] .editor { } .collection-summary-title { + min-width: 0; + flex: 1 1 auto; overflow: hidden; color: var(--text-secondary); font-size: 0.82rem; @@ -1124,6 +1162,45 @@ body[data-file-picker-active="true"] .editor { gap: 0.22rem; } +.collection-visibility-toggle { + display: inline-flex; + height: 2.2rem; + align-items: center; + gap: 0.38rem; + border-radius: var(--launcher-radius-circle); + background: rgba(255, 255, 255, 0.07); + padding: 0 0.52rem; + color: var(--text-secondary); + cursor: pointer; +} + +.collection-visibility-toggle:hover { + background: rgba(255, 255, 255, 0.14); + color: var(--text-primary); +} + +.collection-visibility-toggle span { + font-size: 0.68rem; + font-weight: 850; + line-height: 1; + text-transform: uppercase; +} + +.collection-visibility-toggle input { + width: 2rem; + height: 1.12rem; + flex: 0 0 auto; + padding: 0.13rem; +} + +.collection-visibility-toggle input::before { + width: 0.86rem; +} + +.collection-visibility-toggle input:checked::before { + transform: translateX(0.88rem); +} + .collection-drag-handle, .collection-icon-btn { display: grid; @@ -1589,6 +1666,33 @@ textarea { padding-top: 0; } +.delete-modal-layer { + z-index: 240; +} + +.delete-modal { + width: min(30rem, calc(100vw - 2.8rem)); +} + +.delete-modal-head { + padding-right: 1.25rem; +} + +.delete-modal-foot { + justify-content: flex-end; +} + +.delete-confirm-btn { + min-width: 4.4rem; + background: rgba(255, 255, 255, 0.92); + color: rgba(8, 8, 10, 0.96); +} + +.delete-confirm-btn:hover { + background: #fff; + color: rgba(8, 8, 10, 1); +} + @media (max-width: 1100px) { body { overflow: auto; diff --git a/admin/admin.js b/admin/admin.js index a6084a7..13e0a9b 100644 --- a/admin/admin.js +++ b/admin/admin.js @@ -9,8 +9,12 @@ const state = { filePickerActive: false, mediaUploadActive: false, filePickerResetTimer: null, + deleteConfirmResolve: null, }; +const SECTIONS_REGION_ID = "sections"; +const SECTIONS_REGION_LABEL = "Секции"; + const el = { regions: document.querySelector("#regions"), addBlock: document.querySelector("#add-block"), @@ -40,6 +44,11 @@ const el = { templateList: document.querySelector("#template-list"), templateModalClose: document.querySelector("#template-modal-close"), templateModalCancel: document.querySelector("#template-modal-cancel"), + deleteModal: document.querySelector("#delete-modal"), + deleteModalTitle: document.querySelector("#delete-modal-title"), + deleteModalBody: document.querySelector("#delete-modal-body"), + deleteModalCancel: document.querySelector("#delete-modal-cancel"), + deleteModalConfirm: document.querySelector("#delete-modal-confirm"), }; const STATIC_FALLBACK = { @@ -423,6 +432,32 @@ function ensureStaticElements() { state.page.staticElements.editableFields = clone(STATIC_FALLBACK.editableFields); } +function ensureSectionsRegion() { + const regions = Array.isArray(state.page.regions) ? state.page.regions : []; + const blocks = regions.flatMap((region) => + (region.blocks ?? []).map((block) => { + block.region ||= region.id; + return block; + }), + ); + + state.page.regions = [ + { + id: SECTIONS_REGION_ID, + label: SECTIONS_REGION_LABEL, + description: "Единый порядок секций главной страницы.", + blocks, + }, + ]; +} + +function sectionsRegionIndex() { + return Math.max( + 0, + state.page.regions.findIndex((region) => region.id === SECTIONS_REGION_ID), + ); +} + function isStaticSelection() { return state.selected?.kind === "static"; } @@ -559,8 +594,7 @@ function readFileAsDataUrl(file) { } function labelForRegion(region) { - if (region.id === "beforeCViz") return "Главная"; - if (region.id === "cViz") return "Секции"; + if (region.id === SECTIONS_REGION_ID || region.id === "beforeCViz" || region.id === "cViz") return SECTIONS_REGION_LABEL; return region.label || region.id; } @@ -855,14 +889,13 @@ function renderRegions() { } function blockTemplates() { - return state.templates.map((template) => { - const regionIndex = state.page.regions.findIndex((region) => region.id === template.regionId); - const region = state.page.regions[regionIndex] || state.page.regions[0]; + const regionIndex = sectionsRegionIndex(); + return state.templates.map((template) => { return { ...template, - regionIndex: regionIndex >= 0 ? regionIndex : 0, - regionLabel: template.regionLabel || labelForRegion(region), + regionIndex, + regionLabel: SECTIONS_REGION_LABEL, }; }); } @@ -907,6 +940,29 @@ function closeTemplateModal() { el.addBlock.focus(); } +function closeDeleteModal(confirmed = false) { + const resolve = state.deleteConfirmResolve; + + state.deleteConfirmResolve = null; + el.deleteModal.classList.add("hidden"); + if (resolve) resolve(confirmed); +} + +function requestDeleteConfirmation({ + title = "Подтвердите действие", + body = "Вы действительно подтвердить удаление элемента?", +} = {}) { + if (state.deleteConfirmResolve) closeDeleteModal(false); + + return new Promise((resolve) => { + state.deleteConfirmResolve = resolve; + el.deleteModalTitle.textContent = title; + el.deleteModalBody.textContent = body; + el.deleteModal.classList.remove("hidden"); + el.deleteModalConfirm.focus(); + }); +} + function addBlockFromTemplate(templateKey) { const template = blockTemplates().find((candidate) => candidate.key === templateKey); if (!template) return; @@ -1142,18 +1198,20 @@ async function uploadMediaFile({ block, editableField, hiddenInput, urlInput, fi fileName.textContent = "Сохраняем в assets..."; try { - const dataUrl = await readFileAsDataUrl(file); + const formData = new FormData(); + formData.append("file", file, file.name); + formData.append("fileName", file.name); + formData.append("mimeType", file.type || "application/octet-stream"); + formData.append("bucket", uploadBucketForField(block, editableField)); + const response = await fetch("/api/upload/asset", { method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - fileName: file.name, - mimeType: file.type || "application/octet-stream", - bucket: uploadBucketForField(block, editableField), - dataUrl, - }), + body: formData, }); - const payload = await response.json(); + const payload = await response.json().catch(() => ({ + ok: false, + error: `Сервер вернул ${response.status}`, + })); if (!response.ok || !payload.ok) { throw new Error(payload.error || "Файл не удалось сохранить"); @@ -1500,6 +1558,54 @@ function renderRangeField({ block, editableField, grid }) { grid.append(label); } +function renderNumberField({ block, editableField, grid }) { + const label = document.createElement("label"); + const labelRow = document.createElement("span"); + const labelText = document.createElement("span"); + const kind = document.createElement("span"); + const path = document.createElement("span"); + const control = document.createElement("span"); + const input = document.createElement("input"); + const suffix = document.createElement("span"); + const rawValue = Number.parseFloat(getByPath(block, editableField.path)); + const fallback = Number.parseFloat(editableField.defaultValue ?? editableField.min ?? 0); + const value = Number.isFinite(rawValue) ? rawValue : fallback; + + label.className = "field-control number-field"; + labelRow.className = "field-label-row"; + labelText.className = "field-label-text"; + labelText.textContent = editableField.label || editableField.path; + kind.className = "field-kind"; + kind.textContent = editableField.kind || "number"; + path.className = "field-path"; + path.textContent = editableField.path; + control.className = "number-control"; + suffix.className = "number-suffix"; + suffix.textContent = editableField.suffix || ""; + labelRow.append(labelText, kind); + + input.dataset.path = editableField.path; + input.dataset.valueType = "number"; + input.type = "number"; + input.value = Number.isFinite(value) ? String(value) : "0"; + input.autocomplete = "off"; + + if (editableField.min != null) input.min = String(editableField.min); + if (editableField.max != null) input.max = String(editableField.max); + if (editableField.step != null) input.step = String(editableField.step); + + input.addEventListener("input", () => { + const active = activeBlock(); + if (!active) return; + syncFieldValue(active, editableField.path, input); + }); + + control.append(input); + if (suffix.textContent) control.append(suffix); + label.append(labelRow, control, path); + grid.append(label); +} + function renderHeaderBooleanToggle({ block, editableField, head }) { const label = document.createElement("label"); const input = document.createElement("input"); @@ -1532,6 +1638,11 @@ function renderCollectionScalarField({ block, field, grid }) { return; } + if (field.kind === "number") { + renderNumberField({ block, editableField: field, grid }); + return; + } + const label = document.createElement("label"); const labelRow = document.createElement("span"); const labelText = document.createElement("span"); @@ -1666,8 +1777,14 @@ function renderCollectionField({ block, editableField, grid }) { const dragHandle = document.createElement("button"); const body = document.createElement("div"); const itemGrid = document.createElement("div"); + const hasVisibilityToggle = + item && + typeof item === "object" && + (Object.prototype.hasOwnProperty.call(item, "enabled") || + Object.prototype.hasOwnProperty.call(editableField.itemTemplate ?? {}, "enabled")); details.className = "collection-item-card"; + details.classList.toggle("is-hidden", item?.enabled === false); if (index === 0 && items.length <= 8) details.open = true; summary.className = "collection-summary"; summaryTitle.className = "collection-summary-title"; @@ -1685,6 +1802,29 @@ function renderCollectionField({ block, editableField, grid }) { dragHandle.addEventListener("pointerdown", (event) => startCollectionPointerDrag(event, details, items, index, rerender)); controls.append(dragHandle); + if (hasVisibilityToggle) { + if (!Object.prototype.hasOwnProperty.call(item, "enabled")) item.enabled = true; + + const visibilityToggle = document.createElement("span"); + const visibilityInput = document.createElement("input"); + const visibilityText = document.createElement("span"); + + visibilityToggle.className = "collection-visibility-toggle"; + visibilityToggle.title = "Скрыть/показать"; + visibilityToggle.setAttribute("aria-label", "Скрыть/показать"); + visibilityInput.type = "checkbox"; + visibilityInput.checked = item.enabled !== false; + visibilityText.textContent = "Показать"; + visibilityToggle.addEventListener("click", (event) => event.stopPropagation()); + visibilityInput.addEventListener("click", (event) => event.stopPropagation()); + visibilityInput.addEventListener("change", () => { + item.enabled = visibilityInput.checked; + rerender(visibilityInput.checked ? "Элемент коллекции показан локально." : "Элемент коллекции скрыт локально."); + }); + visibilityToggle.append(visibilityInput, visibilityText); + controls.append(visibilityToggle); + } + [ ["↑", "Поднять", () => { if (index === 0) return; @@ -1702,7 +1842,9 @@ function renderCollectionField({ block, editableField, grid }) { items.splice(index + 1, 0, prepareCollectionItemForInsert(items, item)); rerender("Элемент коллекции продублирован локально."); }], - ["⌫", "Удалить", () => { + ["⌫", "Удалить", async () => { + const confirmed = await requestDeleteConfirmation(); + if (!confirmed) return; items.splice(index, 1); rerender("Элемент коллекции удалён локально."); }], @@ -1716,7 +1858,7 @@ function renderCollectionField({ block, editableField, grid }) { button.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); - handler(); + Promise.resolve(handler()).catch((error) => setStatus(`Ошибка действия коллекции: ${error.message}`)); }); controls.append(button); }); @@ -1802,6 +1944,11 @@ function renderContentFields(block) { continue; } + if (editableField.kind === "number") { + renderNumberField({ block, editableField, grid }); + continue; + } + const label = document.createElement("label"); const labelRow = document.createElement("span"); const labelText = document.createElement("span"); @@ -1886,6 +2033,7 @@ async function loadPage() { state.page = await pageResponse.json(); state.templates = await templatesResponse.json(); ensureStaticElements(); + ensureSectionsRegion(); state.selected = { kind: "static" }; renderAll(); setDirty(false); @@ -1979,11 +2127,14 @@ function pasteAfterSelected() { markDirty(`Блок «${pasted.adminLabel || pasted.id}» вставлен локально.`); } -function deleteSelected() { +async function deleteSelected() { if (isStaticSelection()) return; const region = activeRegion(); if (!region || state.selected.blockIndex == null) return; + const confirmed = await requestDeleteConfirmation(); + if (!confirmed) 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 = { kind: "static" }; @@ -2010,8 +2161,20 @@ el.templateModalCancel.addEventListener("click", closeTemplateModal); el.templateModal.addEventListener("click", (event) => { if (event.target === el.templateModal) closeTemplateModal(); }); +el.deleteModalCancel.addEventListener("click", () => closeDeleteModal(false)); +el.deleteModalConfirm.addEventListener("click", () => closeDeleteModal(true)); +el.deleteModal.addEventListener("click", (event) => { + if (event.target === el.deleteModal) closeDeleteModal(false); +}); document.addEventListener("keydown", (event) => { - if (event.key === "Escape" && !el.templateModal.classList.contains("hidden")) { + if (event.key !== "Escape") return; + + if (!el.deleteModal.classList.contains("hidden")) { + closeDeleteModal(false); + return; + } + + if (!el.templateModal.classList.contains("hidden")) { closeTemplateModal(); } }); @@ -2023,7 +2186,7 @@ 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); +el.deleteBlock.addEventListener("click", () => deleteSelected().catch((error) => setStatus(`Ошибка удаления: ${error.message}`))); window.addEventListener("error", (event) => { recoverEditorSurface(); diff --git a/admin/index.html b/admin/index.html index b7e5272..5059f0e 100644 --- a/admin/index.html +++ b/admin/index.html @@ -190,6 +190,21 @@ +