diff --git a/l4d2web/l4d2web/static/js/files-overlay.js b/l4d2web/l4d2web/static/js/files-overlay.js index 1ba0f63..26ece4e 100644 --- a/l4d2web/l4d2web/static/js/files-overlay.js +++ b/l4d2web/l4d2web/static/js/files-overlay.js @@ -29,7 +29,6 @@ const csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content") || ""; - const editorDialog = document.getElementById("files-editor-modal"); const newFolderDialog = document.getElementById("files-new-folder-modal"); const conflictDialog = document.getElementById("files-conflict-modal"); const deleteDialog = document.getElementById("files-delete-modal"); @@ -210,6 +209,8 @@ conflictDialog.showModal(); }); } + // Exposed for editor.js (Phase A). Moves to dialogs.js in Step 3. + if (window.__filesOverlay) window.__filesOverlay.askConflict = askConflict; // Attach a path-collision suffix: foo.txt → foo (1).txt function withCollisionSuffix(path) { @@ -220,6 +221,8 @@ } return path + " (1)"; } + // Exposed for editor.js (Phase A). Moves to uploads.js in Step 4. + if (window.__filesOverlay) window.__filesOverlay.withCollisionSuffix = withCollisionSuffix; // ---------- delete modal ------------------------------------------------ @@ -249,419 +252,11 @@ } // ---------- editor modal ------------------------------------------------ - - // Editor state. Only one editor is open at a time. - const editor = { - mode: null, // "text" | "binary" - creating: false, - originalPath: null, - folder: null, - queuedReplacement: null, // File object - }; - - const editorEls = { - title: editorDialog.querySelector(".files-editor-title-text"), - filename: editorDialog.querySelector(".files-editor-filename"), - renameHint: editorDialog.querySelector(".files-editor-rename-hint"), - renameFrom: editorDialog.querySelector(".files-rename-from"), - renameTo: editorDialog.querySelector(".files-rename-to"), - textPanel: editorDialog.querySelector(".files-editor-text"), - contentBox: editorDialog.querySelector(".files-editor-content"), - byteCount: editorDialog.querySelector(".files-editor-byte-count"), - binaryPanel: editorDialog.querySelector(".files-editor-binary"), - binarySize: editorDialog.querySelector(".files-editor-binary-size"), - replaceZone: editorDialog.querySelector(".files-editor-replace-zone"), - replaceIdle: editorDialog.querySelector(".files-editor-replace-idle"), - replaceQueued: editorDialog.querySelector(".files-editor-replace-queued"), - replaceName: editorDialog.querySelector(".files-editor-replace-name"), - replaceSize: editorDialog.querySelector(".files-editor-replace-size"), - replaceClear: editorDialog.querySelector(".files-editor-replace-clear"), - replaceBrowse: editorDialog.querySelector(".files-editor-replace-browse"), - replaceInput: editorDialog.querySelector(".files-editor-replace-input"), - deleteBtn: editorDialog.querySelector(".files-editor-delete"), - downloadBtn: editorDialog.querySelector(".files-editor-download"), - saveBtn: editorDialog.querySelector(".files-editor-save"), - }; - - // Bridge to the CodeMirror 6 controller, set up by static/js/editor.js - // on the .files-editor-content textarea. Falls back to the textarea - // directly if the bundle didn't load (no-JS fallback / file open - // before the controller has been mounted). - function getEditorValue() { - return (window.__filesEditor && window.__filesEditor.getValue) - ? window.__filesEditor.getValue() - : editorEls.contentBox.value; - } - function setEditorValue(text) { - if (window.__filesEditor && window.__filesEditor.setContent) { - window.__filesEditor.setContent(text); - } else { - editorEls.contentBox.value = text; - } - } - - function setEditorTitle(text) { - editorEls.title.textContent = text; - } - - function updateByteCount() { - const bytes = new TextEncoder().encode(getEditorValue()).length; - editorEls.byteCount.textContent = `UTF-8 · ${bytes} bytes`; - } - - function updateRenameHint() { - const current = editorEls.filename.value.trim(); - const original = basename(editor.originalPath || ""); - if (editor.creating || !current || current === original) { - editorEls.renameHint.hidden = true; - return; - } - editorEls.renameFrom.textContent = original; - editorEls.renameTo.textContent = current; - editorEls.renameHint.hidden = false; - } - - function updateSaveEnabled() { - if (editor.mode === "binary" && !editor.creating) { - const filenameChanged = - editorEls.filename.value.trim() !== basename(editor.originalPath || ""); - const hasReplacement = !!editor.queuedReplacement; - editorEls.saveBtn.disabled = !filenameChanged && !hasReplacement; - editorEls.saveBtn.textContent = "Save"; - } else if (editor.creating) { - editorEls.saveBtn.disabled = !editorEls.filename.value.trim(); - editorEls.saveBtn.textContent = "Create"; - } else { - editorEls.saveBtn.disabled = false; - editorEls.saveBtn.textContent = "Save"; - } - } - - function setQueuedReplacement(file) { - editor.queuedReplacement = file; - if (file) { - editorEls.replaceIdle.hidden = true; - editorEls.replaceQueued.hidden = false; - editorEls.replaceName.textContent = file.name; - editorEls.replaceSize.textContent = humanSize(file.size); - } else { - editorEls.replaceIdle.hidden = false; - editorEls.replaceQueued.hidden = true; - } - updateSaveEnabled(); - } - - function openEditorTextNew(folder) { - editor.mode = "text"; - editor.creating = true; - editor.originalPath = null; - editor.folder = folder; - editor.queuedReplacement = null; - - setEditorTitle(`${folder ? folder + "/" : ""}…new file`); - editorEls.filename.value = ""; - editorEls.filename.disabled = false; - setEditorValue(""); - editorEls.contentBox.disabled = false; - editorEls.renameHint.hidden = true; - editorEls.textPanel.hidden = false; - editorEls.binaryPanel.hidden = true; - editorEls.deleteBtn.hidden = true; - editorEls.downloadBtn.hidden = true; - editorEls.saveBtn.textContent = "Create"; - updateByteCount(); - updateSaveEnabled(); - editorDialog.showModal(); - setTimeout(() => editorEls.filename.focus(), 0); - } - - async function openEditorForFile(path, isEditable) { - editor.creating = false; - editor.originalPath = path; - editor.folder = parentOf(path); - editor.queuedReplacement = null; - setQueuedReplacement(null); - - editorEls.filename.value = basename(path); - editorEls.filename.disabled = false; - editorEls.renameHint.hidden = true; - editorEls.deleteBtn.hidden = false; - editorEls.downloadBtn.hidden = false; - editorEls.downloadBtn.href = `${baseUrl}/files/download?path=${encodeURIComponent(path)}`; - setEditorTitle(path); - - if (isEditable) { - editor.mode = "text"; - editorEls.textPanel.hidden = false; - editorEls.binaryPanel.hidden = true; - setEditorValue("Loading…"); - editorEls.contentBox.disabled = true; - - const r = await fetchJson( - `${baseUrl}/files/content?path=${encodeURIComponent(path)}` - ); - if (r.ok && r.body) { - setEditorValue(r.body.content); - editorEls.contentBox.disabled = false; - updateByteCount(); - updateSaveEnabled(); - editorDialog.showModal(); - setTimeout(() => editorEls.contentBox.focus(), 0); - return; - } - - // Fallback: server says not editable. Re-open as binary. - editorEls.contentBox.disabled = false; - editor.mode = "binary"; - } else { - editor.mode = "binary"; - } - - // Binary mode setup. - editorEls.textPanel.hidden = true; - editorEls.binaryPanel.hidden = false; - editorEls.binarySize.textContent = "—"; // server gave us no size; cosmetic - updateSaveEnabled(); - editorDialog.showModal(); - setTimeout(() => editorEls.filename.focus(), 0); - } - - editorEls.filename.addEventListener("input", () => { - updateRenameHint(); - updateSaveEnabled(); - }); - editorEls.contentBox.addEventListener("input", () => { - updateByteCount(); - updateSaveEnabled(); - }); - editorEls.contentBox.addEventListener("keydown", (event) => { - if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") { - event.preventDefault(); - editorEls.saveBtn.click(); - } - }); - editorEls.replaceClear.addEventListener("click", () => setQueuedReplacement(null)); - editorEls.replaceBrowse.addEventListener("click", () => editorEls.replaceInput.click()); - editorEls.replaceInput.addEventListener("change", () => { - const f = editorEls.replaceInput.files && editorEls.replaceInput.files[0]; - if (f) setQueuedReplacement(f); - }); - editorEls.replaceZone.addEventListener("dragover", (event) => { - if (Array.from(event.dataTransfer.types).includes("Files")) { - event.preventDefault(); - editorEls.replaceZone.classList.add("is-drop-target"); - } - }); - editorEls.replaceZone.addEventListener("dragleave", () => { - editorEls.replaceZone.classList.remove("is-drop-target"); - }); - editorEls.replaceZone.addEventListener("drop", (event) => { - if (!Array.from(event.dataTransfer.types).includes("Files")) return; - event.preventDefault(); - editorEls.replaceZone.classList.remove("is-drop-target"); - const f = event.dataTransfer.files && event.dataTransfer.files[0]; - if (f) setQueuedReplacement(f); - }); - editorDialog.addEventListener("close", () => { - setQueuedReplacement(null); - }); - - editorEls.saveBtn.addEventListener("click", async () => { - const filename = editorEls.filename.value.trim(); - if (!filename) return; - if (filename.includes("/")) { - alert("Filename can't contain '/'. Use drag-to-move to relocate."); - return; - } - const folder = editor.folder || ""; - const newRel = joinPath(folder, filename); - - if (editor.creating) { - // Text-flavor create → /save with no new_path. - const r = await postJson(`${baseUrl}/files/save`, { - path: newRel, - content: getEditorValue(), - }); - if (r.ok) { - editorDialog.close(); - scheduleRefresh(folder); - } else if (r.status === 409) { - const action = await askConflict(newRel); - if (action === "overwrite") { - // Re-call /save (no overwrite flag — /save just writes); skip - // the conflict by writing in-place which is what users want. - // First delete the colliding entry to avoid the implicit - // "destination is not a file" branch when it's a directory. - // For files, a plain /save overwrite is fine. - const r2 = await postJson(`${baseUrl}/files/save`, { - path: newRel, - content: getEditorValue(), - }); - if (r2.ok) { - editorDialog.close(); - scheduleRefresh(folder); - } else { - alert( - (r2.body && r2.body.error) || - `Save failed (HTTP ${r2.status}).` - ); - } - } else if (action === "keep-both") { - const altered = withCollisionSuffix(newRel); - const r2 = await postJson(`${baseUrl}/files/save`, { - path: altered, - content: getEditorValue(), - }); - if (r2.ok) { - editorDialog.close(); - scheduleRefresh(folder); - } - } - } else { - alert((r.body && r.body.error) || `Save failed (HTTP ${r.status}).`); - } - return; - } - - const renaming = newRel !== editor.originalPath; - if (editor.mode === "text") { - const payload = { - path: editor.originalPath, - content: getEditorValue(), - }; - if (renaming) payload.new_path = newRel; - const r = await postJson(`${baseUrl}/files/save`, payload); - if (r.ok) { - editorDialog.close(); - scheduleRefresh(folder); - } else { - alert( - (r.body && r.body.error) || `Save failed (HTTP ${r.status}).` - ); - } - return; - } - - // Binary mode. - const fd = new FormData(); - fd.append("path", editor.originalPath); - fd.append("csrf_token", csrfToken); - if (renaming) fd.append("new_path", newRel); - if (editor.queuedReplacement) { - fd.append("file", editor.queuedReplacement); - const r = await postForm(`${baseUrl}/files/replace`, fd); - if (r.ok) { - editorDialog.close(); - scheduleRefresh(folder); - } else { - alert( - (r.body && r.body.error) || `Replace failed (HTTP ${r.status}).` - ); - } - } else if (renaming) { - // Rename only via /move (no content change). - const r = await postJson(`${baseUrl}/files/move`, { - src: editor.originalPath, - dst: newRel, - }); - if (r.ok) { - editorDialog.close(); - scheduleRefresh(folder); - } else { - alert( - (r.body && r.body.error) || `Rename failed (HTTP ${r.status}).` - ); - } - } - }); - - editorEls.deleteBtn.addEventListener("click", async () => { - if (!editor.originalPath) return; - if (!confirm(`Delete ${editor.originalPath}?`)) return; - const fd = new FormData(); - fd.append("path", editor.originalPath); - fd.append("csrf_token", csrfToken); - const r = await postForm(`${baseUrl}/files/delete`, fd); - if (r.ok) { - editorDialog.close(); - scheduleRefresh(parentOf(editor.originalPath)); - } else { - alert((r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`); - } - }); - - // ---------- modal-content save / delete (URL-addressable modal) ---------- - // The new server-rendered editor (loaded into #modal-content) has its own - // .files-editor-save and .files-editor-delete buttons. Those are not the - // same elements as editorEls.saveBtn / editorEls.deleteBtn (which live in - // the old inline dialog still present for create-new-file / binary flows). - // Use event delegation so the handlers fire on dynamically swapped content. - - document.addEventListener("click", async (event) => { - const modalContent = document.getElementById("modal-content"); - if (!modalContent) return; - - const saveBtn = event.target.closest(".files-editor-save"); - if (saveBtn && modalContent.contains(saveBtn)) { - const ta = modalContent.querySelector("textarea[data-rel-path]"); - if (!ta) return; - const relPath = ta.dataset.relPath; - if (!relPath) return; - const content = (window.__filesEditor && window.__filesEditor.getValue) - ? window.__filesEditor.getValue() - : ta.value; - - // Rename-on-save: if the user edited the filename input, compose the - // new path (sibling rename only — joining parent of relPath with the - // new filename). Send payload.new_path so the server moves and writes - // atomically. Matches the legacy save handler's contract. - 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("/")) : ""; - let newPath = null; - if (editedFilename && editedFilename !== originalLeaf) { - newPath = parent ? `${parent}/${editedFilename}` : editedFilename; - } - - const payload = { path: relPath, content }; - if (newPath) payload.new_path = newPath; - - const r = await postJson(`${baseUrl}/files/save`, payload); - if (r.ok) { - if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted(); - scheduleRefresh(parentOf(newPath || relPath)); - } else if (r.status === 409) { - // Conflict (destination already exists) — show error and keep modal - // open so the user can pick a different filename. - alert(r.rawText || `Conflict: destination already exists.`); - return; - } else { - alert((r.body && r.body.error) || r.rawText || `Save failed (HTTP ${r.status}).`); - } - return; - } - - const deleteBtn = event.target.closest(".files-editor-delete"); - if (deleteBtn && modalContent.contains(deleteBtn)) { - const ta = modalContent.querySelector("textarea[data-rel-path]"); - if (!ta) return; - const relPath = ta.dataset.relPath; - if (!relPath) return; - if (!confirm(`Delete ${relPath}?`)) return; - const fd = new FormData(); - fd.append("path", relPath); - fd.append("csrf_token", csrfToken); - const r = await postForm(`${baseUrl}/files/delete`, fd); - if (r.ok) { - if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted(); - scheduleRefresh(parentOf(relPath)); - } else { - alert((r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`); - } - return; - } - }); + // + // Migrated to static/js/files-overlay/editor.js (Phase A, Step 2). + // This module no longer touches the editor. Helpers it still needs + // (askConflict, withCollisionSuffix) are exposed above on + // window.__filesOverlay and de-duplicate in Steps 3 and 4. // ---------- new-folder modal -------------------------------------------- @@ -1059,29 +654,12 @@ if (!manager.contains(action)) return; const op = action.dataset.action; const path = action.dataset.targetPath || ""; - if (op === "new-file") { - openEditorTextNew(path); - } else if (op === "new-folder") { + // new-file + edit: dispatched via __filesOverlay registry (editor.js). + if (op === "new-folder") { openNewFolder(path); } else if (op === "zip") { const url = `${baseUrl}/files/download_zip?path=${encodeURIComponent(path)}`; window.location.href = url; - } else if (op === "edit") { - const editable = action.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 navigation - // still hits the same route and renders the standalone editor page. - window.location.href = editUrl; - } - } else { - // Binary files: keep old inline dialog (binary replace deferred from pilot). - openEditorForFile(path, false); - } } else if (op === "delete") { const kind = action.dataset.rowKind; const name = action.dataset.rowName || basename(path); diff --git a/l4d2web/l4d2web/static/js/files-overlay/editor.js b/l4d2web/l4d2web/static/js/files-overlay/editor.js new file mode 100644 index 0000000..994a693 --- /dev/null +++ b/l4d2web/l4d2web/static/js/files-overlay/editor.js @@ -0,0 +1,550 @@ +// files-overlay/editor.js — Phase A, Step 2. +// +// Owns the editor flows during Phase A. Dual-purpose: drives both the +// legacy inline #files-editor-modal (binary-replace + create- +// new-file) and the URL-addressable modal swapped into #modal-content +// (editable text files). Phase B migrates the legacy flows to URL- +// addressable too and removes the legacy branches here. +// +// Action-registry dispatch (registered into __filesOverlay): +// * "new-file" → openEditorTextNew(folder) (legacy dialog) +// * "edit" → URL-addressable modal for editable files; +// openEditorForFile(path, false) for binary. +// +// Save / delete: a single document-level click listener handles both +// the legacy dialog and the URL-addressable modal, discriminated by +// which ancestor contains the click target. +// +// Direct-bound (per plan, escape hatch): filename `input`, content +// textarea `input` + `keydown` — high-frequency events on persistent +// inputs inside the persistent legacy dialog. +// +// Cross-module deps consumed via window.__filesOverlay (set by +// core.js): manager, overlayId, baseUrl, csrfToken, helpers, plus +// askConflict (set by the legacy files-overlay.js, used here for save +// 409-conflict handling). + +(function () { + "use strict"; + + const fo = window.__filesOverlay; + if (!fo) return; + + const { overlayId, baseUrl, csrfToken } = fo; + const { + joinPath, parentOf, basename, humanSize, + fetchJson, postJson, postForm, scheduleRefresh, + } = fo.helpers; + + // Legacy inline dialog — present in Phase A. Phase B deletes it; the + // legacy-branch handlers below short-circuit on its absence. + const editorDialog = document.getElementById("files-editor-modal"); + + // ---------- legacy editor state + DOM refs ---------- + + const editor = { + mode: null, // "text" | "binary" + creating: false, + originalPath: null, + folder: null, + queuedReplacement: null, // File object + }; + + const editorEls = editorDialog ? { + title: editorDialog.querySelector(".files-editor-title-text"), + filename: editorDialog.querySelector(".files-editor-filename"), + renameHint: editorDialog.querySelector(".files-editor-rename-hint"), + renameFrom: editorDialog.querySelector(".files-rename-from"), + renameTo: editorDialog.querySelector(".files-rename-to"), + textPanel: editorDialog.querySelector(".files-editor-text"), + contentBox: editorDialog.querySelector(".files-editor-content"), + byteCount: editorDialog.querySelector(".files-editor-byte-count"), + binaryPanel: editorDialog.querySelector(".files-editor-binary"), + binarySize: editorDialog.querySelector(".files-editor-binary-size"), + replaceZone: editorDialog.querySelector(".files-editor-replace-zone"), + replaceIdle: editorDialog.querySelector(".files-editor-replace-idle"), + replaceQueued: editorDialog.querySelector(".files-editor-replace-queued"), + replaceName: editorDialog.querySelector(".files-editor-replace-name"), + replaceSize: editorDialog.querySelector(".files-editor-replace-size"), + replaceClear: editorDialog.querySelector(".files-editor-replace-clear"), + replaceBrowse: editorDialog.querySelector(".files-editor-replace-browse"), + replaceInput: editorDialog.querySelector(".files-editor-replace-input"), + deleteBtn: editorDialog.querySelector(".files-editor-delete"), + downloadBtn: editorDialog.querySelector(".files-editor-download"), + saveBtn: editorDialog.querySelector(".files-editor-save"), + } : null; + + // ---------- CM6 bridge + UI updates (legacy dialog) ---------- + + // Bridge to the CodeMirror 6 controller, set up by static/js/editor.js + // on the .files-editor-content textarea. Falls back to the textarea + // directly if the bundle didn't load (no-JS fallback / file open + // before the controller has been mounted). + function getEditorValue() { + return (window.__filesEditor && window.__filesEditor.getValue) + ? window.__filesEditor.getValue() + : editorEls.contentBox.value; + } + function setEditorValue(text) { + if (window.__filesEditor && window.__filesEditor.setContent) { + window.__filesEditor.setContent(text); + } else { + editorEls.contentBox.value = text; + } + } + + function setEditorTitle(text) { + editorEls.title.textContent = text; + } + + function updateByteCount() { + const bytes = new TextEncoder().encode(getEditorValue()).length; + editorEls.byteCount.textContent = `UTF-8 · ${bytes} bytes`; + } + + function updateRenameHint() { + const current = editorEls.filename.value.trim(); + const original = basename(editor.originalPath || ""); + if (editor.creating || !current || current === original) { + editorEls.renameHint.hidden = true; + return; + } + editorEls.renameFrom.textContent = original; + editorEls.renameTo.textContent = current; + editorEls.renameHint.hidden = false; + } + + function updateSaveEnabled() { + if (editor.mode === "binary" && !editor.creating) { + const filenameChanged = + editorEls.filename.value.trim() !== basename(editor.originalPath || ""); + const hasReplacement = !!editor.queuedReplacement; + editorEls.saveBtn.disabled = !filenameChanged && !hasReplacement; + editorEls.saveBtn.textContent = "Save"; + } else if (editor.creating) { + editorEls.saveBtn.disabled = !editorEls.filename.value.trim(); + editorEls.saveBtn.textContent = "Create"; + } else { + editorEls.saveBtn.disabled = false; + editorEls.saveBtn.textContent = "Save"; + } + } + + function setQueuedReplacement(file) { + editor.queuedReplacement = file; + if (file) { + editorEls.replaceIdle.hidden = true; + editorEls.replaceQueued.hidden = false; + editorEls.replaceName.textContent = file.name; + editorEls.replaceSize.textContent = humanSize(file.size); + } else { + editorEls.replaceIdle.hidden = false; + editorEls.replaceQueued.hidden = true; + } + updateSaveEnabled(); + } + + // ---------- legacy editor openers ---------- + + function openEditorTextNew(folder) { + if (!editorDialog) return; + editor.mode = "text"; + editor.creating = true; + editor.originalPath = null; + editor.folder = folder; + editor.queuedReplacement = null; + + setEditorTitle(`${folder ? folder + "/" : ""}…new file`); + editorEls.filename.value = ""; + editorEls.filename.disabled = false; + setEditorValue(""); + editorEls.contentBox.disabled = false; + editorEls.renameHint.hidden = true; + editorEls.textPanel.hidden = false; + editorEls.binaryPanel.hidden = true; + editorEls.deleteBtn.hidden = true; + editorEls.downloadBtn.hidden = true; + editorEls.saveBtn.textContent = "Create"; + updateByteCount(); + updateSaveEnabled(); + editorDialog.showModal(); + setTimeout(() => editorEls.filename.focus(), 0); + } + + async function openEditorForFile(path, isEditable) { + if (!editorDialog) return; + editor.creating = false; + editor.originalPath = path; + editor.folder = parentOf(path); + editor.queuedReplacement = null; + setQueuedReplacement(null); + + editorEls.filename.value = basename(path); + editorEls.filename.disabled = false; + editorEls.renameHint.hidden = true; + editorEls.deleteBtn.hidden = false; + editorEls.downloadBtn.hidden = false; + editorEls.downloadBtn.href = `${baseUrl}/files/download?path=${encodeURIComponent(path)}`; + setEditorTitle(path); + + if (isEditable) { + editor.mode = "text"; + editorEls.textPanel.hidden = false; + editorEls.binaryPanel.hidden = true; + setEditorValue("Loading…"); + editorEls.contentBox.disabled = true; + + const r = await fetchJson( + `${baseUrl}/files/content?path=${encodeURIComponent(path)}` + ); + if (r.ok && r.body) { + setEditorValue(r.body.content); + editorEls.contentBox.disabled = false; + updateByteCount(); + updateSaveEnabled(); + editorDialog.showModal(); + setTimeout(() => editorEls.contentBox.focus(), 0); + return; + } + + // Fallback: server says not editable. Re-open as binary. + editorEls.contentBox.disabled = false; + editor.mode = "binary"; + } else { + editor.mode = "binary"; + } + + // Binary mode setup. + editorEls.textPanel.hidden = true; + editorEls.binaryPanel.hidden = false; + editorEls.binarySize.textContent = "—"; // server gave us no size; cosmetic + updateSaveEnabled(); + editorDialog.showModal(); + setTimeout(() => editorEls.filename.focus(), 0); + } + + // ---------- direct-bound persistent-input listeners (legacy dialog) ---------- + + if (editorDialog) { + // Per plan: filename + content textarea input/keydown stay direct- + // bound. They're high-frequency events on persistent inputs inside + // the persistent legacy dialog; delegation would add per-keystroke + // selector-matching overhead. + editorEls.filename.addEventListener("input", () => { + updateRenameHint(); + updateSaveEnabled(); + }); + editorEls.contentBox.addEventListener("input", () => { + updateByteCount(); + updateSaveEnabled(); + }); + editorEls.contentBox.addEventListener("keydown", (event) => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") { + event.preventDefault(); + editorEls.saveBtn.click(); + } + }); + editorDialog.addEventListener("close", () => { + setQueuedReplacement(null); + }); + } + + // ---------- delegated handlers (legacy + URL-addressable) ---------- + + function inLegacyEditor(el) { + return !!editorDialog && editorDialog.contains(el); + } + function inRoutedEditor(el) { + const mc = document.getElementById("modal-content"); + return !!mc && mc.contains(el); + } + + // Replace-zone drag (legacy binary mode). Document-level delegation + // gated on the zone being inside the legacy dialog. + document.addEventListener("dragover", (event) => { + const zone = event.target?.closest?.(".files-editor-replace-zone"); + if (!zone || !inLegacyEditor(zone)) return; + if (Array.from(event.dataTransfer.types).includes("Files")) { + event.preventDefault(); + zone.classList.add("is-drop-target"); + } + }); + document.addEventListener("dragleave", (event) => { + const zone = event.target?.closest?.(".files-editor-replace-zone"); + if (!zone || !inLegacyEditor(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 (!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); + }); + + // Replace-input change (browse-fallback): low frequency, delegated. + document.addEventListener("change", (event) => { + const input = event.target?.closest?.(".files-editor-replace-input"); + if (!input || !inLegacyEditor(input)) return; + const f = input.files && input.files[0]; + if (f) setQueuedReplacement(f); + }); + + document.addEventListener("click", async (event) => { + const target = event.target; + if (!target) return; + + // Legacy: replace-clear button. + const clearBtn = target.closest?.(".files-editor-replace-clear"); + if (clearBtn && inLegacyEditor(clearBtn)) { + setQueuedReplacement(null); + return; + } + // Legacy: replace-browse button → trigger hidden file input. + const browseBtn = target.closest?.(".files-editor-replace-browse"); + if (browseBtn && inLegacyEditor(browseBtn)) { + editorEls.replaceInput.click(); + return; + } + + // Save (both editors). + const saveBtn = target.closest?.(".files-editor-save"); + if (saveBtn) { + if (inLegacyEditor(saveBtn)) { + await legacySaveClicked(); + return; + } + if (inRoutedEditor(saveBtn)) { + await routedSaveClicked(); + return; + } + } + + // Delete (both editors). + const delBtn = target.closest?.(".files-editor-delete"); + if (delBtn) { + if (inLegacyEditor(delBtn)) { + await legacyDeleteClicked(); + return; + } + if (inRoutedEditor(delBtn)) { + await routedDeleteClicked(); + return; + } + } + }); + + async function legacySaveClicked() { + const filename = editorEls.filename.value.trim(); + if (!filename) return; + if (filename.includes("/")) { + alert("Filename can't contain '/'. Use drag-to-move to relocate."); + return; + } + const folder = editor.folder || ""; + const newRel = joinPath(folder, filename); + + if (editor.creating) { + // Text-flavor create → /save with no new_path. + const r = await postJson(`${baseUrl}/files/save`, { + path: newRel, + content: getEditorValue(), + }); + if (r.ok) { + editorDialog.close(); + scheduleRefresh(folder); + } else if (r.status === 409) { + // askConflict lives in the legacy files-overlay.js for Phase A; + // exposed there on __filesOverlay. Phase A Step 3 moves it to + // dialogs.js (still on __filesOverlay), so this call site + // doesn't change in Step 3. + const askConflict = fo.askConflict; + const withCollisionSuffix = fo.withCollisionSuffix; + if (typeof askConflict !== "function" || typeof withCollisionSuffix !== "function") { + alert(`Save failed (HTTP ${r.status}).`); + return; + } + const action = await askConflict(newRel); + if (action === "overwrite") { + const r2 = await postJson(`${baseUrl}/files/save`, { + path: newRel, + content: getEditorValue(), + }); + if (r2.ok) { + editorDialog.close(); + scheduleRefresh(folder); + } else { + alert( + (r2.body && r2.body.error) || + `Save failed (HTTP ${r2.status}).` + ); + } + } else if (action === "keep-both") { + const altered = withCollisionSuffix(newRel); + const r2 = await postJson(`${baseUrl}/files/save`, { + path: altered, + content: getEditorValue(), + }); + if (r2.ok) { + editorDialog.close(); + scheduleRefresh(folder); + } + } + } else { + alert((r.body && r.body.error) || `Save failed (HTTP ${r.status}).`); + } + return; + } + + const renaming = newRel !== editor.originalPath; + if (editor.mode === "text") { + const payload = { + path: editor.originalPath, + content: getEditorValue(), + }; + if (renaming) payload.new_path = newRel; + const r = await postJson(`${baseUrl}/files/save`, payload); + if (r.ok) { + editorDialog.close(); + scheduleRefresh(folder); + } else { + alert( + (r.body && r.body.error) || `Save failed (HTTP ${r.status}).` + ); + } + return; + } + + // Binary mode. + const fd = new FormData(); + fd.append("path", editor.originalPath); + fd.append("csrf_token", csrfToken); + if (renaming) fd.append("new_path", newRel); + if (editor.queuedReplacement) { + fd.append("file", editor.queuedReplacement); + const r = await postForm(`${baseUrl}/files/replace`, fd); + if (r.ok) { + editorDialog.close(); + scheduleRefresh(folder); + } else { + alert( + (r.body && r.body.error) || `Replace failed (HTTP ${r.status}).` + ); + } + } else if (renaming) { + // Rename only via /move (no content change). + const r = await postJson(`${baseUrl}/files/move`, { + src: editor.originalPath, + dst: newRel, + }); + if (r.ok) { + editorDialog.close(); + scheduleRefresh(folder); + } else { + alert( + (r.body && r.body.error) || `Rename failed (HTTP ${r.status}).` + ); + } + } + } + + async function legacyDeleteClicked() { + if (!editor.originalPath) return; + if (!confirm(`Delete ${editor.originalPath}?`)) return; + const fd = new FormData(); + fd.append("path", editor.originalPath); + fd.append("csrf_token", csrfToken); + const r = await postForm(`${baseUrl}/files/delete`, fd); + if (r.ok) { + editorDialog.close(); + scheduleRefresh(parentOf(editor.originalPath)); + } else { + alert((r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`); + } + } + + // ---------- URL-addressable modal (#modal-content) save / delete ---------- + + async function routedSaveClicked() { + const modalContent = document.getElementById("modal-content"); + if (!modalContent) return; + const ta = modalContent.querySelector("textarea[data-rel-path]"); + if (!ta) return; + const relPath = ta.dataset.relPath; + if (!relPath) return; + const content = (window.__filesEditor && window.__filesEditor.getValue) + ? window.__filesEditor.getValue() + : ta.value; + + // Rename-on-save: if the user edited the filename input, compose the + // new path (sibling rename only — joining parent of relPath with the + // new filename). Send payload.new_path so the server moves and writes + // atomically. Matches the legacy save handler's contract. + 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("/")) : ""; + let newPath = null; + if (editedFilename && editedFilename !== originalLeaf) { + newPath = parent ? `${parent}/${editedFilename}` : editedFilename; + } + + const payload = { path: relPath, content }; + if (newPath) payload.new_path = newPath; + + const r = await postJson(`${baseUrl}/files/save`, payload); + if (r.ok) { + if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted(); + scheduleRefresh(parentOf(newPath || relPath)); + } else if (r.status === 409) { + alert(r.rawText || `Conflict: destination already exists.`); + } else { + alert((r.body && r.body.error) || r.rawText || `Save failed (HTTP ${r.status}).`); + } + } + + async function routedDeleteClicked() { + const modalContent = document.getElementById("modal-content"); + if (!modalContent) return; + const ta = modalContent.querySelector("textarea[data-rel-path]"); + if (!ta) return; + const relPath = ta.dataset.relPath; + if (!relPath) return; + if (!confirm(`Delete ${relPath}?`)) return; + const fd = new FormData(); + fd.append("path", relPath); + fd.append("csrf_token", csrfToken); + const r = await postForm(`${baseUrl}/files/delete`, fd); + if (r.ok) { + if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted(); + scheduleRefresh(parentOf(relPath)); + } else { + alert((r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`); + } + } + + // ---------- register action-registry handlers ---------- + + fo.registerHandler("new-file", (path) => openEditorTextNew(path)); + + 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; + } + // Binary files: legacy inline dialog (Phase B migrates this to + // URL-addressable too). + openEditorForFile(path, false); + }); +})(); diff --git a/l4d2web/l4d2web/templates/overlay_detail.html b/l4d2web/l4d2web/templates/overlay_detail.html index b23fd0c..5450102 100644 --- a/l4d2web/l4d2web/templates/overlay_detail.html +++ b/l4d2web/l4d2web/templates/overlay_detail.html @@ -283,6 +283,7 @@ + {% endif %} {% endblock %}