feat(config): support *_MB env vars for upload size limits
- Add `applyMegabytesOrBytesEnv` to accept size settings in either bytes or MB - Prefer `*_BYTES` when set, otherwise convert `*_MB` to bytes with overflow guard - Add coverage for MB-based environment overrides - Introduce `static/js/upload-popups.js` to lazy-load and cache popup templatesfeat(config): support *_MB env vars for upload size limits - Add `applyMegabytesOrBytesEnv` to accept size settings in either bytes or MB - Prefer `*_BYTES` when set, otherwise convert `*_MB` to bytes with overflow guard - Add coverage for MB-based environment overrides - Introduce `static/js/upload-popups.js` to lazy-load and cache popup templates
This commit is contained in:
277
static/js/app.js
277
static/js/app.js
@@ -54,6 +54,8 @@ let uploadLocked = false;
|
||||
let statusTimer = null;
|
||||
let pendingDuplicateFiles = [];
|
||||
let apiKeyTimer = null;
|
||||
let completedImpactKeys = new Set();
|
||||
let overallImpactDone = false;
|
||||
|
||||
function numberFromDataset(value) {
|
||||
const number = Number.parseInt(value || "0", 10);
|
||||
@@ -177,6 +179,28 @@ function showToast(message, type = "info") {
|
||||
showToast.timer = setTimeout(() => el.toast.classList.remove("is-visible"), 2600);
|
||||
}
|
||||
|
||||
function disabledReasonFor(target) {
|
||||
const control = target.closest("[data-disabled-reason], button, input, select, textarea, .upload-dropzone");
|
||||
if (!control) return "";
|
||||
if (control.classList.contains("upload-dropzone") && uploadLocked) {
|
||||
return control.dataset.disabledReason || "The current box is sealed after upload. Press Clear to start a new box.";
|
||||
}
|
||||
if (control.disabled || control.readOnly || control.getAttribute("aria-disabled") === "true") {
|
||||
return control.dataset.disabledReason || control.title || "This control is disabled right now.";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function announceDisabledReason(event) {
|
||||
const reason = disabledReasonFor(event.target);
|
||||
if (!reason) return false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
showToast(reason, "warning");
|
||||
setStatus(reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
function stopStatusAnimation() {
|
||||
if (statusTimer) {
|
||||
clearInterval(statusTimer);
|
||||
@@ -214,6 +238,14 @@ function setOverallProgress(percent) {
|
||||
if (el.overallPercent) el.overallPercent.textContent = display;
|
||||
}
|
||||
|
||||
function flashProgressBar(bar) {
|
||||
if (!bar) return;
|
||||
bar.classList.remove("just-completed");
|
||||
void bar.offsetWidth;
|
||||
bar.classList.add("just-completed");
|
||||
setTimeout(() => bar.classList.remove("just-completed"), 620);
|
||||
}
|
||||
|
||||
function setRowProgress(item, percent) {
|
||||
const bar = item.row?.querySelector(".upload-progress-bar");
|
||||
if (bar) bar.style.width = `${Math.max(0, Math.min(100, percent))}%`;
|
||||
@@ -275,6 +307,10 @@ function updateOverallProgress() {
|
||||
const uploadedCount = files.filter((item) => item.uploaded).length;
|
||||
const percent = overallProgress();
|
||||
setOverallProgress(percent >= 100 && uploadedCount < files.length ? 99 : percent);
|
||||
if (percent >= 100 && files.length && !overallImpactDone) {
|
||||
overallImpactDone = true;
|
||||
flashProgressBar(el.overallBar);
|
||||
}
|
||||
}
|
||||
|
||||
function createFileRow(item, index) {
|
||||
@@ -310,6 +346,7 @@ function createFileRow(item, index) {
|
||||
remove.dataset.remove = String(index);
|
||||
remove.title = uploadLocked ? "This file cannot be removed because this upload box was already created." : "Remove file";
|
||||
remove.disabled = uploadLocked;
|
||||
remove.dataset.disabledReason = uploadLocked ? "Files cannot be removed after the box is created. Press Clear to start another upload." : "";
|
||||
|
||||
const progress = document.createElement("span");
|
||||
progress.className = "upload-progress";
|
||||
@@ -395,17 +432,9 @@ function addFiles(fileList) {
|
||||
function showDuplicateDialog(duplicates) {
|
||||
pendingDuplicateFiles = duplicates;
|
||||
const list = duplicates.map((item) => `<li><strong>${htmlEscape(item.displayName)}</strong> <span>${formatBytes(item.file.size)}</span></li>`).join("");
|
||||
openPopup("Duplicate file names", `
|
||||
<h3>Duplicate file names detected</h3>
|
||||
<p>These files have the same names as files already in the queue.</p>
|
||||
<ol class="duplicate-list">${list}</ol>
|
||||
<p>Skip them, or append numbers so they become names like <code>file (2).zip</code>.</p>
|
||||
<div class="copy-fallback-actions">
|
||||
<button class="win98-button" type="button" id="duplicate-append">Append numbers</button>
|
||||
<button class="win98-button" type="button" id="duplicate-skip">Skip duplicates</button>
|
||||
</div>`);
|
||||
showTemplatePopup("Duplicate file names", "duplicate", { list })
|
||||
.then(() => document.querySelector("#duplicate-append")?.focus());
|
||||
showToast("Duplicate names found. Choose skip or append numbers.", "warning");
|
||||
setTimeout(() => document.querySelector("#duplicate-append")?.focus(), 0);
|
||||
}
|
||||
|
||||
function appendPendingDuplicates() {
|
||||
@@ -443,6 +472,8 @@ function clearQueue() {
|
||||
files = [];
|
||||
pendingDuplicateFiles = [];
|
||||
uploadLocked = false;
|
||||
completedImpactKeys = new Set();
|
||||
overallImpactDone = false;
|
||||
stopStatusAnimation();
|
||||
setBoxOptionsLocked(false);
|
||||
setShareUrl("");
|
||||
@@ -461,14 +492,8 @@ function confirmClearQueue() {
|
||||
showToast("Nothing to clear.");
|
||||
return;
|
||||
}
|
||||
openPopup("Clear WarpBox?", `
|
||||
<h3>Confirm clear</h3>
|
||||
<p>This removes the current queue, resets progress, and unlocks the Start upload button.</p>
|
||||
<div class="copy-fallback-actions">
|
||||
<button class="win98-button" type="button" id="confirm-clear-yes">Clear</button>
|
||||
<button class="win98-button" type="button" id="confirm-clear-no">Cancel</button>
|
||||
</div>`);
|
||||
setTimeout(() => document.querySelector("#confirm-clear-no")?.focus(), 0);
|
||||
showTemplatePopup("Clear WarpBox?", "clear")
|
||||
.then(() => document.querySelector("#confirm-clear-no")?.focus());
|
||||
}
|
||||
|
||||
async function createBox() {
|
||||
@@ -521,6 +546,13 @@ function setFileFailed(item, message) {
|
||||
updateOverallProgress();
|
||||
}
|
||||
|
||||
function markCompletedImpact(item) {
|
||||
const key = item.boxFile?.id || item.displayName;
|
||||
if (completedImpactKeys.has(key)) return;
|
||||
completedImpactKeys.add(key);
|
||||
flashProgressBar(item.row?.querySelector(".upload-progress-bar"));
|
||||
}
|
||||
|
||||
function uploadFile(item, onComplete) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
@@ -566,6 +598,7 @@ function uploadFile(item, onComplete) {
|
||||
item.row?.classList.add("is-uploaded");
|
||||
if (item.row) item.row.title = "Uploaded";
|
||||
setRowProgress(item, 100);
|
||||
markCompletedImpact(item);
|
||||
|
||||
try {
|
||||
const result = JSON.parse(xhr.responseText);
|
||||
@@ -635,6 +668,8 @@ async function startUpload() {
|
||||
item.failed = false;
|
||||
item.error = "";
|
||||
});
|
||||
completedImpactKeys = new Set();
|
||||
overallImpactDone = false;
|
||||
renderFiles();
|
||||
|
||||
let completedCount = 0;
|
||||
@@ -731,6 +766,12 @@ function updateDisabledReasons() {
|
||||
el.startButton.dataset.disabledReason = reason;
|
||||
el.startButton.title = reason;
|
||||
}
|
||||
if (el.fileInput) {
|
||||
el.fileInput.dataset.disabledReason = uploadLocked ? "The current box is sealed after upload. Press Clear to start a new box." : (!uploadsEnabled ? "Guest uploads are disabled." : "");
|
||||
}
|
||||
if (el.dropzone) {
|
||||
el.dropzone.dataset.disabledReason = uploadLocked ? "The current box is sealed after upload. Press Clear to start a new box." : (!uploadsEnabled ? "Guest uploads are disabled." : "");
|
||||
}
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
@@ -773,9 +814,6 @@ function loadSettings() {
|
||||
}
|
||||
|
||||
function syncMenuChecks() {
|
||||
document.querySelectorAll("[data-expiry-check]").forEach((node) => {
|
||||
node.textContent = node.dataset.expiryCheck === el.expiry?.value ? "✓" : "";
|
||||
});
|
||||
const downloadCheck = document.querySelector("[data-download-page-check]");
|
||||
if (downloadCheck) downloadCheck.textContent = el.downloadPage?.checked ? "✓" : "";
|
||||
}
|
||||
@@ -826,6 +864,14 @@ function slugify(value) {
|
||||
.slice(0, 32);
|
||||
}
|
||||
|
||||
function sanitizeSlugInput(value) {
|
||||
return String(value || "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, "")
|
||||
.replace(/-+/g, "-")
|
||||
.slice(0, 32);
|
||||
}
|
||||
|
||||
function syncSlugFromName(force = false) {
|
||||
if (!el.customSlug || !el.boxName) return;
|
||||
if (force || !el.customSlug.value || el.customSlug.dataset.auto === "true") {
|
||||
@@ -846,9 +892,9 @@ function randomPassword() {
|
||||
|
||||
function randomBoxName() {
|
||||
if (!el.boxName || uploadLocked) return;
|
||||
const adjectives = ["neon", "turbo", "quiet", "cosmic", "lucky", "midnight", "pixel", "rapid"];
|
||||
const nouns = ["floppy", "archive", "packet", "portal", "folder", "upload", "cache", "drive"];
|
||||
el.boxName.value = `${adjectives[Math.floor(Math.random() * adjectives.length)]}-${nouns[Math.floor(Math.random() * nouns.length)]}`;
|
||||
const adjectives = ["Neon", "Turbo", "Quiet", "Cosmic", "Lucky", "Midnight", "Pixel", "Rapid"];
|
||||
const nouns = ["Floppy Disk", "Archive Box", "Packet Portal", "Upload Folder", "Cache Drive", "Release Bundle"];
|
||||
el.boxName.value = `${adjectives[Math.floor(Math.random() * adjectives.length)]} ${nouns[Math.floor(Math.random() * nouns.length)]}`;
|
||||
syncSlugFromName(true);
|
||||
setStatus("Generated a local box name");
|
||||
}
|
||||
@@ -891,14 +937,11 @@ async function copyText(kind, value, openUrl = "") {
|
||||
}
|
||||
|
||||
function showCopyFallback(kind, value, openUrl) {
|
||||
openPopup(`${kind} copy failed`, `
|
||||
<h3>Clipboard access failed</h3>
|
||||
<p>The browser refused clipboard access. Copy it manually from the field below.</p>
|
||||
<textarea class="copy-fallback-text" readonly>${htmlEscape(value)}</textarea>
|
||||
<div class="copy-fallback-actions">
|
||||
${openUrl ? `<a class="win98-button" href="${htmlEscape(openUrl)}" target="_blank" rel="noreferrer">Open</a>` : ""}
|
||||
<button class="win98-button" type="button" id="fallback-close">Close</button>
|
||||
</div>`);
|
||||
const openLink = openUrl ? `<a class="win98-button" href="${htmlEscape(openUrl)}" target="_blank" rel="noreferrer">Open</a>` : "";
|
||||
showTemplatePopup(`${kind} copy failed`, "copy-failed", {
|
||||
value: htmlEscape(value),
|
||||
openLink,
|
||||
});
|
||||
}
|
||||
|
||||
function quotaWarningHtml(message) {
|
||||
@@ -916,10 +959,10 @@ function quotaWarningHtml(message) {
|
||||
}
|
||||
|
||||
function showWarningDialog(title, message) {
|
||||
openPopup(title, `
|
||||
<h3>${htmlEscape(title)}</h3>
|
||||
${quotaWarningHtml(message)}
|
||||
<div class="copy-fallback-actions"><button class="win98-button" type="button" id="fallback-close">OK</button></div>`);
|
||||
showTemplatePopup(title, "warning", {
|
||||
title: htmlEscape(title),
|
||||
content: quotaWarningHtml(message),
|
||||
});
|
||||
}
|
||||
|
||||
function openPopup(title, html, about = false) {
|
||||
@@ -936,96 +979,41 @@ function closeDoc() {
|
||||
el.modalBackdrop?.classList.remove("is-visible");
|
||||
}
|
||||
|
||||
const docs = {
|
||||
cli: {
|
||||
title: "CLI Guide",
|
||||
html: `
|
||||
<h3>Upload with cURL</h3>
|
||||
<p>WarpBox accepts normal multipart form uploads through the compatibility endpoint:</p>
|
||||
<pre>curl \\
|
||||
-F 'files=@./my-file.zip' \\
|
||||
-F 'retention=1h' \\
|
||||
${window.location.origin}/upload</pre>
|
||||
<h4>Browser flow</h4>
|
||||
<p>The browser uses the manifest API: it creates a box, uploads each file, and marks failed uploads so the download page does not wait forever.</p>
|
||||
`,
|
||||
},
|
||||
faq: {
|
||||
title: "Help & FAQ",
|
||||
html: `
|
||||
<h3>Help & FAQ</h3>
|
||||
<section class="shortcut-section">
|
||||
<h4>Keyboard shortcuts</h4>
|
||||
<ul class="shortcut-list">
|
||||
<li><span><span class="kbd">Ctrl</span> + <span class="kbd">O</span></span><span>Browse for files.</span></li>
|
||||
<li><span><span class="kbd">Ctrl</span> + <span class="kbd">U</span></span><span>Start the current upload.</span></li>
|
||||
<li><span><span class="kbd">Ctrl</span> + <span class="kbd">K</span></span><span>Copy the full cURL command.</span></li>
|
||||
<li><span><span class="kbd">Ctrl</span> + <span class="kbd">L</span></span><span>Copy the share URL after upload.</span></li>
|
||||
<li><span><span class="kbd">F1</span></span><span>Open this window.</span></li>
|
||||
<li><span><span class="kbd">Esc</span></span><span>Close menus and popups.</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
<div class="faq-list">
|
||||
<div class="faq-item"><p><strong>Can I password protect uploads?</strong></p><p>Yes. Set a password in Box Options before starting the upload.</p></div>
|
||||
<div class="faq-item"><p><strong>What happens if one file fails?</strong></p><p>The failed row stays red, successful files remain available, and WarpBox marks the failed file in the manifest.</p></div>
|
||||
<div class="faq-item"><p><strong>Are all options server-backed?</strong></p><p>Expiry, password, ZIP download, and one-time download are sent to the backend. Notes like box name, custom slug, and API key mode are saved locally until backend support exists.</p></div>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
dailyQuota: {
|
||||
title: "Upload limits",
|
||||
html: `
|
||||
<h3>Upload limits</h3>
|
||||
<div class="quota-meter-list">
|
||||
<div class="quota-meter">
|
||||
<div class="quota-meter-head"><span>Box size</span><span>${maxBoxBytes ? formatBytes(maxBoxBytes) : "No configured limit"}</span></div>
|
||||
<div class="quota-meter-track"><span class="quota-meter-bar" style="width:${maxBoxBytes ? Math.min(100, Math.round((totalBytes() / maxBoxBytes) * 100)) : 0}%"></span></div>
|
||||
</div>
|
||||
<div class="quota-meter">
|
||||
<div class="quota-meter-head"><span>Single file</span><span>${maxFileBytes ? formatBytes(maxFileBytes) : "No configured limit"}</span></div>
|
||||
<div class="quota-meter-track"><span class="quota-meter-bar" style="width:${oversizedFiles().length ? 100 : 0}%"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="quota-note">These values come from the running WarpBox configuration.</p>
|
||||
`,
|
||||
},
|
||||
about: {
|
||||
title: "About WarpBox",
|
||||
about: true,
|
||||
html: `
|
||||
<h3>WarpBox</h3>
|
||||
<p><strong>WarpBox</strong> was made by <strong>Daniel Legt</strong>.</p>
|
||||
<p>Temporary file boxes, terminal-friendly uploads, and old-web UI charm.</p>
|
||||
`,
|
||||
},
|
||||
examples: {
|
||||
title: "Examples",
|
||||
html: `
|
||||
<h3>Upload examples</h3>
|
||||
<h4>Basic CLI upload</h4>
|
||||
<pre>curl \\
|
||||
-F 'files=@./photo.png' \\
|
||||
-F 'retention=24h' \\
|
||||
${window.location.origin}/upload</pre>
|
||||
<h4>Multiple files with password</h4>
|
||||
<pre>curl \\
|
||||
-F 'files=@./one.png' \\
|
||||
-F 'files=@./two.zip' \\
|
||||
-F 'retention=1h' \\
|
||||
-F 'password=secret-pass' \\
|
||||
${window.location.origin}/upload</pre>
|
||||
`,
|
||||
},
|
||||
};
|
||||
async function showTemplatePopup(title, templateName, data = {}, about = false) {
|
||||
try {
|
||||
const html = await window.WBPopups.renderTemplate(templateName, data);
|
||||
openPopup(title, html, about);
|
||||
} catch (error) {
|
||||
showToast(error.message || `Could not load ${title}.`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function openDoc(name) {
|
||||
const doc = docs[name];
|
||||
if (!doc) return;
|
||||
openPopup(doc.title, doc.html, doc.about);
|
||||
setStatus(`${doc.title} opened`);
|
||||
function popupTemplateData(name) {
|
||||
const data = { origin: window.location.origin };
|
||||
if (name !== "dailyQuota") return data;
|
||||
return {
|
||||
...data,
|
||||
boxLimit: maxBoxBytes ? formatBytes(maxBoxBytes) : "No configured limit",
|
||||
boxPercent: maxBoxBytes ? Math.min(100, Math.round((totalBytes() / maxBoxBytes) * 100)) : 0,
|
||||
fileLimit: maxFileBytes ? formatBytes(maxFileBytes) : "No configured limit",
|
||||
filePercent: oversizedFiles().length ? 100 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function openDoc(name) {
|
||||
try {
|
||||
const doc = await window.WBPopups.renderDoc(name, popupTemplateData(name));
|
||||
if (!doc) return;
|
||||
openPopup(doc.title, doc.html, doc.about);
|
||||
setStatus(`${doc.title} opened`);
|
||||
} catch (error) {
|
||||
showToast(error.message || "Could not load help window.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if (announceDisabledReason(event)) return;
|
||||
|
||||
const menuButton = event.target.closest(".menu-button");
|
||||
if (menuButton) {
|
||||
const item = menuButton.closest(".menu-item");
|
||||
@@ -1067,19 +1055,16 @@ document.addEventListener("click", (event) => {
|
||||
}
|
||||
if (action === "help" || action === "side-help") openDoc("faq");
|
||||
if (action === "terminal-help") el.terminal?.focus();
|
||||
if (action === "coming-soon") showToast("That shortcut is decorative for now.");
|
||||
if (action === "side-close" || action === "side-folder-close" || action === "fake-close" || action === "minimize" || action === "toggle-fit") showToast("Window controls are decorative on this page.");
|
||||
return;
|
||||
}
|
||||
|
||||
const expiry = event.target.closest("[data-expiry]")?.dataset.expiry;
|
||||
if (expiry && el.expiry) {
|
||||
el.expiry.value = expiry;
|
||||
syncZipForRetention();
|
||||
saveSettings();
|
||||
syncMenuChecks();
|
||||
updateTerminal();
|
||||
setStatus(`Expiry set to ${event.target.textContent.trim()}`);
|
||||
if (action === "coming-soon") showToast("Coming Soon, not implemented just yet.");
|
||||
if (action === "fake-close") showToast("Close button denied. The upload window is staying open.", "warning");
|
||||
if (action === "minimize") showToast("Minimize requested. WarpBox stays visible so your queue is safe.");
|
||||
if (action === "toggle-fit") {
|
||||
document.body.classList.toggle("fit-window");
|
||||
showToast("Maximize requested. The pixel rectangle feels important now.");
|
||||
}
|
||||
if (action === "side-close") showToast("Box Options refuses to leave. Settings stay visible.");
|
||||
if (action === "side-help") showToast("Terminal help opened. Copy the command and feed it files.");
|
||||
if (action === "side-folder-close") showToast("The folder window saw that click and chose denial.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1115,6 +1100,22 @@ document.addEventListener("click", (event) => {
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("mousedown", (event) => {
|
||||
announceDisabledReason(event);
|
||||
}, true);
|
||||
|
||||
document.querySelectorAll(".menu-item").forEach((item) => {
|
||||
item.addEventListener("mouseenter", () => {
|
||||
if (!document.querySelector(".menu-item.is-open")) return;
|
||||
document.querySelectorAll(".menu-item.is-open").forEach((node) => {
|
||||
node.classList.remove("is-open");
|
||||
node.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
|
||||
});
|
||||
item.classList.add("is-open");
|
||||
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "true");
|
||||
});
|
||||
});
|
||||
|
||||
el.fileInput?.addEventListener("change", () => addFiles(el.fileInput.files));
|
||||
|
||||
[el.dropSurface, el.dropzone].filter(Boolean).forEach((target) => {
|
||||
@@ -1150,7 +1151,11 @@ el.modalBackdrop?.addEventListener("click", closeDoc);
|
||||
[el.expiry, el.password, el.maxViews, el.boxName, el.customSlug, el.downloadPage, el.allowZip, el.allowPreview, el.keepFilenames, el.privateBox, el.apiKeyMode, el.apiKeyInput].filter(Boolean).forEach((control) => {
|
||||
control.addEventListener("input", () => {
|
||||
if (control === el.boxName) syncSlugFromName();
|
||||
if (control === el.customSlug) el.customSlug.dataset.auto = "false";
|
||||
if (control === el.customSlug) {
|
||||
const clean = sanitizeSlugInput(el.customSlug.value);
|
||||
if (el.customSlug.value !== clean) el.customSlug.value = clean;
|
||||
el.customSlug.dataset.auto = "false";
|
||||
}
|
||||
if (control === el.apiKeyInput) validateApiKeyField();
|
||||
saveSettings();
|
||||
updateTerminal();
|
||||
|
||||
Reference in New Issue
Block a user