Files
RustProxy/src/admin/web/app/dashboard-inline-layout.js
T
2026-11-07 08:16:11 +01:00

387 lines
20 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.
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,
};
})();