feat(backend): handle processing errors and add PWA routes
- Block file downloads and previews with a 424 StatusFailedDependency if file processing failed or the box has issues. - Register routes for `/service-worker.js` and `/share-target` to support PWA features. - Update README.md with an AI usage disclosure.
This commit is contained in:
173
backend/static/css/19-popups.css
Normal file
173
backend/static/css/19-popups.css
Normal file
@@ -0,0 +1,173 @@
|
||||
.warpbox-popups {
|
||||
position: fixed;
|
||||
z-index: 120;
|
||||
inset-block-start: calc(1rem + env(safe-area-inset-top));
|
||||
inset-inline-end: calc(1rem + env(safe-area-inset-right));
|
||||
width: min(26rem, calc(100vw - 2rem));
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.warpbox-popup {
|
||||
pointer-events: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.25rem);
|
||||
background: color-mix(in srgb, var(--card) 96%, transparent);
|
||||
color: var(--card-foreground);
|
||||
box-shadow: var(--shadow);
|
||||
opacity: 0;
|
||||
transform: translateY(-0.55rem);
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.warpbox-popup.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.warpbox-popup-chrome {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 0.85rem;
|
||||
align-items: start;
|
||||
padding: 0.95rem;
|
||||
}
|
||||
|
||||
.warpbox-popup-icon {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
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-popup-warning .warpbox-popup-icon {
|
||||
background: color-mix(in srgb, var(--primary) 26%, transparent);
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.warpbox-popup-error .warpbox-popup-icon {
|
||||
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.warpbox-popup-title {
|
||||
display: block;
|
||||
margin: 0 0 0.18rem;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.warpbox-popup-message {
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.warpbox-popup-close {
|
||||
min-height: 1.8rem;
|
||||
width: 1.8rem;
|
||||
padding: 0;
|
||||
border-color: var(--border);
|
||||
color: var(--muted-foreground);
|
||||
background: var(--surface-1);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.warpbox-popup-close:hover {
|
||||
color: var(--foreground);
|
||||
background: var(--surface-1-hover);
|
||||
}
|
||||
|
||||
.warpbox-popup-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.55rem;
|
||||
padding: 0 0.95rem 0.95rem;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-popups {
|
||||
inset-block-start: 2.65rem;
|
||||
font-family: "PixeloidSans", "PixelOperator", "Microsoft Sans Serif", Tahoma, sans-serif;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-popup {
|
||||
border: 1px solid #000000;
|
||||
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, 3px 3px 0 rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-popup::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-popup-error::before {
|
||||
content: "Warpbox - Error";
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-popup-warning::before {
|
||||
content: "Warpbox - Warning";
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-popup-info::before {
|
||||
content: "Warpbox - Info";
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-popup-chrome {
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-popup-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-popup-warning .warpbox-popup-icon {
|
||||
color: #9a5b00;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-popup-error .warpbox-popup-icon {
|
||||
color: #c00000;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-popup-message {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-popup-close {
|
||||
width: 1.45rem;
|
||||
height: 1.25rem;
|
||||
min-height: 1.25rem;
|
||||
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.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.warpbox-popups {
|
||||
inset-inline: 1rem;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,10 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.install-pwa-button[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
text-align: center;
|
||||
}
|
||||
@@ -395,6 +399,10 @@ button {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.upload-file-state-shared {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.upload-recovery-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -698,6 +698,12 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.button.is-disabled {
|
||||
opacity: .62;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.upload-processing-alert {
|
||||
margin: 1rem 0;
|
||||
padding: .85rem 1rem;
|
||||
@@ -707,6 +713,11 @@
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.upload-processing-alert-error {
|
||||
border-color: color-mix(in srgb, var(--danger) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--danger) 14%, transparent);
|
||||
}
|
||||
|
||||
.thumb-link {
|
||||
flex: 0 0 4.75rem;
|
||||
width: 4.75rem;
|
||||
@@ -870,6 +881,24 @@
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.file-card.is-failed {
|
||||
border-color: color-mix(in srgb, var(--danger) 55%, var(--border));
|
||||
background: color-mix(in srgb, var(--danger) 8%, var(--background));
|
||||
}
|
||||
|
||||
.file-card.is-failed .file-open {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.file-error {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin-top: 0.18rem;
|
||||
color: var(--danger);
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.file-reaction-dock {
|
||||
position: static;
|
||||
z-index: 2;
|
||||
|
||||
43
backend/static/js/02-pwa.js
Normal file
43
backend/static/js/02-pwa.js
Normal file
@@ -0,0 +1,43 @@
|
||||
(function () {
|
||||
let installPrompt = null;
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker.register("/service-worker.js").catch(() => {
|
||||
/* Service workers are progressive enhancement here. */
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("beforeinstallprompt", (event) => {
|
||||
const button = document.querySelector("[data-install-pwa]");
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
installPrompt = event;
|
||||
button.hidden = false;
|
||||
button.addEventListener("click", async () => {
|
||||
if (!installPrompt) {
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
try {
|
||||
await installPrompt.prompt();
|
||||
await installPrompt.userChoice;
|
||||
} finally {
|
||||
installPrompt = null;
|
||||
button.hidden = true;
|
||||
button.disabled = false;
|
||||
}
|
||||
}, { once: true });
|
||||
});
|
||||
|
||||
window.addEventListener("appinstalled", () => {
|
||||
const button = document.querySelector("[data-install-pwa]");
|
||||
if (button) {
|
||||
button.hidden = true;
|
||||
}
|
||||
installPrompt = null;
|
||||
});
|
||||
})();
|
||||
174
backend/static/js/03-popups.js
Normal file
174
backend/static/js/03-popups.js
Normal file
@@ -0,0 +1,174 @@
|
||||
(function () {
|
||||
const DEFAULT_DURATION = 6200;
|
||||
const VARIANTS = ["info", "warning", "error"];
|
||||
const GENERIC_ERROR_MESSAGE = "Something went wrong on this page. Please try again in a moment.";
|
||||
|
||||
window.Warpbox = window.Warpbox || {};
|
||||
let lastGlobalErrorAt = 0;
|
||||
|
||||
function ensureRegion() {
|
||||
let region = document.querySelector("[data-warpbox-popups]");
|
||||
if (region) {
|
||||
return region;
|
||||
}
|
||||
region = document.createElement("div");
|
||||
region.className = "warpbox-popups";
|
||||
region.setAttribute("data-warpbox-popups", "");
|
||||
region.setAttribute("aria-live", "polite");
|
||||
region.setAttribute("aria-atomic", "false");
|
||||
document.body.append(region);
|
||||
return region;
|
||||
}
|
||||
|
||||
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 || "",
|
||||
duration: Number.isFinite(options.duration) ? options.duration : DEFAULT_DURATION,
|
||||
actions: Array.isArray(options.actions) ? options.actions : [],
|
||||
};
|
||||
}
|
||||
|
||||
function defaultTitle(variant) {
|
||||
if (variant === "error") {
|
||||
return "Error";
|
||||
}
|
||||
if (variant === "warning") {
|
||||
return "Warning";
|
||||
}
|
||||
return "Info";
|
||||
}
|
||||
|
||||
function notify(options, message) {
|
||||
const config = normalizeOptions(options, message);
|
||||
const region = ensureRegion();
|
||||
const popup = document.createElement("section");
|
||||
popup.className = "warpbox-popup warpbox-popup-" + config.variant;
|
||||
popup.setAttribute("role", config.variant === "error" ? "alert" : "status");
|
||||
|
||||
const chrome = document.createElement("div");
|
||||
chrome.className = "warpbox-popup-chrome";
|
||||
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "warpbox-popup-icon";
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
icon.textContent = config.variant === "error" ? "!" : config.variant === "warning" ? "?" : "i";
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "warpbox-popup-body";
|
||||
|
||||
const title = document.createElement("strong");
|
||||
title.className = "warpbox-popup-title";
|
||||
title.textContent = config.title;
|
||||
|
||||
const text = document.createElement("p");
|
||||
text.className = "warpbox-popup-message";
|
||||
text.textContent = config.message;
|
||||
|
||||
body.append(title, text);
|
||||
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "warpbox-popup-close";
|
||||
close.setAttribute("aria-label", "Dismiss notification");
|
||||
close.textContent = "x";
|
||||
close.addEventListener("click", () => dismiss(popup));
|
||||
|
||||
chrome.append(icon, body, close);
|
||||
popup.append(chrome);
|
||||
|
||||
if (config.actions.length > 0) {
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "warpbox-popup-actions";
|
||||
config.actions.forEach((action) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "button " + (action.kind === "primary" ? "button-primary" : "button-outline");
|
||||
button.textContent = action.label || "Action";
|
||||
button.addEventListener("click", () => {
|
||||
if (typeof action.onClick === "function") {
|
||||
action.onClick();
|
||||
}
|
||||
if (action.dismiss !== false) {
|
||||
dismiss(popup);
|
||||
}
|
||||
});
|
||||
actions.append(button);
|
||||
});
|
||||
popup.append(actions);
|
||||
}
|
||||
|
||||
region.append(popup);
|
||||
window.requestAnimationFrame(() => popup.classList.add("is-visible"));
|
||||
|
||||
let timer = null;
|
||||
if (config.duration > 0) {
|
||||
timer = window.setTimeout(() => dismiss(popup), config.duration);
|
||||
}
|
||||
|
||||
return {
|
||||
element: popup,
|
||||
close: function closePopup() {
|
||||
if (timer) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
dismiss(popup);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dismiss(popup) {
|
||||
if (!popup || popup.dataset.closing === "true") {
|
||||
return;
|
||||
}
|
||||
popup.dataset.closing = "true";
|
||||
popup.classList.remove("is-visible");
|
||||
window.setTimeout(() => popup.remove(), 180);
|
||||
}
|
||||
|
||||
window.Warpbox.notify = notify;
|
||||
window.Warpbox.info = function info(message, options) {
|
||||
return notify({ ...(options || {}), variant: "info", message });
|
||||
};
|
||||
window.Warpbox.warning = function warning(message, options) {
|
||||
return notify({ ...(options || {}), variant: "warning", message });
|
||||
};
|
||||
window.Warpbox.error = function error(message, options) {
|
||||
return notify({ ...(options || {}), variant: "error", message });
|
||||
};
|
||||
|
||||
function showGlobalError() {
|
||||
const now = Date.now();
|
||||
if (now - lastGlobalErrorAt < 2500) {
|
||||
return;
|
||||
}
|
||||
lastGlobalErrorAt = now;
|
||||
notify({
|
||||
variant: "error",
|
||||
title: "Page error",
|
||||
message: GENERIC_ERROR_MESSAGE,
|
||||
duration: 9000,
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("error", function (event) {
|
||||
if (event && event.target && event.target !== window) {
|
||||
return;
|
||||
}
|
||||
showGlobalError();
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", function () {
|
||||
showGlobalError();
|
||||
});
|
||||
})();
|
||||
@@ -15,6 +15,8 @@
|
||||
const manageLink = document.querySelector("#manage-link");
|
||||
const newUpload = document.querySelector("#new-upload");
|
||||
const RESUMABLE_SESSIONS_KEY = "warpbox-resumable-sessions";
|
||||
const SHARE_CACHE = "warpbox-share-target-v1";
|
||||
const SHARE_LATEST_KEY = "/__warpbox_share_target__/latest";
|
||||
|
||||
if (!form || !dropZone || !fileInput) {
|
||||
return;
|
||||
@@ -47,6 +49,9 @@
|
||||
let uploadLocked = false;
|
||||
let recoveredDraft = null;
|
||||
let resumeMode = false;
|
||||
let sharedTargetDraft = null;
|
||||
const maxUploadBytes = parseInt(form.dataset.maxUploadBytes || "-1", 10);
|
||||
const maxUploadLabel = form.dataset.maxUploadLabel || (maxUploadBytes > 0 && window.Warpbox.formatBytes ? window.Warpbox.formatBytes(maxUploadBytes) : "the configured limit");
|
||||
|
||||
["dragenter", "dragover"].forEach((eventName) => {
|
||||
dropZone.addEventListener(eventName, (event) => {
|
||||
@@ -93,6 +98,12 @@
|
||||
event.preventDefault();
|
||||
if (selectedFiles.length === 0) {
|
||||
updateStatus("Choose at least one file first.");
|
||||
notify("warning", "Choose at least one file first.", {
|
||||
title: "No files selected",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!validateSelectedFilesWithinLimit(selectedFiles)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -108,8 +119,10 @@
|
||||
try {
|
||||
const payload = await uploadResumable(form.action, formData, selectedFiles);
|
||||
renderResult(payload);
|
||||
await clearSharedTargetPayload();
|
||||
form.reset();
|
||||
selectedFiles = [];
|
||||
sharedTargetDraft = null;
|
||||
resumeMode = false;
|
||||
recoveredDraft = null;
|
||||
fileInput.value = "";
|
||||
@@ -123,6 +136,7 @@
|
||||
}
|
||||
} catch (error) {
|
||||
updateStatus(error.message || "Upload failed");
|
||||
notifyUploadError(error);
|
||||
} finally {
|
||||
setLoading(false, submit);
|
||||
}
|
||||
@@ -136,26 +150,168 @@
|
||||
|
||||
if (newUpload) {
|
||||
newUpload.addEventListener("click", () => {
|
||||
if (sharedTargetDraft) {
|
||||
clearSharedTargetPayload().finally(() => resetFreshUploadState());
|
||||
return;
|
||||
}
|
||||
cancelRecoveredDraft().catch((error) => {
|
||||
updateStatus(error.message || "Upload draft could not be deleted");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
recoverResumableSessions();
|
||||
if (isShareTargetLaunch()) {
|
||||
loadSharedTargetFiles();
|
||||
} else {
|
||||
recoverResumableSessions();
|
||||
}
|
||||
|
||||
function addSelectedFiles(files) {
|
||||
if (uploadLocked) {
|
||||
return;
|
||||
}
|
||||
const rejected = [];
|
||||
Array.from(files || []).forEach((file) => {
|
||||
if (fileExceedsUploadLimit(file)) {
|
||||
rejected.push(file);
|
||||
return;
|
||||
}
|
||||
if (!selectedFiles.some((existing) => fileIdentity(existing) === fileIdentity(file))) {
|
||||
selectedFiles.push(file);
|
||||
}
|
||||
});
|
||||
if (rejected.length > 0) {
|
||||
notifyRejectedFiles(rejected);
|
||||
}
|
||||
updateSelectedState();
|
||||
}
|
||||
|
||||
function fileExceedsUploadLimit(file) {
|
||||
return Number.isFinite(maxUploadBytes) && maxUploadBytes > 0 && file && file.size > maxUploadBytes;
|
||||
}
|
||||
|
||||
function validateSelectedFilesWithinLimit(files) {
|
||||
const rejected = Array.from(files || []).filter(fileExceedsUploadLimit);
|
||||
if (rejected.length === 0) {
|
||||
return true;
|
||||
}
|
||||
selectedFiles = selectedFiles.filter((file) => !fileExceedsUploadLimit(file));
|
||||
notifyRejectedFiles(rejected);
|
||||
updateSelectedState();
|
||||
return false;
|
||||
}
|
||||
|
||||
function notifyRejectedFiles(files) {
|
||||
const names = files.slice(0, 3).map((file) => `"${file.name}" (${window.Warpbox.formatBytes(file.size)})`).join(", ");
|
||||
const extra = files.length > 3 ? `, and ${files.length - 3} more` : "";
|
||||
const message = `${names}${extra} ${files.length === 1 ? "is" : "are"} over the ${maxUploadLabel} upload limit.`;
|
||||
updateStatus(message);
|
||||
notify("error", message, {
|
||||
title: "Upload limit exceeded",
|
||||
duration: 9000,
|
||||
});
|
||||
}
|
||||
|
||||
function notifyUploadError(error) {
|
||||
const message = error && error.message ? error.message : "Upload failed";
|
||||
const lower = message.toLowerCase();
|
||||
const isLimit = lower.includes("limit") || lower.includes("quota") || lower.includes("too large") || lower.includes("exceeds");
|
||||
notify("error", message, {
|
||||
title: isLimit ? "Upload limit reached" : "Upload failed",
|
||||
duration: isLimit ? 9000 : 7200,
|
||||
});
|
||||
}
|
||||
|
||||
function notify(variant, message, options) {
|
||||
if (window.Warpbox && typeof window.Warpbox.notify === "function") {
|
||||
window.Warpbox.notify({ ...(options || {}), variant, message });
|
||||
}
|
||||
}
|
||||
|
||||
function isShareTargetLaunch() {
|
||||
const params = new URLSearchParams(window.location.search || "");
|
||||
return params.has("share-target");
|
||||
}
|
||||
|
||||
async function loadSharedTargetFiles() {
|
||||
if (!("caches" in window) || typeof File === "undefined") {
|
||||
updateStatus("Shared files could not be loaded in this browser.");
|
||||
recoverResumableSessions();
|
||||
return;
|
||||
}
|
||||
updateStatus("Loading shared files...");
|
||||
try {
|
||||
const cache = await caches.open(SHARE_CACHE);
|
||||
const metadataResponse = await cache.match(SHARE_LATEST_KEY);
|
||||
if (!metadataResponse) {
|
||||
updateStatus(new URLSearchParams(window.location.search).get("share-target") === "unsupported"
|
||||
? "Install Warpbox as an app to share files into it from your device."
|
||||
: "No shared files were found.");
|
||||
recoverResumableSessions();
|
||||
return;
|
||||
}
|
||||
const metadata = await metadataResponse.json();
|
||||
if (metadata.error) {
|
||||
updateStatus(metadata.error);
|
||||
recoverResumableSessions();
|
||||
return;
|
||||
}
|
||||
const files = [];
|
||||
for (const item of metadata.files || []) {
|
||||
if (!item.key) {
|
||||
continue;
|
||||
}
|
||||
const response = await cache.match(item.key);
|
||||
if (!response) {
|
||||
continue;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
files.push(new File([blob], item.name || "shared-file", {
|
||||
type: item.type || blob.type || "application/octet-stream",
|
||||
lastModified: item.lastModified || Date.now(),
|
||||
}));
|
||||
}
|
||||
sharedTargetDraft = metadata;
|
||||
selectedFiles = files;
|
||||
resumeMode = false;
|
||||
recoveredDraft = null;
|
||||
validateSelectedFilesWithinLimit(selectedFiles);
|
||||
if (selectedFiles.length > 0) {
|
||||
renderQueue(selectedFiles, "queued", { shared: true });
|
||||
updateStatus("Shared files ready.");
|
||||
} else {
|
||||
updateStatus("No files were included in this share.");
|
||||
}
|
||||
updateSelectedState();
|
||||
} catch (error) {
|
||||
updateStatus(error.message || "Shared files could not be loaded.");
|
||||
recoverResumableSessions();
|
||||
}
|
||||
}
|
||||
|
||||
async function clearSharedTargetPayload() {
|
||||
const draft = sharedTargetDraft;
|
||||
sharedTargetDraft = null;
|
||||
if (!draft || !("caches" in window)) {
|
||||
sharedTargetDraft = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cache = await caches.open(SHARE_CACHE);
|
||||
for (const item of draft.files || []) {
|
||||
if (item.key) {
|
||||
await cache.delete(item.key);
|
||||
}
|
||||
}
|
||||
if (draft.id) {
|
||||
await cache.delete("/__warpbox_share_target__/meta/" + encodeURIComponent(draft.id));
|
||||
}
|
||||
await cache.delete(SHARE_LATEST_KEY);
|
||||
} catch (error) {
|
||||
/* ignore cache cleanup failures */
|
||||
}
|
||||
}
|
||||
|
||||
function removeSelectedFile(index) {
|
||||
if (uploadLocked) {
|
||||
return;
|
||||
@@ -175,12 +331,18 @@
|
||||
fileSummary.textContent = count === 0
|
||||
? "Reselect missing files to resume, or add extra files to this upload."
|
||||
: `${count} local file${count === 1 ? "" : "s"} ready for the recovered upload.`;
|
||||
} else if (sharedTargetDraft) {
|
||||
fileSummary.textContent = count === 0
|
||||
? "No shared files were received."
|
||||
: `${count} shared file${count === 1 ? "" : "s"} ready. Review options, then upload.`;
|
||||
} else {
|
||||
fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`;
|
||||
}
|
||||
}
|
||||
if (resumeMode && recoveredDraft) {
|
||||
renderResumeQueue(recoveredDraft.session, selectedFiles);
|
||||
} else if (sharedTargetDraft && count > 0) {
|
||||
renderQueue(selectedFiles, "queued", { shared: true });
|
||||
} else if (count > 0) {
|
||||
renderQueue(selectedFiles, "queued");
|
||||
} else if (uploadQueue) {
|
||||
@@ -194,7 +356,7 @@
|
||||
if (!newUpload) {
|
||||
return;
|
||||
}
|
||||
const visible = Boolean(resumeMode && recoveredDraft);
|
||||
const visible = Boolean((resumeMode && recoveredDraft) || sharedTargetDraft);
|
||||
newUpload.hidden = !visible;
|
||||
newUpload.style.display = visible ? "" : "none";
|
||||
}
|
||||
@@ -803,6 +965,7 @@
|
||||
selectedFiles = [];
|
||||
resumeMode = false;
|
||||
recoveredDraft = null;
|
||||
sharedTargetDraft = null;
|
||||
fileInput.value = "";
|
||||
result.hidden = true;
|
||||
if (resultList) {
|
||||
@@ -913,20 +1076,22 @@
|
||||
return Math.max(0, Math.min(100, Math.round((bytes / total) * 100)));
|
||||
}
|
||||
|
||||
function renderQueue(files, status) {
|
||||
function renderQueue(files, status, options) {
|
||||
if (!uploadQueue) {
|
||||
return;
|
||||
}
|
||||
const shared = Boolean(options && options.shared);
|
||||
uploadQueue.hidden = files.length === 0;
|
||||
uploadQueue.replaceChildren();
|
||||
files.forEach((file, index) => {
|
||||
uploadQueue.append(createFileRow({
|
||||
name: file.name,
|
||||
meta: window.Warpbox.formatBytes(file.size),
|
||||
meta: shared ? `${window.Warpbox.formatBytes(file.size)} · Shared from device` : window.Warpbox.formatBytes(file.size),
|
||||
progress: status === "queued" ? 0 : 100,
|
||||
status,
|
||||
index,
|
||||
removable: status === "queued",
|
||||
shared,
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -965,6 +1130,12 @@
|
||||
badge.textContent = "Needs local file";
|
||||
side.append(badge);
|
||||
}
|
||||
if (file.shared) {
|
||||
const badge = document.createElement("small");
|
||||
badge.className = "upload-file-state upload-file-state-shared";
|
||||
badge.textContent = "Shared from device";
|
||||
side.append(badge);
|
||||
}
|
||||
if (file.removable) {
|
||||
const remove = document.createElement("button");
|
||||
remove.className = "upload-file-remove";
|
||||
|
||||
110
backend/static/js/service-worker.js
Normal file
110
backend/static/js/service-worker.js
Normal file
@@ -0,0 +1,110 @@
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
if (event.request.method === "POST" && url.origin === self.location.origin && url.pathname === "/share-target") {
|
||||
event.respondWith(handleShareTarget(event.request));
|
||||
}
|
||||
});
|
||||
|
||||
const SHARE_CACHE = "warpbox-share-target-v1";
|
||||
const SHARE_PREFIX = "/__warpbox_share_target__/";
|
||||
const LATEST_KEY = SHARE_PREFIX + "latest";
|
||||
|
||||
async function handleShareTarget(request) {
|
||||
const id = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 10);
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const files = collectSharedFiles(formData);
|
||||
const cache = await caches.open(SHARE_CACHE);
|
||||
const metadata = {
|
||||
id,
|
||||
title: stringValue(formData.get("title")),
|
||||
text: stringValue(formData.get("text")),
|
||||
url: stringValue(formData.get("url")),
|
||||
createdAt: new Date().toISOString(),
|
||||
files: [],
|
||||
};
|
||||
|
||||
await deletePreviousShare(cache);
|
||||
for (let index = 0; index < files.length; index += 1) {
|
||||
const file = files[index];
|
||||
const key = SHARE_PREFIX + "file/" + encodeURIComponent(id) + "/" + index;
|
||||
metadata.files.push({
|
||||
key,
|
||||
name: file.name || "shared-file",
|
||||
type: file.type || "application/octet-stream",
|
||||
size: file.size || 0,
|
||||
lastModified: file.lastModified || Date.now(),
|
||||
});
|
||||
await cache.put(key, new Response(file, {
|
||||
headers: {
|
||||
"Content-Type": file.type || "application/octet-stream",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
await cache.put(LATEST_KEY, jsonResponse(metadata));
|
||||
await cache.put(SHARE_PREFIX + "meta/" + encodeURIComponent(id), jsonResponse(metadata));
|
||||
} catch (error) {
|
||||
await storeShareError(id, error);
|
||||
}
|
||||
|
||||
return Response.redirect("/?share-target=1&share-id=" + encodeURIComponent(id), 303);
|
||||
}
|
||||
|
||||
function collectSharedFiles(formData) {
|
||||
const files = [];
|
||||
["files", "file", "sharex"].forEach((name) => {
|
||||
formData.getAll(name).forEach((value) => {
|
||||
if (value instanceof File && value.size > 0) {
|
||||
files.push(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
return files;
|
||||
}
|
||||
|
||||
function stringValue(value) {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function jsonResponse(payload) {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function storeShareError(id, error) {
|
||||
const cache = await caches.open(SHARE_CACHE);
|
||||
await cache.put(LATEST_KEY, jsonResponse({
|
||||
id,
|
||||
error: error && error.message ? error.message : "Shared files could not be staged.",
|
||||
createdAt: new Date().toISOString(),
|
||||
files: [],
|
||||
}));
|
||||
}
|
||||
|
||||
async function deletePreviousShare(cache) {
|
||||
const previous = await cache.match(LATEST_KEY);
|
||||
if (!previous) {
|
||||
return;
|
||||
}
|
||||
let metadata = null;
|
||||
try {
|
||||
metadata = await previous.json();
|
||||
} catch (error) {
|
||||
metadata = null;
|
||||
}
|
||||
for (const file of metadata && metadata.files ? metadata.files : []) {
|
||||
if (file.key) {
|
||||
await cache.delete(file.key);
|
||||
}
|
||||
}
|
||||
if (metadata && metadata.id) {
|
||||
await cache.delete(SHARE_PREFIX + "meta/" + encodeURIComponent(metadata.id));
|
||||
}
|
||||
await cache.delete(LATEST_KEY);
|
||||
}
|
||||
@@ -7,6 +7,22 @@
|
||||
"display": "standalone",
|
||||
"background_color": "#0b0b16",
|
||||
"theme_color": "#8b5cf6",
|
||||
"share_target": {
|
||||
"action": "/share-target",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"text": "text",
|
||||
"url": "url",
|
||||
"files": [
|
||||
{
|
||||
"name": "files",
|
||||
"accept": ["*/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/android-chrome-192x192.png",
|
||||
|
||||
Reference in New Issue
Block a user