diff --git a/admin/admin.css b/admin/admin.css index 49b60ea..f6c63f3 100644 --- a/admin/admin.css +++ b/admin/admin.css @@ -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; diff --git a/admin/admin.js b/admin/admin.js index 3d501f7..3e22554 100644 --- a/admin/admin.js +++ b/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,50 +1217,22 @@ 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, - }), - }); - 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 = ""; - } + fileButton.addEventListener("pointerdown", () => { + setFilePickerActive(true); + scheduleFilePickerRecovery(); + }); + fileButton.addEventListener("click", () => { + openMediaFilePicker({ + block, + editableField, + hiddenInput, + urlInput, + fileName, + preview, + hint, + error, + setSource, + }); }); 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));