feat(upload): warn on large uploads over slow/metered connections
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m54s
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m54s
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.
This commit is contained in:
299
backend/static/js/04-dialogs.js
Normal file
299
backend/static/js/04-dialogs.js
Normal file
@@ -0,0 +1,299 @@
|
||||
(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());
|
||||
});
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user