diff --git a/l4d2web/l4d2web/static/js/files-overlay/editor.js b/l4d2web/l4d2web/static/js/files-overlay/editor.js index 29840cd..c965183 100644 --- a/l4d2web/l4d2web/static/js/files-overlay/editor.js +++ b/l4d2web/l4d2web/static/js/files-overlay/editor.js @@ -259,11 +259,58 @@ return !!mc && mc.contains(el); } - // Replace-zone drag (legacy binary mode). Document-level delegation - // gated on the zone being inside the legacy dialog. + // ---------- routed binary-replace state ---------- + + // The queued File for the URL-addressable binary editor. Cleared on + // modal-container close so reopening starts fresh. + let routedReplacement = null; + + function isRoutedBinaryMode(modalContent) { + return !!modalContent?.querySelector(".files-editor-binary"); + } + + function setRoutedReplacement(modalContent, file) { + routedReplacement = file; + const idle = modalContent.querySelector(".files-editor-replace-idle"); + const queued = modalContent.querySelector(".files-editor-replace-queued"); + if (file) { + if (idle) idle.hidden = true; + if (queued) queued.hidden = false; + const name = modalContent.querySelector(".files-editor-replace-name"); + const size = modalContent.querySelector(".files-editor-replace-size"); + if (name) name.textContent = file.name; + if (size) size.textContent = humanSize(file.size); + } else { + if (idle) idle.hidden = false; + if (queued) queued.hidden = true; + } + updateRoutedBinarySaveEnabled(modalContent); + } + + // Enables the Save (Replace) button in routed binary mode when either + // a replacement file is queued OR the filename input has been edited. + // Mirrors legacy updateSaveEnabled's binary-mode branch. + function updateRoutedBinarySaveEnabled(modalContent) { + const panel = modalContent.querySelector(".files-editor-binary[data-rel-path]"); + const saveBtn = modalContent.querySelector(".files-editor-save"); + if (!panel || !saveBtn) return; + const relPath = panel.dataset.relPath || ""; + const originalLeaf = relPath.split("/").pop() || relPath; + const filenameInput = modalContent.querySelector("[data-editor-filename]"); + const filenameChanged = + filenameInput && filenameInput.value.trim() && + filenameInput.value.trim() !== originalLeaf; + saveBtn.disabled = !routedReplacement && !filenameChanged; + } + + // Replace-zone drag — delegated for both legacy and routed binary + // modes. The zone lives inside #files-editor-modal (legacy) or + // #modal-content (routed); the in-{legacy,routed}Editor checks + // dispatch to the right reaction. document.addEventListener("dragover", (event) => { const zone = event.target?.closest?.(".files-editor-replace-zone"); - if (!zone || !inLegacyEditor(zone)) return; + if (!zone) return; + if (!inLegacyEditor(zone) && !inRoutedEditor(zone)) return; if (Array.from(event.dataTransfer.types).includes("Files")) { event.preventDefault(); zone.classList.add("is-drop-target"); @@ -271,45 +318,88 @@ }); document.addEventListener("dragleave", (event) => { const zone = event.target?.closest?.(".files-editor-replace-zone"); - if (!zone || !inLegacyEditor(zone)) return; + if (!zone) return; + if (!inLegacyEditor(zone) && !inRoutedEditor(zone)) return; zone.classList.remove("is-drop-target"); }); document.addEventListener("drop", (event) => { const zone = event.target?.closest?.(".files-editor-replace-zone"); - if (!zone || !inLegacyEditor(zone)) return; + if (!zone) return; + const isLegacy = inLegacyEditor(zone); + const isRouted = !isLegacy && inRoutedEditor(zone); + if (!isLegacy && !isRouted) return; if (!Array.from(event.dataTransfer.types).includes("Files")) return; event.preventDefault(); zone.classList.remove("is-drop-target"); const f = event.dataTransfer.files && event.dataTransfer.files[0]; - if (f) setQueuedReplacement(f); + if (!f) return; + if (isLegacy) { + setQueuedReplacement(f); + } else { + setRoutedReplacement(document.getElementById("modal-content"), f); + } }); - // Replace-input change (browse-fallback): low frequency, delegated. + // Replace-input change (browse-fallback): low frequency, delegated + // for both editors. document.addEventListener("change", (event) => { const input = event.target?.closest?.(".files-editor-replace-input"); - if (!input || !inLegacyEditor(input)) return; + if (!input) return; + const isLegacy = inLegacyEditor(input); + const isRouted = !isLegacy && inRoutedEditor(input); + if (!isLegacy && !isRouted) return; const f = input.files && input.files[0]; - if (f) setQueuedReplacement(f); + if (!f) return; + if (isLegacy) { + setQueuedReplacement(f); + } else { + setRoutedReplacement(document.getElementById("modal-content"), f); + } + }); + + // Filename input in routed binary mode: re-evaluate Save enablement + // when the user types a new name (rename-only is a valid Replace). + document.addEventListener("input", (event) => { + const input = event.target?.closest?.("[data-editor-filename]"); + if (!input || !inRoutedEditor(input)) return; + const mc = document.getElementById("modal-content"); + if (!mc || !isRoutedBinaryMode(mc)) return; + updateRoutedBinarySaveEnabled(mc); }); document.addEventListener("click", async (event) => { const target = event.target; if (!target) return; - // Legacy: replace-clear button. + // Replace-clear (both editors). const clearBtn = target.closest?.(".files-editor-replace-clear"); - if (clearBtn && inLegacyEditor(clearBtn)) { - setQueuedReplacement(null); - return; + if (clearBtn) { + if (inLegacyEditor(clearBtn)) { + setQueuedReplacement(null); + return; + } + if (inRoutedEditor(clearBtn)) { + setRoutedReplacement(document.getElementById("modal-content"), null); + return; + } } - // Legacy: replace-browse button → trigger hidden file input. + // Replace-browse (both editors) → trigger hidden file input. const browseBtn = target.closest?.(".files-editor-replace-browse"); - if (browseBtn && inLegacyEditor(browseBtn)) { - editorEls.replaceInput.click(); - return; + if (browseBtn) { + if (inLegacyEditor(browseBtn)) { + editorEls.replaceInput.click(); + return; + } + if (inRoutedEditor(browseBtn)) { + const mc = document.getElementById("modal-content"); + const fileInput = mc?.querySelector(".files-editor-replace-input"); + fileInput?.click(); + return; + } } - // Save (both editors). + // Save (both editors). Routed mode further branches on text vs. + // binary panel. const saveBtn = target.closest?.(".files-editor-save"); if (saveBtn) { if (inLegacyEditor(saveBtn)) { @@ -317,7 +407,12 @@ return; } if (inRoutedEditor(saveBtn)) { - await routedSaveClicked(); + const mc = document.getElementById("modal-content"); + if (isRoutedBinaryMode(mc)) { + await routedReplaceClicked(); + } else { + await routedSaveClicked(); + } return; } } @@ -336,6 +431,12 @@ } }); + // Clear routed binary state when the modal closes. Otherwise the next + // open would still see a "queued" replacement from the prior session. + document.getElementById("modal-container")?.addEventListener("close", () => { + routedReplacement = null; + }); + async function legacySaveClicked() { const filename = editorEls.filename.value.trim(); if (!filename) return; @@ -525,6 +626,57 @@ } } + async function routedReplaceClicked() { + const modalContent = document.getElementById("modal-content"); + if (!modalContent) return; + const panel = modalContent.querySelector(".files-editor-binary[data-rel-path]"); + if (!panel) return; + const relPath = panel.dataset.relPath; + if (!relPath) return; + + // Rename-on-replace: same shape as routedSaveClicked, sibling-only. + const filenameInput = modalContent.querySelector("[data-editor-filename]"); + const editedFilename = filenameInput ? filenameInput.value.trim() : ""; + const originalLeaf = relPath.split("/").pop() || relPath; + const parent = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/")) : ""; + const renaming = !!editedFilename && editedFilename !== originalLeaf; + const newPath = renaming + ? (parent ? `${parent}/${editedFilename}` : editedFilename) + : null; + + // Two flows: a queued replacement → /files/replace (multipart); + // rename-only (no queued file) → /files/move. Mirrors the legacy + // binary save handler. + if (routedReplacement) { + const fd = new FormData(); + fd.append("path", relPath); + fd.append("csrf_token", csrfToken); + if (newPath) fd.append("new_path", newPath); + fd.append("file", routedReplacement); + const r = await postForm(`${baseUrl}/files/replace`, fd); + if (r.ok) { + routedReplacement = null; + if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted(); + scheduleRefresh(parentOf(newPath || relPath)); + } else { + alert((r.body && r.body.error) || `Replace failed (HTTP ${r.status}).`); + } + return; + } + if (renaming) { + const r = await postJson(`${baseUrl}/files/move`, { + src: relPath, + dst: newPath, + }); + if (r.ok) { + if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted(); + scheduleRefresh(parentOf(newPath)); + } else { + alert((r.body && r.body.error) || `Rename failed (HTTP ${r.status}).`); + } + } + } + async function routedDeleteClicked() { const modalContent = document.getElementById("modal-content"); if (!modalContent) return; @@ -561,22 +713,20 @@ } }); - fo.registerHandler("edit", (path, actionEl) => { - const editable = actionEl?.dataset?.editable === "1"; - if (editable) { - // Editable text files: open via URL-addressable modal. - const editUrl = `/overlays/${overlayId}/files/edit?path=${encodeURIComponent(path)}`; - if (typeof window.modals?.openRouted === "function") { - window.modals.openRouted(editUrl); - } else { - // Graceful fallback if modals.js didn't load — full-page nav - // hits the same route and renders the standalone editor page. - window.location.href = editUrl; - } - return; + fo.registerHandler("edit", (path, _actionEl) => { + // Phase B Step 8: both editable text and binary files route to the + // URL-addressable modal. The server's /files/edit route picks the + // template branch (text editor vs. binary-replace panel) based on + // is_editable(target). The data-editable attribute on the action + // element is now informational only — the server is the + // source of truth. + const editUrl = `/overlays/${overlayId}/files/edit?path=${encodeURIComponent(path)}`; + if (typeof window.modals?.openRouted === "function") { + window.modals.openRouted(editUrl); + } else { + // Graceful fallback if modals.js didn't load — full-page nav + // hits the same route and renders the standalone editor page. + window.location.href = editUrl; } - // Binary files: legacy inline dialog (Phase B migrates this to - // URL-addressable too). - openEditorForFile(path, false); }); })();