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