Files
warpbox/static/js/app.js

1208 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const SETTINGS_KEY = "warpbox.upload.settings.v1";
const el = {
form: document.querySelector("#upload-form"),
fileInput: document.querySelector("#file-upload"),
dropSurface: document.querySelector("#drop-surface"),
dropzone: document.querySelector("#dropzone"),
fileList: document.querySelector("#file-list"),
queueLabel: document.querySelector("#queue-label"),
queueSize: document.querySelector("#queue-size"),
limitHint: document.querySelector("#limit-hint"),
boxSpaceText: document.querySelector("#box-space-text"),
boxSpaceBar: document.querySelector("#box-space-bar"),
overallBar: document.querySelector("#overall-bar"),
overallPercent: document.querySelector("#overall-percent"),
shareLink: document.querySelector("#share-link"),
copyButton: document.querySelector("#copy-button"),
startButton: document.querySelector("#start-button"),
statusText: document.querySelector("#status-text"),
toast: document.querySelector("#toast"),
terminal: document.querySelector("#terminal-box"),
copyCurlButton: document.querySelector("#copy-curl-button"),
docPopup: document.querySelector("#doc-popup"),
modalBackdrop: document.querySelector("#modal-backdrop"),
docPopupTitle: document.querySelector("#doc-popup-title"),
docPopupBody: document.querySelector("#doc-popup-body"),
docPopupClose: document.querySelector("#doc-popup-close"),
expiry: document.querySelector("#expiry-select"),
password: document.querySelector("#password-input"),
optionsForm: document.querySelector("#box-options-form"),
maxViews: document.querySelector("#max-views"),
boxName: document.querySelector("#box-name"),
customSlug: document.querySelector("#custom-slug"),
downloadPage: document.querySelector("#download-page"),
allowZip: document.querySelector("#allow-zip"),
allowPreview: document.querySelector("#allow-preview"),
keepFilenames: document.querySelector("#keep-filenames"),
privateBox: document.querySelector("#private-box"),
apiKeyMode: document.querySelector("#api-key-mode"),
apiKeyInput: document.querySelector("#api-key-input"),
apiKeyRow: document.querySelector("#api-key-row"),
apiKeyState: document.querySelector("#api-key-state"),
};
const uploadsEnabled = el.form?.dataset.uploadsEnabled === "true";
const defaultRetention = el.form?.dataset.defaultRetention || "10s";
const maxFileBytes = numberFromDataset(el.form?.dataset.maxFileBytes);
const maxBoxBytes = numberFromDataset(el.form?.dataset.maxBoxBytes);
const oneTimeRetentionKey = "one-time";
let files = [];
let shareUrl = "";
let uploadLocked = false;
let statusTimer = null;
let pendingDuplicateFiles = [];
let apiKeyTimer = null;
function numberFromDataset(value) {
const number = Number.parseInt(value || "0", 10);
return Number.isFinite(number) && number > 0 ? number : 0;
}
function formatBytes(bytes) {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
}
function htmlEscape(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function shellQuote(value) {
return `'${String(value).replaceAll("'", "'\\''")}'`;
}
function totalBytes() {
return files.reduce((sum, item) => sum + item.file.size, 0);
}
function uploadedBytes() {
return files.reduce((sum, item) => sum + item.loaded, 0);
}
function overallProgress() {
const total = totalBytes();
return total ? Math.round((uploadedBytes() / total) * 100) : 0;
}
function oversizedFiles() {
return maxFileBytes ? files.filter((item) => item.file.size > maxFileBytes) : [];
}
function isOverBoxQuota() {
return maxBoxBytes ? totalBytes() > maxBoxBytes : false;
}
function hasQuotaError() {
return isOverBoxQuota() || oversizedFiles().length > 0;
}
function normalizedFileName(name) {
return String(name || "").trim().toLowerCase();
}
function splitNameForIncrement(name) {
const value = String(name || "file");
const dot = value.lastIndexOf(".");
if (dot > 0 && dot < value.length - 1) return [value.slice(0, dot), value.slice(dot)];
return [value, ""];
}
function nextIncrementedFileName(name, usedNames) {
const [base, ext] = splitNameForIncrement(name);
let index = 2;
let candidate = `${base} (${index})${ext}`;
while (usedNames.has(normalizedFileName(candidate))) {
index += 1;
candidate = `${base} (${index})${ext}`;
}
usedNames.add(normalizedFileName(candidate));
return candidate;
}
function makeQueuedFile(file, displayName = file.name) {
return {
file,
displayName,
loaded: 0,
uploaded: false,
failed: false,
error: "",
row: null,
boxID: "",
boxFile: null,
previewURL: file.type?.startsWith("image/") ? URL.createObjectURL(file) : "",
};
}
function iconForFile(file) {
const filename = file.name || "";
const mimeType = file.type || "";
const extension = filename.includes(".") ? filename.slice(filename.lastIndexOf(".")).toLowerCase() : "";
if (extension === ".exe") return "/static/img/icons/Program Files Icons - PNG/MSONSEXT.DLL_14_6-0.png";
if (mimeType.startsWith("image/")) return "/static/img/sprites/bitmap.png";
if (mimeType.startsWith("video/") || mimeType.startsWith("audio/")) return "/static/img/icons/netshow_notransm-1.png";
if (mimeType.startsWith("text/") || extension === ".md") return "/static/img/sprites/notepad_file-1.png";
if (mimeType.includes("zip") || mimeType.includes("compressed") || [".rar", ".7z", ".tar", ".gz"].includes(extension)) return "/static/img/icons/Windows Icons - PNG/zipfldr.dll_14_101-0.png";
if ([".ttf", ".otf", ".woff", ".woff2"].includes(extension)) return "/static/img/sprites/font.png";
if (extension === ".pdf") return "/static/img/sprites/journal.png";
if ([".html", ".css", ".js"].includes(extension)) return "/static/img/sprites/frame_web-0.png";
return "/static/img/icons/Windows Icons - PNG/ole2.dll_14_DEFICON.png";
}
function setStatus(message) {
if (el.statusText) el.statusText.textContent = message;
}
function showToast(message, type = "info") {
if (!el.toast) return;
el.toast.textContent = message;
el.toast.classList.remove("toast-info", "toast-warning", "toast-error", "is-visible");
el.toast.classList.add(`toast-${type}`, "is-visible");
clearTimeout(showToast.timer);
showToast.timer = setTimeout(() => el.toast.classList.remove("is-visible"), 2600);
}
function stopStatusAnimation() {
if (statusTimer) {
clearInterval(statusTimer);
statusTimer = null;
}
}
function animateUploadStatus(getPrefix) {
let dotCount = 0;
stopStatusAnimation();
statusTimer = setInterval(() => {
dotCount = (dotCount % 3) + 1;
setStatus(`${getPrefix()} Uploading${".".repeat(dotCount)}`);
}, 350);
}
function setShareUrl(url) {
shareUrl = url ? new URL(url, window.location.origin).toString() : "";
if (!el.shareLink || !el.copyButton) return;
el.shareLink.textContent = shareUrl || "Not created yet";
el.shareLink.href = shareUrl || "#";
el.shareLink.title = shareUrl;
el.shareLink.classList.toggle("is-empty", !shareUrl);
el.shareLink.setAttribute("aria-disabled", shareUrl ? "false" : "true");
el.copyButton.disabled = !shareUrl;
el.copyButton.dataset.disabledReason = shareUrl ? "" : "There is no share URL yet. Start an upload first.";
updateTerminal();
updateCurrentStep();
}
function setOverallProgress(percent) {
const clamped = Math.max(0, Math.min(100, percent));
const display = `${Math.round(clamped)}%`;
if (el.overallBar) el.overallBar.style.width = display;
if (el.overallPercent) el.overallPercent.textContent = display;
}
function setRowProgress(item, percent) {
const bar = item.row?.querySelector(".upload-progress-bar");
if (bar) bar.style.width = `${Math.max(0, Math.min(100, percent))}%`;
}
function updateCurrentStep() {
const hasFiles = files.length > 0;
const allDone = hasFiles && files.every((item) => item.uploaded);
el.dropzone?.classList.toggle("is-current-step", uploadsEnabled && !hasFiles && !uploadLocked);
el.startButton?.classList.toggle("is-current-step", uploadsEnabled && hasFiles && !allDone && !uploadLocked && !hasQuotaError());
document.querySelector(".upload-result")?.classList.toggle("is-current-step", allDone && Boolean(shareUrl));
}
function quotaWarningMessage(incoming = []) {
const combined = [...files, ...incoming];
const tooBig = maxFileBytes ? combined.filter((item) => item.file.size > maxFileBytes) : [];
const total = combined.reduce((sum, item) => sum + item.file.size, 0);
if (tooBig.length) {
const list = tooBig.slice(0, 4).map((item) => `${item.displayName} (${formatBytes(item.file.size)})`).join(", ");
const more = tooBig.length > 4 ? ` and ${tooBig.length - 4} more` : "";
return `These files are over the single-file limit of ${formatBytes(maxFileBytes)}: ${list}${more}. Remove them before uploading.`;
}
if (maxBoxBytes && total > maxBoxBytes) {
return `This box is ${formatBytes(total - maxBoxBytes)} over the ${formatBytes(maxBoxBytes)} limit. Remove some files before uploading.`;
}
return "";
}
function updateLimitHint() {
if (!el.limitHint) return;
const parts = [];
if (maxBoxBytes) parts.push(`Max box: ${formatBytes(maxBoxBytes)}`);
if (maxFileBytes) parts.push(`max file: ${formatBytes(maxFileBytes)}`);
parts.push("links expire automatically");
el.limitHint.textContent = parts.join(" · ");
}
function updateQuota() {
const used = totalBytes();
const limitText = maxBoxBytes ? ` / ${formatBytes(maxBoxBytes)}` : "";
const overQuota = isOverBoxQuota();
const overFile = oversizedFiles().length > 0;
const percent = maxBoxBytes ? Math.min(100, Math.round((used / maxBoxBytes) * 100)) : 0;
document.querySelector(".upload-quota")?.classList.toggle("is-quota-warning", overQuota || overFile);
if (el.boxSpaceText) el.boxSpaceText.textContent = `${formatBytes(used)}${limitText}${overQuota ? " - over quota" : ""}`;
if (el.boxSpaceBar) {
el.boxSpaceBar.style.width = `${percent}%`;
el.boxSpaceBar.classList.toggle("is-over-quota", overQuota || overFile);
}
}
function updateQueueSummary() {
const count = files.length;
if (el.queueLabel) el.queueLabel.textContent = count ? `${count} file${count === 1 ? "" : "s"} selected` : "No files selected";
if (el.queueSize) el.queueSize.textContent = `${formatBytes(totalBytes())} total`;
}
function updateOverallProgress() {
const uploadedCount = files.filter((item) => item.uploaded).length;
const percent = overallProgress();
setOverallProgress(percent >= 100 && uploadedCount < files.length ? 99 : percent);
}
function createFileRow(item, index) {
const row = document.createElement("div");
row.className = "upload-file-row";
row.dataset.index = String(index);
row.classList.toggle("has-thumbnail", Boolean(item.previewURL));
row.classList.toggle("is-too-large", maxFileBytes > 0 && item.file.size > maxFileBytes);
row.classList.toggle("is-working", item.loaded > 0 && !item.uploaded && !item.failed);
row.classList.toggle("is-uploaded", item.uploaded);
row.classList.toggle("is-failed", item.failed);
row.title = item.error || "";
const icon = document.createElement("img");
icon.className = "upload-file-icon";
icon.src = item.previewURL || iconForFile(item.file);
icon.alt = "";
icon.setAttribute("aria-hidden", "true");
const name = document.createElement("span");
name.className = "upload-file-name";
name.textContent = item.displayName;
name.title = item.displayName;
const size = document.createElement("span");
size.className = "upload-file-size";
size.textContent = formatBytes(item.file.size);
const remove = document.createElement("button");
remove.className = "win98-button upload-file-remove";
remove.type = "button";
remove.textContent = "×";
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;
const progress = document.createElement("span");
progress.className = "upload-progress";
progress.setAttribute("aria-label", `Upload progress ${Math.round(item.file.size ? (item.loaded / item.file.size) * 100 : 0)} percent`);
const progressBar = document.createElement("span");
progressBar.className = "upload-progress-bar";
progressBar.style.width = `${item.uploaded ? 100 : item.failed ? 100 : Math.max(0, Math.min(100, item.file.size ? (item.loaded / item.file.size) * 100 : 0))}%`;
progress.append(progressBar);
row.append(icon, name, size, remove, progress);
item.row = row;
return row;
}
function renderFiles() {
if (!el.fileList) return;
el.fileList.replaceChildren();
if (!files.length) {
const empty = document.createElement("p");
empty.className = "upload-empty-state";
empty.textContent = uploadsEnabled
? "No files in the box yet. Drop files here, use File > Add files, or click the dropzone."
: "Guest uploads are disabled.";
el.fileList.append(empty);
} else {
const fragment = document.createDocumentFragment();
files.forEach((item, index) => fragment.append(createFileRow(item, index)));
el.fileList.append(fragment);
}
updateQueueSummary();
updateQuota();
updateOverallProgress();
updateTerminal();
updateDisabledReasons();
updateCurrentStep();
}
function duplicateFileReport(incoming = []) {
const used = new Set(files.map((item) => normalizedFileName(item.displayName)));
const duplicates = [];
const unique = [];
incoming.forEach((item) => {
const key = normalizedFileName(item.displayName);
if (used.has(key)) {
duplicates.push(item);
return;
}
used.add(key);
unique.push(item);
});
return { unique, duplicates };
}
function addFiles(fileList) {
if (!uploadsEnabled) {
showToast("Guest uploads are disabled.", "warning");
return;
}
if (uploadLocked) {
showToast("This box is sealed. Clear it to create a fresh upload.", "warning");
return;
}
const incoming = Array.from(fileList || []).map((file) => makeQueuedFile(file));
if (!incoming.length) return;
const { unique, duplicates } = duplicateFileReport(incoming);
if (unique.length) {
files.push(...unique);
setShareUrl("");
renderFiles();
const warning = quotaWarningMessage();
if (warning) showWarningDialog("Quota warning", warning);
}
if (duplicates.length) showDuplicateDialog(duplicates);
if (unique.length) setStatus(`${unique.length} file${unique.length === 1 ? "" : "s"} added to queue`);
if (duplicates.length && !unique.length) setStatus(`${duplicates.length} duplicate file${duplicates.length === 1 ? "" : "s"} need your choice`);
}
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>`);
showToast("Duplicate names found. Choose skip or append numbers.", "warning");
setTimeout(() => document.querySelector("#duplicate-append")?.focus(), 0);
}
function appendPendingDuplicates() {
if (!pendingDuplicateFiles.length) return;
const used = new Set(files.map((item) => normalizedFileName(item.displayName)));
pendingDuplicateFiles.forEach((item) => {
item.displayName = nextIncrementedFileName(item.displayName, used);
files.push(item);
});
const count = pendingDuplicateFiles.length;
pendingDuplicateFiles = [];
closeDoc();
setShareUrl("");
renderFiles();
showToast("Duplicate files added with numbered names.", "info");
setStatus(`${count} duplicate file${count === 1 ? "" : "s"} added with numbered names`);
}
function removeFile(index) {
if (uploadLocked) {
showToast("Box already created. Clear it before editing the queue.", "warning");
return;
}
const [removed] = files.splice(index, 1);
if (removed?.previewURL) URL.revokeObjectURL(removed.previewURL);
setShareUrl("");
renderFiles();
setStatus("File removed from queue");
}
function clearQueue() {
files.forEach((item) => {
if (item.previewURL) URL.revokeObjectURL(item.previewURL);
});
files = [];
pendingDuplicateFiles = [];
uploadLocked = false;
stopStatusAnimation();
setBoxOptionsLocked(false);
setShareUrl("");
if (el.fileInput) {
el.fileInput.value = "";
el.fileInput.disabled = !uploadsEnabled;
}
el.dropzone?.classList.remove("is-locked");
renderFiles();
setStatus(uploadsEnabled ? "Queue cleared" : "Guest uploads are disabled");
showToast("Queue cleared.");
}
function confirmClearQueue() {
if (!files.length && !shareUrl) {
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);
}
async function createBox() {
const response = await fetch("/box", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
retention_key: el.expiry?.value || defaultRetention,
password: el.password?.value || "",
allow_zip: isOneTimeDownloadSelected() || !el.allowZip || el.allowZip.checked,
files: files.map((item) => ({ name: item.displayName, size: item.file.size })),
}),
});
const result = await readJSON(response);
if (!response.ok) throw new Error(result.error || "Could not create upload box");
return result;
}
async function readJSON(response) {
try {
return await response.json();
} catch (_) {
return {};
}
}
async function markFileStatus(item, status) {
if (!item.boxID || !item.boxFile) return;
try {
await fetch(`/box/${item.boxID}/files/${item.boxFile.id}/status`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
} catch (_) {
// Best effort only. The upload endpoint also marks hard failures.
}
}
function setFileFailed(item, message) {
item.failed = true;
item.uploaded = false;
item.error = message || "Failed to upload";
item.loaded = item.file.size;
item.row?.classList.remove("is-working", "is-uploaded");
item.row?.classList.add("is-failed");
if (item.row) item.row.title = item.error;
setRowProgress(item, 100);
updateOverallProgress();
}
function uploadFile(item, onComplete) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append("file", item.file, item.displayName);
xhr.open("POST", item.boxFile.upload_path);
xhr.upload.addEventListener("loadstart", () => {
item.loaded = 0;
item.failed = false;
item.uploaded = false;
item.row?.classList.remove("is-failed", "is-uploaded");
item.row?.classList.add("is-working");
setRowProgress(item, 2);
updateOverallProgress();
});
xhr.upload.addEventListener("progress", (event) => {
if (!event.lengthComputable) return;
item.loaded = Math.min(event.loaded, item.file.size);
const percent = (event.loaded / event.total) * 100;
setRowProgress(item, percent >= 100 ? 99 : percent);
updateOverallProgress();
});
xhr.addEventListener("load", async () => {
if (xhr.status < 200 || xhr.status >= 300) {
let message = "Upload failed";
try {
message = JSON.parse(xhr.responseText).error || message;
} catch (_) {}
setFileFailed(item, message);
await markFileStatus(item, "failed");
reject(new Error(message));
return;
}
item.uploaded = true;
item.failed = false;
item.loaded = item.file.size;
item.row?.classList.remove("is-working", "is-failed");
item.row?.classList.add("is-uploaded");
if (item.row) item.row.title = "Uploaded";
setRowProgress(item, 100);
try {
const result = JSON.parse(xhr.responseText);
if (result.file) {
item.boxFile = result.file;
const icon = item.row?.querySelector(".upload-file-icon");
if (icon && result.file.thumbnail_path) {
item.row.classList.add("has-thumbnail");
icon.src = result.file.thumbnail_path;
} else if (icon && result.file.icon_path && !item.previewURL) {
icon.src = result.file.icon_path;
}
}
} catch (_) {}
updateOverallProgress();
onComplete();
resolve();
});
xhr.addEventListener("error", async () => {
setFileFailed(item, "Network error while uploading");
await markFileStatus(item, "failed");
reject(new Error("Network error while uploading"));
});
xhr.addEventListener("abort", async () => {
setFileFailed(item, "Upload cancelled");
await markFileStatus(item, "failed");
reject(new Error("Upload cancelled"));
});
markFileStatus(item, "uploading");
xhr.send(formData);
});
}
async function startUpload() {
if (!uploadsEnabled) {
showToast("Guest uploads are disabled.", "warning");
return;
}
if (uploadLocked) {
showToast("Upload already started. Press Clear to create another box.", "warning");
return;
}
if (!files.length) {
showWarningDialog("No files selected", "There are no files selected. Please select files to upload.");
showToast("No files selected. Please select files to upload.", "warning");
setStatus("No files selected");
return;
}
if (hasQuotaError()) {
showWarningDialog("Over maximum upload size", quotaWarningMessage() || "Over maximum upload size.");
showToast("Over maximum upload size.", "error");
return;
}
uploadLocked = true;
setBoxOptionsLocked(true);
if (el.fileInput) el.fileInput.disabled = true;
el.dropzone?.classList.add("is-locked");
setShareUrl("");
files.forEach((item) => {
item.loaded = 0;
item.uploaded = false;
item.failed = false;
item.error = "";
});
renderFiles();
let completedCount = 0;
const totalCount = files.length;
const statusPrefix = () => `${completedCount}/${totalCount}`;
setStatus(`${statusPrefix()} Uploading.`);
animateUploadStatus(statusPrefix);
try {
const box = await createBox();
setShareUrl(box.box_url);
files.forEach((item, index) => {
item.boxID = box.box_id;
item.boxFile = box.files[index];
item.displayName = item.boxFile?.name || item.displayName;
const icon = item.row?.querySelector(".upload-file-icon");
if (icon && item.boxFile?.thumbnail_path) {
item.row.classList.add("has-thumbnail");
icon.src = item.boxFile.thumbnail_path;
} else if (icon && item.boxFile?.icon_path && !item.previewURL) {
icon.src = item.boxFile.icon_path;
}
});
const results = await Promise.allSettled(files.map((item) => uploadFile(item, () => { completedCount += 1; })));
stopStatusAnimation();
const failedCount = results.filter((result) => result.status === "rejected").length;
if (failedCount > 0) {
setStatus(`${completedCount}/${totalCount} uploaded, ${failedCount} failed`);
showToast(`${failedCount} file${failedCount === 1 ? "" : "s"} failed. The share URL contains the successful files.`, "error");
renderFiles();
return;
}
setOverallProgress(100);
setStatus(`${completedCount}/${totalCount} uploaded. Share URL created. Press Clear to start another upload.`);
showToast("Upload complete. Share URL created.");
renderFiles();
} catch (error) {
stopStatusAnimation();
uploadLocked = false;
setBoxOptionsLocked(false);
if (el.fileInput) el.fileInput.disabled = !uploadsEnabled;
el.dropzone?.classList.remove("is-locked");
setShareUrl("");
setStatus(error.message || "Upload failed");
showToast(error.message || "Upload failed", "error");
renderFiles();
}
}
function isOneTimeDownloadSelected() {
return el.expiry?.value === oneTimeRetentionKey;
}
function syncZipForRetention() {
if (!el.allowZip) return;
if (isOneTimeDownloadSelected()) {
el.allowZip.checked = true;
el.allowZip.disabled = true;
} else if (!uploadLocked) {
el.allowZip.disabled = false;
}
}
function setBoxOptionsLocked(locked) {
const controls = [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);
el.optionsForm?.classList.toggle("is-locked", locked);
controls.forEach((control) => {
control.dataset.disabledReason = locked ? "Box Options are locked because this box was already created. Press Clear to start another upload." : "";
if (control.tagName === "INPUT" && !["checkbox", "radio", "file"].includes(control.type)) {
control.readOnly = locked;
} else {
control.disabled = locked;
}
});
if (el.password) el.password.type = locked ? "password" : "text";
if (!locked) {
syncZipForRetention();
syncApiKeyField();
}
updateDisabledReasons();
}
function updateDisabledReasons() {
if (el.startButton) {
let reason = "";
if (!uploadsEnabled) reason = "Guest uploads are disabled.";
else if (uploadLocked) reason = "This upload already started. Press Clear to create another box.";
else if (hasQuotaError()) reason = "Over maximum upload size. Remove highlighted files or clear some files.";
else if (!files.length) reason = "There are no files selected. Please select files to upload.";
el.startButton.disabled = !uploadsEnabled || uploadLocked || hasQuotaError();
el.startButton.dataset.disabledReason = reason;
el.startButton.title = reason;
}
}
function saveSettings() {
const settings = {
expiry: el.expiry?.value || defaultRetention,
password: el.password?.value || "",
maxViews: el.maxViews?.value || "",
boxName: el.boxName?.value || "",
customSlug: el.customSlug?.value || "",
downloadPage: Boolean(el.downloadPage?.checked),
allowZip: Boolean(el.allowZip?.checked),
allowPreview: Boolean(el.allowPreview?.checked),
keepFilenames: Boolean(el.keepFilenames?.checked),
privateBox: Boolean(el.privateBox?.checked),
apiKeyMode: Boolean(el.apiKeyMode?.checked),
apiKey: el.apiKeyInput?.value || "",
};
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
}
function loadSettings() {
let settings = {};
try {
settings = JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}");
} catch (_) {}
if (settings.expiry && Array.from(el.expiry?.options || []).some((option) => option.value === settings.expiry)) el.expiry.value = settings.expiry;
if (el.password) el.password.value = settings.password || "";
if (el.maxViews) el.maxViews.value = settings.maxViews || "";
if (el.boxName) el.boxName.value = settings.boxName || "";
if (el.customSlug) el.customSlug.value = settings.customSlug || "";
if (el.downloadPage) el.downloadPage.checked = settings.downloadPage !== false;
if (el.allowZip) el.allowZip.checked = settings.allowZip !== false;
if (el.allowPreview) el.allowPreview.checked = settings.allowPreview !== false;
if (el.keepFilenames) el.keepFilenames.checked = settings.keepFilenames !== false;
if (el.privateBox) el.privateBox.checked = Boolean(settings.privateBox);
if (el.apiKeyMode) el.apiKeyMode.checked = Boolean(settings.apiKeyMode);
if (el.apiKeyInput) el.apiKeyInput.value = settings.apiKey || "";
syncZipForRetention();
syncApiKeyField();
}
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 ? "✓" : "";
}
function syncApiKeyField() {
const enabled = Boolean(el.apiKeyMode?.checked) && !uploadLocked;
el.apiKeyRow?.classList.toggle("is-visible", Boolean(el.apiKeyMode?.checked));
if (el.apiKeyInput) {
el.apiKeyInput.disabled = !enabled;
el.apiKeyInput.dataset.disabledReason = enabled ? "" : "Enable Use API key for larger quota before typing an API key.";
}
validateApiKeyField();
}
function validateApiKeyField() {
if (!el.apiKeyInput || !el.apiKeyState) return;
clearTimeout(apiKeyTimer);
const wrapper = el.apiKeyInput.closest(".api-key-field");
wrapper?.classList.remove("is-checking");
if (!el.apiKeyMode?.checked) {
el.apiKeyState.textContent = "";
return;
}
const value = el.apiKeyInput.value.trim();
if (!value) {
el.apiKeyState.textContent = "waiting";
return;
}
el.apiKeyInput.disabled = true;
wrapper?.classList.add("is-checking");
el.apiKeyState.textContent = "checking";
apiKeyTimer = setTimeout(() => {
wrapper?.classList.remove("is-checking");
el.apiKeyInput.disabled = uploadLocked;
el.apiKeyState.textContent = value.length >= 12 ? "saved locally" : "too short";
if (value.length < 12) showToast("API key looks too short. It was saved locally, but not sent during browser uploads.", "warning");
}, 650);
}
function slugify(value) {
return String(value || "")
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/-+/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") {
el.customSlug.value = slugify(el.boxName.value);
el.customSlug.dataset.auto = "true";
}
saveSettings();
updateTerminal();
}
function randomPassword() {
if (!el.password || uploadLocked) return;
el.password.value = `${Math.random().toString(36).slice(2, 8)}-${Math.random().toString(36).slice(2, 6)}`;
saveSettings();
updateTerminal();
setStatus("Generated a password");
}
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)]}`;
syncSlugFromName(true);
setStatus("Generated a local box name");
}
function getCurlCommand({ full = true } = {}) {
const args = [];
const selectedFiles = files.length ? files : [{ displayName: "build.zip" }];
const previewLimit = full ? selectedFiles.length : 4;
selectedFiles.slice(0, previewLimit).forEach((item) => args.push(` -F ${shellQuote(`files=@${item.displayName}`)}`));
const hiddenFileCount = !full && selectedFiles.length > previewLimit ? selectedFiles.length - previewLimit : 0;
args.push(` -F ${shellQuote(`retention=${el.expiry?.value || defaultRetention}`)}`);
if (el.password?.value) args.push(` -F ${shellQuote("password=YOUR_PASSWORD")}`);
if (el.allowZip && !el.allowZip.checked) args.push(` -F ${shellQuote("allow_zip=false")}`);
const commandLines = ["curl"];
if (el.apiKeyMode?.checked) commandLines.push(` -H ${shellQuote("Authorization: Bearer YOUR_API_KEY")}`);
commandLines.push(...args, ` ${window.location.origin}/upload`);
const command = commandLines.join(" \\\n");
return hiddenFileCount ? `${command}\n# and ${hiddenFileCount} other files included when copying` : command;
}
function updateTerminal() {
if (!el.terminal) return;
const command = getCurlCommand({ full: false });
el.terminal.innerHTML = `<span class="terminal-muted">warpbox@cli</span>:~$ ${htmlEscape(command)}`;
}
async function copyText(kind, value, openUrl = "") {
if (!value) {
showToast(`No ${kind.toLowerCase()} yet.`, "warning");
return;
}
try {
await navigator.clipboard.writeText(value);
showToast(`${kind} copied to clipboard.`);
setStatus(`Copied ${kind.toLowerCase()}`);
} catch (_) {
showCopyFallback(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>`);
}
function quotaWarningHtml(message) {
const tooLarge = oversizedFiles();
const parts = [];
if (tooLarge.length) {
parts.push("<p class=\"quota-dialog-summary\"><strong>Single-file limit exceeded.</strong> Remove these files before uploading.</p>");
parts.push(`<ol class="quota-dialog-list">${tooLarge.map((item) => `<li><strong>${htmlEscape(item.displayName)}</strong> <span>${formatBytes(item.file.size)} / max ${formatBytes(maxFileBytes)}</span></li>`).join("")}</ol>`);
}
if (isOverBoxQuota()) {
parts.push(`<p class="quota-dialog-summary"><strong>Box quota exceeded.</strong> Current total is ${formatBytes(totalBytes())}. The limit is ${formatBytes(maxBoxBytes)}. Remove ${formatBytes(totalBytes() - maxBoxBytes)} or more.</p>`);
}
if (!parts.length) parts.push(`<p>${htmlEscape(message)}</p>`);
return parts.join("");
}
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>`);
}
function openPopup(title, html, about = false) {
if (!el.docPopup || !el.docPopupTitle || !el.docPopupBody) return;
el.docPopupTitle.textContent = title;
el.docPopupBody.innerHTML = html;
el.docPopup.classList.toggle("is-about-popup", about);
el.docPopup.classList.add("is-visible");
el.modalBackdrop?.classList.add("is-visible");
}
function closeDoc() {
el.docPopup?.classList.remove("is-visible", "is-about-popup");
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>
`,
},
};
function openDoc(name) {
const doc = docs[name];
if (!doc) return;
openPopup(doc.title, doc.html, doc.about);
setStatus(`${doc.title} opened`);
}
document.addEventListener("click", (event) => {
const menuButton = event.target.closest(".menu-button");
if (menuButton) {
const item = menuButton.closest(".menu-item");
const isOpen = item.classList.contains("is-open");
document.querySelectorAll(".menu-item.is-open").forEach((node) => {
node.classList.remove("is-open");
node.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
});
item.classList.toggle("is-open", !isOpen);
menuButton.setAttribute("aria-expanded", String(!isOpen));
return;
}
const action = event.target.closest("[data-action]")?.dataset.action;
if (action) {
document.querySelectorAll(".menu-item.is-open").forEach((node) => node.classList.remove("is-open"));
if (action === "browse") el.fileInput?.click();
if (action === "start-upload") startUpload();
if (action === "copy-link") copyText("Share URL", shareUrl, shareUrl);
if (action === "clear") confirmClearQueue();
if (action === "toggle-delete-once" && el.expiry?.querySelector(`option[value="${oneTimeRetentionKey}"]`)) {
el.expiry.value = isOneTimeDownloadSelected() ? defaultRetention : oneTimeRetentionKey;
syncZipForRetention();
saveSettings();
syncMenuChecks();
updateTerminal();
}
if (action === "random-password") randomPassword();
if (action === "random-box-name") randomBoxName();
if (action === "clear-password" && el.password && !uploadLocked) {
el.password.value = "";
saveSettings();
updateTerminal();
}
if (action === "toggle-page" && el.downloadPage && !uploadLocked) {
el.downloadPage.checked = !el.downloadPage.checked;
saveSettings();
syncMenuChecks();
}
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()}`);
return;
}
const doc = event.target.closest("[data-doc]")?.dataset.doc;
if (doc) {
openDoc(doc);
return;
}
const remove = event.target.closest("[data-remove]");
if (remove) {
removeFile(Number(remove.dataset.remove));
return;
}
if (event.target.id === "duplicate-append") appendPendingDuplicates();
if (event.target.id === "duplicate-skip") {
pendingDuplicateFiles = [];
closeDoc();
showToast("Duplicate files skipped.");
}
if (event.target.id === "confirm-clear-yes") {
closeDoc();
clearQueue();
}
if (event.target.id === "confirm-clear-no" || event.target.id === "fallback-close") closeDoc();
if (!event.target.closest(".menu-item")) {
document.querySelectorAll(".menu-item.is-open").forEach((node) => {
node.classList.remove("is-open");
node.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
});
}
});
el.fileInput?.addEventListener("change", () => addFiles(el.fileInput.files));
[el.dropSurface, el.dropzone].filter(Boolean).forEach((target) => {
target.addEventListener("dragover", (event) => {
event.preventDefault();
el.dropzone?.classList.add("is-dragging");
});
target.addEventListener("dragleave", () => el.dropzone?.classList.remove("is-dragging"));
target.addEventListener("drop", (event) => {
event.preventDefault();
el.dropzone?.classList.remove("is-dragging");
addFiles(event.dataTransfer.files);
});
});
el.dropzone?.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
el.fileInput?.click();
}
});
el.form?.addEventListener("submit", (event) => {
event.preventDefault();
startUpload();
});
el.copyButton?.addEventListener("click", () => copyText("Share URL", shareUrl, shareUrl));
el.copyCurlButton?.addEventListener("click", () => copyText("cURL command", getCurlCommand({ full: true })));
el.docPopupClose?.addEventListener("click", closeDoc);
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.apiKeyInput) validateApiKeyField();
saveSettings();
updateTerminal();
});
control.addEventListener("change", () => {
if (control === el.expiry) syncZipForRetention();
if (control === el.apiKeyMode) syncApiKeyField();
saveSettings();
syncMenuChecks();
updateTerminal();
});
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closeDoc();
document.querySelectorAll(".menu-item.is-open").forEach((node) => node.classList.remove("is-open"));
}
if (event.key === "F1") {
event.preventDefault();
openDoc("faq");
}
if (event.ctrlKey && !event.shiftKey && !event.altKey) {
const key = event.key.toLowerCase();
if (key === "o") {
event.preventDefault();
el.fileInput?.click();
}
if (key === "u") {
event.preventDefault();
startUpload();
}
if (key === "k") {
event.preventDefault();
copyText("cURL command", getCurlCommand({ full: true }));
}
if (key === "l") {
event.preventDefault();
copyText("Share URL", shareUrl, shareUrl);
}
}
});
window.addEventListener("beforeunload", () => {
files.forEach((item) => {
if (item.previewURL) URL.revokeObjectURL(item.previewURL);
});
});
loadSettings();
updateLimitHint();
syncMenuChecks();
renderFiles();
updateTerminal();