window.rustProxyInlineLayout = (() => { const grid = $("dashboard-overview-grid"); const unlock = $("overview-layout-unlock"); const actions = $("overview-inline-actions"); const saveButton = $("overview-inline-save"); const cancelButton = $("overview-inline-cancel"); const hint = $("overview-inline-hint"); const hiddenShelf = $("overview-inline-hidden"); const hiddenList = $("overview-inline-hidden-list"); const hiddenCount = $("overview-inline-hidden-count"); const modalButton = $("overview-layout-open"); const compactButton = $("overview-inline-compact"); let draft = null; let original = null; let opening = false; let saving = false; const visibleIds = () => dashboardVisualOrder(draft.order, draft.sizes) .filter(id => !draft.hidden.has(id)); function enterFreeLayout() { if (draft.sizes === null) { draft.order = dashboardVisualOrder(draft.order, null); draft.sizes = {}; } } const nodes = () => [...grid.children].filter(node => node.dataset.dashboardWidget); const nodeFor = id => nodes().find(node => node.dataset.dashboardWidget === id); const label = id => dashboardWidgetLabel(id); const dirty = () => draft && original && (draft.order.some((id, i) => id !== original.order[i]) || [...draft.hidden].some(id => !original.hidden.has(id)) || [...original.hidden].some(id => !draft.hidden.has(id)) || JSON.stringify(draft.sizes) !== JSON.stringify(original.sizes)); function renderHidden() { if (!hiddenList || !draft) return; const entries = draft.order.filter(id => draft.hidden.has(id)); if (hiddenCount) hiddenCount.textContent = `(${entries.length})`; hiddenList.replaceChildren(...(entries.length ? entries.map(id => { const add = control("+", `${t("overviewLayoutInlineRestore")}: ${label(id)}`, "overview-inline-restore", () => { draft.hidden.delete(id); paint(); nodeFor(id)?.querySelector(".overview-inline-grip")?.focus(); }); const item = el("div", undefined, "overview-inline-hidden-item"); item.append(el("span", label(id)), dashboardWidgetTypeBadge(id), add); return item; }) : [el("span", t("overviewLayoutInlineHiddenEmpty"), "fine")])); } function hide(id) { if (!draft || !visibleIds().includes(id)) return; draft.hidden.add(id); paint(); if (hiddenShelf) { hiddenShelf.open = true; hiddenShelf.querySelector("summary")?.focus(); } } function paint() { applyDashboardLayout(draft.order, [...draft.hidden], draft.sizes); renderHidden(); const visible = visibleIds(); for (const node of nodes()) { const i = visible.indexOf(node.dataset.dashboardWidget); const up = node.querySelector(".overview-inline-up"); const down = node.querySelector(".overview-inline-down"); if (up) up.disabled = i <= 0; if (down) down.disabled = i < 0 || i === visible.length - 1; const widths = dashboardAllowedWidths(node.dataset.dashboardWidget); const span = dashboardWidgetWidth(node.dataset.dashboardWidget, draft.sizes); const at = widths.indexOf(span); const value = node.querySelector(".overview-inline-width-value"); if (value) value.textContent = `${span}/12`; const narrow = node.querySelector(".overview-inline-narrow"); const widen = node.querySelector(".overview-inline-widen"); if (narrow) narrow.disabled = at <= 0; if (widen) widen.disabled = at === widths.length - 1; } } function clearDrag() { if (pointerDrag) settle(false); } function move(id, target, after = false) { if (!draft || id === target) return; if (!visibleIds().includes(id) || !visibleIds().includes(target)) return; enterFreeLayout(); const visible = visibleIds(); visible.splice(visible.indexOf(id), 1); visible.splice(visible.indexOf(target) + (after ? 1 : 0), 0, id); const iter = visible.values(); draft.order = draft.order.map(item => draft.hidden.has(item) ? item : iter.next().value); paint(); } function resize(id, delta) { if (!draft) return; const widths = dashboardAllowedWidths(id); const next = widths[widths.indexOf(dashboardWidgetWidth(id, draft.sizes)) + delta]; if (next === undefined) return; enterFreeLayout(); if (next === dashboardDefaultWidth(id)) delete draft.sizes[id]; else draft.sizes[id] = next; paint(); nodeFor(id)?.querySelector(delta < 0 ? ".overview-inline-narrow" : ".overview-inline-widen")?.focus(); } function nudge(id, delta) { const visible = visibleIds(); const index = visible.indexOf(id), target = visible[index + delta]; if (!target) return; move(id, target, delta > 0); nodeFor(id)?.querySelector(delta < 0 ? ".overview-inline-up" : ".overview-inline-down")?.focus(); } function control(text, title, className, run) { const button = el("button", text, `quiet ${className}`); button.type = "button"; button.setAttribute("aria-label", title); button.title = title; button.addEventListener("click", run); return button; } function toolbar(node) { const id = node.dataset.dashboardWidget; const title = label(id); const bar = el("div", undefined, "overview-inline-toolbar"); const grip = control("⠿", `${t("overviewLayoutInlineDrag")}: ${title}`, "overview-inline-grip", () => { }); grip.setAttribute("aria-describedby", "overview-inline-hint"); grip.dataset.overviewDragHandle = id; const sizeControls = el("span", undefined, "overview-inline-width"); const widthValue = el("span", "", "overview-inline-width-value"); widthValue.setAttribute("aria-label", t("overviewLayoutWidth")); sizeControls.append( control("−", `${t("overviewLayoutNarrow")}: ${title}`, "overview-inline-narrow", () => resize(id, -1)), widthValue, control("+", `${t("overviewLayoutWiden")}: ${title}`, "overview-inline-widen", () => resize(id, 1))); bar.append(grip, dashboardWidgetTypeBadge(id), sizeControls, control("←", `${t("moveUp")}: ${title}`, "overview-inline-up", () => nudge(id, -1)), control("→", `${t("moveDown")}: ${title}`, "overview-inline-down", () => nudge(id, 1)), control("×", `${t("overviewLayoutInlineHide")}: ${title}`, "overview-inline-hide", () => hide(id))); node.append(bar); } function showEditing() { grid.classList.add("is-unlocked"); unlock.setAttribute("aria-pressed", "true"); unlock.dataset.i18n = "overviewLayoutLock"; unlock.textContent = t("overviewLayoutLock"); unlock.hidden = true; // Save and lock is the only save action while unlocked. actions.hidden = false; hint.hidden = false; hint.textContent = t("overviewLayoutInlineHint"); if (compactButton) compactButton.title = t("overviewLayoutInlineCompactHint"); if (hiddenShelf) { hiddenShelf.hidden = false; hiddenShelf.open = false; } if (modalButton) modalButton.disabled = true; for (const node of nodes()) toolbar(node); paint(); } function finish(preserveSaved = false) { clearDrag(); for (const node of nodes()) node.querySelector(".overview-inline-toolbar")?.remove(); grid.classList.remove("is-unlocked"); unlock.setAttribute("aria-pressed", "false"); unlock.dataset.i18n = "overviewLayoutUnlock"; unlock.textContent = t("overviewLayoutUnlock"); unlock.hidden = false; actions.hidden = true; hint.hidden = true; if (hiddenShelf) { hiddenShelf.open = false; hiddenShelf.hidden = true; } if (hiddenList) hiddenList.replaceChildren(); if (modalButton) modalButton.disabled = false; if (!preserveSaved && original) applyDashboardLayout(original.order, [...original.hidden], original.sizes); draft = original = null; } async function begin() { if (draft || opening || saving || $("overview-presets-dialog")?.open) return; opening = true; unlock.disabled = true; try { await window.rustProxyPreferences?.load?.(); const saved = window.rustProxyPreferences?.current?.() || ADMIN_APPEARANCE_DEFAULTS; const order = normalizeDashboardWidgets(saved.dashboard_widgets); const hidden = new Set(normalizeHiddenDashboardWidgets(saved.dashboard_hidden_widgets, saved.dashboard_widgets)); const sizes = normalizeDashboardWidgetSizes(saved.dashboard_widget_sizes); original = { order: [...order], hidden: new Set(hidden), sizes: sizes === null ? null : { ...sizes } }; draft = { order: [...order], hidden: new Set(hidden), sizes: sizes === null ? null : { ...sizes } }; showEditing(); } catch (error) { if (error?.status !== 401) toast(error.message, true); } finally { opening = false; unlock.disabled = false; } } async function save() { if (!draft || saving) return; if (!dirty()) { finish(false); return; } saving = true; saveButton.disabled = cancelButton.disabled = unlock.disabled = true; try { await window.rustProxyPreferences.update({ dashboard_widgets: [...draft.order], dashboard_hidden_widgets: [...draft.hidden], dashboard_widget_sizes: draft.sizes === null ? null : { ...draft.sizes }, }); finish(true); toast(t("overviewLayoutEditorSaved")); } catch (error) { hint.textContent = error.message || t("overviewLayoutInlineError"); } finally { saving = false; saveButton.disabled = cancelButton.disabled = unlock.disabled = false; } } function cancel() { if (draft && !saving) finish(false); } // Pointer-driven sorting with a live placeholder. The grid never back-fills // holes while a block is lifted, so the placeholder shows exactly where the // block lands; neighbours glide (FLIP) instead of jumping. const DRAG_THRESHOLD = 5, EDGE = 64, MOVE_COOLDOWN_MS = 90; const reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches; let pointerDrag = null; function flip(mutate) { const before = new Map(nodes().filter(node => !node.hidden && node !== pointerDrag?.node) .map(node => [node, node.getBoundingClientRect()])); mutate(); if (reducedMotion()) return; for (const [node, rect] of before) { const after = node.getBoundingClientRect(); const dx = rect.left - after.left, dy = rect.top - after.top; if (!dx && !dy) continue; node.animate?.([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "translate(0, 0)" }], { duration: 160, easing: "cubic-bezier(.2, .7, .3, 1)" }); } } function placeholderTarget(x, y) { const drag = pointerDrag; const hit = document.elementFromPoint(x, y); const widget = hit?.closest?.("[data-dashboard-widget]"); if (widget && grid.contains(widget) && widget !== drag.node && !widget.hidden) return { node: widget }; if (hit === drag.placeholder || drag.placeholder.contains(hit)) return null; if (hit !== grid) return null; // Empty grid area (gap or hole at a row end): attach to the nearest block in that row. const candidates = nodes().filter(node => node !== drag.node && !node.hidden); let best = null, bestDistance = Infinity; for (const node of candidates) { const rect = node.getBoundingClientRect(); if (y < rect.top - 8 || y > rect.bottom + 8) continue; const distance = x < rect.left ? rect.left - x : x > rect.right ? x - rect.right : 0; if (distance < bestDistance) { bestDistance = distance; best = { node, after: x > rect.left + rect.width / 2 }; } } if (best) return best; const last = candidates.at(-1); if (last && y > last.getBoundingClientRect().bottom) return { node: last, after: true }; return null; } function updatePlaceholder(x, y) { const drag = pointerDrag; if (performance.now() < drag.cooldownUntil) return; const target = placeholderTarget(x, y); if (!target) return; const children = [...grid.children]; const placeholderIndex = children.indexOf(drag.placeholder), targetIndex = children.indexOf(target.node); // Entering a block swaps the placeholder past it; this is stable and never oscillates. const after = target.after ?? placeholderIndex < targetIndex; const reference = after ? target.node.nextSibling : target.node; if (reference === drag.placeholder || (after ? target.node.nextSibling === drag.placeholder : target.node.previousSibling === drag.placeholder)) return; flip(() => grid.insertBefore(drag.placeholder, reference)); drag.cooldownUntil = performance.now() + MOVE_COOLDOWN_MS; } function autoScroll() { const drag = pointerDrag; if (!drag?.active) return; const height = window.innerHeight; let speed = 0; if (drag.y < EDGE) speed = -Math.ceil((EDGE - drag.y) / 4); else if (drag.y > height - EDGE) speed = Math.ceil((drag.y - (height - EDGE)) / 4); if (speed) { const scroller = document.scrollingElement || document.documentElement; const previous = scroller.scrollTop; scroller.scrollTop += speed; if (scroller.scrollTop !== previous) updatePlaceholder(drag.x, drag.y); } drag.frame = requestAnimationFrame(autoScroll); } function liftBlock() { const drag = pointerDrag; enterFreeLayout(); paint(); const rect = drag.node.getBoundingClientRect(); const placeholder = el("div", undefined, "overview-drop-placeholder"); placeholder.dataset.dashboardSpan = drag.node.dataset.dashboardSpan || "2"; placeholder.style.minHeight = `${Math.round(rect.height)}px`; placeholder.setAttribute("aria-hidden", "true"); drag.origin = drag.node.nextSibling; drag.placeholder = placeholder; drag.offsetX = drag.startX - rect.left; drag.offsetY = drag.startY - rect.top; drag.width = rect.width; drag.height = rect.height; drag.active = true; drag.cooldownUntil = 0; grid.insertBefore(placeholder, drag.node); grid.classList.add("is-overview-sorting"); drag.node.classList.add("is-overview-lifted"); Object.assign(drag.node.style, { position: "fixed", left: "0px", top: "0px", width: `${rect.width}px`, height: `${rect.height}px`, zIndex: "60", pointerEvents: "none", margin: "0", }); moveLifted(); hint.textContent = t("overviewLayoutInlineDragging"); drag.frame = requestAnimationFrame(autoScroll); } function moveLifted() { const drag = pointerDrag; drag.node.style.transform = `translate(${drag.x - drag.offsetX}px, ${drag.y - drag.offsetY}px) rotate(.6deg) scale(1.015)`; } function settle(commit) { const drag = pointerDrag; pointerDrag = null; if (!drag) return; window.removeEventListener("pointermove", onPointerMove); window.removeEventListener("pointerup", onPointerUp); window.removeEventListener("pointercancel", onPointerCancel); window.removeEventListener("keydown", onDragKey, true); if (!drag.active) return; cancelAnimationFrame(drag.frame); for (const key of ["position", "left", "top", "width", "height", "zIndex", "pointerEvents", "margin", "transform"]) drag.node.style[key] = ""; drag.node.classList.remove("is-overview-lifted"); grid.classList.remove("is-overview-sorting"); if (commit) drag.placeholder.replaceWith(drag.node); else { drag.placeholder.remove(); grid.insertBefore(drag.node, drag.origin?.parentNode === grid ? drag.origin : null); } hint.textContent = t("overviewLayoutInlineHint"); if (commit) { const domOrder = nodes().map(node => node.dataset.dashboardWidget).filter(id => !draft.hidden.has(id)); const iter = domOrder.values(); draft.order = draft.order.map(item => draft.hidden.has(item) ? item : iter.next().value); } paint(); drag.node.querySelector(".overview-inline-grip")?.focus({ preventScroll: true }); drag.node.animate?.([{ boxShadow: "0 0 0 3px var(--accent)" }, { boxShadow: "0 0 0 0 transparent" }], { duration: 450 }); } function onPointerMove(event) { const drag = pointerDrag; if (!drag || event.pointerId !== drag.pointerId) return; drag.x = event.clientX; drag.y = event.clientY; if (!drag.active) { if (Math.hypot(drag.x - drag.startX, drag.y - drag.startY) < DRAG_THRESHOLD) return; liftBlock(); } event.preventDefault(); moveLifted(); updatePlaceholder(drag.x, drag.y); } function onPointerUp(event) { if (pointerDrag && event.pointerId === pointerDrag.pointerId) settle(true); } function onPointerCancel(event) { if (pointerDrag && event.pointerId === pointerDrag.pointerId) settle(false); } function onDragKey(event) { if (event.key === "Escape" && pointerDrag) { event.preventDefault(); event.stopPropagation(); settle(false); } } grid?.addEventListener("pointerdown", event => { const handle = event.target?.closest?.("[data-overview-drag-handle]"); if (!draft || saving || pointerDrag || !handle || !grid.contains(handle) || event.button !== 0) return; const node = nodeFor(handle.dataset.overviewDragHandle); if (!node) return; event.preventDefault(); pointerDrag = { node, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, x: event.clientX, y: event.clientY, active: false }; window.addEventListener("pointermove", onPointerMove, { passive: false }); window.addEventListener("pointerup", onPointerUp); window.addEventListener("pointercancel", onPointerCancel); window.addEventListener("keydown", onDragKey, true); }); grid?.addEventListener("keydown", event => { const handle = event.target?.closest?.("[data-overview-drag-handle]"); if (!draft || !handle) return; const delta = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[event.key]; if (!delta) return; event.preventDefault(); nudge(handle.dataset.overviewDragHandle, delta); nodeFor(handle.dataset.overviewDragHandle)?.querySelector(".overview-inline-grip")?.focus(); }); function compact() { // Explicit, one-off gap filling: reads the dense packing the browser would // produce and adopts it as the new order, so nothing moves unexpectedly later. if (!draft) return; enterFreeLayout(); paint(); flip(() => { grid.style.gridAutoFlow = "row dense"; const placed = nodes().filter(node => !node.hidden).map(node => ({ id: node.dataset.dashboardWidget, rect: node.getBoundingClientRect() })); grid.style.gridAutoFlow = ""; placed.sort((a, b) => Math.round(a.rect.top) - Math.round(b.rect.top) || a.rect.left - b.rect.left); const iter = placed.map(item => item.id).values(); draft.order = draft.order.map(item => draft.hidden.has(item) ? item : iter.next().value); paint(); }); } compactButton?.addEventListener("click", compact); unlock?.addEventListener("click", () => { if (draft) void save(); else void begin(); }); saveButton?.addEventListener("click", () => { void save(); }); cancelButton?.addEventListener("click", cancel); return { isEditing: () => !!draft, currentDraft: () => draft ? { order: [...draft.order], hidden: [...draft.hidden], sizes: draft.sizes === null ? null : { ...draft.sizes }, } : null, cancel, }; })();