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.
This commit is contained in:
@@ -240,11 +240,27 @@ h1 {
|
||||
|
||||
.option-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.checkbox-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.checkbox-field input {
|
||||
width: 1rem;
|
||||
min-height: 1rem;
|
||||
}
|
||||
|
||||
.checkbox-field span {
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
label span {
|
||||
display: block;
|
||||
margin-bottom: 0.4rem;
|
||||
@@ -334,6 +350,16 @@ button {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.button-danger {
|
||||
border-color: rgba(248, 113, 113, 0.28);
|
||||
background: rgba(127, 29, 29, 0.3);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.button-danger:hover {
|
||||
background: rgba(127, 29, 29, 0.55);
|
||||
}
|
||||
|
||||
.button-wide {
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
@@ -365,7 +391,8 @@ button {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
transform-origin: left center;
|
||||
animation: progress-pulse 1.1s ease-in-out infinite;
|
||||
transform: scaleX(0);
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.upload-result {
|
||||
@@ -396,6 +423,10 @@ button {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.upload-queue {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.result-item,
|
||||
.download-item {
|
||||
display: flex;
|
||||
@@ -422,6 +453,23 @@ code {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-progress-side {
|
||||
width: min(10rem, 32vw);
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.file-progress-percent {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.75rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.file-progress {
|
||||
height: 0.35rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.result-item small,
|
||||
.download-item small,
|
||||
code {
|
||||
@@ -443,6 +491,10 @@ code {
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.download-view-wide {
|
||||
width: min(58rem, calc(100% - 2rem));
|
||||
}
|
||||
|
||||
.download-card {
|
||||
text-align: center;
|
||||
}
|
||||
@@ -489,6 +541,103 @@ code {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.view-toolbar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.button.is-active {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.file-browser {
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.file-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.thumb-link {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
flex: 0 0 4.75rem;
|
||||
width: 4.75rem;
|
||||
aspect-ratio: 16 / 10;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.thumb-link img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.file-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
color: var(--foreground);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.file-browser.is-thumbs {
|
||||
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
||||
}
|
||||
|
||||
.file-browser.is-thumbs .file-card {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.file-browser.is-thumbs .thumb-link {
|
||||
width: 100%;
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.file-browser.is-thumbs .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-browser.images-only .file-card:not([data-kind="image"]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.unlock-form {
|
||||
margin: 1rem auto 0;
|
||||
display: grid;
|
||||
max-width: 22rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.preview-stage {
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.preview-stage img,
|
||||
.preview-stage video {
|
||||
width: 100%;
|
||||
max-height: 55vh;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.preview-stage audio {
|
||||
width: calc(100% - 2rem);
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
width: min(72rem, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
@@ -504,16 +653,107 @@ code {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@keyframes progress-pulse {
|
||||
0% {
|
||||
transform: scaleX(0.12);
|
||||
}
|
||||
50% {
|
||||
transform: scaleX(0.72);
|
||||
}
|
||||
100% {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
.form-error {
|
||||
margin: 1rem 0 0;
|
||||
color: #fecaca;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.admin-view {
|
||||
width: min(72rem, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 2rem 0 3rem;
|
||||
}
|
||||
|
||||
.admin-header,
|
||||
.table-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0 0 0.4rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(24, 24, 27, 0.78);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.metric-card span,
|
||||
.table-header p {
|
||||
display: block;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.metric-card strong {
|
||||
display: block;
|
||||
margin-top: 0.4rem;
|
||||
color: var(--foreground);
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.admin-table-card {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.table-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.table-header p {
|
||||
margin: 0.3rem 0 0;
|
||||
}
|
||||
|
||||
.admin-table-wrap {
|
||||
overflow-x: auto;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
color: var(--muted-foreground);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.table-actions form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@@ -536,10 +776,18 @@ code {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.option-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-progress-side {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.result-actions .button {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -551,4 +799,14 @@ code {
|
||||
.drop-zone {
|
||||
min-height: 15rem;
|
||||
}
|
||||
|
||||
.admin-header,
|
||||
.table-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
BIN
backend/static/img/file-placeholder.webp
Normal file
BIN
backend/static/img/file-placeholder.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 670 B |
@@ -8,14 +8,38 @@
|
||||
const result = document.querySelector("#upload-result");
|
||||
const resultMeta = document.querySelector("#result-meta");
|
||||
const resultList = document.querySelector("#result-list");
|
||||
const copyAll = document.querySelector("#copy-all");
|
||||
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 latestLinks = [];
|
||||
let latestBoxURL = "";
|
||||
let selectedFiles = [];
|
||||
|
||||
["dragenter", "dragover"].forEach((eventName) => {
|
||||
dropZone.addEventListener(eventName, (event) => {
|
||||
@@ -51,22 +75,12 @@
|
||||
|
||||
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 response = await fetch(form.action, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "Upload failed");
|
||||
}
|
||||
|
||||
const payload = await uploadWithProgress(form.action, formData, selectedFiles);
|
||||
renderResult(payload);
|
||||
form.reset();
|
||||
updateSelectedState([]);
|
||||
@@ -77,14 +91,15 @@
|
||||
}
|
||||
});
|
||||
|
||||
if (copyAll) {
|
||||
copyAll.addEventListener("click", () => {
|
||||
copyText(latestLinks.join("\n"), copyAll, "Copied");
|
||||
if (copyURL) {
|
||||
copyURL.addEventListener("click", () => {
|
||||
copyText(latestBoxURL, copyURL, "Copied");
|
||||
});
|
||||
}
|
||||
|
||||
function updateSelectedState(files) {
|
||||
const count = files.length || 0;
|
||||
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`;
|
||||
@@ -92,6 +107,12 @@
|
||||
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) {
|
||||
@@ -103,6 +124,7 @@
|
||||
submit.textContent = isLoading ? "Uploading..." : "Upload files";
|
||||
}
|
||||
updateStatus(isLoading ? "Transferring files..." : "");
|
||||
setTotalProgress(isLoading ? 0 : 100);
|
||||
}
|
||||
|
||||
function updateStatus(message) {
|
||||
@@ -116,48 +138,129 @@
|
||||
return;
|
||||
}
|
||||
|
||||
latestLinks = [payload.boxUrl, payload.zipUrl].concat(payload.files.map((file) => file.url));
|
||||
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) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "result-item";
|
||||
|
||||
const body = document.createElement("span");
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = file.name;
|
||||
const url = document.createElement("code");
|
||||
url.textContent = file.url;
|
||||
body.append(name, url);
|
||||
|
||||
const copy = document.createElement("button");
|
||||
copy.className = "button button-outline";
|
||||
copy.type = "button";
|
||||
copy.textContent = "Copy";
|
||||
copy.addEventListener("click", () => copyText(file.url, copy, "Copied"));
|
||||
|
||||
row.append(body, copy);
|
||||
resultList.append(row);
|
||||
resultList.append(createFileRow({
|
||||
name: file.name,
|
||||
meta: `${file.size} · ${file.url}`,
|
||||
progress: 100,
|
||||
status: "complete",
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
const zip = document.createElement("div");
|
||||
zip.className = "result-item";
|
||||
const zipBody = document.createElement("span");
|
||||
const zipName = document.createElement("strong");
|
||||
zipName.textContent = "Download all as zip";
|
||||
const zipUrl = document.createElement("code");
|
||||
zipUrl.textContent = payload.zipUrl;
|
||||
zipBody.append(zipName, zipUrl);
|
||||
const zipCopy = document.createElement("button");
|
||||
zipCopy.className = "button button-outline";
|
||||
zipCopy.type = "button";
|
||||
zipCopy.textContent = "Copy";
|
||||
zipCopy.addEventListener("click", () => copyText(payload.zipUrl, zipCopy, "Copied"));
|
||||
zip.append(zipBody, zipCopy);
|
||||
resultList.append(zip);
|
||||
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) {
|
||||
@@ -183,4 +286,18 @@
|
||||
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]}`;
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user