diff --git a/l4d2web/routes/page_routes.py b/l4d2web/routes/page_routes.py index 9051dee..5bba87e 100644 --- a/l4d2web/routes/page_routes.py +++ b/l4d2web/routes/page_routes.py @@ -297,12 +297,15 @@ def blueprint_page(blueprint_id: int): ).all() overlay_positions = {overlay_id: position + 1 for overlay_id, position in position_rows} + selected_ids = {overlay.id for overlay in selected_overlays} + available_overlays = [overlay for overlay in all_overlays if overlay.id not in selected_ids] return render_template( "blueprint_detail.html", blueprint=blueprint, selected_overlays=selected_overlays, + available_overlays=available_overlays, all_overlays=all_overlays, - selected_overlay_ids={overlay.id for overlay in selected_overlays}, + selected_overlay_ids=selected_ids, overlay_positions=overlay_positions, arguments=json.loads(blueprint.arguments), config_lines=json.loads(blueprint.config), diff --git a/l4d2web/static/css/components.css b/l4d2web/static/css/components.css index 0eb2fdc..1e81a9e 100644 --- a/l4d2web/static/css/components.css +++ b/l4d2web/static/css/components.css @@ -274,3 +274,82 @@ dialog.modal::backdrop { .file-tree-row-truncated { font-style: italic; } + +.overlay-picker { + display: grid; + gap: var(--space-m); +} + +.overlay-picker-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-xs); + min-height: 3rem; +} + +.overlay-picker-row { + display: flex; + align-items: center; + gap: var(--space-s); + padding: var(--space-s) var(--space-m); + background: var(--color-surface-muted); + border: var(--line); + border-radius: var(--radius-s); + cursor: grab; + user-select: none; +} + +.overlay-picker-row:active { + cursor: grabbing; +} + +.overlay-picker-row.is-dragging { + opacity: 0.4; +} + +.overlay-picker-row.drop-before { + box-shadow: 0 2px 0 0 var(--color-focus) inset; +} + +.overlay-picker-row.drop-after { + box-shadow: 0 -2px 0 0 var(--color-focus) inset; +} + +.overlay-picker-handle { + color: var(--color-muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: -0.1em; +} + +.overlay-picker-name { + flex: 1; +} + +.overlay-picker-remove { + background: none; + color: var(--color-muted); + padding: 0 var(--space-s); + font-size: 1.1rem; + line-height: 1; +} + +.overlay-picker-remove:hover { + color: var(--color-danger); +} + +.overlay-picker-empty { + margin: 0; +} + +.overlay-picker-add { + display: flex; + align-items: center; + gap: var(--space-s); +} + +.overlay-picker-add select { + flex: 1; +} diff --git a/l4d2web/static/js/blueprint-overlay-picker.js b/l4d2web/static/js/blueprint-overlay-picker.js new file mode 100644 index 0000000..ede8855 --- /dev/null +++ b/l4d2web/static/js/blueprint-overlay-picker.js @@ -0,0 +1,159 @@ +(() => { + 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 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, 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(); +})(); diff --git a/l4d2web/templates/blueprint_detail.html b/l4d2web/templates/blueprint_detail.html index b107e32..c30612f 100644 --- a/l4d2web/templates/blueprint_detail.html +++ b/l4d2web/templates/blueprint_detail.html @@ -12,20 +12,28 @@

Overlay order matters: the first overlay has highest precedence.

- - - - {% for overlay in all_overlays %} - - - - - - {% else %} - +
+
    + {% for overlay in selected_overlays %} +
  1. + + {{ overlay.name }} + + +
  2. {% endfor %} -
-
UseOrderOverlay
{{ overlay.name }}
No overlays available.
+ +

No overlays selected. Pick one below to add.

+ + @@ -48,4 +56,5 @@ + {% endblock %} diff --git a/l4d2web/tests/test_blueprints.py b/l4d2web/tests/test_blueprints.py index 3fe17e0..85c01b8 100644 --- a/l4d2web/tests/test_blueprints.py +++ b/l4d2web/tests/test_blueprints.py @@ -1,6 +1,7 @@ import json import pytest +from sqlalchemy import select from l4d2web.app import create_app from l4d2web.auth import hash_password @@ -247,3 +248,48 @@ def test_form_update_preserves_ordered_overlays_and_multiline_fields(user_client assert update.status_code == 302 assert update.headers["Location"] == "/blueprints/1" + + +def test_blueprint_detail_renders_picker_list_and_select(user_client) -> None: + with session_scope() as session: + session.add(Overlay(name="o3", path="/opt/l4d2/overlays/o3")) + user = session.scalar(select(User).where(User.username == "alice")) + blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]") + session.add(blueprint) + session.flush() + session.add(BlueprintOverlay(blueprint_id=blueprint.id, overlay_id=1, position=0)) + blueprint_id = blueprint.id + + response = user_client.get(f"/blueprints/{blueprint_id}") + assert response.status_code == 200 + body = response.get_data(as_text=True) + assert "data-overlay-list" in body + assert "data-overlay-add" in body + assert body.count('data-overlay-id="1"') == 1 + assert 'data-overlay-id="2"' not in body + assert '