161 lines
6.2 KiB
JavaScript
161 lines
6.2 KiB
JavaScript
/*
|
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
|
* Source-Available Code / Dual-Licensed.
|
|
*
|
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
|
* Commercial or production use requires a valid paid license.
|
|
* See LICENSE file in repository root for details.
|
|
*/
|
|
|
|
let hideTimer;
|
|
|
|
function toastElement(selector = "#toast") {
|
|
return document.querySelector(selector);
|
|
}
|
|
|
|
function show(element, duration = null) {
|
|
element.classList.add("visible");
|
|
clearTimeout(hideTimer);
|
|
if (duration != null) hideTimer = setTimeout(() => element.classList.remove("visible"), duration);
|
|
}
|
|
|
|
function reset(element) {
|
|
element.className = "toast";
|
|
element.removeAttribute("aria-busy");
|
|
element.removeAttribute("aria-label");
|
|
}
|
|
|
|
function formatBytes(value) {
|
|
const bytes = Math.max(0, Number(value) || 0);
|
|
if (bytes < 1024) return `${Math.round(bytes)} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes >= 10240 ? 0 : 1)} KB`;
|
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes >= 10 * 1024 * 1024 ? 1 : 2)} MB`;
|
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
}
|
|
|
|
export function toast(message, options = {}) {
|
|
const element = toastElement(options.selector);
|
|
if (!element) return;
|
|
reset(element);
|
|
element.textContent = String(message ?? "");
|
|
show(element, options.duration ?? 1800);
|
|
}
|
|
|
|
export function createUploadToast(filename, options = {}) {
|
|
const element = toastElement(options.selector);
|
|
if (!element) {
|
|
return { start() { }, update() { }, fail() { }, success() { }, dismiss() { } };
|
|
}
|
|
|
|
reset(element);
|
|
element.classList.add("toast--upload", "toast--interactive");
|
|
element.setAttribute("aria-live", "polite");
|
|
element.innerHTML = `
|
|
<div class="upload-toast__header">
|
|
<div class="upload-toast__heading">
|
|
<strong class="upload-toast__title">Uploading file</strong>
|
|
<span class="upload-toast__filename"></span>
|
|
</div>
|
|
</div>
|
|
<div class="upload-toast__progress" role="progressbar" aria-label="File upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span></span></div>
|
|
<div class="upload-toast__meta"><span data-upload-amount>Preparing…</span><span data-upload-speed>—</span></div>
|
|
<p class="upload-toast__error" data-upload-error hidden></p>
|
|
<div class="upload-toast__actions" data-upload-actions hidden>
|
|
<button type="button" class="upload-toast__retry">Retry</button>
|
|
<button type="button" class="upload-toast__dismiss">Dismiss</button>
|
|
</div>`;
|
|
|
|
const title = element.querySelector(".upload-toast__title");
|
|
const filenameElement = element.querySelector(".upload-toast__filename");
|
|
const progress = element.querySelector(".upload-toast__progress");
|
|
const progressFill = progress.querySelector("span");
|
|
const amount = element.querySelector("[data-upload-amount]");
|
|
const speedElement = element.querySelector("[data-upload-speed]");
|
|
const errorElement = element.querySelector("[data-upload-error]");
|
|
const actions = element.querySelector("[data-upload-actions]");
|
|
const retryButton = element.querySelector(".upload-toast__retry");
|
|
const dismissButton = element.querySelector(".upload-toast__dismiss");
|
|
let retryHandler = null;
|
|
|
|
filenameElement.textContent = String(filename || "file");
|
|
|
|
function dismiss() {
|
|
clearTimeout(hideTimer);
|
|
element.classList.remove("visible");
|
|
}
|
|
|
|
function start() {
|
|
reset(element);
|
|
element.classList.add("toast--upload", "toast--interactive", "visible");
|
|
element.setAttribute("aria-live", "polite");
|
|
element.setAttribute("aria-busy", "true");
|
|
title.textContent = "Uploading file";
|
|
filenameElement.textContent = String(filename || "file");
|
|
progress.classList.remove("is-error", "is-complete");
|
|
progress.setAttribute("aria-valuenow", "0");
|
|
progressFill.style.width = "0%";
|
|
amount.textContent = "Preparing…";
|
|
speedElement.textContent = "—";
|
|
errorElement.hidden = true;
|
|
errorElement.textContent = "";
|
|
actions.hidden = true;
|
|
retryButton.disabled = false;
|
|
clearTimeout(hideTimer);
|
|
}
|
|
|
|
function update({ loaded = 0, total = 0, speed = 0, percent = null, phase = "uploading" } = {}) {
|
|
const normalizedPercent = Number.isFinite(percent)
|
|
? Math.max(0, Math.min(100, percent))
|
|
: total > 0 ? Math.max(0, Math.min(100, (loaded / total) * 100)) : null;
|
|
if (normalizedPercent != null) {
|
|
progress.setAttribute("aria-valuenow", String(Math.round(normalizedPercent)));
|
|
progressFill.style.width = `${normalizedPercent}%`;
|
|
} else {
|
|
progress.removeAttribute("aria-valuenow");
|
|
progressFill.style.width = "24%";
|
|
progress.classList.add("is-indeterminate");
|
|
}
|
|
amount.textContent = total > 0
|
|
? `${Math.round(normalizedPercent || 0)}% · ${formatBytes(loaded)} / ${formatBytes(total)}`
|
|
: formatBytes(loaded);
|
|
speedElement.textContent = phase === "processing" ? "Processing…" : speed > 0 ? `${formatBytes(speed)}/s` : "Starting…";
|
|
}
|
|
|
|
function fail(message, { retryable = true, onRetry = null } = {}) {
|
|
element.removeAttribute("aria-busy");
|
|
title.textContent = "Upload failed";
|
|
progress.classList.remove("is-indeterminate", "is-complete");
|
|
progress.classList.add("is-error");
|
|
errorElement.textContent = String(message || "Upload failed.");
|
|
errorElement.hidden = false;
|
|
actions.hidden = false;
|
|
retryButton.hidden = !retryable;
|
|
retryHandler = typeof onRetry === "function" ? onRetry : null;
|
|
show(element);
|
|
}
|
|
|
|
function success(message = "File uploaded") {
|
|
element.removeAttribute("aria-busy");
|
|
title.textContent = message;
|
|
progress.classList.remove("is-error", "is-indeterminate");
|
|
progress.classList.add("is-complete");
|
|
progress.setAttribute("aria-valuenow", "100");
|
|
progressFill.style.width = "100%";
|
|
amount.textContent = "100%";
|
|
speedElement.textContent = "Complete";
|
|
errorElement.hidden = true;
|
|
actions.hidden = true;
|
|
show(element, 1800);
|
|
}
|
|
|
|
retryButton.addEventListener("click", async () => {
|
|
if (!retryHandler) return;
|
|
retryButton.disabled = true;
|
|
await retryHandler();
|
|
});
|
|
dismissButton.addEventListener("click", dismiss);
|
|
|
|
start();
|
|
return { start, update, fail, success, dismiss };
|
|
}
|