aboutsummaryrefslogtreecommitdiff
path: root/asset-sources/fietsboek.ts
diff options
context:
space:
mode:
authorDaniel Schadt <kingdread@gmx.de>2023-03-24 23:41:52 +0100
committerDaniel Schadt <kingdread@gmx.de>2023-03-24 23:41:52 +0100
commitc05da87ec1039278ae77d3e3b53432614398d8a8 (patch)
tree452c60b09eb00b9738f4673833c5e88215c9539e /asset-sources/fietsboek.ts
parent4425cb797baf29c106922f4d46e9a154f95d3050 (diff)
downloadfietsboek-c05da87ec1039278ae77d3e3b53432614398d8a8.tar.gz
fietsboek-c05da87ec1039278ae77d3e3b53432614398d8a8.tar.bz2
fietsboek-c05da87ec1039278ae77d3e3b53432614398d8a8.zip
move (most of the) JavaScript code to TypeScript
Yay, types! (And a lot of escape hatches)
Diffstat (limited to 'asset-sources/fietsboek.ts')
-rw-r--r--asset-sources/fietsboek.ts408
1 files changed, 408 insertions, 0 deletions
diff --git a/asset-sources/fietsboek.ts b/asset-sources/fietsboek.ts
new file mode 100644
index 0000000..aed2852
--- /dev/null
+++ b/asset-sources/fietsboek.ts
@@ -0,0 +1,408 @@
+declare const FRIENDS_URL: string;
+declare const LOCALE: string;
+
+/**
+ * Object that we get back from the friend API.
+ */
+interface JsonFriend {
+ name: string,
+ id: number,
+}
+
+interface Window {
+ fietsboekImageIndex: number,
+ fietsboekCurrentImage: HTMLDivElement | null,
+}
+
+// Make eslint happy about the Window redefinition
+(_: Window) => null;
+
+/**
+ * Type alias to make it clear what kind of string we are expecting.
+ */
+type Selector = string;
+
+/**
+ * Installs a listener to the given DOM objects.
+ *
+ * @param selector - The query selector to find the DOM objects.
+ * @param event - The event name to listen to.
+ * @param handler - The handler function.
+ */
+function addHandler<K extends keyof GlobalEventHandlersEventMap>(
+ selector: Selector,
+ event: K,
+ handler: (ev: GlobalEventHandlersEventMap[K]) => any,
+) {
+ document.querySelectorAll(selector).
+ forEach((obj) => obj.addEventListener(event, handler as EventListener));
+}
+
+/**
+ * Handler for when a tag is clicked. Removes the tag from the tag list.
+ *
+ * @param event - The triggering event.
+ */
+function tagClicked(event: MouseEvent) {
+ const span = (event.target as HTMLElement).closest('span')!;
+ span.parentNode!.removeChild(span);
+}
+
+addHandler(".tag-badge", "click", tagClicked);
+
+/**
+ * Handler to add a new tag when the button is pressed.
+ */
+function addTag() {
+ const newTag = document.querySelector("#new-tag") as HTMLInputElement;
+ if (newTag.value === "") {
+ return;
+ }
+ const node = document.createElement("span");
+ node.classList.add("tag-badge");
+ node.classList.add("badge");
+ node.classList.add("rounded-pill");
+ node.classList.add("bg-info");
+ node.classList.add("text-dark");
+ node.addEventListener("click", tagClicked);
+ const text = document.createTextNode(newTag.value);
+ node.appendChild(text);
+ const icon = document.createElement("i");
+ icon.classList.add("bi");
+ icon.classList.add("bi-x");
+ node.appendChild(icon);
+ const input = document.createElement("input");
+ input.hidden = true;
+ input.name = "tag[]";
+ input.value = newTag.value;
+ node.appendChild(input);
+ document.querySelector("#formTags")?.appendChild(node);
+ const space = document.createTextNode(" ");
+ document.querySelector("#formTags")?.appendChild(space);
+ newTag.value = "";
+}
+
+addHandler("#add-tag-btn", "click", addTag);
+// Also add a tag when enter is pressed
+addHandler("#new-tag", "keypress", (event) => {
+ if (event.code == "Enter") {
+ event.preventDefault();
+ addTag();
+ }
+});
+
+/**
+ * Function to check for password validity.
+ *
+ * @param main - Selector for the actual entered password input.
+ * @param repeat - Selector for the repeated password, must match `main`.
+ */
+function checkPasswordValidity(main: Selector, repeat: Selector) {
+ const mainPassword = document.querySelector(main) as HTMLInputElement;
+ const repeatPassword = document.querySelector(repeat) as HTMLInputElement;
+
+ const form = mainPassword.closest('form')!;
+ form.classList.remove('was-validated');
+
+ // Check password requirements. The validation errors are not actually
+ // displayed, as the HTML template contains pre-filled texts for that.
+ if (mainPassword.value.length != 0 && mainPassword.value.length < 8) {
+ mainPassword.setCustomValidity('Too short');
+ } else {
+ mainPassword.setCustomValidity('');
+ }
+
+ if (mainPassword.value != repeatPassword.value) {
+ repeatPassword.setCustomValidity('Needs to match');
+ } else {
+ repeatPassword.setCustomValidity('');
+ }
+}
+
+// This function is used via a HTML onchange= handler, so make eslint happy
+checkPasswordValidity;
+
+/**
+ * Function to check for name validity.
+ *
+ * @param name - Selector name that should be checked.
+ */
+function checkNameValidity(name: Selector) {
+ const nameField = document.querySelector(name) as HTMLInputElement;
+ if (nameField.value.length == 0) {
+ nameField.setCustomValidity('Needs a name');
+ }
+}
+
+// This function is used via a HTML onchange= handler, so make eslint happy
+checkNameValidity;
+
+/**
+ * Hit the endpoint to search for friends. This populates the friend selector
+ * when tagging friends.
+ */
+function searchFriends() {
+ const searchPattern = (document.querySelector("#friendSearchQuery") as HTMLInputElement).
+ value.toLowerCase();
+ const friendSearch = document.querySelector("#friendSearch")!;
+ friendSearch.innerHTML = "";
+ fetch(FRIENDS_URL)
+ .then((response) => response.json())
+ .then((response: [JsonFriend]) => {
+ const blueprint = document.querySelector("#friendSearchBlueprint") as HTMLLIElement;
+
+ // Only show friends with a matching name
+ const friends = response.filter(
+ (obj) => obj.name.toLowerCase().indexOf(searchPattern) != -1
+ );
+
+ friends.forEach((friend) => {
+ const copy = blueprint.cloneNode(true) as HTMLLIElement;
+ copy.removeAttribute("id");
+ (copy.querySelector(".friend-name") as HTMLSpanElement).textContent = friend.name;
+ copy.querySelector("button")?.addEventListener("click", (event: MouseEvent) => {
+ const button = (event.target as HTMLElement).closest("button")!;
+ button.parentNode!.parentNode!.removeChild(button.parentNode!);
+
+ const added = document.querySelector("#friendAddedBlueprint")!.
+ cloneNode(true) as HTMLLIElement;
+ added.removeAttribute("id");
+ (added.querySelector(".friend-name") as HTMLSpanElement).
+ textContent = friend.name;
+ added.querySelector("input")!.value = friend.id.toString();
+ added.querySelector("input")!.removeAttribute("disabled");
+ added.querySelector("button")!.addEventListener("click", removeFriendClicked);
+ document.querySelector('#taggedFriends')!.appendChild(added);
+ });
+ friendSearch.appendChild(copy);
+ });
+ });
+}
+
+addHandler("#add-friend-btn", "click", () => searchFriends());
+// Also trigger the search on Enter keypress
+addHandler("#friendSearchQuery", "keypress", (event) => {
+ if (event.code == "Enter") {
+ event.preventDefault();
+ searchFriends();
+ }
+});
+
+/**
+ * Handler for when a "Remove friend" button is clicked.
+ *
+ * @param event - The triggering event.
+ */
+function removeFriendClicked(event: MouseEvent) {
+ const button = (event.target as HTMLElement).closest("button")!;
+ button.parentNode!.parentNode!.removeChild(button.parentNode!);
+}
+
+addHandler(".remove-friend-button", "click", removeFriendClicked);
+
+/**
+ * Handler for when the image input is changed.
+ *
+ * This handler splits the multiple images up into single input fields, such
+ * that each one can be removed individually. It also adds preview images, and
+ * adds the button to delete and edit the image's description.
+ *
+ * @param event - The triggering event.
+ */
+function imageSelectorChanged(event: Event) {
+ console.log(event);
+ const target = event.target as HTMLInputElement;
+ for (const file of Array.from(target.files ?? [])) {
+ window.fietsboekImageIndex++;
+
+ const input = document.createElement("input");
+ input.type = "file";
+ input.hidden = true;
+ input.name = `image[${window.fietsboekImageIndex}]`;
+
+ const transfer = new DataTransfer();
+ transfer.items.add(file);
+ input.files = transfer.files;
+
+ const preview = document.querySelector("#trackImagePreviewBlueprint")!.
+ cloneNode(true) as HTMLDivElement;
+ preview.removeAttribute("id");
+ preview.querySelector("img")!.src = URL.createObjectURL(file);
+ preview.querySelector("button.delete-image")!.
+ addEventListener("click", deleteImageButtonClicked as EventListener);
+ preview.querySelector("button.edit-image-description")!.
+ addEventListener("click", editImageDescriptionClicked as EventListener);
+ (preview.querySelector("input.image-description-input") as HTMLInputElement).
+ name = `image-description[${window.fietsboekImageIndex}]`;
+ preview.appendChild(input);
+
+ document.querySelector("#trackImageList")!.appendChild(preview);
+ }
+
+ target.value = "";
+}
+
+addHandler("#imageSelector", "change", imageSelectorChanged);
+
+/**
+ * Handler to remove a picture from a track.
+ *
+ * @param event - The triggering event.
+ */
+function deleteImageButtonClicked(event: MouseEvent) {
+ const preview = (event.target as HTMLElement).closest("div.track-image-preview")!;
+ /* If this was a image yet to be uploaded, simply remove it */
+ const input = preview.querySelector("input[type=file]");
+ if (input) {
+ preview.parentNode!.removeChild(preview);
+ return;
+ }
+
+ /* Otherwise, we need to remove it but also insert a "delete-image" input */
+ const deleter = preview.querySelector("input.image-deleter-input")!;
+ deleter.removeAttribute("disabled");
+ preview.removeChild(deleter);
+ preview.parentNode!.appendChild(deleter);
+ preview.parentNode!.removeChild(preview);
+}
+
+addHandler("button.delete-image", "click", deleteImageButtonClicked);
+
+/**
+ * Handler to show the image description editor.
+ *
+ * @param event - The triggering event.
+ */
+function editImageDescriptionClicked(event: MouseEvent) {
+ window.fietsboekCurrentImage = (event.target as HTMLElement).closest("div")!;
+
+ const imageInput = (
+ <HTMLInputElement>
+ window.fietsboekCurrentImage.querySelector("input.image-description-input")
+ );
+ const currentDescription = imageInput.value;
+ const modalDom = document.getElementById("imageDescriptionModal")!;
+ modalDom.querySelector("textarea")!.value = currentDescription;
+
+ const modal = bootstrap.Modal.getOrCreateInstance(modalDom, {});
+ modal.show();
+}
+
+addHandler("button.edit-image-description", "click", editImageDescriptionClicked);
+
+/**
+ * Handler to save the image description of the currently edited image.
+ *
+ * @param event - The triggering event.
+ */
+function saveImageDescriptionClicked(_event: MouseEvent) {
+ const modalDom = document.getElementById("imageDescriptionModal")!;
+ const wantedDescription = modalDom.querySelector("textarea")!.value;
+ (window.fietsboekCurrentImage!.
+ querySelector("input.image-description-input") as HTMLInputElement).
+ value = wantedDescription;
+ window.fietsboekCurrentImage!.
+ querySelector("img")!.title = wantedDescription;
+
+ const modal = bootstrap.Modal.getOrCreateInstance(modalDom, {});
+ modal.hide();
+
+ window.fietsboekCurrentImage = null;
+}
+
+addHandler("#imageDescriptionModal button.btn-success", "click", saveImageDescriptionClicked);
+
+/**
+ * Handler to toggle (collapse/expand) the yearly/monthly summary.
+ *
+ * @param event - The triggering event.
+ */
+function toggleSummary(event: MouseEvent) {
+ const chevron = event.target as HTMLElement;
+ const containing = chevron.closest("a")!;
+ const summary = containing.nextElementSibling!;
+ bootstrap.Collapse.getOrCreateInstance(summary).toggle();
+ if (chevron.classList.contains("bi-chevron-down")) {
+ chevron.classList.remove("bi-chevron-down");
+ chevron.classList.add("bi-chevron-right");
+ } else {
+ chevron.classList.remove("bi-chevron-right");
+ chevron.classList.add("bi-chevron-down");
+ }
+}
+
+addHandler(".summary-toggler", "click", toggleSummary);
+
+/*
+ * Handler to enable the "Download archive button" ...
+ */
+addHandler("#archiveDownloadButton", "click", () => {
+ const checked = document.querySelectorAll(".archive-checkbox:checked");
+ const url = new URL("/track/archive", window.location.href);
+ checked.forEach((c) => {
+ url.searchParams.append("track_id[]", (c as HTMLInputElement).value);
+ });
+ window.location.assign(url);
+});
+/*
+ * ... and the listeners on the checkboxes to disable and enable the button.
+ */
+addHandler(".archive-checkbox", "change", () => {
+ const checked = document.querySelectorAll(".archive-checkbox:checked");
+ const downloadButton = document.querySelector("#archiveDownloadButton") as HTMLButtonElement;
+ downloadButton.disabled = (checked.length == 0);
+});
+
+/**
+ * Handler to clear the input when a .button-clear-input is pressed.
+ *
+ * The button must be in an input-group with the input.
+ *
+ * @param event - The triggering event.
+ */
+function clearInputButtonClicked(event: MouseEvent) {
+ const target = event.target as HTMLElement;
+ target.closest(".input-group")!.querySelectorAll("input").forEach((i) => i.value = "");
+ target.closest(".input-group")!.querySelectorAll("select").forEach((i) => i.value = "");
+}
+
+addHandler(".button-clear-input", "click", clearInputButtonClicked);
+
+
+document.addEventListener('DOMContentLoaded', function() {
+ window.fietsboekImageIndex = 0;
+
+ /* Enable tooltips */
+ const tooltipTriggerList = [].slice.call(
+ document.querySelectorAll('[data-bs-toggle="tooltip"]')
+ );
+ tooltipTriggerList.map((tooltipTriggerEl) => {
+ return new bootstrap.Tooltip(tooltipTriggerEl, { sanitize: false });
+ });
+
+ /* Enable Bootstrap form validation */
+ const forms = document.querySelectorAll('.needs-validation');
+ Array.from(forms).forEach((form) => {
+ form.addEventListener('submit', (event) => {
+ if (!(form as HTMLFormElement).checkValidity()) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ form.classList.add('was-validated');
+ }, false);
+ });
+
+ /* Format all datetimes to the local timezone */
+ document.querySelectorAll(".fietsboek-local-datetime").forEach((obj) => {
+ const timestamp = parseFloat(obj.attributes.getNamedItem("data-utc-timestamp")!.value);
+ const date = new Date(timestamp * 1000);
+ // TypeScript complains about this, but according to MDN it is fine, at
+ // least in "somewhat modern" browsers
+ const intl = new Intl.DateTimeFormat(LOCALE, {
+ dateStyle: "medium",
+ timeStyle: "medium",
+ } as any);
+ obj.innerHTML = intl.format(date);
+ });
+});