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 @@ +