Fix admin media picker state
This commit is contained in:
parent
819fc3c0ba
commit
8472611ef3
|
|
@ -303,6 +303,10 @@ p {
|
|||
-webkit-backdrop-filter: blur(28px);
|
||||
}
|
||||
|
||||
body[data-file-picker-active="true"] .editor {
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: relative;
|
||||
display: grid;
|
||||
|
|
@ -1136,7 +1140,7 @@ textarea {
|
|||
padding-left: 0.15rem;
|
||||
}
|
||||
|
||||
.service-media-file-control input[type="file"],
|
||||
.admin-file-picker-input,
|
||||
.service-media-hidden-input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
|
|
|
|||
213
admin/admin.js
213
admin/admin.js
|
|
@ -6,6 +6,9 @@ const state = {
|
|||
drag: null,
|
||||
dragTarget: null,
|
||||
dirty: false,
|
||||
filePickerActive: false,
|
||||
mediaUploadActive: false,
|
||||
filePickerResetTimer: null,
|
||||
};
|
||||
|
||||
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 mediaFilePicker = createMediaFilePicker();
|
||||
|
||||
function field(path, label, kind = "text", renderAs = kind === "html" ? "html" : "text", group = null, options = {}) {
|
||||
return { path, label, kind, renderAs, group: group || inferFieldGroup(path), ...options };
|
||||
|
|
@ -198,6 +202,42 @@ function markDirty(message = null) {
|
|||
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) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
|
@ -981,6 +1021,102 @@ function updateMediaFieldValue({ block, editableField, hiddenInput, urlInput, fi
|
|||
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 }) {
|
||||
const value = getByPath(block, editableField.path) ?? "";
|
||||
const container = document.createElement("div");
|
||||
|
|
@ -990,9 +1126,8 @@ function renderMediaField({ block, editableField, grid }) {
|
|||
const hiddenInput = document.createElement("input");
|
||||
const control = document.createElement("div");
|
||||
const fileControl = document.createElement("div");
|
||||
const fileLabel = document.createElement("label");
|
||||
const fileButton = document.createElement("button");
|
||||
const fileName = document.createElement("span");
|
||||
const fileInput = document.createElement("input");
|
||||
const urlInput = document.createElement("input");
|
||||
const sourceSwitch = document.createElement("div");
|
||||
const fileSourceButton = document.createElement("button");
|
||||
|
|
@ -1001,7 +1136,6 @@ function renderMediaField({ block, editableField, grid }) {
|
|||
const path = document.createElement("span");
|
||||
const hint = 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";
|
||||
|
||||
function setSource(nextSource) {
|
||||
|
|
@ -1028,16 +1162,13 @@ function renderMediaField({ block, editableField, grid }) {
|
|||
control.className = "service-media-control";
|
||||
|
||||
fileControl.className = "service-media-file-control";
|
||||
fileLabel.className = "service-media-file-button";
|
||||
fileLabel.htmlFor = inputId;
|
||||
fileLabel.textContent = "Выберите файл";
|
||||
fileButton.className = "service-media-file-button";
|
||||
fileButton.type = "button";
|
||||
fileButton.textContent = "Выберите файл";
|
||||
fileName.className = "service-media-file-name";
|
||||
fileName.textContent = truncateText(fileNameFromUrl(value), 28);
|
||||
fileName.title = fileNameFromUrl(value);
|
||||
fileInput.id = inputId;
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = MEDIA_ACCEPT;
|
||||
fileControl.append(fileLabel, fileName, fileInput);
|
||||
fileControl.append(fileButton, fileName);
|
||||
|
||||
urlInput.className = "service-media-url-input";
|
||||
urlInput.type = "text";
|
||||
|
|
@ -1086,32 +1217,12 @@ function renderMediaField({ block, editableField, grid }) {
|
|||
});
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", async () => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
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,
|
||||
}),
|
||||
fileButton.addEventListener("pointerdown", () => {
|
||||
setFilePickerActive(true);
|
||||
scheduleFilePickerRecovery();
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
throw new Error(payload.error || "Файл не удалось сохранить");
|
||||
}
|
||||
|
||||
updateMediaFieldValue({
|
||||
fileButton.addEventListener("click", () => {
|
||||
openMediaFilePicker({
|
||||
block,
|
||||
editableField,
|
||||
hiddenInput,
|
||||
|
|
@ -1119,17 +1230,9 @@ function renderMediaField({ block, editableField, grid }) {
|
|||
fileName,
|
||||
preview,
|
||||
hint,
|
||||
value: payload.url,
|
||||
error,
|
||||
setSource,
|
||||
});
|
||||
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);
|
||||
|
|
@ -1638,7 +1741,14 @@ for (const input of [el.id, el.label, el.anchor, el.enabled]) {
|
|||
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.templateModalClose.addEventListener("click", closeTemplateModal);
|
||||
el.templateModalCancel.addEventListener("click", closeTemplateModal);
|
||||
|
|
@ -1660,4 +1770,15 @@ el.copy.addEventListener("click", copySelected);
|
|||
el.pasteAfter.addEventListener("click", pasteAfterSelected);
|
||||
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));
|
||||
|
|
|
|||
Loading…
Reference in New Issue