diff --git a/l4d2web/l4d2web/static/js/files-overlay.js b/l4d2web/l4d2web/static/js/files-overlay.js index 26ece4e..e3e0032 100644 --- a/l4d2web/l4d2web/static/js/files-overlay.js +++ b/l4d2web/l4d2web/static/js/files-overlay.js @@ -29,9 +29,6 @@ const csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content") || ""; - const newFolderDialog = document.getElementById("files-new-folder-modal"); - const conflictDialog = document.getElementById("files-conflict-modal"); - const deleteDialog = document.getElementById("files-delete-modal"); const uploadsPanel = document.querySelector(".files-uploads"); const uploadsList = uploadsPanel?.querySelector(".files-uploads-list"); const uploadsClearBtn = uploadsPanel?.querySelector(".files-uploads-clear"); @@ -194,23 +191,11 @@ } // ---------- conflict modal ---------------------------------------------- - - function askConflict(path) { - return new Promise((resolve) => { - conflictDialog.querySelector(".files-conflict-path").textContent = path; - const handler = (event) => { - const action = event.target?.dataset?.filesConflictAction; - if (!action) return; - conflictDialog.removeEventListener("click", handler); - conflictDialog.close(); - resolve(action); - }; - conflictDialog.addEventListener("click", handler); - conflictDialog.showModal(); - }); - } - // Exposed for editor.js (Phase A). Moves to dialogs.js in Step 3. - if (window.__filesOverlay) window.__filesOverlay.askConflict = askConflict; + // + // Migrated to static/js/files-overlay/dialogs.js (Phase A, Step 3). + // askConflict is now exposed there on window.__filesOverlay. The + // legacy upload + drag-drop code below reads it via __filesOverlay + // at call time. // Attach a path-collision suffix: foo.txt → foo (1).txt function withCollisionSuffix(path) { @@ -225,31 +210,10 @@ if (window.__filesOverlay) window.__filesOverlay.withCollisionSuffix = withCollisionSuffix; // ---------- delete modal ------------------------------------------------ - - function openDelete(targetPath, kind, name) { - deleteDialog.querySelector(".files-delete-name").textContent = name; - const errEl = deleteDialog.querySelector(".files-delete-error"); - errEl.hidden = true; - errEl.textContent = ""; - - const confirmBtn = deleteDialog.querySelector(".files-delete-confirm"); - const onConfirm = async () => { - const fd = new FormData(); - fd.append("path", targetPath); - fd.append("csrf_token", csrfToken); - const r = await postForm(`${baseUrl}/files/delete`, fd); - if (r.ok) { - deleteDialog.close(); - scheduleRefresh(parentOf(targetPath)); - } else { - errEl.hidden = false; - errEl.textContent = (r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`; - } - }; - confirmBtn.replaceWith(confirmBtn.cloneNode(true)); - deleteDialog.querySelector(".files-delete-confirm").addEventListener("click", onConfirm); - deleteDialog.showModal(); - } + // + // Migrated to static/js/files-overlay/dialogs.js (Phase A, Step 3). + // Now dispatched via the "delete" action-registry handler that + // dialogs.js registers. // ---------- editor modal ------------------------------------------------ // @@ -259,53 +223,10 @@ // window.__filesOverlay and de-duplicate in Steps 3 and 4. // ---------- new-folder modal -------------------------------------------- - - function openNewFolder(targetFolder) { - const folder = targetFolder || ""; - newFolderDialog.querySelector(".files-new-folder-target").textContent = - folder ? folder + "/" : "/"; - const input = newFolderDialog.querySelector(".files-new-folder-name"); - const errEl = newFolderDialog.querySelector(".files-new-folder-error"); - input.value = ""; - errEl.hidden = true; - errEl.textContent = ""; - - const createBtn = newFolderDialog.querySelector(".files-new-folder-create"); - const fresh = createBtn.cloneNode(true); - createBtn.replaceWith(fresh); - fresh.addEventListener("click", async () => { - const name = input.value.trim().replace(/^\/+|\/+$/g, ""); - if (!name) return; - const fullPath = joinPath(folder, name); - const r = await postJson(`${baseUrl}/files/mkdir`, { path: fullPath }); - if (r.ok) { - newFolderDialog.close(); - scheduleRefresh(folder); - } else { - errEl.hidden = false; - errEl.textContent = - (r.body && r.body.error) || - (r.status === 409 - ? "A file or folder with that name already exists." - : `Failed (HTTP ${r.status}).`); - } - }); - - newFolderDialog.showModal(); - setTimeout(() => input.focus(), 0); - } - - // Enter on the new-folder input submits — bound once at module init so - // it survives multiple openings of the dialog. (A previous version used - // `{once: true}` inside openNewFolder, which was consumed by the first - // letter the user typed and never saw Enter.) - newFolderDialog - .querySelector(".files-new-folder-name") - .addEventListener("keydown", (event) => { - if (event.key !== "Enter") return; - event.preventDefault(); - newFolderDialog.querySelector(".files-new-folder-create")?.click(); - }); + // + // Migrated to static/js/files-overlay/dialogs.js (Phase A, Step 3). + // Now dispatched via the "new-folder" action-registry handler that + // dialogs.js registers. // ---------- upload queue + progress panel ------------------------------- @@ -449,7 +370,9 @@ } else if (xhr.status === 409 && !overwriteFlag) { setRowState(item, "conflict"); const collidingPath = joinPath(item.targetFolder || "", item.relative); - askConflict(collidingPath).then((action) => { + // askConflict moved to dialogs.js in Step 3 — reach it via the + // shared registry rather than a closure-scope reference. + window.__filesOverlay.askConflict(collidingPath).then((action) => { if (action === "overwrite") { runUpload(item, true); } else if (action === "keep-both") { @@ -566,7 +489,8 @@ scheduleRefresh(parentOf(src)); if (parentOf(src) !== dropFolder) scheduleRefresh(dropFolder); } else if (r.status === 409) { - const action = await askConflict(dst); + // askConflict moved to dialogs.js in Step 3 — see Edit above. + const action = await window.__filesOverlay.askConflict(dst); if (action === "overwrite") { const r2 = await postJson(`${baseUrl}/files/move`, { src, dst, overwrite: true }); if (r2.ok) { @@ -654,16 +578,12 @@ if (!manager.contains(action)) return; const op = action.dataset.action; const path = action.dataset.targetPath || ""; - // new-file + edit: dispatched via __filesOverlay registry (editor.js). - if (op === "new-folder") { - openNewFolder(path); - } else if (op === "zip") { + // new-file + edit dispatched via registry (editor.js); new-folder + + // delete dispatched via registry (dialogs.js). Only "zip" remains + // here — it's pure URL navigation with no module dependency. + if (op === "zip") { const url = `${baseUrl}/files/download_zip?path=${encodeURIComponent(path)}`; window.location.href = url; - } else if (op === "delete") { - const kind = action.dataset.rowKind; - const name = action.dataset.rowName || basename(path); - openDelete(path, kind, name); } }); })(); diff --git a/l4d2web/l4d2web/static/js/files-overlay/dialogs.js b/l4d2web/l4d2web/static/js/files-overlay/dialogs.js new file mode 100644 index 0000000..1f2673e --- /dev/null +++ b/l4d2web/l4d2web/static/js/files-overlay/dialogs.js @@ -0,0 +1,212 @@ +// files-overlay/dialogs.js — Phase A, Step 3. +// +// Owns the three inline dialogs that surround the file manager: +// * #files-new-folder-modal — "+ folder" / mkdir prompt +// * #files-delete-modal — delete-confirm for files and folders +// * #files-conflict-modal — overwrite / keep-both / cancel choice +// shown when save / upload / move hits 409 +// +// Dispatch: +// * "new-folder" registered into __filesOverlay → openNewFolder(path) +// * "delete" registered into __filesOverlay → openDelete(path, +// kind, name) — kind + name read from actionEl +// dataset. +// +// askConflict(path) → Promise<"overwrite" | "keep-both" | "cancel"> +// is exposed on __filesOverlay so legacy upload / drag-drop callers in +// files-overlay.js (lines runUpload at 452 and the drop handler at +// 569) can still reach it after the function moved out of their file. +// editor.js's save 409-conflict path also goes through this same +// exposure. +// +// All three dialogs use a single delegated click listener per dialog, +// reading per-dialog state from module-scope. The clone-and-rebind +// pattern from the legacy file (replaceWith(cloneNode(true)) to drop +// stale listeners) is gone — the listener is attached once and reads +// freshly-set state each time the dialog opens. +// +// Dialogs open through window.modals.openInline / closeInline (the +// consolidated modal API at static/js/modals.js) rather than calling +// dialog.showModal() / close() directly, completing the inline-modal +// convention from commit c51089d. + +(function () { + "use strict"; + + const fo = window.__filesOverlay; + if (!fo) return; + + const { baseUrl, csrfToken } = fo; + const { joinPath, parentOf, basename, postJson, postForm, scheduleRefresh } = fo.helpers; + + const newFolderDialog = document.getElementById("files-new-folder-modal"); + const deleteDialog = document.getElementById("files-delete-modal"); + const conflictDialog = document.getElementById("files-conflict-modal"); + + function openModal(dialog) { + if (window.modals && typeof window.modals.openInline === "function") { + window.modals.openInline(dialog); + } else { + dialog.showModal(); + } + } + function closeModal(dialog) { + if (window.modals && typeof window.modals.closeInline === "function") { + window.modals.closeInline(dialog); + } else { + dialog.close(); + } + } + + // ---------- conflict modal ---------------------------------------------- + + // While a conflict dialog is open, conflictState.resolve is the + // pending Promise resolver. The delegated click handler calls it and + // nulls it so subsequent clicks don't double-resolve. + let conflictState = null; + + function askConflict(path) { + return new Promise((resolve) => { + if (!conflictDialog) { + resolve("cancel"); + return; + } + conflictDialog.querySelector(".files-conflict-path").textContent = path; + conflictState = { resolve }; + openModal(conflictDialog); + }); + } + fo.askConflict = askConflict; + + if (conflictDialog) { + conflictDialog.addEventListener("click", (event) => { + const action = event.target?.dataset?.filesConflictAction; + if (!action) return; + const state = conflictState; + conflictState = null; + closeModal(conflictDialog); + if (state && typeof state.resolve === "function") { + state.resolve(action); + } + }); + // If the user dismisses the dialog any other way (Esc, backdrop, + // programmatic close), resolve as "cancel" so callers don't hang. + conflictDialog.addEventListener("close", () => { + const state = conflictState; + conflictState = null; + if (state && typeof state.resolve === "function") { + state.resolve("cancel"); + } + }); + } + + // ---------- delete-confirm modal ---------------------------------------- + + let deleteState = null; + + function openDelete(targetPath, kind, name) { + if (!deleteDialog) return; + deleteDialog.querySelector(".files-delete-name").textContent = name; + const errEl = deleteDialog.querySelector(".files-delete-error"); + errEl.hidden = true; + errEl.textContent = ""; + deleteState = { path: targetPath, kind, errEl }; + openModal(deleteDialog); + } + + if (deleteDialog) { + deleteDialog.addEventListener("click", async (event) => { + const btn = event.target?.closest?.(".files-delete-confirm"); + if (!btn) return; + if (!deleteState) return; + const { path, errEl } = deleteState; + const fd = new FormData(); + fd.append("path", path); + fd.append("csrf_token", csrfToken); + const r = await postForm(`${baseUrl}/files/delete`, fd); + if (r.ok) { + deleteState = null; + closeModal(deleteDialog); + scheduleRefresh(parentOf(path)); + } else { + errEl.hidden = false; + errEl.textContent = + (r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`; + } + }); + deleteDialog.addEventListener("close", () => { + deleteState = null; + }); + } + + // ---------- new-folder modal -------------------------------------------- + + let newFolderState = null; + + function openNewFolder(targetFolder) { + if (!newFolderDialog) return; + const folder = targetFolder || ""; + newFolderDialog.querySelector(".files-new-folder-target").textContent = + folder ? folder + "/" : "/"; + const input = newFolderDialog.querySelector(".files-new-folder-name"); + const errEl = newFolderDialog.querySelector(".files-new-folder-error"); + input.value = ""; + errEl.hidden = true; + errEl.textContent = ""; + newFolderState = { folder, input, errEl }; + openModal(newFolderDialog); + setTimeout(() => input.focus(), 0); + } + + async function newFolderSubmit() { + if (!newFolderState) return; + const { folder, input, errEl } = newFolderState; + const name = input.value.trim().replace(/^\/+|\/+$/g, ""); + if (!name) return; + const fullPath = joinPath(folder, name); + const r = await postJson(`${baseUrl}/files/mkdir`, { path: fullPath }); + if (r.ok) { + newFolderState = null; + closeModal(newFolderDialog); + scheduleRefresh(folder); + } else { + errEl.hidden = false; + errEl.textContent = + (r.body && r.body.error) || + (r.status === 409 + ? "A file or folder with that name already exists." + : `Failed (HTTP ${r.status}).`); + } + } + + if (newFolderDialog) { + newFolderDialog.addEventListener("click", (event) => { + if (event.target?.closest?.(".files-new-folder-create")) { + newFolderSubmit(); + } + }); + // Enter inside the name input submits. Direct-bound to the + // persistent input — high-frequency keydown, no benefit from + // delegation. + newFolderDialog + .querySelector(".files-new-folder-name") + .addEventListener("keydown", (event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + newFolderSubmit(); + }); + newFolderDialog.addEventListener("close", () => { + newFolderState = null; + }); + } + + // ---------- register action-registry handlers ---------- + + fo.registerHandler("new-folder", (path) => openNewFolder(path)); + + fo.registerHandler("delete", (path, actionEl) => { + const kind = actionEl?.dataset?.rowKind; + const name = actionEl?.dataset?.rowName || basename(path); + openDelete(path, kind, name); + }); +})(); diff --git a/l4d2web/l4d2web/templates/overlay_detail.html b/l4d2web/l4d2web/templates/overlay_detail.html index 5450102..fde0996 100644 --- a/l4d2web/l4d2web/templates/overlay_detail.html +++ b/l4d2web/l4d2web/templates/overlay_detail.html @@ -284,6 +284,7 @@ + {% endif %} {% endblock %}