diff options
author | 2023-08-10 19:46:45 -0700 | |
---|---|---|
committer | 2023-08-10 20:29:34 -0700 | |
commit | 168a870c025bfef6efdeb46e166e79a16093c157 (patch) | |
tree | 4d8ab69c7e3ef03a7ade06e7b5e5053429a64c3b /internal/ui/static/js | |
parent | c2349032552891745cbbc3d2a9e772845a0239f4 (diff) | |
download | v2-168a870c025bfef6efdeb46e166e79a16093c157.tar.gz v2-168a870c025bfef6efdeb46e166e79a16093c157.tar.zst v2-168a870c025bfef6efdeb46e166e79a16093c157.zip |
Move internal packages to an internal folder
For reference: https://go.dev/doc/go1.4#internalpackages
Diffstat (limited to 'internal/ui/static/js')
-rw-r--r-- | internal/ui/static/js/.jshintrc | 3 | ||||
-rw-r--r-- | internal/ui/static/js/app.js | 692 | ||||
-rw-r--r-- | internal/ui/static/js/bootstrap.js | 126 | ||||
-rw-r--r-- | internal/ui/static/js/dom_helper.js | 65 | ||||
-rw-r--r-- | internal/ui/static/js/keyboard_handler.js | 72 | ||||
-rw-r--r-- | internal/ui/static/js/modal_handler.js | 101 | ||||
-rw-r--r-- | internal/ui/static/js/request_builder.js | 48 | ||||
-rw-r--r-- | internal/ui/static/js/service_worker.js | 44 | ||||
-rw-r--r-- | internal/ui/static/js/touch_handler.js | 187 |
9 files changed, 1338 insertions, 0 deletions
diff --git a/internal/ui/static/js/.jshintrc b/internal/ui/static/js/.jshintrc new file mode 100644 index 00000000..80fc4c09 --- /dev/null +++ b/internal/ui/static/js/.jshintrc @@ -0,0 +1,3 @@ +{ + "esversion": 8 +}
\ No newline at end of file diff --git a/internal/ui/static/js/app.js b/internal/ui/static/js/app.js new file mode 100644 index 00000000..a89da8bd --- /dev/null +++ b/internal/ui/static/js/app.js @@ -0,0 +1,692 @@ +// OnClick attaches a listener to the elements that match the selector. +function onClick(selector, callback, noPreventDefault) { + let elements = document.querySelectorAll(selector); + elements.forEach((element) => { + element.onclick = (event) => { + if (!noPreventDefault) { + event.preventDefault(); + } + + callback(event); + }; + }); +} + +function onAuxClick(selector, callback, noPreventDefault) { + let elements = document.querySelectorAll(selector); + elements.forEach((element) => { + element.onauxclick = (event) => { + if (!noPreventDefault) { + event.preventDefault(); + } + + callback(event); + }; + }); +} + +// Show and hide the main menu on mobile devices. +function toggleMainMenu() { + let menu = document.querySelector(".header nav ul"); + if (DomHelper.isVisible(menu)) { + menu.style.display = "none"; + } else { + menu.style.display = "block"; + } + + let searchElement = document.querySelector(".header .search"); + if (DomHelper.isVisible(searchElement)) { + searchElement.style.display = "none"; + } else { + searchElement.style.display = "block"; + } +} + +// Handle click events for the main menu (<li> and <a>). +function onClickMainMenuListItem(event) { + let element = event.target; + + if (element.tagName === "A") { + window.location.href = element.getAttribute("href"); + } else { + window.location.href = element.querySelector("a").getAttribute("href"); + } +} + +// Change the button label when the page is loading. +function handleSubmitButtons() { + let elements = document.querySelectorAll("form"); + elements.forEach((element) => { + element.onsubmit = () => { + let button = element.querySelector("button"); + + if (button) { + button.innerHTML = button.dataset.labelLoading; + button.disabled = true; + } + }; + }); +} + +// Set cursor focus to the search input. +function setFocusToSearchInput(event) { + event.preventDefault(); + event.stopPropagation(); + + let toggleSwitchElement = document.querySelector(".search-toggle-switch"); + if (toggleSwitchElement) { + toggleSwitchElement.style.display = "none"; + } + + let searchFormElement = document.querySelector(".search-form"); + if (searchFormElement) { + searchFormElement.style.display = "block"; + } + + let searchInputElement = document.getElementById("search-input"); + if (searchInputElement) { + searchInputElement.focus(); + searchInputElement.value = ""; + } +} + +// Show modal dialog with the list of keyboard shortcuts. +function showKeyboardShortcuts() { + let template = document.getElementById("keyboard-shortcuts"); + if (template !== null) { + ModalHandler.open(template.content, "dialog-title"); + } +} + +// Mark as read visible items of the current page. +function markPageAsRead() { + let items = DomHelper.getVisibleElements(".items .item"); + let entryIDs = []; + + items.forEach((element) => { + element.classList.add("item-status-read"); + entryIDs.push(parseInt(element.dataset.id, 10)); + }); + + if (entryIDs.length > 0) { + updateEntriesStatus(entryIDs, "read", () => { + // Make sure the Ajax request reach the server before we reload the page. + + let element = document.querySelector("a[data-action=markPageAsRead]"); + let showOnlyUnread = false; + if (element) { + showOnlyUnread = element.dataset.showOnlyUnread || false; + } + + if (showOnlyUnread) { + window.location.href = window.location.href; + } else { + goToPage("next", true); + } + }); + } +} + +/** + * Handle entry status changes from the list view and entry view. + * Focus the next or the previous entry if it exists. + * @param {string} item Item to focus: "previous" or "next". + * @param {Element} element + * @param {boolean} setToRead + */ +function handleEntryStatus(item, element, setToRead) { + let toasting = !element; + let currentEntry = findEntry(element); + if (currentEntry) { + if (!setToRead || currentEntry.querySelector("a[data-toggle-status]").dataset.value == "unread") { + toggleEntryStatus(currentEntry, toasting); + } + if (isListView() && currentEntry.classList.contains('current-item')) { + switch (item) { + case "previous": + goToListItem(-1); + break; + case "next": + goToListItem(1); + break; + } + } + } +} + +// Change the entry status to the opposite value. +function toggleEntryStatus(element, toasting) { + let entryID = parseInt(element.dataset.id, 10); + let link = element.querySelector("a[data-toggle-status]"); + + let currentStatus = link.dataset.value; + let newStatus = currentStatus === "read" ? "unread" : "read"; + + link.querySelector("span").innerHTML = link.dataset.labelLoading; + updateEntriesStatus([entryID], newStatus, () => { + let iconElement, label; + + if (currentStatus === "read") { + iconElement = document.querySelector("template#icon-read"); + label = link.dataset.labelRead; + if (toasting) { + showToast(link.dataset.toastUnread, iconElement); + } + } else { + iconElement = document.querySelector("template#icon-unread"); + label = link.dataset.labelUnread; + if (toasting) { + showToast(link.dataset.toastRead, iconElement); + } + } + + link.innerHTML = iconElement.innerHTML + '<span class="icon-label">' + label + '</span>'; + link.dataset.value = newStatus; + + if (element.classList.contains("item-status-" + currentStatus)) { + element.classList.remove("item-status-" + currentStatus); + element.classList.add("item-status-" + newStatus); + } + }); +} + +// Mark a single entry as read. +function markEntryAsRead(element) { + if (element.classList.contains("item-status-unread")) { + element.classList.remove("item-status-unread"); + element.classList.add("item-status-read"); + + let entryID = parseInt(element.dataset.id, 10); + updateEntriesStatus([entryID], "read"); + } +} + +// Send the Ajax request to refresh all feeds in the background +function handleRefreshAllFeeds() { + let url = document.body.dataset.refreshAllFeedsUrl; + let request = new RequestBuilder(url); + + request.withCallback(() => { + window.location.reload(); + }); + + request.withHttpMethod("GET"); + request.execute(); +} + +// Send the Ajax request to change entries statuses. +function updateEntriesStatus(entryIDs, status, callback) { + let url = document.body.dataset.entriesStatusUrl; + let request = new RequestBuilder(url); + request.withBody({entry_ids: entryIDs, status: status}); + request.withCallback((resp) => { + resp.json().then(count => { + if (callback) { + callback(resp); + } + + if (status === "read") { + decrementUnreadCounter(count); + } else { + incrementUnreadCounter(count); + } + }); + }); + request.execute(); +} + +// Handle save entry from list view and entry view. +function handleSaveEntry(element) { + let toasting = !element; + let currentEntry = findEntry(element); + if (currentEntry) { + saveEntry(currentEntry.querySelector("a[data-save-entry]"), toasting); + } +} + +// Send the Ajax request to save an entry. +function saveEntry(element, toasting) { + if (!element) { + return; + } + + if (element.dataset.completed) { + return; + } + + let previousInnerHTML = element.innerHTML; + element.innerHTML = '<span class="icon-label">' + element.dataset.labelLoading + '</span>'; + + let request = new RequestBuilder(element.dataset.saveUrl); + request.withCallback(() => { + element.innerHTML = previousInnerHTML; + element.dataset.completed = true; + if (toasting) { + let iconElement = document.querySelector("template#icon-save"); + showToast(element.dataset.toastDone, iconElement); + } + }); + request.execute(); +} + +// Handle bookmark from the list view and entry view. +function handleBookmark(element) { + let toasting = !element; + let currentEntry = findEntry(element); + if (currentEntry) { + toggleBookmark(currentEntry, toasting); + } +} + +// Send the Ajax request and change the icon when bookmarking an entry. +function toggleBookmark(parentElement, toasting) { + let element = parentElement.querySelector("a[data-toggle-bookmark]"); + if (!element) { + return; + } + + element.innerHTML = '<span class="icon-label">' + element.dataset.labelLoading + '</span>'; + + let request = new RequestBuilder(element.dataset.bookmarkUrl); + request.withCallback(() => { + + let currentStarStatus = element.dataset.value; + let newStarStatus = currentStarStatus === "star" ? "unstar" : "star"; + + let iconElement, label; + + if (currentStarStatus === "star") { + iconElement = document.querySelector("template#icon-star"); + label = element.dataset.labelStar; + if (toasting) { + showToast(element.dataset.toastUnstar, iconElement); + } + } else { + iconElement = document.querySelector("template#icon-unstar"); + label = element.dataset.labelUnstar; + if (toasting) { + showToast(element.dataset.toastStar, iconElement); + } + } + + element.innerHTML = iconElement.innerHTML + '<span class="icon-label">' + label + '</span>'; + element.dataset.value = newStarStatus; + }); + request.execute(); +} + +// Send the Ajax request to download the original web page. +function handleFetchOriginalContent() { + if (isListView()) { + return; + } + + let element = document.querySelector("a[data-fetch-content-entry]"); + if (!element) { + return; + } + + let previousInnerHTML = element.innerHTML; + element.innerHTML = '<span class="icon-label">' + element.dataset.labelLoading + '</span>'; + + let request = new RequestBuilder(element.dataset.fetchContentUrl); + request.withCallback((response) => { + element.innerHTML = previousInnerHTML; + + response.json().then((data) => { + if (data.hasOwnProperty("content") && data.hasOwnProperty("reading_time")) { + document.querySelector(".entry-content").innerHTML = data.content; + document.querySelector(".entry-reading-time").innerHTML = data.reading_time; + } + }); + }); + request.execute(); +} + +function openOriginalLink(openLinkInCurrentTab) { + let entryLink = document.querySelector(".entry h1 a"); + if (entryLink !== null) { + if (openLinkInCurrentTab) { + window.location.href = entryLink.getAttribute("href"); + } else { + DomHelper.openNewTab(entryLink.getAttribute("href")); + } + return; + } + + let currentItemOriginalLink = document.querySelector(".current-item a[data-original-link]"); + if (currentItemOriginalLink !== null) { + DomHelper.openNewTab(currentItemOriginalLink.getAttribute("href")); + + let currentItem = document.querySelector(".current-item"); + // If we are not on the list of starred items, move to the next item + if (document.location.href != document.querySelector('a[data-page=starred]').href) { + goToListItem(1); + } + markEntryAsRead(currentItem); + } +} + +function openCommentLink(openLinkInCurrentTab) { + if (!isListView()) { + let entryLink = document.querySelector("a[data-comments-link]"); + if (entryLink !== null) { + if (openLinkInCurrentTab) { + window.location.href = entryLink.getAttribute("href"); + } else { + DomHelper.openNewTab(entryLink.getAttribute("href")); + } + return; + } + } else { + let currentItemCommentsLink = document.querySelector(".current-item a[data-comments-link]"); + if (currentItemCommentsLink !== null) { + DomHelper.openNewTab(currentItemCommentsLink.getAttribute("href")); + } + } +} + +function openSelectedItem() { + let currentItemLink = document.querySelector(".current-item .item-title a"); + if (currentItemLink !== null) { + window.location.href = currentItemLink.getAttribute("href"); + } +} + +function unsubscribeFromFeed() { + let unsubscribeLinks = document.querySelectorAll("[data-action=remove-feed]"); + if (unsubscribeLinks.length === 1) { + let unsubscribeLink = unsubscribeLinks[0]; + + let request = new RequestBuilder(unsubscribeLink.dataset.url); + request.withCallback(() => { + if (unsubscribeLink.dataset.redirectUrl) { + window.location.href = unsubscribeLink.dataset.redirectUrl; + } else { + window.location.reload(); + } + }); + request.execute(); + } +} + +/** + * @param {string} page Page to redirect to. + * @param {boolean} fallbackSelf Refresh actual page if the page is not found. + */ +function goToPage(page, fallbackSelf) { + let element = document.querySelector("a[data-page=" + page + "]"); + + if (element) { + document.location.href = element.href; + } else if (fallbackSelf) { + window.location.reload(); + } +} + +function goToPrevious() { + if (isListView()) { + goToListItem(-1); + } else { + goToPage("previous"); + } +} + +function goToNext() { + if (isListView()) { + goToListItem(1); + } else { + goToPage("next"); + } +} + +function goToFeedOrFeeds() { + if (isEntry()) { + goToFeed(); + } else { + goToPage('feeds'); + } +} + +function goToFeed() { + if (isEntry()) { + let feedAnchor = document.querySelector("span.entry-website a"); + if (feedAnchor !== null) { + window.location.href = feedAnchor.href; + } + } else { + let currentItemFeed = document.querySelector(".current-item a[data-feed-link]"); + if (currentItemFeed !== null) { + window.location.href = currentItemFeed.getAttribute("href"); + } + } +} + +/** + * @param {number} offset How many items to jump for focus. + */ +function goToListItem(offset) { + let items = DomHelper.getVisibleElements(".items .item"); + if (items.length === 0) { + return; + } + + if (document.querySelector(".current-item") === null) { + items[0].classList.add("current-item"); + items[0].querySelector('.item-header a').focus(); + return; + } + + for (let i = 0; i < items.length; i++) { + if (items[i].classList.contains("current-item")) { + items[i].classList.remove("current-item"); + + let item = items[(i + offset + items.length) % items.length]; + + item.classList.add("current-item"); + DomHelper.scrollPageTo(item); + item.querySelector('.item-header a').focus(); + + break; + } + } +} + +function scrollToCurrentItem() { + let currentItem = document.querySelector(".current-item"); + if (currentItem !== null) { + DomHelper.scrollPageTo(currentItem, true); + } +} + +function decrementUnreadCounter(n) { + updateUnreadCounterValue((current) => { + return current - n; + }); +} + +function incrementUnreadCounter(n) { + updateUnreadCounterValue((current) => { + return current + n; + }); +} + +function updateUnreadCounterValue(callback) { + let counterElements = document.querySelectorAll("span.unread-counter"); + counterElements.forEach((element) => { + let oldValue = parseInt(element.textContent, 10); + element.innerHTML = callback(oldValue); + }); + + if (window.location.href.endsWith('/unread')) { + let oldValue = parseInt(document.title.split('(')[1], 10); + let newValue = callback(oldValue); + + document.title = document.title.replace( + /(.*?)\(\d+\)(.*?)/, + function (match, prefix, suffix, offset, string) { + return prefix + '(' + newValue + ')' + suffix; + } + ); + } +} + +function isEntry() { + return document.querySelector("section.entry") !== null; +} + +function isListView() { + return document.querySelector(".items") !== null; +} + +function findEntry(element) { + if (isListView()) { + if (element) { + return DomHelper.findParent(element, "item"); + } else { + return document.querySelector(".current-item"); + } + } else { + return document.querySelector(".entry"); + } +} + +function handleConfirmationMessage(linkElement, callback) { + if (linkElement.tagName != 'A') { + linkElement = linkElement.parentNode; + } + + linkElement.style.display = "none"; + + let containerElement = linkElement.parentNode; + let questionElement = document.createElement("span"); + + function createLoadingElement() { + let loadingElement = document.createElement("span"); + loadingElement.className = "loading"; + loadingElement.appendChild(document.createTextNode(linkElement.dataset.labelLoading)); + + questionElement.remove(); + containerElement.appendChild(loadingElement); + } + + let yesElement = document.createElement("a"); + yesElement.href = "#"; + yesElement.appendChild(document.createTextNode(linkElement.dataset.labelYes)); + yesElement.onclick = (event) => { + event.preventDefault(); + + createLoadingElement(); + + callback(linkElement.dataset.url, linkElement.dataset.redirectUrl); + }; + + let noElement = document.createElement("a"); + noElement.href = "#"; + noElement.appendChild(document.createTextNode(linkElement.dataset.labelNo)); + noElement.onclick = (event) => { + event.preventDefault(); + + const noActionUrl = linkElement.dataset.noActionUrl; + if (noActionUrl) { + createLoadingElement(); + + callback(noActionUrl, linkElement.dataset.redirectUrl); + } else { + linkElement.style.display = "inline"; + questionElement.remove(); + } + }; + + questionElement.className = "confirm"; + questionElement.appendChild(document.createTextNode(linkElement.dataset.labelQuestion + " ")); + questionElement.appendChild(yesElement); + questionElement.appendChild(document.createTextNode(", ")); + questionElement.appendChild(noElement); + + containerElement.appendChild(questionElement); +} + +function showToast(label, iconElement) { + if (!label || !iconElement) { + return; + } + + const toastMsgElement = document.getElementById("toast-msg"); + if (toastMsgElement) { + toastMsgElement.innerHTML = iconElement.innerHTML + '<span class="icon-label">' + label + '</span>'; + + const toastElementWrapper = document.getElementById("toast-wrapper"); + if (toastElementWrapper) { + toastElementWrapper.classList.remove('toast-animate'); + setTimeout(function () { + toastElementWrapper.classList.add('toast-animate'); + }, 100); + } + } +} + +/** Navigate to the new subscription page. */ +function goToAddSubscription() { + window.location.href = document.body.dataset.addSubscriptionUrl; +} + +/** + * save player position to allow to resume playback later + * @param {Element} playerElement + */ +function handlePlayerProgressionSave(playerElement) { + const currentPositionInSeconds = Math.floor(playerElement.currentTime); // we do not need a precise value + const lastKnownPositionInSeconds = parseInt(playerElement.dataset.lastPosition, 10); + const recordInterval = 10; + + // we limit the number of update to only one by interval. Otherwise, we would have multiple update per seconds + if (currentPositionInSeconds >= (lastKnownPositionInSeconds + recordInterval) || + currentPositionInSeconds <= (lastKnownPositionInSeconds - recordInterval) + ) { + playerElement.dataset.lastPosition = currentPositionInSeconds.toString(); + let request = new RequestBuilder(playerElement.dataset.saveUrl); + request.withBody({progression: currentPositionInSeconds}); + request.execute(); + } +} + +/** + * handle new share entires and already shared entries + */ +function handleShare() { + let link = document.querySelector('a[data-share-status]'); + let title = document.querySelector("body > main > section > header > h1 > a"); + if (link.dataset.shareStatus === "shared") { + checkShareAPI(title, link.href); + } + if (link.dataset.shareStatus === "share") { + let request = new RequestBuilder(link.href); + request.withCallback((r) => { + checkShareAPI(title, r.url); + }); + request.withHttpMethod("GET"); + request.execute(); + } +} + +/** +* wrapper for Web Share API +*/ +function checkShareAPI(title, url) { + if (!navigator.canShare) { + console.error("Your browser doesn't support the Web Share API."); + window.location = url; + return; + } + try { + navigator.share({ + title: title, + url: url + }); + window.location.reload(); + } catch (err) { + console.error(err); + window.location.reload(); + } +}
\ No newline at end of file diff --git a/internal/ui/static/js/bootstrap.js b/internal/ui/static/js/bootstrap.js new file mode 100644 index 00000000..0cd878ef --- /dev/null +++ b/internal/ui/static/js/bootstrap.js @@ -0,0 +1,126 @@ +document.addEventListener("DOMContentLoaded", function () { + handleSubmitButtons(); + + if (!document.querySelector("body[data-disable-keyboard-shortcuts=true]")) { + let keyboardHandler = new KeyboardHandler(); + keyboardHandler.on("g u", () => goToPage("unread")); + keyboardHandler.on("g b", () => goToPage("starred")); + keyboardHandler.on("g h", () => goToPage("history")); + keyboardHandler.on("g f", () => goToFeedOrFeeds()); + keyboardHandler.on("g c", () => goToPage("categories")); + keyboardHandler.on("g s", () => goToPage("settings")); + keyboardHandler.on("ArrowLeft", () => goToPrevious()); + keyboardHandler.on("ArrowRight", () => goToNext()); + keyboardHandler.on("k", () => goToPrevious()); + keyboardHandler.on("p", () => goToPrevious()); + keyboardHandler.on("j", () => goToNext()); + keyboardHandler.on("n", () => goToNext()); + keyboardHandler.on("h", () => goToPage("previous")); + keyboardHandler.on("l", () => goToPage("next")); + keyboardHandler.on("z t", () => scrollToCurrentItem()); + keyboardHandler.on("o", () => openSelectedItem()); + keyboardHandler.on("v", () => openOriginalLink()); + keyboardHandler.on("V", () => openOriginalLink(true)); + keyboardHandler.on("c", () => openCommentLink()); + keyboardHandler.on("C", () => openCommentLink(true)); + keyboardHandler.on("m", () => handleEntryStatus("next")); + keyboardHandler.on("M", () => handleEntryStatus("previous")); + keyboardHandler.on("A", () => markPageAsRead()); + keyboardHandler.on("s", () => handleSaveEntry()); + keyboardHandler.on("d", () => handleFetchOriginalContent()); + keyboardHandler.on("f", () => handleBookmark()); + keyboardHandler.on("F", () => goToFeed()); + keyboardHandler.on("R", () => handleRefreshAllFeeds()); + keyboardHandler.on("?", () => showKeyboardShortcuts()); + keyboardHandler.on("+", () => goToAddSubscription()); + keyboardHandler.on("#", () => unsubscribeFromFeed()); + keyboardHandler.on("/", (e) => setFocusToSearchInput(e)); + keyboardHandler.on("a", () => { + let enclosureElement = document.querySelector('.entry-enclosures'); + if (enclosureElement) { + enclosureElement.toggleAttribute('open'); + } + }); + keyboardHandler.on("Escape", () => ModalHandler.close()); + keyboardHandler.listen(); + } + + let touchHandler = new TouchHandler(); + touchHandler.listen(); + + onClick("a[data-save-entry]", (event) => handleSaveEntry(event.target)); + onClick("a[data-toggle-bookmark]", (event) => handleBookmark(event.target)); + onClick("a[data-fetch-content-entry]", () => handleFetchOriginalContent()); + onClick("a[data-action=search]", (event) => setFocusToSearchInput(event)); + onClick("a[data-share-status]", () => handleShare()); + onClick("a[data-action=markPageAsRead]", (event) => handleConfirmationMessage(event.target, () => markPageAsRead())); + onClick("a[data-toggle-status]", (event) => handleEntryStatus("next", event.target)); + + onClick("a[data-confirm]", (event) => handleConfirmationMessage(event.target, (url, redirectURL) => { + let request = new RequestBuilder(url); + + request.withCallback((response) => { + if (redirectURL) { + window.location.href = redirectURL; + } else if (response && response.redirected && response.url) { + window.location.href = response.url; + } else { + window.location.reload(); + } + }); + + request.execute(); + })); + + onClick("a[data-original-link='true']", (event) => { + handleEntryStatus("next", event.target, true); + }, true); + onAuxClick("a[data-original-link='true']", (event) => { + if (event.button == 1) { + handleEntryStatus("next", event.target, true); + } + }, true); + + if (document.documentElement.clientWidth < 600) { + onClick(".logo", () => toggleMainMenu()); + onClick(".header nav li", (event) => onClickMainMenuListItem(event)); + } + + if ("serviceWorker" in navigator) { + let scriptElement = document.getElementById("service-worker-script"); + if (scriptElement) { + navigator.serviceWorker.register(scriptElement.src); + } + } + + window.addEventListener('beforeinstallprompt', (e) => { + // Prevent Chrome 67 and earlier from automatically showing the prompt. + e.preventDefault(); + + let deferredPrompt = e; + const promptHomeScreen = document.getElementById('prompt-home-screen'); + if (promptHomeScreen) { + promptHomeScreen.style.display = "block"; + + const btnAddToHomeScreen = document.getElementById('btn-add-to-home-screen'); + if (btnAddToHomeScreen) { + btnAddToHomeScreen.addEventListener('click', (e) => { + e.preventDefault(); + deferredPrompt.prompt(); + deferredPrompt.userChoice.then(() => { + deferredPrompt = null; + promptHomeScreen.style.display = "none"; + }); + }); + } + } + }); + + // enclosure media player position save & resume + const elements = document.querySelectorAll("audio[data-last-position],video[data-last-position]"); + elements.forEach((element) => { + // we set the current time of media players + if (element.dataset.lastPosition){ element.currentTime = element.dataset.lastPosition; } + element.ontimeupdate = () => handlePlayerProgressionSave(element); + }); +}); diff --git a/internal/ui/static/js/dom_helper.js b/internal/ui/static/js/dom_helper.js new file mode 100644 index 00000000..fffa6965 --- /dev/null +++ b/internal/ui/static/js/dom_helper.js @@ -0,0 +1,65 @@ +class DomHelper { + static isVisible(element) { + return element.offsetParent !== null; + } + + static openNewTab(url) { + let win = window.open(""); + win.opener = null; + win.location = url; + win.focus(); + } + + static scrollPageTo(element, evenIfOnScreen) { + let windowScrollPosition = window.pageYOffset; + let windowHeight = document.documentElement.clientHeight; + let viewportPosition = windowScrollPosition + windowHeight; + let itemBottomPosition = element.offsetTop + element.offsetHeight; + + if (evenIfOnScreen || viewportPosition - itemBottomPosition < 0 || viewportPosition - element.offsetTop > windowHeight) { + window.scrollTo(0, element.offsetTop - 10); + } + } + + static getVisibleElements(selector) { + let elements = document.querySelectorAll(selector); + let result = []; + + for (let i = 0; i < elements.length; i++) { + if (this.isVisible(elements[i])) { + result.push(elements[i]); + } + } + + return result; + } + + static findParent(element, selector) { + for (; element && element !== document; element = element.parentNode) { + if (element.classList.contains(selector)) { + return element; + } + } + + return null; + } + + static hasPassiveEventListenerOption() { + var passiveSupported = false; + + try { + var options = Object.defineProperty({}, "passive", { + get: function() { + passiveSupported = true; + } + }); + + window.addEventListener("test", options, options); + window.removeEventListener("test", options, options); + } catch(err) { + passiveSupported = false; + } + + return passiveSupported; + } +} diff --git a/internal/ui/static/js/keyboard_handler.js b/internal/ui/static/js/keyboard_handler.js new file mode 100644 index 00000000..037f9949 --- /dev/null +++ b/internal/ui/static/js/keyboard_handler.js @@ -0,0 +1,72 @@ +class KeyboardHandler { + constructor() { + this.queue = []; + this.shortcuts = {}; + this.triggers = []; + } + + on(combination, callback) { + this.shortcuts[combination] = callback; + this.triggers.push(combination.split(" ")[0]); + } + + listen() { + document.onkeydown = (event) => { + let key = this.getKey(event); + if (this.isEventIgnored(event, key) || this.isModifierKeyDown(event)) { + return; + } + + event.preventDefault(); + this.queue.push(key); + + for (let combination in this.shortcuts) { + let keys = combination.split(" "); + + if (keys.every((value, index) => value === this.queue[index])) { + this.queue = []; + this.shortcuts[combination](event); + return; + } + + if (keys.length === 1 && key === keys[0]) { + this.queue = []; + this.shortcuts[combination](event); + return; + } + } + + if (this.queue.length >= 2) { + this.queue = []; + } + }; + } + + isEventIgnored(event, key) { + return event.target.tagName === "INPUT" || + event.target.tagName === "TEXTAREA" || + (this.queue.length < 1 && !this.triggers.includes(key)); + } + + isModifierKeyDown(event) { + return event.getModifierState("Control") || event.getModifierState("Alt") || event.getModifierState("Meta"); + } + + getKey(event) { + const mapping = { + 'Esc': 'Escape', + 'Up': 'ArrowUp', + 'Down': 'ArrowDown', + 'Left': 'ArrowLeft', + 'Right': 'ArrowRight' + }; + + for (let key in mapping) { + if (mapping.hasOwnProperty(key) && key === event.key) { + return mapping[key]; + } + } + + return event.key; + } +} diff --git a/internal/ui/static/js/modal_handler.js b/internal/ui/static/js/modal_handler.js new file mode 100644 index 00000000..0fa55bfa --- /dev/null +++ b/internal/ui/static/js/modal_handler.js @@ -0,0 +1,101 @@ +class ModalHandler { + static exists() { + return document.getElementById("modal-container") !== null; + } + + static getModalContainer() { + return document.getElementById("modal-container"); + } + + static getFocusableElements() { + let container = this.getModalContainer(); + + if (container === null) { + return null; + } + + return container.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); + } + + static setupFocusTrap() { + let focusableElements = this.getFocusableElements(); + + if (focusableElements === null) { + return; + } + + let firstFocusableElement = focusableElements[0]; + let lastFocusableElement = focusableElements[focusableElements.length - 1]; + + this.getModalContainer().onkeydown = (e) => { + if (e.key !== 'Tab') { + return; + } + + // If there is only one focusable element in the dialog we always want to focus that one with the tab key. + // This handles the special case of having just one focusable element in a dialog where keyboard focus is placed on an element that is not in the tab order. + if (focusableElements.length === 1) { + firstFocusableElement.focus(); + e.preventDefault(); + return; + } + + if (e.shiftKey && document.activeElement === firstFocusableElement) { + lastFocusableElement.focus(); + e.preventDefault(); + } else if (!e.shiftKey && document.activeElement === lastFocusableElement) { + firstFocusableElement.focus(); + e.preventDefault(); + } + }; + } + + static open(fragment, initialFocusElementId) { + if (ModalHandler.exists()) { + return; + } + + this.activeElement = document.activeElement; + + let container = document.createElement("div"); + container.id = "modal-container"; + container.setAttribute("role", "dialog"); + container.appendChild(document.importNode(fragment, true)); + document.body.appendChild(container); + + let closeButton = document.querySelector("button.btn-close-modal"); + if (closeButton !== null) { + closeButton.onclick = (event) => { + event.preventDefault(); + ModalHandler.close(); + }; + } + + let initialFocusElement; + if (initialFocusElementId !== undefined) { + initialFocusElement = document.getElementById(initialFocusElementId); + } else { + let focusableElements = this.getFocusableElements(); + if (focusableElements !== null) { + initialFocusElement = focusableElements[0]; + } + } + + if (initialFocusElement !== undefined) { + initialFocusElement.focus(); + } + + this.setupFocusTrap(); + } + + static close() { + let container = this.getModalContainer(); + if (container !== null) { + container.parentNode.removeChild(container); + } + + if (this.activeElement !== undefined && this.activeElement !== null) { + this.activeElement.focus(); + } + } +} diff --git a/internal/ui/static/js/request_builder.js b/internal/ui/static/js/request_builder.js new file mode 100644 index 00000000..e19168fc --- /dev/null +++ b/internal/ui/static/js/request_builder.js @@ -0,0 +1,48 @@ +class RequestBuilder { + constructor(url) { + this.callback = null; + this.url = url; + this.options = { + method: "POST", + cache: "no-cache", + credentials: "include", + body: null, + headers: new Headers({ + "Content-Type": "application/json", + "X-Csrf-Token": this.getCsrfToken() + }) + }; + } + + withHttpMethod(method) { + this.options.method = method; + return this; + } + + withBody(body) { + this.options.body = JSON.stringify(body); + return this; + } + + withCallback(callback) { + this.callback = callback; + return this; + } + + getCsrfToken() { + let element = document.querySelector("body[data-csrf-token]"); + if (element !== null) { + return element.dataset.csrfToken; + } + + return ""; + } + + execute() { + fetch(new Request(this.url, this.options)).then((response) => { + if (this.callback) { + this.callback(response); + } + }); + } +} diff --git a/internal/ui/static/js/service_worker.js b/internal/ui/static/js/service_worker.js new file mode 100644 index 00000000..37cce257 --- /dev/null +++ b/internal/ui/static/js/service_worker.js @@ -0,0 +1,44 @@ + +// Incrementing OFFLINE_VERSION will kick off the install event and force +// previously cached resources to be updated from the network. +const OFFLINE_VERSION = 1; +const CACHE_NAME = "offline"; + +self.addEventListener("install", (event) => { + event.waitUntil( + (async () => { + const cache = await caches.open(CACHE_NAME); + + // Setting {cache: 'reload'} in the new request will ensure that the + // response isn't fulfilled from the HTTP cache; i.e., it will be from + // the network. + await cache.add(new Request(OFFLINE_URL, { cache: "reload" })); + })() + ); + + // Force the waiting service worker to become the active service worker. + self.skipWaiting(); +}); + +self.addEventListener("fetch", (event) => { + // We proxify requests through fetch() only if we are offline because it's slower. + if (navigator.onLine === false && event.request.mode === "navigate") { + event.respondWith( + (async () => { + try { + // Always try the network first. + const networkResponse = await fetch(event.request); + return networkResponse; + } catch (error) { + // catch is only triggered if an exception is thrown, which is likely + // due to a network error. + // If fetch() returns a valid HTTP response with a response code in + // the 4xx or 5xx range, the catch() will NOT be called. + const cache = await caches.open(CACHE_NAME); + const cachedResponse = await cache.match(OFFLINE_URL); + return cachedResponse; + } + })() + ); + } +}); diff --git a/internal/ui/static/js/touch_handler.js b/internal/ui/static/js/touch_handler.js new file mode 100644 index 00000000..99c1d5b2 --- /dev/null +++ b/internal/ui/static/js/touch_handler.js @@ -0,0 +1,187 @@ +class TouchHandler { + constructor() { + this.reset(); + } + + reset() { + this.touch = { + start: { x: -1, y: -1 }, + move: { x: -1, y: -1 }, + moved: false, + time: 0, + element: null + }; + } + + calculateDistance() { + if (this.touch.start.x >= -1 && this.touch.move.x >= -1) { + let horizontalDistance = Math.abs(this.touch.move.x - this.touch.start.x); + let verticalDistance = Math.abs(this.touch.move.y - this.touch.start.y); + + if (horizontalDistance > 30 && verticalDistance < 70 || this.touch.moved) { + return this.touch.move.x - this.touch.start.x; + } + } + + return 0; + } + + findElement(element) { + if (element.classList.contains("entry-swipe")) { + return element; + } + + return DomHelper.findParent(element, "entry-swipe"); + } + + onItemTouchStart(event) { + if (event.touches === undefined || event.touches.length !== 1) { + return; + } + + this.reset(); + this.touch.start.x = event.touches[0].clientX; + this.touch.start.y = event.touches[0].clientY; + this.touch.element = this.findElement(event.touches[0].target); + this.touch.element.style.transitionDuration = "0s"; + } + + onItemTouchMove(event) { + if (event.touches === undefined || event.touches.length !== 1 || this.element === null) { + return; + } + + this.touch.move.x = event.touches[0].clientX; + this.touch.move.y = event.touches[0].clientY; + + let distance = this.calculateDistance(); + let absDistance = Math.abs(distance); + + if (absDistance > 0) { + this.touch.moved = true; + + let tx = absDistance > 75 ? Math.pow(absDistance - 75, 0.5) + 75 : absDistance; + + if (distance < 0) { + tx = -tx; + } + + this.touch.element.style.transform = "translateX(" + tx + "px)"; + + event.preventDefault(); + } + } + + onItemTouchEnd(event) { + if (event.touches === undefined) { + return; + } + + if (this.touch.element !== null) { + let absDistance = Math.abs(this.calculateDistance()); + + if (absDistance > 75) { + toggleEntryStatus(this.touch.element); + } + + if (this.touch.moved) { + this.touch.element.style.transitionDuration = "0.15s"; + this.touch.element.style.transform = "none"; + } + } + + this.reset(); + } + + onContentTouchStart(event) { + if (event.touches === undefined || event.touches.length !== 1) { + return; + } + + this.reset(); + this.touch.start.x = event.touches[0].clientX; + this.touch.start.y = event.touches[0].clientY; + this.touch.time = Date.now(); + } + + onContentTouchMove(event) { + if (event.touches === undefined || event.touches.length !== 1 || this.element === null) { + return; + } + + this.touch.move.x = event.touches[0].clientX; + this.touch.move.y = event.touches[0].clientY; + } + + onContentTouchEnd(event) { + if (event.touches === undefined) { + return; + } + + let distance = this.calculateDistance(); + let absDistance = Math.abs(distance); + let now = Date.now(); + + if (now - this.touch.time <= 1000 && absDistance > 75) { + if (distance > 0) { + goToPage("previous"); + } else { + goToPage("next"); + } + } + + this.reset(); + } + + onTapEnd(event) { + if (event.touches === undefined) { + return; + } + + let now = Date.now(); + + if (this.touch.start.x !== -1 && now - this.touch.time <= 200) { + let innerWidthHalf = window.innerWidth / 2; + + if (this.touch.start.x >= innerWidthHalf && event.changedTouches[0].clientX >= innerWidthHalf) { + goToPage("next"); + } else if (this.touch.start.x < innerWidthHalf && event.changedTouches[0].clientX < innerWidthHalf) { + goToPage("previous"); + } + + this.reset(); + } else { + this.reset(); + this.touch.start.x = event.changedTouches[0].clientX; + this.touch.time = now; + } + } + + listen() { + let hasPassiveOption = DomHelper.hasPassiveEventListenerOption(); + + let elements = document.querySelectorAll(".entry-swipe"); + + elements.forEach((element) => { + element.addEventListener("touchstart", (e) => this.onItemTouchStart(e), hasPassiveOption ? { passive: true } : false); + element.addEventListener("touchmove", (e) => this.onItemTouchMove(e), hasPassiveOption ? { passive: false } : false); + element.addEventListener("touchend", (e) => this.onItemTouchEnd(e), hasPassiveOption ? { passive: true } : false); + element.addEventListener("touchcancel", () => this.reset(), hasPassiveOption ? { passive: true } : false); + }); + + let element = document.querySelector(".entry-content"); + + if (element) { + if (element.classList.contains("gesture-nav-tap")) { + element.addEventListener("touchend", (e) => this.onTapEnd(e), hasPassiveOption ? { passive: true } : false); + element.addEventListener("touchmove", () => this.reset(), hasPassiveOption ? { passive: true } : false); + element.addEventListener("touchcancel", () => this.reset(), hasPassiveOption ? { passive: true } : false); + } else if (element.classList.contains("gesture-nav-swipe")) { + element.addEventListener("touchstart", (e) => this.onContentTouchStart(e), hasPassiveOption ? { passive: true } : false); + element.addEventListener("touchmove", (e) => this.onContentTouchMove(e), hasPassiveOption ? { passive: true } : false); + element.addEventListener("touchend", (e) => this.onContentTouchEnd(e), hasPassiveOption ? { passive: true } : false); + element.addEventListener("touchcancel", () => this.reset(), hasPassiveOption ? { passive: true } : false); + } + } + } +} |