Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Download progress indicator #598 #1677

Draft
wants to merge 7 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"cSpell.words": [
"arxiv",
"Arxiv",
"autohide",
"biblio",
"Biblio",
"bibtex",
Expand Down
12 changes: 2 additions & 10 deletions assets/css/flash.scss
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
#flash-messages {
position: absolute;
top: 2rem;
top: 12rem;
right: 2rem;
display: flex;
flex-direction: column-reverse;
gap: 2rem;
background-color: transparent;
pointer-events: none;

.toast {
margin: 10rem 0.3rem 1.3rem 0;

&.fade {
opacity: 1;
display: block;
}
}
}
5 changes: 5 additions & 0 deletions assets/css/spinner.scss
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,8 @@
display: inline-block;
opacity: 1;
}

.toast .spinner-border {
display: inline-block;
opacity: 1;
}
15 changes: 10 additions & 5 deletions assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,20 @@ import collapseSubSidebar from "./ui/collapsible_sub_sidebar.js";
import fileUpload from "./ui/file_upload.js";
import tags from "./ui/tags.js";
import facetDropdowns from "./ui/facet_dropdowns.js";
import fileDownload from "./ui/file_download.js";

// configure htmx
htmx.config.defaultFocusScroll = true;

// apply bootstrap js to new dom content
htmx.onLoad(initCallback);
htmx.onLoad((el) => {
// apply Bootstrap JS to new DOM content (not on initial load)
if (el !== document.body) {
initCallback(el);
}
});

// load htmx extensions
window.htmx = htmx;
require("htmx-ext-remove-me");
// load htmx extensions (uncomment if any HTMX extensions are needed)
// window.htmx = htmx;

// initialize everything
document.addEventListener("DOMContentLoaded", function () {
Expand All @@ -49,4 +53,5 @@ document.addEventListener("DOMContentLoaded", function () {

htmx.onLoad(function (el) {
clipboard(el);
fileDownload(el);
});
71 changes: 71 additions & 0 deletions assets/js/ui/file_download.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import FlashMessage from "./flash_message";

export default function fileDownload(el) {
el.querySelectorAll("a[download]").forEach((anchor) => {
anchor.addEventListener("click", async (e) => {
e.preventDefault();

const flashMessage = new FlashMessage({
isLoading: true,
text: "Preparing download...",
isDismissible: false,
toastOptions: {
autohide: false,
},
});
flashMessage.show();

try {
const response = await fetch(anchor.href);
if (!response.ok) {
throw new Error(
"An unexpected error occurred while downloading the file. Please try again.",
);
}

const filename =
anchor.getAttribute("download") || extractFileName(response);
const blob = await response.blob();

const dummyAnchor = document.createElement("a");
dummyAnchor.href = URL.createObjectURL(blob);
dummyAnchor.setAttribute("class", "link-primary text-nowrap");
dummyAnchor.setAttribute("download", filename);
dummyAnchor.textContent = filename;

flashMessage.setLevel("success");
flashMessage.setIsLoading(false);
flashMessage.setText("Download ready: " + dummyAnchor.outerHTML);
flashMessage.setIsDismissible(true);

// Trigger download (save-as window or auto-download, depending on browser settings)
dummyAnchor.click();
} catch (error) {
flashMessage.hide();

new FlashMessage({
level: "error",
text: error.message,
}).show();
}
});
});
}

function extractFileName(response: Response) {
const FILENAME_REGEX = /filename\*?=(UTF-8'')?/;
const contentDispositionHeader = response.headers.get("content-disposition");

if (contentDispositionHeader) {
const fileNamePart = contentDispositionHeader
.split(";")
.find((n) => n.match(FILENAME_REGEX));

if (fileNamePart) {
const fileName = fileNamePart.replace(FILENAME_REGEX, "").trim();
return decodeURIComponent(fileName);
}
}

return "";
}
139 changes: 139 additions & 0 deletions assets/js/ui/flash_message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { Toast, type ToastOptions } from "bootstrap.native";

const levelMap = {
success: "success",
info: "primary",
warning: "warning",
error: "error",
} as const;

type Level = keyof typeof levelMap;

type FlashMessageOptions = {
level?: Level;
isLoading?: boolean;
title?: string;
text: string;
isDismissible?: boolean;
toastOptions?: Partial<ToastOptions>;
};

export default class FlashMessage {
private toastEl: HTMLElement;
private toast: Toast;

constructor({
level,
isLoading = false,
title,
text,
isDismissible = true,
toastOptions,
}: FlashMessageOptions) {
this.initFlashMessage();

this.toast = new Toast(this.toastEl, toastOptions);

this.setLevel(level);
this.setIsLoading(isLoading);
this.setTitle(title);
this.setText(text);
this.setIsDismissible(isDismissible);
}

setLevel(level: Level | undefined) {
this.toastEl.querySelectorAll(".toast-body i.if").forEach((el) => {
el.classList.add("d-none");
});

if (level && Object.keys(levelMap).includes(level)) {
this.query(`.if--${levelMap[level]}`).classList.remove("d-none");
}
}

setIsLoading(isLoading: boolean) {
this.query(".spinner-border").classList.toggle("d-none", !isLoading);
}

setTitle(title: string | undefined) {
const titleEl = this.query(".alert-title");
if (title) {
titleEl.classList.remove("d-none");
titleEl.textContent = title;
} else {
titleEl.classList.add("d-none");
}
}

setText(text: string) {
this.query(".toast-text").innerHTML = text;
}

setIsDismissible(isDismissible: boolean) {
this.query(".btn-close").classList.toggle("d-none", !isDismissible);
}

setAutohide(autohide: boolean, delay = 5000) {
this.toast.options.autohide = autohide;
this.toast.options.delay = delay;
this.toast.show();
}

show() {
this.toast.show();

this.toastEl.addEventListener("hidden.bs.toast", () => {
this.toastEl.remove();
});
}

hide() {
// For some reason, BSN doesn't show the toast for 17ms, so we wait 20ms first before trying to hide to prevent a race condition.
// https://github.com/thednp/bootstrap.native/blob/master/src/components/toast.ts#L140
setTimeout(() => {
this.toast.hide();
}, 20);
}

private initFlashMessage() {
const flashMessages = document.querySelector("#flash-messages");
if (!flashMessages) {
throw new Error("Container for flash messages not found.");
}

const templateFlashMessage = document.querySelector<HTMLTemplateElement>(
"template#template-flash-message",
);
if (!templateFlashMessage) {
throw new Error("Template for flash messages not found.");
}

const toastFragment = templateFlashMessage.content.cloneNode(
true,
) as HTMLElement;
const toastEl = toastFragment.querySelector<HTMLElement>(".toast");
if (!toastEl) {
throw new Error(
"Template for flash messages does not contain a '.toast' element.",
);
}

flashMessages.appendChild(toastEl);

this.toastEl = toastEl;
}

private query(selector: string) {
if (!this.toastEl) {
throw new Error("FlashMessage is not yet initialized.");
}

const el = this.toastEl.querySelector(selector);

if (!el) {
throw new Error(`Element not found: ${selector}`);
}

return el;
}
}
51 changes: 32 additions & 19 deletions assets/js/ui/modal_error.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,43 @@
import { Modal } from "bootstrap.native";

export default function (error) {
let modals = document.querySelector("#modals");
if (modals) {
/*
* Expects somewhere in the document ..
*
* <template id="template-modal-error"></template>
*
* .. a template that encapsulates the modal body
* */
const templateModalError = document.querySelector(
"template#template-modal-error",
);

if (!modals) return;
if (templateModalError) {
const modalEl = templateModalError.content
.cloneNode(true)
.querySelector(".modal");

/*
* Expects somewhere in the document ..
*
* <template class="template-modal-error"></template>
*
* .. a template that encapsulates the modal body
* */
let templateModalError = document.querySelector(
"template.template-modal-error",
);
modalEl.querySelector(".msg").textContent = error;

if (!templateModalError) return;
modals.innerHTML = "";
modals.appendChild(modalEl);

let modal = templateModalError.content.cloneNode(true);
initModal(modalEl);
}
}
}

// modal-close not triggered for dynamically added modals
modal.querySelector(".modal-close").addEventListener("click", function () {
modals.innerHTML = "";
function initModal(modalEl) {
const modal = new Modal(modalEl, {
backdrop: "static",
keyboard: false,
});

modal.querySelector(".msg").textContent = error;
modal.show();

modals.innerHTML = "";
modals.appendChild(modal);
modalEl.addEventListener("hidden.bs.modal", function () {
modalEl.remove();
});
}
34 changes: 14 additions & 20 deletions assets/js/ui/popover.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,19 @@
import htmx from "htmx.org/dist/htmx.esm.js";
import { Popover } from "bootstrap.native";
import Popover from "bootstrap.native/popover";

export default function () {
let addEvents = function (rootEl) {
rootEl
.querySelectorAll("[data-bs-toggle=popover-custom]")
.forEach(function (el) {
let container = document.querySelector(el.dataset.popoverContent);
let content = container.querySelector(".popover-body");
let heading = container.querySelector(".popover-heading");
let title = "";
if (heading) {
title = heading.innerHTML;
}
new Popover(el, {
content: content.innerHTML,
title: title,
delay: 1000,
});
});
};
htmx.onLoad((rootEl) => {
rootEl.querySelectorAll("[data-bs-toggle=popover-custom]").forEach((el) => {
const container = document.querySelector(el.dataset.popoverContent);
const content = container.querySelector(".popover-body");
const heading = container.querySelector(".popover-heading");
const title = heading?.innerHTML ?? "";

htmx.onLoad(addEvents);
new Popover(el, {
content: content.innerHTML,
title,
delay: 1000,
});
});
});
}
Loading