Update promo admin media blocks
This commit is contained in:
+108
-4
@@ -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;
|
||||
|
||||
+184
-21
@@ -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();
|
||||
|
||||
@@ -190,6 +190,21 @@
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
<div id="delete-modal" class="template-modal-layer delete-modal-layer hidden" role="dialog" aria-modal="true" aria-labelledby="delete-modal-title" aria-describedby="delete-modal-body">
|
||||
<article class="template-modal delete-modal">
|
||||
<header class="template-modal-head delete-modal-head">
|
||||
<div>
|
||||
<div class="section-kicker">Удаление</div>
|
||||
<h2 id="delete-modal-title">Подтвердите действие</h2>
|
||||
<p id="delete-modal-body">Вы действительно подтвердить удаление элемента?</p>
|
||||
</div>
|
||||
</header>
|
||||
<footer class="template-modal-foot delete-modal-foot">
|
||||
<button id="delete-modal-cancel" class="ghost-btn" type="button">Отмена</button>
|
||||
<button id="delete-modal-confirm" class="danger-btn delete-confirm-btn" type="button">Да</button>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
<script src="./admin.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user