Fix admin media picker state
This commit is contained in:
parent
819fc3c0ba
commit
8472611ef3
|
|
@ -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;
|
||||||
|
|
|
||||||
213
admin/admin.js
213
admin/admin.js
|
|
@ -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,32 +1217,12 @@ 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 = "";
|
|
||||||
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();
|
fileButton.addEventListener("click", () => {
|
||||||
|
openMediaFilePicker({
|
||||||
if (!response.ok || !payload.ok) {
|
|
||||||
throw new Error(payload.error || "Файл не удалось сохранить");
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMediaFieldValue({
|
|
||||||
block,
|
block,
|
||||||
editableField,
|
editableField,
|
||||||
hiddenInput,
|
hiddenInput,
|
||||||
|
|
@ -1119,17 +1230,9 @@ function renderMediaField({ block, editableField, grid }) {
|
||||||
fileName,
|
fileName,
|
||||||
preview,
|
preview,
|
||||||
hint,
|
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);
|
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));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue