diff --git a/l4d2web/l4d2web/static/js/files-overlay/core.js b/l4d2web/l4d2web/static/js/files-overlay/core.js new file mode 100644 index 0000000..6fdb454 --- /dev/null +++ b/l4d2web/l4d2web/static/js/files-overlay/core.js @@ -0,0 +1,247 @@ +// files-overlay/core.js — Phase A scaffold (step 1/12). +// +// First module of the multi-file rewrite of files-overlay.js. Provides: +// * Manager-element detection (.files-manager guard, same as legacy). +// * Helpers (joinPath, parentOf, basename, escapeHtml, humanSize, +// fetchJson, postJson, postForm, refreshFolder, findRowByPath, +// cssEscape, scheduleRefresh) — duplicated from the legacy file +// for the duration of Phase A. They de-duplicate in steps 2–4 as +// features migrate out and the legacy file shrinks. +// * window.__filesOverlay registry: feature modules call +// registerHandler(op, fn); a document-level click listener here +// dispatches matching actions via handleAction. +// +// Until Steps 2–4 populate the registry, the click listener here is a +// no-op — the legacy files-overlay.js still owns dispatch via its own +// switch-case at the same selector. Both listeners fire on every click +// during the transition; the new one matches and dispatches into an +// empty registry, the old one runs the actual handler. Per-op +// migration removes each case from the legacy switch as the +// corresponding feature module registers its handler. + +(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") || ""; + + // ---------- 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); + 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); + } + + // ---------- action-dispatch registry ---------- + + const handlers = new Map(); + + function registerHandler(op, fn) { + handlers.set(op, fn); + } + + function handleAction(op, path, actionEl) { + const fn = handlers.get(op); + if (typeof fn === "function") fn(path, actionEl); + } + + // ---------- action click delegation ---------- + // + // Document-level delegation mirroring the legacy switch-case in + // files-overlay.js. While the registry is empty (Step 1 state), this + // is a no-op — the legacy file still owns dispatch. Steps 2–4 + // migrate one op at a time: feature module registers its handler + // here, then the matching case is deleted from the legacy file. + + 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 || ""; + handleAction(op, path, action); + }); + + // ---------- public ---------- + + window.__filesOverlay = { + manager, + overlayId, + baseUrl, + treeRoot, + csrfToken, + helpers: { + joinPath, + parentOf, + basename, + escapeHtml, + humanSize, + fetchJson, + postJson, + postForm, + refreshFolder, + findRowByPath, + cssEscape, + scheduleRefresh, + }, + registerHandler, + handleAction, + }; +})(); diff --git a/l4d2web/l4d2web/templates/overlay_detail.html b/l4d2web/l4d2web/templates/overlay_detail.html index 7250e30..b23fd0c 100644 --- a/l4d2web/l4d2web/templates/overlay_detail.html +++ b/l4d2web/l4d2web/templates/overlay_detail.html @@ -282,6 +282,7 @@ + {% endif %} {% endblock %}