diff --git a/openlibrary/plugins/openlibrary/js/index.js b/openlibrary/plugins/openlibrary/js/index.js index 36b4bce0442..5cc21bcdc79 100644 --- a/openlibrary/plugins/openlibrary/js/index.js +++ b/openlibrary/plugins/openlibrary/js/index.js @@ -228,10 +228,11 @@ jQuery(function () { .then((module) => new module.LazyThingPreview().init()); } - // Enhance patron export buttons on /account/import - if (location.pathname === '/account/import') { + // Disable data export buttons on form submit + const patronImportForms = document.querySelectorAll('.patron-export-form') + if (patronImportForms.length) { import(/* webpackChunkName: "patron-exports" */ './patron_exports') - .then(module => module.initPatronExportButtons()); + .then(module => module.initPatronExportForms(patronImportForms)); } const $observationModalLinks = $('.observations-modal-link'); diff --git a/openlibrary/plugins/openlibrary/js/patron_exports.js b/openlibrary/plugins/openlibrary/js/patron_exports.js index 9e41e02ee60..9315fa07ba4 100644 --- a/openlibrary/plugins/openlibrary/js/patron_exports.js +++ b/openlibrary/plugins/openlibrary/js/patron_exports.js @@ -1,144 +1,26 @@ /** - * Enhances export buttons on /account/import so that when an export starts: - * - The clicked button shows "Downloading..." - * - The button is disabled and styled as unavailable (grey) - * - Subsequent clicks are prevented while the download is in progress + * Switches the given button to a disabled state. * - * Implementation detail: We use fetch() to retrieve the CSV as a Blob so we - * can reliably detect completion across browsers (some do not fire iframe - * load events when Content-Disposition: attachment is used). Once the blob is - * received, we trigger a programmatic download and then restore button state. + * @param {HTMLElement} buttonElement */ - -function getSubmitButton(formElement) { - // Each export form has a single submit input - return formElement.querySelector('input[type="submit"], button[type="submit"]'); -} - function disableButton(buttonElement) { - if (!buttonElement) return; - buttonElement.dataset.originalValue = buttonElement.value || buttonElement.textContent || ''; - if (buttonElement.tagName === 'INPUT') { - buttonElement.value = 'Downloading...'; - } else { - buttonElement.textContent = 'Downloading...'; - } buttonElement.setAttribute('disabled', 'true'); buttonElement.setAttribute('aria-disabled', 'true'); - buttonElement.classList.remove('cta-btn--available'); - buttonElement.classList.add('cta-btn--unavailable'); - // Show loading affordance consistent with other unavailable actions - buttonElement.classList.add('cta-btn--unavailable--load'); -} - -function enableButton(buttonElement) { - if (!buttonElement) return; - const original = buttonElement.dataset.originalValue || ''; - if (buttonElement.tagName === 'INPUT') { - buttonElement.value = original; - } else { - buttonElement.textContent = original; - } - buttonElement.removeAttribute('disabled'); - buttonElement.setAttribute('aria-disabled', 'false'); - buttonElement.classList.remove('cta-btn--unavailable'); - buttonElement.classList.remove('cta-btn--unavailable--load'); - buttonElement.classList.add('cta-btn--available'); - delete buttonElement.dataset.originalValue; -} - -function buildGetUrlFromForm(formElement) { - const action = formElement.getAttribute('action') || ''; - const params = new URLSearchParams(new FormData(formElement)); - const query = params.toString(); - return query ? `${action}?${query}` : action; -} - -function parseFilenameFromContentDisposition(headerValue) { - if (!headerValue) return null; - // RFC 5987 filename*= - const starMatch = /filename\*=UTF-8''([^;]+)/i.exec(headerValue); - if (starMatch && starMatch[1]) { - try { - return decodeURIComponent(starMatch[1]); - } catch (_) { - return starMatch[1]; - } - } - // Basic filename="..." - const plainMatch = /filename="?([^";]+)"?/i.exec(headerValue); - return plainMatch && plainMatch[1] ? plainMatch[1] : null; } -async function fetchAndDownload(url) { - const response = await fetch(url, { credentials: 'same-origin' }); - if (!response.ok) { - const error = new Error(`Export request failed with status ${response.status}`); - error.status = response.status; - throw error; - } - const blob = await response.blob(); - const contentDisposition = response.headers.get('Content-Disposition') || response.headers.get('content-disposition'); - const fallbackName = 'OpenLibrary_Export.csv'; - const filename = parseFilenameFromContentDisposition(contentDisposition) || fallbackName; - const objectUrl = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = objectUrl; - a.download = filename; - document.body.appendChild(a); - a.click(); - setTimeout(() => { - URL.revokeObjectURL(objectUrl); - if (a.parentNode) a.parentNode.removeChild(a); - }, 0); -} - -export function initPatronExportButtons() { - // Guard: only on the import page - if (location.pathname !== '/account/import') return; - - const exportForms = document.querySelectorAll('form[action="/account/export"][method="GET" i]'); - if (!exportForms.length) return; - - exportForms.forEach((form) => { - const submitButton = getSubmitButton(form); - if (!submitButton) return; - - // Prevent double-binding - if (form.dataset.patronExportBound === 'true') return; - form.dataset.patronExportBound = 'true'; - - form.addEventListener('submit', (event) => { - // If already disabled, block to prevent duplicates - if (submitButton.hasAttribute('disabled')) { - event.preventDefault(); - return; - } - - event.preventDefault(); +/** + * Adds `submit` listeners for the given form elements. + * + * When any of the given forms are submitted, the form's + * submit button is disabled. + * + * @param {NodeList} elems + */ +export function initPatronExportForms(elems) { + elems.forEach((form) => { + const submitButton = form.querySelector('input[type=submit]') + form.addEventListener('submit', () => { disableButton(submitButton); - const startMs = performance.now(); - const url = buildGetUrlFromForm(form); - fetchAndDownload(url) - .catch(() => { - // Swallow errors but restore button; - }) - .finally(() => { - // Ensure state stays visible for at least a short time - const minDurationMs = 500; - const elapsed = performance.now() - startMs; - const remaining = Math.max(0, minDurationMs - elapsed); - setTimeout(() => enableButton(submitButton), remaining); - }); - }); - - // Also handle direct button clicks that might bypass submit event ordering - submitButton.addEventListener('click', (event) => { - if (submitButton.hasAttribute('disabled')) { - event.preventDefault(); - } - }); - }); + }) + }) } - - diff --git a/openlibrary/templates/account/import.html b/openlibrary/templates/account/import.html index 82e0a836a72..34607ff1160 100644 --- a/openlibrary/templates/account/import.html +++ b/openlibrary/templates/account/import.html @@ -23,7 +23,7 @@

$_("Import from Goodreads")

$_("Export your Reading Log")

$_("Download a copy of your reading log.") $_("What is a reading log?")

+ enctype="multipart/form-data" class="olform olform--decoration patron-export-form">
@@ -34,7 +34,7 @@

$_("Export your Reading Log")

$_("Export your book notes")

$_("Download a copy of your book notes.") $_("What are book notes?")

+ enctype="multipart/form-data" class="olform olform--decoration patron-export-form">
@@ -45,7 +45,7 @@

$_("Export your book notes")

$_("Export your reviews")

$_("Download a copy of your review tags.") $_("What are review tags?")

+ enctype="multipart/form-data" class="olform olform--decoration patron-export-form">
@@ -56,7 +56,7 @@

$_("Export your reviews")

$_("Export your list overview")

$_("Download a summary of your lists and their contents.") $_("What are lists?")

+ enctype="multipart/form-data" class="olform olform--decoration patron-export-form">
@@ -67,7 +67,7 @@

$_("Export your list overview")

$_("Export your star ratings")

$_("Download a copy of your star ratings.") $_("What are star ratings?")

+ enctype="multipart/form-data" class="olform olform--decoration patron-export-form">