moviewyrm/bookwyrm/static/js/bookwyrm.js

662 lines
20 KiB
JavaScript
Raw Normal View History

/* exported BookWyrm */
/* globals TabGroup */
2021-12-16 18:53:38 +00:00
let BookWyrm = new (class {
constructor() {
2021-06-08 19:40:01 +00:00
this.MAX_FILE_SIZE_BYTES = 10 * 1000000;
this.initOnDOMLoaded();
this.initReccuringTasks();
this.initEventListeners();
}
2021-01-19 00:32:02 +00:00
initEventListeners() {
2021-12-16 18:53:38 +00:00
document
.querySelectorAll("[data-controls]")
.forEach((button) => button.addEventListener("click", this.toggleAction.bind(this)));
2021-01-29 18:25:31 +00:00
2021-12-16 18:53:38 +00:00
document
.querySelectorAll(".interaction")
.forEach((button) => button.addEventListener("submit", this.interact.bind(this)));
2021-01-14 21:02:28 +00:00
2021-12-16 18:53:38 +00:00
document
.querySelectorAll(".hidden-form input")
.forEach((button) => button.addEventListener("change", this.revealForm.bind(this)));
2021-01-19 22:59:46 +00:00
2021-12-16 18:53:38 +00:00
document
.querySelectorAll("[data-hides]")
.forEach((button) => button.addEventListener("change", this.hideForm.bind(this)));
2021-12-16 18:53:38 +00:00
document
.querySelectorAll("[data-back]")
.forEach((button) => button.addEventListener("click", this.back));
2021-12-16 18:53:38 +00:00
document
.querySelectorAll('input[type="file"]')
.forEach((node) => node.addEventListener("change", this.disableIfTooLarge.bind(this)));
2021-12-29 00:29:50 +00:00
document
2022-01-10 19:47:52 +00:00
.querySelectorAll("[data-modal-open]")
2021-12-29 00:29:50 +00:00
.forEach((node) => node.addEventListener("click", this.handleModalButton.bind(this)));
2021-12-16 18:53:38 +00:00
document
.querySelectorAll("[data-duplicate]")
.forEach((node) => node.addEventListener("click", this.duplicateInput.bind(this)));
2022-01-01 17:17:11 +00:00
2021-12-27 21:39:34 +00:00
document
.querySelectorAll("details.dropdown")
.forEach((node) =>
node.addEventListener("toggle", this.handleDetailsDropdown.bind(this))
);
2021-01-19 00:32:02 +00:00
}
/**
* Execute code once the DOM is loaded.
*/
initOnDOMLoaded() {
2021-06-08 19:40:01 +00:00
const bookwyrm = this;
2021-12-16 18:53:38 +00:00
window.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll(".tab-group").forEach((tabs) => new TabGroup(tabs));
document
.querySelectorAll('input[type="file"]')
.forEach(bookwyrm.disableIfTooLarge.bind(bookwyrm));
document.querySelectorAll("[data-copytext]").forEach(bookwyrm.copyText.bind(bookwyrm));
2022-01-02 15:01:11 +00:00
document
.querySelectorAll(".modal.is-active")
.forEach(bookwyrm.handleActiveModal.bind(bookwyrm));
});
}
/**
* Execute recurring tasks.
*/
initReccuringTasks() {
// Polling
2021-12-16 18:53:38 +00:00
document.querySelectorAll("[data-poll]").forEach((liveArea) => this.polling(liveArea));
2021-01-18 17:57:44 +00:00
}
/**
* Go back in browser history.
*
* @param {Event} event
* @return {undefined}
*/
back(event) {
event.preventDefault();
history.back();
}
2021-01-17 03:57:20 +00:00
/**
* Update a counter with recurring requests to the API
2021-04-07 08:37:11 +00:00
* The delay is slightly randomized and increased on each cycle.
*
* @param {Object} counter - DOM node
* @param {int} delay - frequency for polling in ms
* @return {undefined}
*/
polling(counter, delay) {
const bookwyrm = this;
delay = delay || 10000;
2021-12-16 18:53:38 +00:00
delay += Math.random() * 1000;
setTimeout(
function () {
fetch("/api/updates/" + counter.dataset.poll)
.then((response) => response.json())
.then((data) => bookwyrm.updateCountElement(counter, data));
bookwyrm.polling(counter, delay * 1.25);
},
delay,
counter
);
2021-01-17 18:10:59 +00:00
}
/**
* Update a counter.
*
* @param {object} counter - DOM node
* @param {object} data - json formatted response from a fetch
* @return {undefined}
*/
updateCountElement(counter, data) {
2021-11-24 18:00:30 +00:00
let count = data.count;
const count_by_type = data.count_by_type;
const currentCount = counter.innerText;
const hasMentions = data.has_mentions;
2021-12-16 18:53:38 +00:00
const allowedStatusTypesEl = document.getElementById("unread-notifications-wrapper");
2021-11-24 18:00:30 +00:00
// If we're on the right counter element
2021-12-16 18:53:38 +00:00
if (counter.closest("[data-poll-wrapper]").contains(allowedStatusTypesEl)) {
2021-11-24 18:00:30 +00:00
const allowedStatusTypes = JSON.parse(allowedStatusTypesEl.textContent);
// For keys in common between allowedStatusTypes and count_by_type
// This concerns 'review', 'quotation', 'comment'
2021-12-16 18:53:38 +00:00
count = allowedStatusTypes.reduce(function (prev, currentKey) {
2021-11-24 18:00:30 +00:00
const currentValue = count_by_type[currentKey] | 0;
2021-11-24 18:02:07 +00:00
2021-11-24 18:00:30 +00:00
return prev + currentValue;
}, 0);
// Add all the "other" in count_by_type if 'everything' is allowed
2021-12-16 18:53:38 +00:00
if (allowedStatusTypes.includes("everything")) {
2021-11-24 18:00:30 +00:00
// Clone count_by_type with 0 for reviews/quotations/comments
2021-12-16 18:53:38 +00:00
const count_by_everything_else = Object.assign({}, count_by_type, {
review: 0,
quotation: 0,
comment: 0,
});
count = Object.keys(count_by_everything_else).reduce(function (prev, currentKey) {
const currentValue = count_by_everything_else[currentKey] | 0;
return prev + currentValue;
}, count);
2021-11-24 18:00:30 +00:00
}
}
if (count != currentCount) {
2021-12-16 18:53:38 +00:00
this.addRemoveClass(counter.closest("[data-poll-wrapper]"), "is-hidden", count < 1);
counter.innerText = count;
2021-12-16 18:53:38 +00:00
this.addRemoveClass(counter.closest("[data-poll-wrapper]"), "is-danger", hasMentions);
}
2021-01-17 03:57:20 +00:00
}
/**
* Show form.
2021-12-16 18:53:38 +00:00
*
* @param {Event} event
* @return {undefined}
*/
revealForm(event) {
let trigger = event.currentTarget;
2021-12-16 18:53:38 +00:00
let hidden = trigger.closest(".hidden-form").querySelectorAll(".is-hidden")[0];
2021-10-10 03:02:55 +00:00
if (hidden) {
2021-12-16 18:53:38 +00:00
this.addRemoveClass(hidden, "is-hidden", !hidden);
}
2021-01-17 03:57:20 +00:00
}
/**
* Hide form.
*
* @param {Event} event
* @return {undefined}
*/
hideForm(event) {
let trigger = event.currentTarget;
2021-12-16 18:53:38 +00:00
let targetId = trigger.dataset.hides;
let visible = document.getElementById(targetId);
2021-10-04 11:20:02 +00:00
2021-12-16 18:53:38 +00:00
this.addRemoveClass(visible, "is-hidden", true);
2021-10-10 02:55:18 +00:00
}
/**
* Execute actions on targets based on triggers.
*
* @param {Event} event
* @return {undefined}
*/
toggleAction(event) {
2021-08-17 16:21:57 +00:00
let trigger = event.currentTarget;
2021-08-17 16:28:39 +00:00
2021-08-17 16:21:57 +00:00
if (!trigger.dataset.allowDefault || event.currentTarget == event.target) {
2021-08-17 15:45:10 +00:00
event.preventDefault();
}
2021-12-16 18:53:38 +00:00
let pressed = trigger.getAttribute("aria-pressed") === "false";
let targetId = trigger.dataset.controls;
// Toggle pressed status on all triggers controlling the same target.
2021-12-16 18:53:38 +00:00
document
.querySelectorAll('[data-controls="' + targetId + '"]')
.forEach((otherTrigger) =>
otherTrigger.setAttribute(
"aria-pressed",
otherTrigger.getAttribute("aria-pressed") === "false"
)
);
// @todo Find a better way to handle the exception.
2021-12-16 18:53:38 +00:00
if (targetId && !trigger.classList.contains("pulldown-menu")) {
let target = document.getElementById(targetId);
2021-12-16 18:53:38 +00:00
this.addRemoveClass(target, "is-hidden", !pressed);
this.addRemoveClass(target, "is-active", pressed);
}
// Show/hide pulldown-menus.
2021-12-16 18:53:38 +00:00
if (trigger.classList.contains("pulldown-menu")) {
this.toggleMenu(trigger, targetId);
}
// Show/hide container.
2021-12-16 18:53:38 +00:00
let container = document.getElementById("hide_" + targetId);
if (container) {
this.toggleContainer(container, pressed);
}
// Check checkbox, if appropriate.
let checkbox = trigger.dataset.controlsCheckbox;
if (checkbox) {
this.toggleCheckbox(checkbox, pressed);
}
2021-08-16 19:28:04 +00:00
// Toggle form disabled, if appropriate
let disable = trigger.dataset.disables;
2021-08-16 19:44:36 +00:00
2021-08-16 19:28:04 +00:00
if (disable) {
this.toggleDisabled(disable, !pressed);
}
// Set focus, if appropriate.
let focus = trigger.dataset.focusTarget;
if (focus) {
this.toggleFocus(focus);
}
2021-06-08 19:42:45 +00:00
2021-06-08 19:40:01 +00:00
return false;
}
2021-01-14 21:02:28 +00:00
/**
* Show or hide menus.
*
* @param {Event} event
* @return {undefined}
*/
toggleMenu(trigger, targetId) {
2021-12-16 18:53:38 +00:00
let expanded = trigger.getAttribute("aria-expanded") == "false";
2021-12-16 18:53:38 +00:00
trigger.setAttribute("aria-expanded", expanded);
if (targetId) {
let target = document.getElementById(targetId);
2021-12-16 18:53:38 +00:00
this.addRemoveClass(target, "is-active", expanded);
}
}
/**
* Show or hide generic containers.
*
* @param {object} container - DOM node
* @param {boolean} pressed - Is the trigger pressed?
* @return {undefined}
*/
toggleContainer(container, pressed) {
2021-12-16 18:53:38 +00:00
this.addRemoveClass(container, "is-hidden", pressed);
}
/**
* Check or uncheck a checkbox.
*
* @param {string} checkbox - id of the checkbox
* @param {boolean} pressed - Is the trigger pressed?
* @return {undefined}
*/
toggleCheckbox(checkbox, pressed) {
document.getElementById(checkbox).checked = !!pressed;
}
2021-08-16 19:28:04 +00:00
/**
* Enable or disable a form element or fieldset
*
* @param {string} form_element - id of the element
* @param {boolean} pressed - Is the trigger pressed?
* @return {undefined}
*/
toggleDisabled(form_element, pressed) {
document.getElementById(form_element).disabled = !!pressed;
}
/**
* Give the focus to an element.
2021-04-07 08:37:11 +00:00
* Only move the focus based on user interactions.
2021-04-07 05:53:30 +00:00
*
* @param {string} nodeId - ID of the DOM node to focus (button, link)
* @return {undefined}
*/
toggleFocus(nodeId) {
let node = document.getElementById(nodeId);
node.focus();
2021-12-16 18:53:38 +00:00
setTimeout(function () {
node.selectionStart = node.selectionEnd = 10000;
}, 0);
}
2021-04-06 15:57:39 +00:00
/**
* Make a request and update the UI accordingly.
* This function is used for boosts, favourites, follows and unfollows.
2021-04-06 15:57:39 +00:00
*
* @param {Event} event
* @return {undefined}
*/
interact(event) {
event.preventDefault();
const bookwyrm = this;
const form = event.currentTarget;
const relatedforms = document.querySelectorAll(`.${form.dataset.id}`);
// Toggle class on all related forms.
2021-12-16 18:53:38 +00:00
relatedforms.forEach((relatedForm) =>
bookwyrm.addRemoveClass(
relatedForm,
"is-hidden",
relatedForm.className.indexOf("is-hidden") == -1
)
);
2021-12-16 18:53:38 +00:00
this.ajaxPost(form).catch((error) => {
// @todo Display a notification in the UI instead.
2021-12-16 18:53:38 +00:00
console.warn("Request failed:", error);
});
2021-04-06 15:57:39 +00:00
}
/**
* Submit a form using POST.
*
* @param {object} form - Form to be submitted
* @return {Promise}
*/
ajaxPost(form) {
return fetch(form.action, {
2021-12-16 18:53:38 +00:00
method: "POST",
body: new FormData(form),
headers: {
2021-12-16 18:53:38 +00:00
Accept: "application/json",
},
});
}
/**
* Add or remove a class based on a boolean condition.
*
* @param {object} node - DOM node to change class on
* @param {string} classname - Name of the class
* @param {boolean} add - Add?
* @return {undefined}
*/
addRemoveClass(node, classname, add) {
if (add) {
node.classList.add(classname);
} else {
node.classList.remove(classname);
}
}
disableIfTooLarge(eventOrElement) {
2021-06-08 19:40:01 +00:00
const { addRemoveClass, MAX_FILE_SIZE_BYTES } = this;
const element = eventOrElement.currentTarget || eventOrElement;
2021-06-08 19:40:01 +00:00
const submits = element.form.querySelectorAll('[type="submit"]');
2021-12-16 18:53:38 +00:00
const warns = element.parentElement.querySelectorAll(".file-too-big");
const isTooBig =
element.files && element.files[0] && element.files[0].size > MAX_FILE_SIZE_BYTES;
if (isTooBig) {
2021-12-16 18:53:38 +00:00
submits.forEach((submitter) => (submitter.disabled = true));
warns.forEach((sib) => addRemoveClass(sib, "is-hidden", false));
} else {
2021-12-16 18:53:38 +00:00
submits.forEach((submitter) => (submitter.disabled = false));
warns.forEach((sib) => addRemoveClass(sib, "is-hidden", true));
}
}
2021-11-21 22:18:18 +00:00
2021-11-28 21:10:15 +00:00
/**
2022-01-01 17:17:11 +00:00
* Handle the modal component with a button trigger.
2021-11-28 21:10:15 +00:00
*
* @param {Event} event - Event fired by an element
* with the `data-modal-open` attribute
* pointing to a modal by its id.
* @return {undefined}
2021-12-17 16:00:10 +00:00
*
* See https://github.com/bookwyrm-social/bookwyrm/pull/1633
* for information about using the modal.
2021-11-28 21:10:15 +00:00
*/
handleModalButton(event) {
2022-01-02 15:01:11 +00:00
const { handleFocusTrap } = this;
2021-11-28 21:10:15 +00:00
const modalButton = event.currentTarget;
const targetModalId = modalButton.dataset.modalOpen;
2021-12-29 00:29:50 +00:00
const htmlElement = document.querySelector("html");
2021-11-28 17:34:54 +00:00
const modal = document.getElementById(targetModalId);
2021-12-29 00:29:50 +00:00
2021-11-28 17:34:54 +00:00
if (!modal) {
return;
}
// Helper functions
function handleModalOpen(modalElement) {
2021-12-31 03:09:03 +00:00
event.preventDefault();
2021-12-29 00:29:50 +00:00
htmlElement.classList.add("is-clipped");
modalElement.classList.add("is-active");
modalElement.getElementsByClassName("modal-card")[0].focus();
const closeButtons = modalElement.querySelectorAll("[data-modal-close]");
2021-12-29 00:29:50 +00:00
closeButtons.forEach((button) => {
2021-12-29 00:29:50 +00:00
button.addEventListener("click", function () {
handleModalClose(modalElement);
});
});
2021-12-29 00:29:50 +00:00
document.addEventListener("keydown", function (event) {
if (event.key === "Escape") {
handleModalClose(modalElement);
2021-11-28 17:34:54 +00:00
}
2021-12-29 00:29:50 +00:00
});
2021-12-29 00:29:50 +00:00
modalElement.addEventListener("keydown", handleFocusTrap);
}
function handleModalClose(modalElement) {
2021-12-29 00:29:50 +00:00
modalElement.removeEventListener("keydown", handleFocusTrap);
htmlElement.classList.remove("is-clipped");
modalElement.classList.remove("is-active");
modalButton.focus();
}
2021-11-28 17:34:54 +00:00
// Open modal
handleModalOpen(modal);
}
2021-12-02 20:16:05 +00:00
/**
* Handle the modal component when opened at page load.
*
* @param {Element} modalElement - Active modal element
* @return {undefined}
*
*/
handleActiveModal(modalElement) {
if (!modalElement) {
return;
}
2022-01-02 15:01:11 +00:00
const { handleFocusTrap } = this;
modalElement.getElementsByClassName("modal-card")[0].focus();
const closeButtons = modalElement.querySelectorAll("[data-modal-close]");
closeButtons.forEach((button) => {
button.addEventListener("click", function () {
handleModalClose(modalElement);
});
});
document.addEventListener("keydown", function (event) {
if (event.key === "Escape") {
handleModalClose(modalElement);
}
});
modalElement.addEventListener("keydown", handleFocusTrap);
function handleModalClose(modalElement) {
modalElement.removeEventListener("keydown", handleFocusTrap);
history.back();
}
}
2021-11-28 10:16:57 +00:00
/**
* Display pop up window.
*
* @param {string} url Url to open
* @param {string} windowName windowName
* @return {undefined}
*/
2021-11-29 08:45:52 +00:00
displayPopUp(url, windowName) {
2021-12-16 18:53:38 +00:00
window.open(url, windowName, "left=100,top=100,width=430,height=600");
2021-11-28 10:16:57 +00:00
}
2021-12-02 20:16:56 +00:00
2021-12-16 18:53:38 +00:00
duplicateInput(event) {
const trigger = event.currentTarget;
2021-12-16 18:53:38 +00:00
const input_id = trigger.dataset["duplicate"];
2021-11-21 22:18:18 +00:00
const orig = document.getElementById(input_id);
const parent = orig.parentNode;
2021-12-16 18:53:38 +00:00
const new_count = parent.querySelectorAll("input").length + 1;
2021-11-21 22:18:18 +00:00
let input = orig.cloneNode();
2021-12-16 18:53:38 +00:00
input.id += "-" + new_count;
input.value = "";
2021-11-21 22:18:18 +00:00
let label = parent.querySelector("label").cloneNode();
2021-12-16 18:53:38 +00:00
label.setAttribute("for", input.id);
2021-11-21 22:18:18 +00:00
2021-12-16 18:53:38 +00:00
parent.appendChild(label);
parent.appendChild(input);
2021-11-21 22:18:18 +00:00
}
2021-12-04 15:17:33 +00:00
/**
* Set up a "click-to-copy" component from a textarea element
* with `data-copytext`, `data-copytext-label`, `data-copytext-success`
* attributes.
*
* @param {object} node - DOM node of the text container
* @return {undefined}
*/
copyText(textareaEl) {
const text = textareaEl.textContent;
2021-12-16 18:53:38 +00:00
const copyButtonEl = document.createElement("button");
2021-12-04 15:17:33 +00:00
copyButtonEl.textContent = textareaEl.dataset.copytextLabel;
2021-12-27 21:39:34 +00:00
copyButtonEl.classList.add("button", "is-small", "is-primary", "is-light");
2021-12-16 18:53:38 +00:00
copyButtonEl.addEventListener("click", () => {
navigator.clipboard.writeText(text).then(function () {
textareaEl.classList.add("is-success");
copyButtonEl.classList.replace("is-primary", "is-success");
2021-12-04 15:31:38 +00:00
copyButtonEl.textContent = textareaEl.dataset.copytextSuccess;
});
2021-12-04 15:17:33 +00:00
});
2021-12-16 18:53:38 +00:00
textareaEl.parentNode.appendChild(copyButtonEl);
2021-12-04 15:17:33 +00:00
}
/**
* Handle the details dropdown component.
*
* @param {Event} event - Event fired by a `details` element
* with the `dropdown` class name, on toggle.
* @return {undefined}
*/
handleDetailsDropdown(event) {
const detailsElement = event.target;
2021-12-27 21:39:34 +00:00
const summaryElement = detailsElement.querySelector("summary");
const menuElement = detailsElement.querySelector(".dropdown-menu");
const htmlElement = document.querySelector("html");
if (detailsElement.open) {
// Focus first menu element
2021-12-27 21:39:34 +00:00
menuElement
.querySelectorAll("a[href]:not([disabled]), button:not([disabled])")[0]
.focus();
// Enable focus trap
2021-12-27 21:39:34 +00:00
menuElement.addEventListener("keydown", this.handleFocusTrap);
// Close on Esc
2021-12-27 21:39:34 +00:00
detailsElement.addEventListener("keydown", handleEscKey);
// Clip page if Mobile
if (this.isMobile()) {
2021-12-27 21:39:34 +00:00
htmlElement.classList.add("is-clipped");
}
} else {
summaryElement.focus();
2021-12-27 21:39:34 +00:00
// Disable focus trap
2021-12-27 21:39:34 +00:00
menuElement.removeEventListener("keydown", this.handleFocusTrap);
// Unclip page
if (this.isMobile()) {
2021-12-27 21:39:34 +00:00
htmlElement.classList.remove("is-clipped");
}
}
function handleEscKey(event) {
2021-12-27 21:39:34 +00:00
if (event.key !== "Escape") {
return;
}
summaryElement.click();
}
}
/**
* Check if windows matches mobile media query.
*
* @return {Boolean}
*/
isMobile() {
return window.matchMedia("(max-width: 768px)").matches;
}
/**
* Focus trap handler
*
* @param {Event} event - Keydown event.
* @return {undefined}
*/
handleFocusTrap(event) {
2021-12-27 21:39:34 +00:00
if (event.key !== "Tab") {
return;
}
const focusableEls = event.currentTarget.querySelectorAll(
[
2021-12-27 21:39:34 +00:00
"a[href]:not([disabled])",
"button:not([disabled])",
"textarea:not([disabled])",
'input:not([type="hidden"]):not([disabled])',
2021-12-27 21:39:34 +00:00
"select:not([disabled])",
"details:not([disabled])",
'[tabindex]:not([tabindex="-1"]):not([disabled])',
].join(",")
);
2021-12-27 21:39:34 +00:00
const firstFocusableEl = focusableEls[0];
const lastFocusableEl = focusableEls[focusableEls.length - 1];
2021-12-27 21:39:34 +00:00
if (event.shiftKey) {
/* Shift + tab */ if (document.activeElement === firstFocusableEl) {
lastFocusableEl.focus();
event.preventDefault();
}
2021-12-27 21:39:34 +00:00
} /* Tab */ else {
if (document.activeElement === lastFocusableEl) {
firstFocusableEl.focus();
event.preventDefault();
}
}
}
2021-12-16 18:53:38 +00:00
})();