Fix admin media picker state

This commit is contained in:
dcconstructions 2026-06-26 13:13:07 +03:00
parent 819fc3c0ba
commit 8472611ef3
2 changed files with 181 additions and 56 deletions

View File

@ -303,6 +303,10 @@ p {
-webkit-backdrop-filter: blur(28px); -webkit-backdrop-filter: blur(28px);
} }
body[data-file-picker-active="true"] .editor {
transform: translateZ(0);
}
.sidebar { .sidebar {
position: relative; position: relative;
display: grid; display: grid;
@ -1136,7 +1140,7 @@ textarea {
padding-left: 0.15rem; padding-left: 0.15rem;
} }
.service-media-file-control input[type="file"], .admin-file-picker-input,
.service-media-hidden-input { .service-media-hidden-input {
position: absolute; position: absolute;
width: 1px; width: 1px;

View File

@ -6,6 +6,9 @@ const state = {
drag: null, drag: null,
dragTarget: null, dragTarget: null,
dirty: false, dirty: false,
filePickerActive: false,
mediaUploadActive: false,
filePickerResetTimer: null,
}; };
const el = { const el = {
@ -163,6 +166,7 @@ const GROUP_COPY = {
}; };
const MEDIA_ACCEPT = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv,.glb,.gltf,.svg,.avif,.webp,.woff,.woff2"; const MEDIA_ACCEPT = "image/*,video/*,.gif,.webm,.mov,.mp4,.m4v,.avi,.mkv,.glb,.gltf,.svg,.avif,.webp,.woff,.woff2";
const mediaFilePicker = createMediaFilePicker();
function field(path, label, kind = "text", renderAs = kind === "html" ? "html" : "text", group = null, options = {}) { function field(path, label, kind = "text", renderAs = kind === "html" ? "html" : "text", group = null, options = {}) {
return { path, label, kind, renderAs, group: group || inferFieldGroup(path), ...options }; return { path, label, kind, renderAs, group: group || inferFieldGroup(path), ...options };
@ -198,6 +202,42 @@ function markDirty(message = null) {
setDirty(true, message); setDirty(true, message);
} }
function createMediaFilePicker() {
const input = document.createElement("input");
input.type = "file";
input.accept = MEDIA_ACCEPT;
input.className = "admin-file-picker-input";
input.tabIndex = -1;
input.setAttribute("aria-hidden", "true");
document.body.append(input);
return input;
}
function setFilePickerActive(active) {
state.filePickerActive = active;
if (active) {
document.body.dataset.filePickerActive = "true";
} else {
delete document.body.dataset.filePickerActive;
}
}
function recoverEditorSurface() {
if (!activeBlock()) return;
el.empty.classList.add("hidden");
el.form.classList.remove("hidden");
}
function scheduleFilePickerRecovery(delay = 360) {
window.clearTimeout(state.filePickerResetTimer);
state.filePickerResetTimer = window.setTimeout(() => {
if (state.mediaUploadActive) return;
setFilePickerActive(false);
recoverEditorSurface();
}, delay);
}
function clone(value) { function clone(value) {
return JSON.parse(JSON.stringify(value)); return JSON.parse(JSON.stringify(value));
} }
@ -981,6 +1021,102 @@ function updateMediaFieldValue({ block, editableField, hiddenInput, urlInput, fi
markDirty(); markDirty();
} }
async function uploadMediaFile({ block, editableField, hiddenInput, urlInput, fileName, preview, hint, error, setSource, file }) {
if (!file) return;
state.mediaUploadActive = true;
setFilePickerActive(true);
recoverEditorSurface();
error.textContent = "";
fileName.textContent = "Сохраняем в assets...";
try {
const dataUrl = await readFileAsDataUrl(file);
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,
}),
});
const payload = await response.json();
if (!response.ok || !payload.ok) {
throw new Error(payload.error || "Файл не удалось сохранить");
}
updateMediaFieldValue({
block,
editableField,
hiddenInput,
urlInput,
fileName,
preview,
hint,
value: payload.url,
});
setSource("file");
markDirty(`Файл сохранён: ${payload.url}. Нажмите «Сохранить модель», чтобы записать путь в content/pages/home.json.`);
} catch (uploadError) {
error.textContent = uploadError.message;
fileName.textContent = truncateText(fileNameFromUrl(hiddenInput.value), 28);
setStatus(`Ошибка загрузки: ${uploadError.message}`);
} finally {
state.mediaUploadActive = false;
mediaFilePicker.value = "";
setFilePickerActive(false);
recoverEditorSurface();
}
}
function openMediaFilePicker(context) {
setFilePickerActive(true);
recoverEditorSurface();
window.clearTimeout(state.filePickerResetTimer);
mediaFilePicker.accept = MEDIA_ACCEPT;
mediaFilePicker.value = "";
mediaFilePicker.oncancel = () => {
setFilePickerActive(false);
recoverEditorSurface();
};
mediaFilePicker.onchange = async () => {
const file = mediaFilePicker.files?.[0];
if (!file) {
setFilePickerActive(false);
recoverEditorSurface();
return;
}
await uploadMediaFile({ ...context, file });
};
window.addEventListener(
"focus",
() => {
window.setTimeout(() => {
if (state.mediaUploadActive || mediaFilePicker.files?.length) return;
setFilePickerActive(false);
recoverEditorSurface();
}, 180);
},
{ once: true },
);
try {
mediaFilePicker.click();
} catch (error) {
setFilePickerActive(false);
recoverEditorSurface();
context.error.textContent = error.message;
setStatus(`Ошибка открытия выбора файла: ${error.message}`);
}
}
function renderMediaField({ block, editableField, grid }) { function renderMediaField({ block, editableField, grid }) {
const value = getByPath(block, editableField.path) ?? ""; const value = getByPath(block, editableField.path) ?? "";
const container = document.createElement("div"); const container = document.createElement("div");
@ -990,9 +1126,8 @@ function renderMediaField({ block, editableField, grid }) {
const hiddenInput = document.createElement("input"); const hiddenInput = document.createElement("input");
const control = document.createElement("div"); const control = document.createElement("div");
const fileControl = document.createElement("div"); const fileControl = document.createElement("div");
const fileLabel = document.createElement("label"); const fileButton = document.createElement("button");
const fileName = document.createElement("span"); const fileName = document.createElement("span");
const fileInput = document.createElement("input");
const urlInput = document.createElement("input"); const urlInput = document.createElement("input");
const sourceSwitch = document.createElement("div"); const sourceSwitch = document.createElement("div");
const fileSourceButton = document.createElement("button"); const fileSourceButton = document.createElement("button");
@ -1001,7 +1136,6 @@ function renderMediaField({ block, editableField, grid }) {
const path = document.createElement("span"); const path = document.createElement("span");
const hint = document.createElement("span"); const hint = document.createElement("span");
const error = document.createElement("span"); const error = document.createElement("span");
const inputId = `media-${editableField.path.replace(/[^a-z0-9_-]+/gi, "-")}-${Math.random().toString(36).slice(2)}`;
let source = /^(https?:)?\/\//i.test(value) ? "url" : "file"; let source = /^(https?:)?\/\//i.test(value) ? "url" : "file";
function setSource(nextSource) { function setSource(nextSource) {
@ -1028,16 +1162,13 @@ function renderMediaField({ block, editableField, grid }) {
control.className = "service-media-control"; control.className = "service-media-control";
fileControl.className = "service-media-file-control"; fileControl.className = "service-media-file-control";
fileLabel.className = "service-media-file-button"; fileButton.className = "service-media-file-button";
fileLabel.htmlFor = inputId; fileButton.type = "button";
fileLabel.textContent = "Выберите файл"; fileButton.textContent = "Выберите файл";
fileName.className = "service-media-file-name"; fileName.className = "service-media-file-name";
fileName.textContent = truncateText(fileNameFromUrl(value), 28); fileName.textContent = truncateText(fileNameFromUrl(value), 28);
fileName.title = fileNameFromUrl(value); fileName.title = fileNameFromUrl(value);
fileInput.id = inputId; fileControl.append(fileButton, fileName);
fileInput.type = "file";
fileInput.accept = MEDIA_ACCEPT;
fileControl.append(fileLabel, fileName, fileInput);
urlInput.className = "service-media-url-input"; urlInput.className = "service-media-url-input";
urlInput.type = "text"; urlInput.type = "text";
@ -1086,50 +1217,22 @@ function renderMediaField({ block, editableField, grid }) {
}); });
}); });
fileInput.addEventListener("change", async () => { fileButton.addEventListener("pointerdown", () => {
const file = fileInput.files?.[0]; setFilePickerActive(true);
if (!file) return; scheduleFilePickerRecovery();
});
error.textContent = ""; fileButton.addEventListener("click", () => {
fileName.textContent = "Сохраняем в assets..."; openMediaFilePicker({
block,
try { editableField,
const dataUrl = await readFileAsDataUrl(file); hiddenInput,
const response = await fetch("/api/upload/asset", { urlInput,
method: "POST", fileName,
headers: { "content-type": "application/json" }, preview,
body: JSON.stringify({ hint,
fileName: file.name, error,
mimeType: file.type || "application/octet-stream", setSource,
bucket: uploadBucketForField(block, editableField), });
dataUrl,
}),
});
const payload = await response.json();
if (!response.ok || !payload.ok) {
throw new Error(payload.error || "Файл не удалось сохранить");
}
updateMediaFieldValue({
block,
editableField,
hiddenInput,
urlInput,
fileName,
preview,
hint,
value: payload.url,
});
setSource("file");
markDirty(`Файл сохранён: ${payload.url}. Нажмите «Сохранить модель», чтобы записать путь в content/pages/home.json.`);
} catch (uploadError) {
error.textContent = uploadError.message;
fileName.textContent = truncateText(fileNameFromUrl(hiddenInput.value), 28);
setStatus(`Ошибка загрузки: ${uploadError.message}`);
} finally {
fileInput.value = "";
}
}); });
setSource(source); setSource(source);
@ -1638,7 +1741,14 @@ for (const input of [el.id, el.label, el.anchor, el.enabled]) {
input.addEventListener("change", syncBlockFromFields); input.addEventListener("change", syncBlockFromFields);
} }
el.json.addEventListener("blur", syncBlockFromJson); el.json.addEventListener("blur", () => {
if (state.filePickerActive || state.mediaUploadActive) {
recoverEditorSurface();
return;
}
syncBlockFromJson();
});
el.addBlock.addEventListener("click", openTemplateModal); el.addBlock.addEventListener("click", openTemplateModal);
el.templateModalClose.addEventListener("click", closeTemplateModal); el.templateModalClose.addEventListener("click", closeTemplateModal);
el.templateModalCancel.addEventListener("click", closeTemplateModal); el.templateModalCancel.addEventListener("click", closeTemplateModal);
@ -1660,4 +1770,15 @@ el.copy.addEventListener("click", copySelected);
el.pasteAfter.addEventListener("click", pasteAfterSelected); el.pasteAfter.addEventListener("click", pasteAfterSelected);
el.deleteBlock.addEventListener("click", deleteSelected); el.deleteBlock.addEventListener("click", deleteSelected);
window.addEventListener("error", (event) => {
recoverEditorSurface();
setStatus(`Ошибка JS: ${event.message}`);
});
window.addEventListener("unhandledrejection", (event) => {
recoverEditorSurface();
const reason = event.reason instanceof Error ? event.reason.message : String(event.reason);
setStatus(`Ошибка JS: ${reason}`);
});
loadPage().catch((error) => setStatus(error.message)); loadPage().catch((error) => setStatus(error.message));