diff --git a/l4d2web/l4d2web/static/js/files-overlay.js b/l4d2web/l4d2web/static/js/files-overlay.js index e3e0032..d1a3486 100644 --- a/l4d2web/l4d2web/static/js/files-overlay.js +++ b/l4d2web/l4d2web/static/js/files-overlay.js @@ -1,589 +1,19 @@ -// Files-overlay UI behavior. Activated only on overlay detail pages whose -// `
` exists (set by the template when the -// overlay is type='files' and the user can edit). The script binds: +// files-overlay.js — empty stub pending deletion in Step 10 of +// docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md. // -// * Per-row hover actions: + new file, + new folder, ⬇ zip, ✕ on -// folders; ⬇ (download), ✕ on files. Clicking the filename opens -// the editor (binary fallback for non-editable files). -// * Drag-and-drop: dragging from the OS uploads (one POST per file, -// queue with concurrency 3); dragging a row inside the tree moves -// (rename/move via /files/move). -// * Editor modal: text mode for editable files; binary "details + -// replace upload" mode for everything else; doubles as new-file -// dialog. Filename input is the rename surface. -// * New-folder modal, conflict-resolution modal, delete-confirm modal. -// * Upload progress panel with per-file rows. +// All behavior migrated in Phase A: +// * Helpers + manager-element detection + action-dispatch registry +// → static/js/files-overlay/core.js (Step 1) +// * Editor flows (legacy inline dialog + URL-addressable modal) +// → static/js/files-overlay/editor.js (Step 2) +// * Dialogs (new-folder, delete-confirm, conflict) +// → static/js/files-overlay/dialogs.js (Step 3) +// * Uploads (queue + progress) + drag-drop + withCollisionSuffix + +// "zip" action handler +// → static/js/files-overlay/uploads.js (Step 4) // -// All operations use the `/overlays//files/...` JSON / multipart -// endpoints. CSRF token comes from the tag. - -(function () { - "use strict"; - - const manager = document.querySelector(".files-manager"); - if (!manager) return; - - const overlayId = manager.dataset.overlayId; - const baseUrl = manager.dataset.baseUrl; // /overlays/ - const treeRoot = manager.querySelector(".files-tree-root"); - const csrfToken = - document.querySelector("meta[name='csrf-token']")?.getAttribute("content") || ""; - - const uploadsPanel = document.querySelector(".files-uploads"); - const uploadsList = uploadsPanel?.querySelector(".files-uploads-list"); - const uploadsClearBtn = uploadsPanel?.querySelector(".files-uploads-clear"); - - // ---------- helpers ------------------------------------------------------ - - function joinPath(folder, leaf) { - folder = (folder || "").replace(/^\/+|\/+$/g, ""); - leaf = (leaf || "").replace(/^\/+|\/+$/g, ""); - if (folder && leaf) return folder + "/" + leaf; - return folder || leaf; - } - - function parentOf(rel) { - const i = (rel || "").lastIndexOf("/"); - return i < 0 ? "" : rel.slice(0, i); - } - - function basename(rel) { - const i = (rel || "").lastIndexOf("/"); - return i < 0 ? rel : rel.slice(i + 1); - } - - function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, (c) => ({ - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - })[c]); - } - - function humanSize(bytes) { - if (bytes === undefined || bytes === null) return ""; - if (bytes < 1024) return bytes + " B"; - const units = ["KB", "MB", "GB", "TB"]; - let v = bytes / 1024; - let u = "KB"; - for (const next of units) { - u = next; - if (v < 1024) break; - v /= 1024; - } - return v.toFixed(1) + " " + u; - } - - async function fetchJson(url, options) { - options = options || {}; - options.headers = Object.assign({ Accept: "application/json" }, options.headers || {}); - if (options.method && options.method !== "GET") { - options.headers["X-CSRF-Token"] = csrfToken; - } - options.credentials = "same-origin"; - const response = await fetch(url, options); - let body = null; - let rawText = ""; - try { - rawText = await response.text(); - body = rawText ? JSON.parse(rawText) : null; - } catch (_e) { - body = null; - } - return { ok: response.ok, status: response.status, body, rawText }; - } - - async function postJson(url, payload) { - return fetchJson(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - } - - async function postForm(url, formData) { - return fetchJson(url, { method: "POST", body: formData }); - } - - // ---------- tree refresh ------------------------------------------------ - - // Re-fetch a folder's listing partial and swap it into the tree. - // `path === ""` refreshes the overlay root container. - async function refreshFolder(path) { - if (!treeRoot) return; - const url = `${baseUrl}/files?path=${encodeURIComponent(path || "")}`; - let html; - try { - const r = await fetch(url, { - headers: { Accept: "text/html" }, - credentials: "same-origin", - }); - if (!r.ok) { - if (r.status === 404) { - // Folder no longer exists — refresh its parent instead. - if (path) await refreshFolder(parentOf(path)); - return; - } - return; - } - html = await r.text(); - } catch (_e) { - return; - } - - if (!path) { - // Overlay root — swap into the synthetic root row's children div. - const target = manager.querySelector(".files-root-children"); - if (!target) return; - const empty = target.querySelector(".files-empty"); - if (empty) empty.remove(); - const existingUl = target.querySelector(":scope > ul.file-tree"); - if (existingUl) existingUl.remove(); - target.insertAdjacentHTML("beforeend", html); - // If the new content is also empty, restore the placeholder. - const newUl = target.querySelector(":scope > ul.file-tree"); - if (newUl && newUl.children.length === 0) { - newUl.remove(); - const p = document.createElement("p"); - p.className = "muted files-empty"; - p.textContent = 'Empty — drop files here, or click "+ new file" on this row.'; - target.appendChild(p); - } - return; - } - - // Sub-folder: find the matching folder row and swap its children div. - const row = findRowByPath(path, "dir"); - if (!row) return; - const childrenDiv = row.querySelector(":scope > .file-tree-children"); - const toggleBtn = row.querySelector(":scope > .file-tree-toggle"); - if (!childrenDiv || !toggleBtn) return; - childrenDiv.innerHTML = html; - childrenDiv.hidden = false; - toggleBtn.setAttribute("aria-expanded", "true"); - toggleBtn.dataset.loaded = "1"; - } - - function findRowByPath(path, kind) { - const sel = kind - ? `[data-target-path="${cssEscape(path)}"][data-row-kind="${kind}"]` - : `[data-target-path="${cssEscape(path)}"]`; - return treeRoot ? treeRoot.querySelector(sel) : null; - } - - function cssEscape(s) { - if (window.CSS && window.CSS.escape) return window.CSS.escape(s); - return String(s).replace(/["\\]/g, "\\$&"); - } - - // Debounce per-folder refreshes so a flurry of finishes coalesces. - const pendingRefresh = new Map(); - function scheduleRefresh(path) { - const key = path || ""; - if (pendingRefresh.has(key)) return; - const timer = setTimeout(() => { - pendingRefresh.delete(key); - refreshFolder(key); - }, 50); - pendingRefresh.set(key, timer); - } - - // ---------- conflict modal ---------------------------------------------- - // - // 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) { - const dot = path.lastIndexOf("."); - const slash = path.lastIndexOf("/"); - if (dot > slash + 0 && dot > -1) { - return path.slice(0, dot) + " (1)" + path.slice(dot); - } - 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 ------------------------------------------------ - // - // 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 ------------------------------------------------ - // - // 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 -------------------------------------------- - // - // 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 ------------------------------- - - const uploadQueue = []; - let uploadActive = 0; - const UPLOAD_CONCURRENCY = 3; - - function showPanel() { - if (uploadsPanel) uploadsPanel.hidden = false; - } - - function maybeHidePanel() { - if (!uploadsList) return; - const anyActive = uploadsList.querySelector("[data-state='active'], [data-state='queued']"); - if (!anyActive && uploadsList.children.length === 0) { - uploadsPanel.hidden = true; - if (uploadsClearBtn) uploadsClearBtn.hidden = true; - } - const anyDone = uploadsList.querySelector("[data-state='done'], [data-state='error']"); - if (uploadsClearBtn) uploadsClearBtn.hidden = !anyDone; - } - - function buildUploadRow(item) { - const li = document.createElement("li"); - li.className = "files-uploads-row"; - li.dataset.state = "queued"; - li.innerHTML = ` -
- - -
-
-
- queued - -
- `; - li.querySelector(".files-uploads-row-name").textContent = item.relative; - li.querySelector(".files-uploads-row-target").textContent = `→ ${item.targetFolder || "(root)"}`; - li.querySelector(".files-uploads-row-cancel").addEventListener("click", () => cancelUpload(item)); - return li; - } - - function setRowState(item, state, percent) { - if (!item.row) return; - item.row.dataset.state = state; - const stateEl = item.row.querySelector(".files-uploads-row-state"); - const cancelBtn = item.row.querySelector(".files-uploads-row-cancel"); - const bar = item.row.querySelector(".files-uploads-bar"); - if (state === "queued") { - stateEl.textContent = "queued"; - bar.style.width = "0%"; - } else if (state === "active") { - stateEl.textContent = `${Math.round(percent || 0)}%`; - bar.style.width = `${percent || 0}%`; - } else if (state === "done") { - stateEl.textContent = "done"; - bar.style.width = "100%"; - bar.classList.add("is-done"); - cancelBtn.textContent = "✓"; - cancelBtn.disabled = true; - } else if (state === "cancelled") { - stateEl.textContent = "cancelled"; - bar.classList.add("is-cancelled"); - cancelBtn.disabled = true; - } else if (state === "error") { - stateEl.textContent = item.errorText || "error"; - bar.classList.add("is-error"); - cancelBtn.textContent = "✕"; - } else if (state === "conflict") { - stateEl.textContent = "conflict — overwrite / keep both"; - } - } - - function cancelUpload(item) { - if (item.xhr && item.row.dataset.state === "active") { - item.cancelled = true; - item.xhr.abort(); - } - if (item.row.dataset.state === "queued") { - const idx = uploadQueue.indexOf(item); - if (idx >= 0) uploadQueue.splice(idx, 1); - setRowState(item, "cancelled"); - } - } - - uploadsClearBtn?.addEventListener("click", () => { - uploadsList.querySelectorAll("[data-state='done'], [data-state='error'], [data-state='cancelled']").forEach((row) => row.remove()); - maybeHidePanel(); - }); - - function enqueueUpload(file, targetFolder, relativePath) { - const item = { - file, - targetFolder, - relative: relativePath || file.name, - cancelled: false, - xhr: null, - errorText: null, - }; - item.row = buildUploadRow(item); - uploadsList.prepend(item.row); - uploadQueue.push(item); - showPanel(); - pump(); - return item; - } - - function pump() { - while (uploadActive < UPLOAD_CONCURRENCY && uploadQueue.length) { - const item = uploadQueue.shift(); - runUpload(item); - } - } - - function runUpload(item, overwriteFlag) { - uploadActive += 1; - const fd = new FormData(); - fd.append("target_path", item.targetFolder || ""); - fd.append("relative_path", item.relative); - fd.append("csrf_token", csrfToken); - if (overwriteFlag) fd.append("overwrite", "1"); - fd.append("file", item.file, item.file.name); - - const xhr = new XMLHttpRequest(); - item.xhr = xhr; - xhr.open("POST", `${baseUrl}/files/upload`); - xhr.setRequestHeader("X-CSRF-Token", csrfToken); - xhr.upload.onprogress = (event) => { - if (event.lengthComputable) { - const pct = (event.loaded / event.total) * 100; - setRowState(item, "active", pct); - } - }; - setRowState(item, "active", 0); - xhr.onload = () => { - uploadActive -= 1; - if (xhr.status >= 200 && xhr.status < 300) { - setRowState(item, "done"); - scheduleRefresh(joinPath(item.targetFolder || "", parentOf(item.relative))); - } else if (xhr.status === 409 && !overwriteFlag) { - setRowState(item, "conflict"); - const collidingPath = joinPath(item.targetFolder || "", item.relative); - // 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") { - item.relative = withCollisionSuffix(item.relative); - item.row.querySelector(".files-uploads-row-name").textContent = item.relative; - runUpload(item, false); - } else { - setRowState(item, "cancelled"); - } - }); - } else { - item.errorText = `HTTP ${xhr.status}`; - try { - const text = xhr.responseText; - if (text) item.errorText = `HTTP ${xhr.status}: ${text.slice(0, 80)}`; - } catch (_e) {} - setRowState(item, "error"); - } - maybeHidePanel(); - pump(); - }; - xhr.onerror = () => { - uploadActive -= 1; - item.errorText = item.cancelled ? "cancelled" : "network error"; - setRowState(item, item.cancelled ? "cancelled" : "error"); - maybeHidePanel(); - pump(); - }; - xhr.onabort = () => { - uploadActive -= 1; - setRowState(item, "cancelled"); - maybeHidePanel(); - pump(); - }; - xhr.send(fd); - } - - // ---------- drag-drop on tree rows -------------------------------------- - - function rowFromEvent(event) { - return event.target.closest("[data-row-kind]"); - } - - function isInternalDrag(event) { - return Array.from(event.dataTransfer.types).includes("application/x-files-overlay"); - } - - function isExternalDrag(event) { - const types = Array.from(event.dataTransfer.types); - return types.includes("Files"); - } - - // Expose containers (rows AND the empty-state placeholder + tree-root) - // as drop targets — both for OS files and internal moves. - if (treeRoot) { - treeRoot.addEventListener("dragstart", (event) => { - const row = rowFromEvent(event); - if (!row) return; - const path = row.dataset.targetPath; - if (!path) return; // overlay root row isn't draggable - event.dataTransfer.setData("application/x-files-overlay", path); - event.dataTransfer.setData("text/plain", path); - event.dataTransfer.effectAllowed = "move"; - row.classList.add("is-drag-source"); - }); - treeRoot.addEventListener("dragend", () => { - treeRoot.querySelectorAll(".is-drag-source").forEach((el) => el.classList.remove("is-drag-source")); - treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target")); - }); - - treeRoot.addEventListener("dragover", (event) => { - // Only react to file or row drags. - if (!isExternalDrag(event) && !isInternalDrag(event)) return; - const row = rowFromEvent(event); - // Find the closest folder row, or the tree-root container itself for - // root-level drops. - const target = row && row.dataset.rowKind === "dir" ? row : null; - const fallbackRoot = !row || row.dataset.rowKind !== "dir" ? treeRoot : null; - const dropEl = target || fallbackRoot; - if (!dropEl) return; - event.preventDefault(); - event.dataTransfer.dropEffect = isInternalDrag(event) ? "move" : "copy"; - // Highlight the dropEl exclusively. - treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target")); - dropEl.classList.add("is-drop-target"); - }); - treeRoot.addEventListener("dragleave", (event) => { - // Leaving the whole tree clears highlights. - if (!treeRoot.contains(event.relatedTarget)) { - treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target")); - } - }); - - treeRoot.addEventListener("drop", async (event) => { - const internal = isInternalDrag(event); - const external = isExternalDrag(event); - if (!internal && !external) return; - event.preventDefault(); - const row = rowFromEvent(event); - const dropFolder = - row && row.dataset.rowKind === "dir" ? row.dataset.targetPath || "" : ""; - - treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target")); - - if (internal) { - const src = event.dataTransfer.getData("application/x-files-overlay"); - if (!src) return; - const dst = joinPath(dropFolder, basename(src)); - if (src === dst) return; - // Cycle guard: refuse moving a folder into itself or descendant. - if (dropFolder === src || dropFolder.startsWith(src + "/")) return; - const r = await postJson(`${baseUrl}/files/move`, { src, dst }); - if (r.ok) { - scheduleRefresh(parentOf(src)); - if (parentOf(src) !== dropFolder) scheduleRefresh(dropFolder); - } else if (r.status === 409) { - // 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) { - scheduleRefresh(parentOf(src)); - scheduleRefresh(dropFolder); - } - } - } else { - alert((r.body && r.body.error) || `Move failed (HTTP ${r.status}).`); - } - return; - } - - // External drop — collect entries via webkitGetAsEntry where it - // returns an Entry (real OS drag with folder support), and fall back - // to getAsFile() for any item whose entry is null (synthetic events, - // browsers without the API, or items that have no folder structure). - const items = event.dataTransfer.items; - const files = []; - const tasks = []; - if (items && items.length) { - for (let i = 0; i < items.length; i++) { - const item = items[i]; - if (typeof item.webkitGetAsEntry === "function") { - const entry = item.webkitGetAsEntry(); - if (entry) { - tasks.push(walkEntry(entry, "", files)); - continue; - } - } - // Fallback: treat as a flat file. - if (typeof item.getAsFile === "function") { - const f = item.getAsFile(); - if (f) files.push({ file: f, rel: f.name }); - } - } - } else if (event.dataTransfer.files) { - for (let i = 0; i < event.dataTransfer.files.length; i++) { - files.push({ file: event.dataTransfer.files[i], rel: event.dataTransfer.files[i].name }); - } - } - await Promise.all(tasks); - for (const f of files) { - enqueueUpload(f.file, dropFolder, f.rel); - } - }); - } - - function walkEntry(entry, prefix, out) { - return new Promise((resolve) => { - if (entry.isFile) { - entry.file((f) => { - out.push({ file: f, rel: prefix + entry.name }); - resolve(); - }); - } else if (entry.isDirectory) { - const reader = entry.createReader(); - const children = []; - const readBatch = () => { - reader.readEntries((batch) => { - if (!batch.length) { - Promise.all( - children.map((c) => walkEntry(c, prefix + entry.name + "/", out)) - ).then(() => resolve()); - return; - } - for (const c of batch) children.push(c); - readBatch(); - }); - }; - readBatch(); - } else { - resolve(); - } - }); - } - - // ---------- click delegation: action buttons ---------------------------- - - document.addEventListener("click", (event) => { - const action = event.target?.closest?.( - ".files-row-action[data-action], .file-tree-name-button[data-action]" - ); - if (!action) return; - if (!manager.contains(action)) return; - const op = action.dataset.action; - const path = action.dataset.targetPath || ""; - // 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; - } - }); -})(); +// The four modules attach independently when .files-manager exists +// (each does its own document.querySelector check). This file is kept +// for the duration of Phase A so that + {% endif %} {% endblock %}