Files
warpbox-dev/backend/static/js/app.js
Daniel Legt 26619bacbc feat: add admin console, cleanup, and thumbnail workers
- Implement a token-authenticated admin console at `/admin` with overview metrics and file management.
- Add a background worker to periodically clean up expired boxes based on `WARPBOX_CLEANUP_EVERY`.
- Add a background worker to generate image and video thumbnails based on `WARPBOX_THUMBNAIL_EVERY`.
- Update file storage paths to use `@each@` and `@thumb@` prefixes to separate original files from thumbnails.
- Add severity fields to startup logs and update configuration templates.
2026-05-25 16:52:57 +03:00

304 lines
9.4 KiB
JavaScript

(function () {
const form = document.querySelector("#upload-form");
const dropZone = document.querySelector(".drop-zone");
const fileInput = document.querySelector("#file-input");
const fileSummary = document.querySelector("#file-summary");
const progress = document.querySelector("#upload-progress");
const uploadStatus = document.querySelector("#upload-status");
const result = document.querySelector("#upload-result");
const resultMeta = document.querySelector("#result-meta");
const resultList = document.querySelector("#result-list");
const uploadQueue = document.querySelector("#upload-queue");
const totalProgressBar = document.querySelector("#total-progress-bar");
const copyURL = document.querySelector("#copy-url");
const openBox = document.querySelector("#open-box");
const fileBrowser = document.querySelector("[data-file-browser]");
const viewButtons = document.querySelectorAll("[data-view-button]");
const previewImages = document.querySelector("[data-preview-images]");
if (fileBrowser) {
viewButtons.forEach((button) => {
button.addEventListener("click", () => {
const view = button.getAttribute("data-view-button");
fileBrowser.classList.toggle("is-list", view === "list");
fileBrowser.classList.toggle("is-thumbs", view === "thumbs");
viewButtons.forEach((item) => item.classList.toggle("is-active", item === button));
});
});
if (previewImages) {
previewImages.addEventListener("click", () => {
fileBrowser.classList.toggle("images-only");
previewImages.classList.toggle("is-active");
});
}
}
if (!form || !dropZone || !fileInput) {
return;
}
let latestBoxURL = "";
let selectedFiles = [];
["dragenter", "dragover"].forEach((eventName) => {
dropZone.addEventListener(eventName, (event) => {
event.preventDefault();
dropZone.classList.add("is-dragging");
});
});
["dragleave", "drop"].forEach((eventName) => {
dropZone.addEventListener(eventName, (event) => {
event.preventDefault();
dropZone.classList.remove("is-dragging");
});
});
dropZone.addEventListener("drop", (event) => {
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
fileInput.files = event.dataTransfer.files;
updateSelectedState(event.dataTransfer.files);
}
});
fileInput.addEventListener("change", () => {
updateSelectedState(fileInput.files);
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (!fileInput.files || fileInput.files.length === 0) {
updateStatus("Choose at least one file first.");
return;
}
const submit = form.querySelector("button[type='submit']");
const formData = new FormData(form);
selectedFiles = Array.from(fileInput.files);
renderQueue(selectedFiles, "queued");
setLoading(true, submit);
try {
const payload = await uploadWithProgress(form.action, formData, selectedFiles);
renderResult(payload);
form.reset();
updateSelectedState([]);
} catch (error) {
updateStatus(error.message || "Upload failed");
} finally {
setLoading(false, submit);
}
});
if (copyURL) {
copyURL.addEventListener("click", () => {
copyText(latestBoxURL, copyURL, "Copied");
});
}
function updateSelectedState(files) {
selectedFiles = Array.from(files || []);
const count = selectedFiles.length || 0;
const title = dropZone.querySelector(".drop-title");
if (title) {
title.textContent = count === 0 ? "Drop files to upload" : count === 1 ? "1 file selected" : `${count} files selected`;
}
if (fileSummary) {
fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`;
}
if (count > 0) {
renderQueue(selectedFiles, "queued");
} else if (uploadQueue) {
uploadQueue.hidden = true;
uploadQueue.replaceChildren();
}
}
function setLoading(isLoading, submit) {
if (progress) {
progress.hidden = !isLoading;
}
if (submit) {
submit.disabled = isLoading;
submit.textContent = isLoading ? "Uploading..." : "Upload files";
}
updateStatus(isLoading ? "Transferring files..." : "");
setTotalProgress(isLoading ? 0 : 100);
}
function updateStatus(message) {
if (uploadStatus) {
uploadStatus.textContent = message;
}
}
function renderResult(payload) {
if (!result || !resultList || !resultMeta || !openBox) {
return;
}
latestBoxURL = payload.boxUrl;
result.hidden = false;
openBox.href = payload.boxUrl;
resultMeta.textContent = `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} · expires ${formatDate(payload.expiresAt)}`;
resultList.replaceChildren();
payload.files.forEach((file) => {
resultList.append(createFileRow({
name: file.name,
meta: `${file.size} · ${file.url}`,
progress: 100,
status: "complete",
}));
});
}
function uploadWithProgress(url, formData, files) {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("POST", url);
request.setRequestHeader("Accept", "application/json");
request.upload.addEventListener("progress", (event) => {
if (!event.lengthComputable) {
updateStatus("Uploading...");
return;
}
const percent = Math.round((event.loaded / event.total) * 100);
updateStatus(`${percent}%`);
setTotalProgress(percent);
setFileProgress(files, percent);
});
request.addEventListener("load", () => {
let payload = {};
try {
payload = JSON.parse(request.responseText || "{}");
} catch (error) {
reject(new Error("Upload response could not be read"));
return;
}
if (request.status < 200 || request.status >= 300) {
reject(new Error(payload.error || "Upload failed"));
return;
}
setTotalProgress(100);
setFileProgress(files, 100);
resolve(payload);
});
request.addEventListener("error", () => reject(new Error("Network error during upload")));
request.addEventListener("abort", () => reject(new Error("Upload aborted")));
request.send(formData);
});
}
function renderQueue(files, status) {
if (!uploadQueue) {
return;
}
uploadQueue.hidden = files.length === 0;
uploadQueue.replaceChildren();
files.forEach((file) => {
uploadQueue.append(createFileRow({
name: file.name,
meta: formatBytes(file.size),
progress: status === "queued" ? 0 : 100,
status,
}));
});
}
function createFileRow(file) {
const row = document.createElement("div");
row.className = "result-item upload-file-row";
row.dataset.fileName = file.name;
const body = document.createElement("span");
const name = document.createElement("strong");
name.textContent = file.name;
const meta = document.createElement("code");
meta.textContent = file.meta;
body.append(name, meta);
const side = document.createElement("div");
side.className = "file-progress-side";
const percent = document.createElement("span");
percent.className = "file-progress-percent";
percent.textContent = `${file.progress}%`;
const bar = document.createElement("div");
bar.className = "progress file-progress";
const fill = document.createElement("span");
fill.style.transform = `scaleX(${file.progress / 100})`;
bar.append(fill);
side.append(percent, bar);
row.append(body, side);
return row;
}
function setTotalProgress(percent) {
if (totalProgressBar) {
totalProgressBar.style.transform = `scaleX(${Math.max(0, Math.min(100, percent)) / 100})`;
}
}
function setFileProgress(files, totalPercent) {
if (!uploadQueue) {
return;
}
const count = files.length || 1;
const completedFloat = (Math.max(0, Math.min(100, totalPercent)) / 100) * count;
uploadQueue.querySelectorAll(".upload-file-row").forEach((row, index) => {
const progress = Math.max(0, Math.min(100, Math.round((completedFloat - index) * 100)));
const percent = row.querySelector(".file-progress-percent");
const fill = row.querySelector(".file-progress span");
if (percent) {
percent.textContent = `${progress}%`;
}
if (fill) {
fill.style.transform = `scaleX(${progress / 100})`;
}
});
}
async function copyText(text, button, copiedLabel) {
if (!text) {
return;
}
await navigator.clipboard.writeText(text);
const previous = button.textContent;
button.textContent = copiedLabel;
setTimeout(() => {
button.textContent = previous;
}, 1400);
}
function formatDate(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function formatBytes(bytes) {
if (bytes < 1024) {
return `${bytes} B`;
}
const units = ["KiB", "MiB", "GiB", "TiB"];
let value = bytes / 1024;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(1)} ${units[unit]}`;
}
})();