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