From 0b4487ac2e647bcb80de33c74ecfa55020da0e1f Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Mon, 8 Jun 2026 13:34:05 +0300 Subject: [PATCH] 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. --- backend/static/css/04-dialogs.css | 263 ++++++++++++++++++++++++ backend/static/js/04-dialogs.js | 299 ++++++++++++++++++++++++++++ backend/static/js/40-upload.js | 57 ++++++ backend/templates/layouts/base.html | 2 + 4 files changed, 621 insertions(+) create mode 100644 backend/static/css/04-dialogs.css create mode 100644 backend/static/js/04-dialogs.js diff --git a/backend/static/css/04-dialogs.css b/backend/static/css/04-dialogs.css new file mode 100644 index 0000000..9ad64a8 --- /dev/null +++ b/backend/static/css/04-dialogs.css @@ -0,0 +1,263 @@ +.warpbox-dialog-overlay { + position: fixed; + inset: 0; + z-index: 130; + display: grid; + place-items: center; + padding: 1rem; + background: color-mix(in srgb, var(--background) 60%, transparent); + backdrop-filter: blur(8px); + opacity: 0; + transition: opacity 160ms ease; +} + +.warpbox-dialog-overlay.is-visible { + opacity: 1; +} + +.warpbox-dialog { + position: relative; + width: min(28rem, 100%); + max-height: min(34rem, 90vh); + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--card); + color: var(--card-foreground); + box-shadow: var(--shadow); + opacity: 0; + transform: translateY(0.6rem) scale(0.98); + transition: opacity 160ms ease, transform 160ms ease; +} + +.warpbox-dialog:focus { + outline: none; +} + +.warpbox-dialog-overlay.is-visible .warpbox-dialog { + opacity: 1; + transform: translateY(0) scale(1); +} + +.warpbox-dialog-head { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.85rem; + align-items: center; + padding: 1.1rem 3.25rem 0 1.1rem; +} + +.warpbox-dialog-icon { + width: 1.9rem; + height: 1.9rem; + display: grid; + place-items: center; + border-radius: 999px; + background: color-mix(in srgb, var(--primary) 20%, transparent); + color: var(--primary); + font-weight: 800; + line-height: 1; +} + +.warpbox-dialog-warning .warpbox-dialog-icon { + background: color-mix(in srgb, var(--primary) 26%, transparent); + color: var(--primary-hover); +} + +.warpbox-dialog-error .warpbox-dialog-icon { + background: color-mix(in srgb, var(--danger) 18%, transparent); + color: var(--danger); +} + +.warpbox-dialog-title { + margin: 0; + font-size: 1.1rem; + line-height: 1.3; +} + +.warpbox-dialog-close { + position: absolute; + top: 1.1rem; + right: 1.1rem; + z-index: 2; + min-height: 1.9rem; + height: 1.9rem; + width: 1.9rem; + padding: 0; + border-color: var(--border); + color: var(--muted-foreground); + background: var(--surface-1); + font-size: 1rem; + line-height: 1; +} + +.warpbox-dialog-close:hover { + color: var(--foreground); + background: var(--surface-1-hover); +} + +.warpbox-dialog-body { + padding: 0.85rem 1.1rem 1.1rem; + overflow: auto; +} + +.warpbox-dialog-message { + margin: 0 0 0.75rem; + color: var(--muted-foreground); + font-size: 0.92rem; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.warpbox-dialog-message:last-child { + margin-bottom: 0; +} + +.warpbox-dialog-field { + width: 100%; + border: 1px solid var(--input); + border-radius: calc(var(--radius) - 0.35rem); + background: var(--surface-1); + color: var(--foreground); + padding: 0.55rem 0.7rem; + font: inherit; +} + +.warpbox-dialog-field:focus { + outline: 2px solid var(--ring); + outline-offset: 1px; +} + +.warpbox-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 0.55rem; + padding: 0 1.1rem 1.1rem; +} + +html.warpbox-dialog-open, +html.warpbox-dialog-open body { + overflow: hidden; + touch-action: none; +} + +.dialog-file-list { + display: grid; + gap: 0.5rem; + margin-top: 0.25rem; + max-height: 14rem; + overflow: auto; + padding-right: 0.25rem; +} + +.dialog-file-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 0.65rem; + padding: 0.5rem 0.65rem; + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 0.35rem); + background: var(--surface-1); +} + +.dialog-file-icon { + width: 1.35rem; + height: 1.35rem; + color: var(--muted-foreground); +} + +.dialog-file-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.86rem; +} + +.dialog-file-size { + color: var(--muted-foreground); + font-size: 0.8rem; + white-space: nowrap; +} + +:root[data-theme="retro"] .warpbox-dialog { + border: 1px solid #000000; + border-radius: 0; + background: #c0c0c0; + color: #000000; + box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf, 4px 4px 0 rgba(0, 0, 0, 0.45); + font-family: "PixeloidSans", "PixelOperator", "Microsoft Sans Serif", Tahoma, sans-serif; +} + +:root[data-theme="retro"] .warpbox-dialog-head { + padding-top: 0.2rem; +} + +:root[data-theme="retro"] .warpbox-dialog::before { + content: "Warpbox"; + display: block; + margin: 0.18rem 0.18rem 0; + padding: 0.22rem 0.35rem; + background: linear-gradient(to right, #000078, 80%, #0f80cd); + color: #ffffff; + font-size: 0.78rem; + font-weight: 700; +} + +:root[data-theme="retro"] .warpbox-dialog-error::before { + content: "Warpbox - Error"; +} + +:root[data-theme="retro"] .warpbox-dialog-warning::before { + content: "Warpbox - Warning"; +} + +:root[data-theme="retro"] .warpbox-dialog-info::before { + content: "Warpbox - Info"; +} + +:root[data-theme="retro"] .warpbox-dialog-icon { + border: 1px solid #000000; + background: #ffffff; + color: #000078; + box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff; +} + +:root[data-theme="retro"] .warpbox-dialog-warning .warpbox-dialog-icon { + color: #9a5b00; +} + +:root[data-theme="retro"] .warpbox-dialog-error .warpbox-dialog-icon { + color: #c00000; +} + +:root[data-theme="retro"] .warpbox-dialog-message { + color: #000000; +} + +:root[data-theme="retro"] .warpbox-dialog-close { + top: 0.36rem; + right: 0.3rem; + width: 1.1rem; + height: 0.95rem; + min-height: 0.95rem; + background: #c0c0c0; + color: #000000; + border: 1px solid #000000; + box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf; + font-size: 0.6rem; + font-weight: 700; +} + +@media (max-width: 640px) { + .warpbox-dialog-overlay { + padding: 0.75rem; + } + + .warpbox-dialog { + width: 100%; + } +} diff --git a/backend/static/js/04-dialogs.js b/backend/static/js/04-dialogs.js new file mode 100644 index 0000000..7c0721a --- /dev/null +++ b/backend/static/js/04-dialogs.js @@ -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()); + }); + }; +})(); diff --git a/backend/static/js/40-upload.js b/backend/static/js/40-upload.js index a33ff12..4965d48 100644 --- a/backend/static/js/40-upload.js +++ b/backend/static/js/40-upload.js @@ -17,6 +17,7 @@ const RESUMABLE_SESSIONS_KEY = "warpbox-resumable-sessions"; const SHARE_CACHE = "warpbox-share-target-v1"; const SHARE_LATEST_KEY = "/__warpbox_share_target__/latest"; + const CELLULAR_WARNING_THRESHOLD_BYTES = 200 * 1024 * 1024; if (!form || !dropZone || !fileInput) { return; @@ -106,6 +107,12 @@ if (!validateSelectedFilesWithinLimit(selectedFiles)) { return; } + if (isSlowOrMeteredConnection() && totalSelectedBytes(selectedFiles) >= CELLULAR_WARNING_THRESHOLD_BYTES) { + const proceed = await confirmCellularUpload(selectedFiles); + if (!proceed) { + return; + } + } const submit = form.querySelector("button[type='submit']"); const formData = uploadFormData(); @@ -228,6 +235,56 @@ } } + function isSlowOrMeteredConnection() { + const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection; + if (!connection) { + return false; + } + if (connection.saveData === true) { + return true; + } + return ["slow-2g", "2g", "3g"].includes(connection.effectiveType); + } + + function totalSelectedBytes(files) { + return files.reduce((sum, file) => sum + file.size, 0); + } + + function confirmCellularUpload(files) { + const list = document.createElement("div"); + list.className = "dialog-file-list"; + files.forEach((file) => { + const icon = document.createElement("span"); + icon.className = "svg-icon svg-icon-document dialog-file-icon"; + icon.setAttribute("aria-hidden", "true"); + + const name = document.createElement("span"); + name.className = "dialog-file-name"; + name.textContent = file.name; + name.title = file.name; + + const size = document.createElement("span"); + size.className = "dialog-file-size"; + size.textContent = window.Warpbox.formatBytes(file.size); + + const row = document.createElement("div"); + row.className = "dialog-file-row"; + row.append(icon, name, size); + list.append(row); + }); + + const totalLabel = window.Warpbox.formatBytes(totalSelectedBytes(files)); + const message = `You're on a slow or metered connection. You're about to upload ${files.length} file${files.length === 1 ? "" : "s"} (${totalLabel} total) — this could take a while or use up your data plan.`; + + return window.Warpbox.confirmDialog(message, { + title: "Slow connection detected", + variant: "warning", + body: list, + confirmLabel: "Upload anyway", + cancelLabel: "Cancel", + }); + } + function isShareTargetLaunch() { const params = new URLSearchParams(window.location.search || ""); return params.has("share-target"); diff --git a/backend/templates/layouts/base.html b/backend/templates/layouts/base.html index 7d58ee5..3077dc8 100644 --- a/backend/templates/layouts/base.html +++ b/backend/templates/layouts/base.html @@ -54,6 +54,7 @@ + @@ -70,6 +71,7 @@ +