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:
274
static/js/box.js
274
static/js/box.js
@@ -1,20 +1,182 @@
|
||||
const boxPanel = document.querySelector(".box-panel[data-box-id]");
|
||||
const boxStatus = document.querySelector(".box-statusbar span:first-child");
|
||||
const boxAddress = document.querySelector("#box-address");
|
||||
const boxExpiryMeta = document.querySelector(".box-meta[data-expires-at]");
|
||||
const boxExpiryText = document.querySelector("#box-expiry-text");
|
||||
const contextMenu = document.querySelector("#box-context-menu");
|
||||
const docPopup = document.querySelector("#doc-popup");
|
||||
const docPopupTitle = document.querySelector("#doc-popup-title");
|
||||
const docPopupBody = document.querySelector("#doc-popup-body");
|
||||
const docPopupClose = document.querySelector("#doc-popup-close");
|
||||
const modalBackdrop = document.querySelector("#modal-backdrop");
|
||||
const toast = document.querySelector("#toast");
|
||||
const zipOnly = boxPanel && boxPanel.dataset.zipOnly === "true";
|
||||
|
||||
document.querySelectorAll('.box-file[aria-disabled="true"]').forEach((item) => {
|
||||
item.addEventListener("click", (event) => {
|
||||
if (item.getAttribute("aria-disabled") === "true") {
|
||||
event.preventDefault();
|
||||
}
|
||||
let contextFile = null;
|
||||
|
||||
function htmlEscape(value) {
|
||||
return String(value || "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function showToast(message, type = "info") {
|
||||
window.WarpBoxUI.toast(message, type, { target: toast });
|
||||
}
|
||||
|
||||
function openPopup(title, html, options = {}) {
|
||||
window.WarpBoxUI.openPopup(title, html, {
|
||||
...options,
|
||||
popup: docPopup,
|
||||
title: docPopupTitle,
|
||||
body: docPopupBody,
|
||||
backdrop: modalBackdrop,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function closePopup() {
|
||||
window.WarpBoxUI.closePopup({ popup: docPopup, backdrop: modalBackdrop });
|
||||
}
|
||||
|
||||
function currentExpiryDate() {
|
||||
const value = boxExpiryMeta?.dataset.expiresAt || "";
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function formatDuration(ms) {
|
||||
if (ms <= 0) return "expired";
|
||||
const totalSeconds = Math.ceil(ms / 1000);
|
||||
const days = Math.floor(totalSeconds / 86400);
|
||||
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (days) return `${days}d ${hours}h ${minutes}m`;
|
||||
if (hours) return `${hours}h ${minutes}m ${seconds}s`;
|
||||
if (minutes) return `${minutes}m ${seconds}s`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function updateExpiryCountdown() {
|
||||
if (!boxExpiryText || !boxExpiryMeta) return;
|
||||
const expiry = currentExpiryDate();
|
||||
if (!expiry) {
|
||||
boxExpiryText.textContent = "Expires after one-time download";
|
||||
return;
|
||||
}
|
||||
boxExpiryText.textContent = `Expires in ${formatDuration(expiry.getTime() - Date.now())}`;
|
||||
boxExpiryText.title = `Expires at ${expiry.toLocaleString()}`;
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenu?.classList.remove("is-visible");
|
||||
contextMenu?.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
function showContextMenu(file, x, y) {
|
||||
if (!contextMenu) return;
|
||||
contextFile = file;
|
||||
contextMenu.style.left = `${Math.min(x, window.innerWidth - 190)}px`;
|
||||
contextMenu.style.top = `${Math.min(y, window.innerHeight - 98)}px`;
|
||||
contextMenu.classList.add("is-visible");
|
||||
contextMenu.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
function fileData(item) {
|
||||
return {
|
||||
id: item.dataset.fileId || "",
|
||||
name: item.dataset.name || item.querySelector(".box-file-name")?.textContent || "",
|
||||
size: item.dataset.size || "",
|
||||
mime: item.dataset.mime || "",
|
||||
status: item.dataset.status || "",
|
||||
statusLabel: item.querySelector(".box-file-meta")?.textContent || "",
|
||||
downloadPath: item.dataset.downloadPath || item.getAttribute("href") || "",
|
||||
thumbnail: item.dataset.thumbnail || "",
|
||||
canDownload: item.getAttribute("aria-disabled") !== "true" && item.getAttribute("href") !== "#",
|
||||
};
|
||||
}
|
||||
|
||||
function downloadFile(item) {
|
||||
const data = fileData(item);
|
||||
if (!data.canDownload) {
|
||||
showToast(zipOnly ? "Individual file downloads are disabled for one-time boxes. Use Download Zip." : "This file is not ready for download yet.", "warning");
|
||||
return;
|
||||
}
|
||||
window.location.href = data.downloadPath;
|
||||
setTimeout(refreshBoxStatus, 900);
|
||||
}
|
||||
|
||||
function previewURL(data) {
|
||||
return data.canDownload ? data.downloadPath : "";
|
||||
}
|
||||
|
||||
async function previewFile(item) {
|
||||
const data = fileData(item);
|
||||
if (zipOnly) {
|
||||
showToast("Previews are disabled for one-time boxes. Use Download Zip.", "warning");
|
||||
return;
|
||||
}
|
||||
const url = previewURL(data);
|
||||
if (!url) {
|
||||
showToast("This file is not ready to preview yet.", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const mime = data.mime.toLowerCase();
|
||||
const name = htmlEscape(data.name);
|
||||
if (mime.startsWith("image/")) {
|
||||
openPopup(`${data.name} preview`, `<img class="preview-frame" src="${htmlEscape(url)}" alt="${name}">`, { preview: true });
|
||||
return;
|
||||
}
|
||||
if (mime.startsWith("video/")) {
|
||||
openPopup(`${data.name} preview`, `<video class="preview-frame" src="${htmlEscape(url)}" controls></video>`, { preview: true });
|
||||
return;
|
||||
}
|
||||
if (mime.startsWith("audio/")) {
|
||||
openPopup(`${data.name} preview`, `<audio class="preview-frame" src="${htmlEscape(url)}" controls></audio>`, { preview: true });
|
||||
return;
|
||||
}
|
||||
if (mime === "application/pdf") {
|
||||
openPopup(`${data.name} preview`, `<iframe class="preview-frame" src="${htmlEscape(url)}" title="${name}"></iframe>`, { preview: true });
|
||||
return;
|
||||
}
|
||||
if (mime.startsWith("text/") || /\.(txt|md|json|csv|log|html|css|js)$/i.test(data.name)) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error("Preview failed");
|
||||
const text = await response.text();
|
||||
openPopup(`${data.name} preview`, `<code class="code-block preview-frame is-text">${htmlEscape(text.slice(0, 120000))}</code>`, { preview: true });
|
||||
} catch (_) {
|
||||
showToast("The browser could not load a text preview.", "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
showToast("This file type cannot be previewed in the browser.", "warning");
|
||||
}
|
||||
|
||||
function showProperties(item) {
|
||||
const data = fileData(item);
|
||||
const url = data.downloadPath ? new URL(data.downloadPath, window.location.origin).toString() : "Not ready";
|
||||
openPopup(`${data.name} Properties`, `
|
||||
<h3>${htmlEscape(data.name)}</h3>
|
||||
<dl class="properties-grid">
|
||||
<dt>Name</dt><dd>${htmlEscape(data.name)}</dd>
|
||||
<dt>Size</dt><dd>${htmlEscape(data.size || "Unknown")}</dd>
|
||||
<dt>Type</dt><dd>${htmlEscape(data.mime || "Unknown")}</dd>
|
||||
<dt>Status</dt><dd>${htmlEscape(data.statusLabel || data.status || "Unknown")}</dd>
|
||||
<dt>File ID</dt><dd>${htmlEscape(data.id)}</dd>
|
||||
<dt>Location</dt><dd>${htmlEscape(url)}</dd>
|
||||
</dl>
|
||||
`, { properties: true });
|
||||
}
|
||||
|
||||
function updateBoxFile(file) {
|
||||
const item = document.querySelector(`.box-file[data-file-id="${file.id}"]`);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
if (!item) return;
|
||||
|
||||
const meta = item.querySelector(".box-file-meta");
|
||||
const icon = item.querySelector(".box-file-icon");
|
||||
@@ -26,6 +188,11 @@ function updateBoxFile(file) {
|
||||
item.classList.toggle("is-loading", !isComplete && !isFailed);
|
||||
item.classList.toggle("has-thumbnail", Boolean(file.thumbnail_path));
|
||||
item.dataset.status = file.status;
|
||||
item.dataset.name = file.name || item.dataset.name || "";
|
||||
item.dataset.size = file.size_label || item.dataset.size || "";
|
||||
item.dataset.mime = file.mime_type || item.dataset.mime || "";
|
||||
item.dataset.downloadPath = file.download_path || item.dataset.downloadPath || "";
|
||||
item.dataset.thumbnail = file.thumbnail_path || "";
|
||||
item.title = file.title;
|
||||
|
||||
if (isComplete && !zipOnly) {
|
||||
@@ -38,27 +205,22 @@ function updateBoxFile(file) {
|
||||
item.setAttribute("aria-disabled", "true");
|
||||
}
|
||||
|
||||
if (meta) {
|
||||
meta.textContent = `${file.status_label} · ${file.size_label}`;
|
||||
}
|
||||
|
||||
if (icon) {
|
||||
icon.src = file.thumbnail_path || file.icon_path;
|
||||
}
|
||||
if (meta) meta.textContent = `${file.status_label} · ${file.size_label}`;
|
||||
if (icon) icon.src = file.thumbnail_path || file.icon_path;
|
||||
}
|
||||
|
||||
async function refreshBoxStatus() {
|
||||
if (!boxPanel) {
|
||||
return false;
|
||||
}
|
||||
if (!boxPanel) return false;
|
||||
|
||||
const boxID = boxPanel.dataset.boxId;
|
||||
const response = await fetch(`/box/${boxID}/status`);
|
||||
if (!response.ok) {
|
||||
return true;
|
||||
}
|
||||
if (!response.ok) return true;
|
||||
|
||||
const result = await response.json();
|
||||
if (boxExpiryMeta && typeof result.expires_at === "string") {
|
||||
boxExpiryMeta.dataset.expiresAt = result.expires_at;
|
||||
updateExpiryCountdown();
|
||||
}
|
||||
result.files.forEach(updateBoxFile);
|
||||
|
||||
if (boxStatus) {
|
||||
@@ -73,17 +235,73 @@ async function refreshBoxStatus() {
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const action = event.target.closest("[data-action]")?.dataset.action;
|
||||
if (action === "fake-close") showToast("Close clicked. The download window is emotionally attached.", "warning");
|
||||
if (action === "minimize") showToast("Minimize clicked. WarpBox refuses to disappear quietly.");
|
||||
if (action === "toggle-fit") {
|
||||
document.body.classList.toggle("fit-window");
|
||||
showToast("Maximize clicked. The window is doing its best.");
|
||||
}
|
||||
|
||||
const contextAction = event.target.closest("[data-context-action]")?.dataset.contextAction;
|
||||
if (contextAction && contextFile) {
|
||||
event.preventDefault();
|
||||
const item = contextFile;
|
||||
closeContextMenu();
|
||||
if (contextAction === "preview") previewFile(item);
|
||||
if (contextAction === "download") downloadFile(item);
|
||||
if (contextAction === "properties") showProperties(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.target.closest("#box-context-menu")) closeContextMenu();
|
||||
});
|
||||
|
||||
document.querySelectorAll(".box-file").forEach((item) => {
|
||||
item.addEventListener("click", (event) => {
|
||||
if (item.getAttribute("aria-disabled") === "true") {
|
||||
event.preventDefault();
|
||||
showToast(zipOnly ? "Individual file downloads are disabled for one-time boxes. Use Download Zip." : "This file is not ready yet.", "warning");
|
||||
return;
|
||||
}
|
||||
setTimeout(refreshBoxStatus, 900);
|
||||
});
|
||||
item.addEventListener("contextmenu", (event) => {
|
||||
event.preventDefault();
|
||||
showContextMenu(item, event.clientX, event.clientY);
|
||||
});
|
||||
});
|
||||
|
||||
boxAddress?.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href);
|
||||
showToast("Current box URL copied.");
|
||||
} catch (_) {
|
||||
openPopup("Copy box URL", `<p>Clipboard access failed. Copy this URL manually.</p><textarea class="copy-fallback-text" readonly>${htmlEscape(window.location.href)}</textarea>`);
|
||||
}
|
||||
});
|
||||
|
||||
docPopupClose?.addEventListener("click", closePopup);
|
||||
modalBackdrop?.addEventListener("click", closePopup);
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
closePopup();
|
||||
closeContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
updateExpiryCountdown();
|
||||
setInterval(updateExpiryCountdown, 1000);
|
||||
|
||||
if (boxPanel) {
|
||||
const pollMS = Number.parseInt(boxPanel.dataset.pollMs, 10) || 5000;
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const hasLoadingFiles = await refreshBoxStatus();
|
||||
if (!hasLoadingFiles) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
} catch (error) {
|
||||
// Keep polling through temporary network/server hiccups; otherwise
|
||||
// an in-progress file can appear stuck forever after one bad poll.
|
||||
if (!hasLoadingFiles) clearInterval(timer);
|
||||
} catch (_) {
|
||||
// Keep polling through temporary network/server hiccups.
|
||||
}
|
||||
}, pollMS);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user