Skip to content

Commit

Permalink
Added download progress indicator #598
Browse files Browse the repository at this point in the history
  • Loading branch information
verheyenkoen committed Jul 30, 2024
1 parent 38585d4 commit 33c0311
Show file tree
Hide file tree
Showing 29 changed files with 371 additions and 77 deletions.
4 changes: 2 additions & 2 deletions assets/css/flash.scss
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
#flash-messages {
position: absolute;
top: 12rem;
right: 2rem;
display: flex;
flex-direction: column-reverse;
gap: 2rem;
background-color: transparent;

@extend .toast-container;
pointer-events: none;
}
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;
}
2 changes: 2 additions & 0 deletions assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ 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;
Expand Down Expand Up @@ -52,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 showFlashMessage from "./flash_message";

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

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

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 = 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) {
if (flashMessage) {
flashMessage.hide();
}

showFlashMessage({
level: "error",
text: error.message,
});
}
});
});
}

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 "";
}
143 changes: 143 additions & 0 deletions assets/js/ui/flash_message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
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 function showFlashMessage({
level,
isLoading = false,
title,
text,
isDismissible = true,
toastOptions,
}: FlashMessageOptions) {
let flashMessages = document.querySelector("#flash-messages");
if (flashMessages) {
/*
* Expects somewhere in the document ..
*
* <template id="template-flash-message"></template>
*
* .. a template that encapsulates the toast
* */
const templateFlashMessage = document.querySelector<HTMLTemplateElement>(
"template#template-flash-message",
);

if (templateFlashMessage) {
const toastEl = (
templateFlashMessage.content.cloneNode(true) as HTMLElement
).querySelector<HTMLElement>(".toast");

if (toastEl) {
const flashMessage = new FlashMessage(toastEl);

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

flashMessages.appendChild(toastEl);

flashMessage.show(toastOptions);

return flashMessage;
}
}
}
}

class FlashMessage {
#toastEl: HTMLElement;
#toast: Toast | null;

constructor(readonly toastEl: HTMLElement) {
this.#toastEl = toastEl;
}

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.#toastEl
.querySelector(`.if--${levelMap[level]}`)
?.classList.remove("d-none");
}
}

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

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

setText(text: string) {
const textEl = this.#toastEl.querySelector(".toast-text");
if (textEl) {
textEl.innerHTML = text;
}
}

setIsDismissible(isDismissible: boolean) {
const btnClose = this.#toastEl.querySelector(".btn-close");
if (btnClose) {
btnClose.classList.toggle("d-none", !isDismissible);
}
}

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

show(toastOptions: Partial<ToastOptions> = {}) {
this.#toast =
Toast.getInstance(this.#toastEl) ??
new Toast(this.#toastEl, toastOptions);

this.#toast.show();

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

hide() {
if (this.#toast) {
this.#toast.hide();
}
}
}
6 changes: 3 additions & 3 deletions assets/js/ui/modal_error.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import Modal from "bootstrap.native/modal";
import { Modal } from "bootstrap.native";

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

if (templateModalError) {
Expand Down
3 changes: 3 additions & 0 deletions static/css/app-MIYFPBC7.css

Large diffs are not rendered by default.

File renamed without changes.

Large diffs are not rendered by default.

3 changes: 0 additions & 3 deletions static/css/app-T57IX4QY.css

This file was deleted.

42 changes: 42 additions & 0 deletions static/js/app-52MMR7QW.js

Large diffs are not rendered by default.

File renamed without changes.

Large diffs are not rendered by default.

42 changes: 0 additions & 42 deletions static/js/app-7672532P.js

This file was deleted.

4 changes: 2 additions & 2 deletions static/manifest.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"/js/app.js": "/static/js/app-7672532P.js",
"/js/app.js": "/static/js/app-52MMR7QW.js",
"/fonts/icon-font.woff2": "/static/fonts/icon-font-MKUEJCPC.woff2",
"/fonts/icon-font.woff": "/static/fonts/icon-font-4S7CKSGJ.woff",
"/css/app.css": "/static/css/app-T57IX4QY.css",
"/css/app.css": "/static/css/app-MIYFPBC7.css",
"/images/plato-logo.svg": "/static/images/plato-logo-EKBIWHHI.svg",
"/images/book-illustration.svg": "/static/images/book-illustration-XEGTEPBW.svg",
"/images/universiteitsbibliotheek.svg": "/static/images/universiteitsbibliotheek-G2PUY5IZ.svg",
Expand Down
2 changes: 1 addition & 1 deletion views/candidaterecord/preview.templ
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ templ actions(c *ctx.Ctx, rec *models.CandidateRecord) {

templ downloadMainFileAction(c *ctx.Ctx, rec *models.CandidateRecord) {
if f := rec.Publication.MainFile(); f != nil {
<a class="btn btn-tertiary btn-lg-only-responsive" href={ templ.URL(c.PathTo("candidate_record_download_file", "id", rec.ID, "file_id", f.ID).String()) }>
<a class="btn btn-tertiary btn-lg-only-responsive" href={ templ.URL(c.PathTo("candidate_record_download_file", "id", rec.ID, "file_id", f.ID).String()) } download>
<i class="if if-eye"></i>
<span class="btn-text">View file</span>
</a>
Expand Down
2 changes: 1 addition & 1 deletion views/candidaterecord/preview_templ.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion views/dataset/search.templ
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ templ Search(c *ctx.Ctx, args *SearchArgs) {
<i class="if if-more"></i>
</button>
<div class="dropdown-menu">
<a class="dropdown-item" target="_blank" href={ templ.URL(datasetSearchExportURL(c, args.SearchArgs).String()) }>
<a class="dropdown-item" target="_blank" href={ templ.URL(datasetSearchExportURL(c, args.SearchArgs).String()) } download>
<i class="if if-download"></i>
<span>{ c.Loc.Get("export_to.xlsx") }</span>
</a>
Expand Down
2 changes: 1 addition & 1 deletion views/dataset/search_templ.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion views/page_layout.templ
Original file line number Diff line number Diff line change
Expand Up @@ -223,12 +223,13 @@ templ PageLayout(c *ctx.Ctx, title string, meta templ.Component) {
</div>
</main>
<div id="modals"></div>
@viewtemplates.ModalError()
@viewtemplates.ModalErrorTemplate()
<div id="flash-messages">
for _, f := range c.Flash {
@flashMessage(f)
}
</div>
@viewtemplates.FlashMessageTemplate()
<script nonce={ c.CSPNonce } type="application/javascript" src={ c.AssetPath("/js/app.js") }></script>
</body>
</html>
Expand Down
16 changes: 12 additions & 4 deletions views/page_layout_templ.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions views/publication/files.templ
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ templ FilesBody(c *ctx.Ctx, p *models.Publication) {
<div class="list-group-item-inner">
<div class="list-group-item-main u-min-w-0">
<div class="c-thumbnail-and-text align-items-start d-block d-lg-flex">
<a href={ templ.URL(c.PathTo("publication_download_file", "id", p.ID, "file_id", f.ID).String()) }>
<a href={ templ.URL(c.PathTo("publication_download_file", "id", p.ID, "file_id", f.ID).String()) } download>
<div class="c-thumbnail c-thumbnail-5-4 c-thumbnail-small c-thumbnail-xl-large mb-6 mb-xl-0 flex-shrink-0 d-none d-lg-block">
<div class="c-thumbnail-inner">
<i class="if if-article"></i>
Expand Down Expand Up @@ -180,7 +180,7 @@ templ FilesBody(c *ctx.Ctx, p *models.Publication) {
</div>
</div>
<h4>
<a href={ templ.URL(c.PathTo("publication_download_file", "id", p.ID, "file_id", f.ID).String()) }>
<a href={ templ.URL(c.PathTo("publication_download_file", "id", p.ID, "file_id", f.ID).String()) } download>
<span class="list-group-item-title">
{ f.Name }
</span>
Expand Down
Loading

0 comments on commit 33c0311

Please sign in to comment.