feat(ui): add clear queue flow and expose ISO expiry
- Add `formatBrowserTime()` and include ISO-8601 `expires_at` in box status JSON and `ExpiresAtISO` in the box view for browser-friendly rendering. - Refresh UI styling (switch to MonoCraft/PixelOperatorMono, tweak base font size) and treat `aria-disabled="true"` like `disabled` for consistent button states. - Introduce a clear-queue action with confirmation to reset upload state, unlock controls, and provide user feedback.feat(ui): add clear queue flow and expose ISO expiry - Add `formatBrowserTime()` and include ISO-8601 `expires_at` in box status JSON and `ExpiresAtISO` in the box view for browser-friendly rendering. - Refresh UI styling (switch to MonoCraft/PixelOperatorMono, tweak base font size) and treat `aria-disabled="true"` like `disabled` for consistent button states. - Introduce a clear-queue action with confirmation to reset upload state, unlock controls, and provide user feedback.
This commit is contained in:
160
static/js/app.js
160
static/js/app.js
@@ -171,17 +171,25 @@ function setStatus(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);
|
||||
window.WarpBoxUI.toast(message, type, { target: el.toast });
|
||||
}
|
||||
|
||||
function closeMenus() {
|
||||
document.querySelectorAll(".menu-item.is-open").forEach((node) => {
|
||||
node.classList.remove("is-open");
|
||||
node.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
|
||||
});
|
||||
}
|
||||
|
||||
function disabledReasonFor(target) {
|
||||
const control = target.closest("[data-disabled-reason], button, input, select, textarea, .upload-dropzone");
|
||||
const control = target.closest("[data-disabled-reason], button, input, select, textarea, .upload-dropzone, .option-check, .option-row");
|
||||
if (!control) return "";
|
||||
if (control.classList.contains("option-check") || control.classList.contains("option-row")) {
|
||||
const nested = control.querySelector("input, select, textarea");
|
||||
if (nested?.disabled || nested?.readOnly || nested?.getAttribute("aria-disabled") === "true") {
|
||||
return nested.dataset.disabledReason || "This option is disabled right now.";
|
||||
}
|
||||
}
|
||||
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.";
|
||||
}
|
||||
@@ -196,6 +204,7 @@ function announceDisabledReason(event) {
|
||||
if (!reason) return false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeMenus();
|
||||
showToast(reason, "warning");
|
||||
setStatus(reason);
|
||||
return true;
|
||||
@@ -225,8 +234,10 @@ function setShareUrl(url) {
|
||||
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.disabled = false;
|
||||
el.copyButton.setAttribute("aria-disabled", shareUrl ? "false" : "true");
|
||||
el.copyButton.dataset.disabledReason = shareUrl ? "" : "There is no share URL yet. Start an upload first.";
|
||||
updateDisabledReasons();
|
||||
updateTerminal();
|
||||
updateCurrentStep();
|
||||
}
|
||||
@@ -345,7 +356,8 @@ function createFileRow(item, index) {
|
||||
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;
|
||||
remove.disabled = false;
|
||||
remove.setAttribute("aria-disabled", uploadLocked ? "true" : "false");
|
||||
remove.dataset.disabledReason = uploadLocked ? "Files cannot be removed after the box is created. Press Clear to start another upload." : "";
|
||||
|
||||
const progress = document.createElement("span");
|
||||
@@ -762,7 +774,8 @@ function updateDisabledReasons() {
|
||||
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.disabled = false;
|
||||
el.startButton.setAttribute("aria-disabled", reason ? "true" : "false");
|
||||
el.startButton.dataset.disabledReason = reason;
|
||||
el.startButton.title = reason;
|
||||
}
|
||||
@@ -772,22 +785,31 @@ function updateDisabledReasons() {
|
||||
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." : "");
|
||||
}
|
||||
document.querySelectorAll('[data-action="start-upload"]').forEach((button) => {
|
||||
const reason = el.startButton?.dataset.disabledReason || "";
|
||||
button.setAttribute("aria-disabled", reason ? "true" : "false");
|
||||
button.dataset.disabledReason = reason;
|
||||
});
|
||||
document.querySelectorAll('[data-action="browse"]').forEach((button) => {
|
||||
const reason = uploadLocked ? "The current box is sealed after upload. Press Clear to start a new box." : (!uploadsEnabled ? "Guest uploads are disabled." : "");
|
||||
button.setAttribute("aria-disabled", reason ? "true" : "false");
|
||||
button.dataset.disabledReason = reason;
|
||||
});
|
||||
document.querySelectorAll('[data-action="copy-link"]').forEach((button) => {
|
||||
button.setAttribute("aria-disabled", shareUrl ? "false" : "true");
|
||||
button.dataset.disabledReason = shareUrl ? "" : "There is no share URL yet. Start an upload first.";
|
||||
});
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
const apiKey = el.apiKeyMode?.checked && validApiKey(el.apiKeyInput?.value || "") ? el.apiKeyInput.value.trim() : "";
|
||||
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 || "",
|
||||
apiKey,
|
||||
};
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
}
|
||||
@@ -797,25 +819,19 @@ function loadSettings() {
|
||||
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 || "";
|
||||
if (el.apiKeyInput) el.apiKeyInput.value = validApiKey(settings.apiKey || "") ? settings.apiKey : "";
|
||||
syncZipForRetention();
|
||||
syncApiKeyField();
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function syncMenuChecks() {
|
||||
const downloadCheck = document.querySelector("[data-download-page-check]");
|
||||
if (downloadCheck) downloadCheck.textContent = el.downloadPage?.checked ? "✓" : "";
|
||||
updateDisabledReasons();
|
||||
}
|
||||
|
||||
function syncApiKeyField() {
|
||||
@@ -841,6 +857,7 @@ function validateApiKeyField() {
|
||||
const value = el.apiKeyInput.value.trim();
|
||||
if (!value) {
|
||||
el.apiKeyState.textContent = "waiting";
|
||||
saveSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -850,11 +867,22 @@ function validateApiKeyField() {
|
||||
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");
|
||||
if (validApiKey(value)) {
|
||||
el.apiKeyState.textContent = "saved locally";
|
||||
saveSettings();
|
||||
} else {
|
||||
el.apiKeyInput.value = "";
|
||||
el.apiKeyState.textContent = "invalid";
|
||||
saveSettings();
|
||||
showToast("Invalid API key removed. Paste a valid API key to save it.", "warning");
|
||||
}
|
||||
}, 650);
|
||||
}
|
||||
|
||||
function validApiKey(value) {
|
||||
return /^[A-Za-z0-9._-]{12,}$/.test(String(value || "").trim());
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return String(value || "")
|
||||
.toLowerCase()
|
||||
@@ -966,17 +994,17 @@ function showWarningDialog(title, message) {
|
||||
}
|
||||
|
||||
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");
|
||||
window.WarpBoxUI.openPopup(title, html, {
|
||||
about,
|
||||
popup: el.docPopup,
|
||||
title: el.docPopupTitle,
|
||||
body: el.docPopupBody,
|
||||
backdrop: el.modalBackdrop,
|
||||
});
|
||||
}
|
||||
|
||||
function closeDoc() {
|
||||
el.docPopup?.classList.remove("is-visible", "is-about-popup");
|
||||
el.modalBackdrop?.classList.remove("is-visible");
|
||||
window.WarpBoxUI.closePopup({ popup: el.docPopup, backdrop: el.modalBackdrop });
|
||||
}
|
||||
|
||||
async function showTemplatePopup(title, templateName, data = {}, about = false) {
|
||||
@@ -1018,10 +1046,7 @@ document.addEventListener("click", (event) => {
|
||||
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");
|
||||
});
|
||||
closeMenus();
|
||||
item.classList.toggle("is-open", !isOpen);
|
||||
menuButton.setAttribute("aria-expanded", String(!isOpen));
|
||||
return;
|
||||
@@ -1029,7 +1054,7 @@ document.addEventListener("click", (event) => {
|
||||
|
||||
const action = event.target.closest("[data-action]")?.dataset.action;
|
||||
if (action) {
|
||||
document.querySelectorAll(".menu-item.is-open").forEach((node) => node.classList.remove("is-open"));
|
||||
closeMenus();
|
||||
if (action === "browse") el.fileInput?.click();
|
||||
if (action === "start-upload") startUpload();
|
||||
if (action === "copy-link") copyText("Share URL", shareUrl, shareUrl);
|
||||
@@ -1054,7 +1079,6 @@ document.addEventListener("click", (event) => {
|
||||
syncMenuChecks();
|
||||
}
|
||||
if (action === "help" || action === "side-help") openDoc("faq");
|
||||
if (action === "terminal-help") el.terminal?.focus();
|
||||
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.");
|
||||
@@ -1093,10 +1117,7 @@ document.addEventListener("click", (event) => {
|
||||
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");
|
||||
});
|
||||
closeMenus();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1107,10 +1128,7 @@ document.addEventListener("mousedown", (event) => {
|
||||
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");
|
||||
});
|
||||
closeMenus();
|
||||
item.classList.add("is-open");
|
||||
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "true");
|
||||
});
|
||||
@@ -1148,6 +1166,46 @@ el.copyCurlButton?.addEventListener("click", () => copyText("cURL command", getC
|
||||
el.docPopupClose?.addEventListener("click", closeDoc);
|
||||
el.modalBackdrop?.addEventListener("click", closeDoc);
|
||||
|
||||
el.maxViews?.addEventListener("wheel", (event) => {
|
||||
if (el.maxViews.disabled || el.maxViews.readOnly) return;
|
||||
event.preventDefault();
|
||||
const delta = event.deltaY < 0 ? 1 : -1;
|
||||
const modifier = event.ctrlKey && event.shiftKey ? 50 : event.shiftKey ? 15 : event.ctrlKey ? 5 : 1;
|
||||
const min = Number.parseInt(el.maxViews.min || "1", 10);
|
||||
const max = Number.parseInt(el.maxViews.max || "9999", 10);
|
||||
const current = Number.parseInt(el.maxViews.value || String(min), 10);
|
||||
el.maxViews.value = String(Math.max(min, Math.min(max, current + (delta * modifier))));
|
||||
saveSettings();
|
||||
updateTerminal();
|
||||
});
|
||||
|
||||
el.apiKeyInput?.addEventListener("keydown", (event) => {
|
||||
const allowed = event.ctrlKey || event.metaKey || event.altKey || [
|
||||
"Tab",
|
||||
"Shift",
|
||||
"Control",
|
||||
"Alt",
|
||||
"Meta",
|
||||
"Escape",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"Home",
|
||||
"End",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
].includes(event.key);
|
||||
if (allowed) return;
|
||||
event.preventDefault();
|
||||
showToast("Only pasting the API key is supported.", "warning");
|
||||
setStatus("Only pasting the API key is supported");
|
||||
});
|
||||
|
||||
el.apiKeyInput?.addEventListener("paste", () => {
|
||||
setTimeout(validateApiKeyField, 0);
|
||||
});
|
||||
|
||||
[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();
|
||||
@@ -1172,7 +1230,7 @@ el.modalBackdrop?.addEventListener("click", closeDoc);
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
closeDoc();
|
||||
document.querySelectorAll(".menu-item.is-open").forEach((node) => node.classList.remove("is-open"));
|
||||
closeMenus();
|
||||
}
|
||||
if (event.key === "F1") {
|
||||
event.preventDefault();
|
||||
|
||||
Reference in New Issue
Block a user