feat(files): scaffold files-overlay/core.js with helpers + action registry
Step 1/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.
Pure scaffolding — no behavior change. core.js is loaded with defer in
overlay_detail.html before the existing files-overlay.js script tag
(both with defer, so execution order follows document order). Both
files run the same .files-manager guard, both attach document-level
click listeners on the action selector. The legacy file's switch-case
still owns dispatch; core.js's listener dispatches into an empty
registry until Steps 2–4 populate handlers.
window.__filesOverlay exposes:
* manager / overlayId / baseUrl / treeRoot / csrfToken (manager-
element-derived state, computed once)
* helpers.{joinPath, parentOf, basename, escapeHtml, humanSize,
fetchJson, postJson, postForm, refreshFolder, findRowByPath,
cssEscape, scheduleRefresh} (duplicated from legacy file for the
duration of Phase A; de-duplicates as feature modules migrate out)
* registerHandler(op, fn) / handleAction(op, path, actionEl) — the
action-dispatch registry that Steps 2–4 populate
Per the canonical plan's errata commit (d76ee05), the script tag goes
in overlay_detail.html (not base.html as the original plan said) and
uses defer to match the existing pattern.
Verified live on /overlays/2 in Chromium: both <script> tags present
in DOM order; window.__filesOverlay shape matches expectation (8
top-level keys, 12 helpers); overlayId="2", baseUrl="/overlays/2",
treeRoot resolved; joinPath('foo/','/bar') === 'foo/bar' smoke test
passes; no console errors; existing 3-file/1-folder tree still
renders. pytest still 573 passed, 1 skipped, 3 deselected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4fa39642b0
commit
052ddcb4f0
2 changed files with 248 additions and 0 deletions
247
l4d2web/l4d2web/static/js/files-overlay/core.js
Normal file
247
l4d2web/l4d2web/static/js/files-overlay/core.js
Normal file
|
|
@ -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/<id>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -282,6 +282,7 @@
|
||||||
<ul class="files-uploads-list"></ul>
|
<ul class="files-uploads-list"></ul>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/files-overlay/core.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='js/files-overlay.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='js/files-overlay.js') }}" defer></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue