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 %}