Files
warpbox-dev/backend/static/js/04-dialogs.js
Daniel Legt 0b4487ac2e
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m54s
feat(upload): warn on large uploads over slow/metered connections
Detects if the user is on a slow (2G/3G) or metered (saveData) connection
and prompts them with a confirmation dialog if they attempt to upload
files totaling 200MB or more.

This prevents accidental high data usage and warns users about potential
long upload times. Also includes the dialogs JS and CSS in the base
layout to support the confirmation modal.
2026-06-08 13:34:05 +03:00

300 lines
9.1 KiB
JavaScript

(function () {
const VARIANTS = ["info", "warning", "error"];
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
window.Warpbox = window.Warpbox || {};
let dialogIdCounter = 0;
function defaultTitle(variant) {
if (variant === "error") {
return "Error";
}
if (variant === "warning") {
return "Warning";
}
return "Info";
}
function normalizeOptions(options, message) {
if (typeof options === "string") {
options = { message: options };
} else {
options = options || {};
}
if (message) {
options.message = message;
}
const variant = VARIANTS.includes(options.variant) ? options.variant : "info";
return {
variant,
title: options.title || defaultTitle(variant),
message: options.message || "",
body: options.body || null,
actions: Array.isArray(options.actions) ? options.actions : [],
dismissible: options.dismissible !== false,
closable: options.closable !== false,
onClose: typeof options.onClose === "function" ? options.onClose : null,
};
}
function focusableElements(container) {
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) => el.offsetParent !== null);
}
function dialog(options, message) {
const config = normalizeOptions(options, message);
const previouslyFocused = document.activeElement;
dialogIdCounter += 1;
const titleId = "warpbox-dialog-title-" + dialogIdCounter;
const overlay = document.createElement("div");
overlay.className = "warpbox-dialog-overlay";
const card = document.createElement("div");
card.className = "warpbox-dialog warpbox-dialog-" + config.variant;
card.setAttribute("role", config.variant === "error" ? "alertdialog" : "dialog");
card.setAttribute("aria-modal", "true");
card.setAttribute("aria-labelledby", titleId);
card.setAttribute("tabindex", "-1");
const head = document.createElement("div");
head.className = "warpbox-dialog-head";
const icon = document.createElement("span");
icon.className = "warpbox-dialog-icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = config.variant === "error" ? "!" : config.variant === "warning" ? "?" : "i";
const title = document.createElement("h2");
title.id = titleId;
title.className = "warpbox-dialog-title";
title.textContent = config.title;
head.append(icon, title);
if (config.closable) {
const close = document.createElement("button");
close.type = "button";
close.className = "warpbox-dialog-close";
close.setAttribute("aria-label", "Close dialog");
close.textContent = "x";
close.addEventListener("click", () => closeDialog());
head.append(close);
}
const body = document.createElement("div");
body.className = "warpbox-dialog-body";
if (config.message) {
const text = document.createElement("p");
text.className = "warpbox-dialog-message";
text.textContent = config.message;
body.append(text);
}
if (config.body) {
const nodes = Array.isArray(config.body) ? config.body : [config.body];
nodes.forEach((node) => {
if (node instanceof Node) {
body.append(node);
}
});
}
card.append(head, body);
let autofocusTarget = null;
if (config.actions.length > 0) {
const actions = document.createElement("div");
actions.className = "warpbox-dialog-actions";
config.actions.forEach((action) => {
const button = document.createElement("button");
button.type = "button";
button.className = "button " + (action.kind === "primary" ? "button-primary" : action.kind === "ghost" ? "button-ghost" : "button-outline");
button.textContent = action.label || "OK";
button.addEventListener("click", () => {
if (typeof action.onClick === "function") {
action.onClick();
}
if (action.dismiss !== false) {
closeDialog();
}
});
if (action.autofocus) {
autofocusTarget = button;
}
actions.append(button);
});
card.append(actions);
}
overlay.append(card);
document.body.append(overlay);
document.documentElement.classList.add("warpbox-dialog-open");
window.requestAnimationFrame(() => {
overlay.classList.add("is-visible");
(autofocusTarget || card).focus();
});
function handleKeydown(event) {
if (event.key === "Escape") {
if (config.dismissible) {
event.preventDefault();
closeDialog();
}
return;
}
if (event.key !== "Tab") {
return;
}
const focusable = focusableElements(card);
if (focusable.length === 0) {
event.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
function handleOverlayClick(event) {
if (config.dismissible && event.target === overlay) {
closeDialog();
}
}
document.addEventListener("keydown", handleKeydown, true);
overlay.addEventListener("click", handleOverlayClick);
let closed = false;
function closeDialog() {
if (closed) {
return;
}
closed = true;
document.removeEventListener("keydown", handleKeydown, true);
overlay.removeEventListener("click", handleOverlayClick);
overlay.classList.remove("is-visible");
document.documentElement.classList.remove("warpbox-dialog-open");
window.setTimeout(() => overlay.remove(), 180);
if (previouslyFocused && typeof previouslyFocused.focus === "function") {
previouslyFocused.focus();
}
if (config.onClose) {
config.onClose();
}
}
return {
element: overlay,
close: closeDialog,
};
}
window.Warpbox.dialog = dialog;
window.Warpbox.alertDialog = function alertDialog(message, options) {
const config = (typeof options === "object" && options) || {};
return new Promise((resolve) => {
dialog({
...config,
message: typeof message === "string" ? message : config.message,
actions: [{ label: config.okLabel || "OK", kind: "primary", autofocus: true }],
onClose: () => {
if (typeof config.onClose === "function") {
config.onClose();
}
resolve();
},
});
});
};
window.Warpbox.confirmDialog = function confirmDialog(message, options) {
const config = (typeof options === "object" && options) || {};
return new Promise((resolve) => {
let settled = false;
function settle(value) {
if (settled) {
return;
}
settled = true;
resolve(value);
}
dialog({
...config,
message: typeof message === "string" ? message : config.message,
actions: [
{ label: config.cancelLabel || "Cancel", kind: "outline", autofocus: true, onClick: () => settle(false) },
{ label: config.confirmLabel || "Confirm", kind: "primary", onClick: () => settle(true) },
],
onClose: () => {
if (typeof config.onClose === "function") {
config.onClose();
}
settle(false);
},
});
});
};
window.Warpbox.promptDialog = function promptDialog(message, options) {
const config = (typeof options === "object" && options) || {};
return new Promise((resolve) => {
let settled = false;
function settle(value) {
if (settled) {
return;
}
settled = true;
resolve(value);
}
const field = document.createElement("input");
field.type = config.inputType || "text";
field.className = "warpbox-dialog-field";
if (config.placeholder) {
field.placeholder = config.placeholder;
}
if (typeof config.value === "string") {
field.value = config.value;
}
let controller = null;
field.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
settle(field.value);
if (controller) {
controller.close();
}
}
});
controller = dialog({
...config,
message: typeof message === "string" ? message : config.message,
body: field,
actions: [
{ label: config.cancelLabel || "Cancel", kind: "outline", onClick: () => settle(null) },
{ label: config.okLabel || "OK", kind: "primary", onClick: () => settle(field.value) },
],
onClose: () => {
if (typeof config.onClose === "function") {
config.onClose();
}
settle(null);
},
});
window.requestAnimationFrame(() => field.focus());
});
};
})();