Update promo admin media blocks

This commit is contained in:
dcconstructions
2026-06-26 22:04:59 +03:00
parent fb1531f7c5
commit 82cdab07f2
43 changed files with 1901 additions and 1095 deletions
+108 -4
View File
@@ -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
View File
@@ -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();
+15
View File
@@ -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>