left4me/l4d2web/static/js/blueprint-overlay-picker.js
mwiegand 985df970f8
feat(l4d2-web): per-overlay server.cfg aliases — expose checkbox + auto-exec
Each linked overlay gets a checkbox on the blueprint detail page that opts
its server.cfg in as exec server_overlay_<id>. The web app builds the
spec with {path, alias} per overlay and prepends exec server_overlay_<id>
lines to the blueprint config in lowest-overlay-first order. The host
stages those copies in the overlayfs upper layer before mounting (avoids
copy-up writes against a sandbox-uid file). A live preview block above the
Config textarea shows what gets auto-executed.

Schema:
- alembic 0007: BlueprintOverlay.expose_server_cfg BOOLEAN

Spec contract:
- l4d2host OverlayRef(path, alias?). load_spec accepts both bare-string
  and {path, alias} entries.

Side effects folded in (same file in l4d2_facade):
- start_server auto-initializes; the manual Initialize step is no longer
  needed before Start.
- initialize_server no longer runs blueprint builders — builds happen on
  overlay save, not on every server Start.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:26:31 +02:00

171 lines
5.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
const root = document.querySelector("[data-overlay-list]");
if (!root) return;
const select = document.querySelector("[data-overlay-add]");
const empty = document.querySelector("[data-overlay-empty]");
let dragRow = null;
const clearDropMarkers = () => {
root
.querySelectorAll(".drop-before, .drop-after")
.forEach((el) => el.classList.remove("drop-before", "drop-after"));
};
const refreshEmpty = () => {
if (!empty) return;
empty.hidden = root.children.length > 0;
};
const buildHiddenInput = (overlayId) => {
const input = document.createElement("input");
input.type = "hidden";
input.name = "overlay_ids";
input.value = overlayId;
return input;
};
const buildRow = (overlayId, overlayName) => {
const li = document.createElement("li");
li.className = "overlay-picker-row";
li.draggable = true;
li.dataset.overlayId = overlayId;
li.dataset.overlayName = overlayName;
const handle = document.createElement("span");
handle.className = "overlay-picker-handle";
handle.setAttribute("aria-hidden", "true");
handle.textContent = "⋮⋮";
const name = document.createElement("span");
name.className = "overlay-picker-name";
name.textContent = overlayName;
const exposeLabel = document.createElement("label");
exposeLabel.className = "overlay-picker-expose";
exposeLabel.title = "Auto-load this overlay's server.cfg before your blueprint config";
const exposeInput = document.createElement("input");
exposeInput.type = "checkbox";
exposeInput.name = "expose_server_cfg_ids";
exposeInput.value = overlayId;
const exposeText = document.createTextNode(" exec ");
const exposeCode = document.createElement("code");
exposeCode.textContent = "server.cfg";
exposeLabel.append(exposeInput, exposeText, exposeCode);
const remove = document.createElement("button");
remove.type = "button";
remove.className = "overlay-picker-remove";
remove.dataset.action = "remove";
remove.setAttribute("aria-label", `Remove ${overlayName}`);
remove.textContent = "×";
li.append(handle, name, exposeLabel, remove, buildHiddenInput(overlayId));
return li;
};
const insertOptionSorted = (overlayId, overlayName) => {
if (!select) return;
const option = document.createElement("option");
option.value = overlayId;
option.dataset.overlayName = overlayName;
option.textContent = overlayName;
const lower = overlayName.toLowerCase();
const existing = Array.from(select.querySelectorAll("option[value]:not([value=''])"));
const next = existing.find(
(opt) => (opt.dataset.overlayName || opt.textContent).toLowerCase() > lower,
);
if (next) {
select.insertBefore(option, next);
} else {
select.appendChild(option);
}
};
root.addEventListener("dragstart", (event) => {
const row = event.target.closest(".overlay-picker-row");
if (!row) return;
dragRow = row;
row.classList.add("is-dragging");
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", row.dataset.overlayId || "");
}
});
root.addEventListener("dragend", () => {
if (dragRow) {
dragRow.classList.remove("is-dragging");
}
clearDropMarkers();
dragRow = null;
});
root.addEventListener("dragover", (event) => {
if (!dragRow) return;
const row = event.target.closest(".overlay-picker-row");
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "move";
}
clearDropMarkers();
if (row && row !== dragRow) {
const rect = row.getBoundingClientRect();
const before = event.clientY < rect.top + rect.height / 2;
row.classList.add(before ? "drop-before" : "drop-after");
}
});
root.addEventListener("dragleave", (event) => {
const row = event.target.closest(".overlay-picker-row");
if (row) {
row.classList.remove("drop-before", "drop-after");
}
});
root.addEventListener("drop", (event) => {
if (!dragRow) return;
event.preventDefault();
const row = event.target.closest(".overlay-picker-row");
if (row && row !== dragRow) {
const before = row.classList.contains("drop-before");
row.classList.remove("drop-before", "drop-after");
if (before) {
root.insertBefore(dragRow, row);
} else {
root.insertBefore(dragRow, row.nextSibling);
}
} else if (!row) {
root.appendChild(dragRow);
}
clearDropMarkers();
});
root.addEventListener("click", (event) => {
const trigger = event.target.closest("[data-action='remove']");
if (!trigger) return;
const row = trigger.closest(".overlay-picker-row");
if (!row) return;
const overlayId = row.dataset.overlayId;
const overlayName = row.dataset.overlayName || "";
row.remove();
if (overlayId) insertOptionSorted(overlayId, overlayName);
refreshEmpty();
});
if (select) {
select.addEventListener("change", () => {
const value = select.value;
if (!value) return;
const option = select.querySelector(`option[value="${CSS.escape(value)}"]`);
const overlayName = option ? option.dataset.overlayName || option.textContent : "";
root.appendChild(buildRow(value, overlayName));
if (option) option.remove();
select.value = "";
refreshEmpty();
});
}
refreshEmpty();
})();