diff --git a/internal/ui/static/js/app.js b/internal/ui/static/js/app.js index 73f99983..3f248c78 100644 --- a/internal/ui/static/js/app.js +++ b/internal/ui/static/js/app.js @@ -1,3 +1,15 @@ +// Sentinel values for specific list navigation +const TOP = 9999; +const BOTTOM = -9999; + +/** + * Get the CSRF token from the HTML document. + * + * @returns {string} The CSRF token. + */ +function getCsrfToken() { + return document.body.dataset.csrfToken || ""; +} /** * Open a new tab with the given URL. @@ -11,6 +23,59 @@ function openNewTab(url) { win.focus(); } +/** + * Scroll the page to the given element. + * + * @param {Element} element + * @param {boolean} evenIfOnScreen + */ +function scrollPageTo(element, evenIfOnScreen) { + const windowScrollPosition = window.scrollY; + const windowHeight = document.documentElement.clientHeight; + const viewportPosition = windowScrollPosition + windowHeight; + const itemBottomPosition = element.offsetTop + element.offsetHeight; + + if (evenIfOnScreen || viewportPosition - itemBottomPosition < 0 || viewportPosition - element.offsetTop > windowHeight) { + window.scrollTo(0, element.offsetTop - 10); + } +} + +/** + * Attach a click event listener to elements matching the selector. + * + * @param {string} selector + * @param {function} callback + * @param {boolean} noPreventDefault + */ +function onClick(selector, callback, noPreventDefault) { + document.querySelectorAll(selector).forEach((element) => { + element.onclick = (event) => { + if (!noPreventDefault) { + event.preventDefault(); + } + callback(event); + }; + }); +} + +/** + * Attach an auxiliary click event listener to elements matching the selector. + * + * @param {string} selector + * @param {function} callback + * @param {boolean} noPreventDefault + */ +function onAuxClick(selector, callback, noPreventDefault) { + document.querySelectorAll(selector).forEach((element) => { + element.onauxclick = (event) => { + if (!noPreventDefault) { + event.preventDefault(); + } + callback(event); + }; + }); +} + /** * Filter visible elements based on the selector. * @@ -32,43 +97,231 @@ function getVisibleEntries() { } /** - * Scroll the page to the given element. + * Check if the current view is a list view. * - * @param {Element} element - * @param {boolean} evenIfOnScreen + * @returns {boolean} */ -function scrollPageTo(element, evenIfOnScreen) { - const windowScrollPosition = window.scrollY; - const windowHeight = document.documentElement.clientHeight; - const viewportPosition = windowScrollPosition + windowHeight; - const itemBottomPosition = element.offsetTop + element.offsetHeight; +function isListView() { + return document.querySelector(".items") !== null; +} - if (evenIfOnScreen || viewportPosition - itemBottomPosition < 0 || viewportPosition - element.offsetTop > windowHeight) { - window.scrollTo(0, element.offsetTop - 10); +/** + * Check if the current view is an entry view. + * + * @return {boolean} + */ +function isEntryView() { + return document.querySelector("section.entry") !== null; +} + +/** + * Find the entry element for the given element. + * + * @returns {Element|null} + */ +function findEntry(element) { + if (isListView()) { + if (element) { + return element.closest(".item"); + } + return document.querySelector(".current-item"); + } + return document.querySelector(".entry"); +} + +/** + * Navigate to a specific page. + * + * @param {string} page - The page to navigate to. + * @param {boolean} reloadOnFail - If true, reload the current page if the target page is not found. + */ +function goToPage(page, reloadOnFail = false) { + const element = document.querySelector(":is(a, button)[data-page=" + page + "]"); + + if (element) { + document.location.href = element.href; + } else if (reloadOnFail) { + window.location.reload(); } } -// OnClick attaches a listener to the elements that match the selector. -function onClick(selector, callback, noPreventDefault) { - document.querySelectorAll(selector).forEach((element) => { - element.onclick = (event) => { - if (!noPreventDefault) { - event.preventDefault(); - } - callback(event); - }; - }); +/** + * Navigate to the previous page. + * + * If the offset is a KeyboardEvent, it will navigate to the previous item in the list. + * If the offset is a number, it will jump that many items in the list. + * If the offset is TOP, it will jump to the first item in the list. + * If the offset is BOTTOM, it will jump to the last item in the list. + * If the current view is an entry view, it will redirect to the previous page. + * + * @param {number|KeyboardEvent} offset - How many items to jump for focus. + */ +function goToPreviousPage(offset) { + if (offset instanceof KeyboardEvent) offset = -1; + if (isListView()) { + goToListItem(offset); + } else { + goToPage("previous"); + } } -function onAuxClick(selector, callback, noPreventDefault) { - document.querySelectorAll(selector).forEach((element) => { - element.onauxclick = (event) => { - if (!noPreventDefault) { - event.preventDefault(); +/** + * Navigate to the next page. + * + * If the offset is a KeyboardEvent, it will navigate to the next item in the list. + * If the offset is a number, it will jump that many items in the list. + * If the offset is TOP, it will jump to the first item in the list. + * If the offset is BOTTOM, it will jump to the last item in the list. + * If the current view is an entry view, it will redirect to the next page. + * + * @param {number|KeyboardEvent} offset - How many items to jump for focus. + */ +function goToNextPage(offset) { + if (offset instanceof KeyboardEvent) offset = 1; + if (isListView()) { + goToListItem(offset); + } else { + goToPage("next"); + } +} + +/** + * Navigate to the individual feed or feeds page. + * + * If the current view is an entry view, it will redirect to the feed link of the entry. + * If the current view is a list view, it will redirect to the feeds page. + */ +function goToFeedOrFeedsPage() { + if (isEntryView()) { + goToFeedPage(); + } else { + goToPage("feeds"); + } +} + +/** + * Navigate to the feed page of the current entry. + * + * If the current view is an entry view, it will redirect to the feed link of the entry. + * If the current view is a list view, it will redirect to the feed link of the currently selected item. + * If no feed link is available, it will do nothing. + */ +function goToFeedPage() { + if (isEntryView()) { + const feedAnchor = document.querySelector("span.entry-website a"); + if (feedAnchor !== null) { + window.location.href = feedAnchor.href; + } + } else { + const currentItemFeed = document.querySelector(".current-item :is(a, button)[data-feed-link]"); + if (currentItemFeed !== null) { + window.location.href = currentItemFeed.getAttribute("href"); + } + } +} + +/** + * Navigate to the add subscription page. + * + * @returns {void} + */ +function goToAddSubscriptionPage() { + window.location.href = document.body.dataset.addSubscriptionUrl; +} + +/** + * Navigate to the next or previous item in the list. + * + * If the offset is TOP, it will jump to the first item in the list. + * If the offset is BOTTOM, it will jump to the last item in the list. + * If the offset is a number, it will jump that many items in the list. + * If the current view is an entry view, it will redirect to the next or previous page. + * + * @param {number} offset - How many items to jump for focus. + * @return {void} + */ +function goToListItem(offset) { + const items = getVisibleEntries(); + if (items.length === 0) { + return; + } + + if (document.querySelector(".current-item") === null) { + items[0].classList.add("current-item"); + items[0].focus(); + return; + } + + for (let i = 0; i < items.length; i++) { + if (items[i].classList.contains("current-item")) { + items[i].classList.remove("current-item"); + + // By default adjust selection by offset + let itemOffset = (i + offset + items.length) % items.length; + // Allow jumping to top or bottom + if (offset === TOP) { + itemOffset = 0; + } else if (offset === BOTTOM) { + itemOffset = items.length - 1; } - callback(event); - }; - }); + const item = items[itemOffset]; + + item.classList.add("current-item"); + scrollPageTo(item); + item.focus(); + + break; + } + } +} + +/** + * Handle the share action for the entry. + * + * If the share status is "shared", it will trigger the Web Share API. + * If the share status is "share", it will send an Ajax request to fetch the share URL and then trigger the Web Share API. + * If the Web Share API is not supported, it will redirect to the entry URL. + */ +async function handleShare() { + const link = document.querySelector(':is(a, button)[data-share-status]'); + const title = document.querySelector(".entry-header > h1 > a"); + if (link.dataset.shareStatus === "shared") { + await triggerWebShare(title, link.href); + } + else if (link.dataset.shareStatus === "share") { + const request = new RequestBuilder(link.href); + request.withCallback((r) => { + // Ensure title is not null before passing to triggerWebShare + triggerWebShare(title, r.url); + }); + request.withHttpMethod("GET"); + request.execute(); + } +} + +/** + * Trigger the Web Share API to share the entry. + * + * If the Web Share API is not supported, it will redirect to the entry URL. + * + * @param {Element} title - The title element of the entry. + * @param {string} url - The URL of the entry to share. + */ +async function triggerWebShare(title, url) { + if (!navigator.canShare) { + console.error("Your browser doesn't support the Web Share API."); + window.location = url; + return; + } + try { + await navigator.share({ + title: title ? title.textContent : url, + url: url + }); + } catch (err) { + console.error(err); + } + window.location.reload(); } // make logo element as button on mobile layout @@ -490,114 +743,6 @@ function unsubscribeFromFeed() { } } -/** - * @param {string} page Page to redirect to. - * @param {boolean} fallbackSelf Refresh actual page if the page is not found. - */ -function goToPage(page, fallbackSelf = false) { - const element = document.querySelector(":is(a, button)[data-page=" + page + "]"); - - if (element) { - document.location.href = element.href; - } else if (fallbackSelf) { - window.location.reload(); - } -} - -/** - * - * @param {(number|event)} offset - many items to jump for focus. - */ -function goToPrevious(offset) { - if (offset instanceof KeyboardEvent) { - offset = -1; - } - if (isListView()) { - goToListItem(offset); - } else { - goToPage("previous"); - } -} - -/** - * - * @param {(number|event)} offset - How many items to jump for focus. - */ -function goToNext(offset) { - if (offset instanceof KeyboardEvent) { - offset = 1; - } - if (isListView()) { - goToListItem(offset); - } else { - goToPage("next"); - } -} - -function goToFeedOrFeeds() { - if (isEntry()) { - goToFeed(); - } else { - goToPage('feeds'); - } -} - -function goToFeed() { - if (isEntry()) { - const feedAnchor = document.querySelector("span.entry-website a"); - if (feedAnchor !== null) { - window.location.href = feedAnchor.href; - } - } else { - const currentItemFeed = document.querySelector(".current-item :is(a, button)[data-feed-link]"); - if (currentItemFeed !== null) { - window.location.href = currentItemFeed.getAttribute("href"); - } - } -} - -// Sentinel values for specific list navigation -const TOP = 9999; -const BOTTOM = -9999; - -/** - * @param {number} offset How many items to jump for focus. - */ -function goToListItem(offset) { - const items = getVisibleEntries(); - if (items.length === 0) { - return; - } - - if (document.querySelector(".current-item") === null) { - items[0].classList.add("current-item"); - items[0].focus(); - return; - } - - for (let i = 0; i < items.length; i++) { - if (items[i].classList.contains("current-item")) { - items[i].classList.remove("current-item"); - - // By default adjust selection by offset - let itemOffset = (i + offset + items.length) % items.length; - // Allow jumping to top or bottom - if (offset === TOP) { - itemOffset = 0; - } else if (offset === BOTTOM) { - itemOffset = items.length - 1; - } - const item = items[itemOffset]; - - item.classList.add("current-item"); - scrollPageTo(item); - item.focus(); - - break; - } - } -} - function scrollToCurrentItem() { const currentItem = document.querySelector(".current-item"); if (currentItem !== null) { @@ -636,24 +781,6 @@ function updateUnreadCounterValue(callback) { } } -function isEntry() { - return document.querySelector("section.entry") !== null; -} - -function isListView() { - return document.querySelector(".items") !== null; -} - -function findEntry(element) { - if (isListView()) { - if (element) { - return element.closest(".item"); - } - return document.querySelector(".current-item"); - } - return document.querySelector(".entry"); -} - function handleConfirmationMessage(linkElement, callback) { if (linkElement.tagName !== 'A' && linkElement.tagName !== "BUTTON") { linkElement = linkElement.parentNode; @@ -724,11 +851,6 @@ function showToast(label, iconElement) { }, 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 @@ -773,54 +895,6 @@ function isPlayerPlaying(element) { element.readyState > 2; //https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/readyState } -/** - * handle new share entires and already shared entries - */ -async function handleShare() { - const link = document.querySelector(':is(a, button)[data-share-status]'); - const title = document.querySelector(".entry-header > h1 > a"); - if (link.dataset.shareStatus === "shared") { - await checkShareAPI(title, link.href); - } - if (link.dataset.shareStatus === "share") { - const request = new RequestBuilder(link.href); - request.withCallback((r) => { - checkShareAPI(title, r.url); - }); - request.withHttpMethod("GET"); - request.execute(); - } -} - -/** -* wrapper for Web Share API -*/ -async function checkShareAPI(title, url) { - if (!navigator.canShare) { - console.error("Your browser doesn't support the Web Share API."); - window.location = url; - return; - } - try { - await navigator.share({ - title: title ? title.textContent : url, - url: url - }); - } catch (err) { - console.error(err); - } - window.location.reload(); -} - -/** - * Get the CSRF token from the HTML document. - * - * @returns {string} The CSRF token. - */ -function getCsrfToken() { - return document.body.dataset.csrfToken || ""; -} - /** * Handle all clicks on media player controls button on enclosures. * Will change the current speed and position of the player accordingly. diff --git a/internal/ui/static/js/bootstrap.js b/internal/ui/static/js/bootstrap.js index 0f93b6e5..f6398a26 100644 --- a/internal/ui/static/js/bootstrap.js +++ b/internal/ui/static/js/bootstrap.js @@ -6,17 +6,17 @@ document.addEventListener("DOMContentLoaded", () => { 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 f", goToFeedOrFeedsPage); keyboardHandler.on("g c", () => goToPage("categories")); keyboardHandler.on("g s", () => goToPage("settings")); - keyboardHandler.on("g g", () => goToPrevious(TOP)); - keyboardHandler.on("G", () => goToNext(BOTTOM)); - 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("g g", () => goToPreviousPage(TOP)); + keyboardHandler.on("G", () => goToNextPage(BOTTOM)); + keyboardHandler.on("ArrowLeft", goToPreviousPage); + keyboardHandler.on("ArrowRight", goToNextPage); + keyboardHandler.on("k", goToPreviousPage); + keyboardHandler.on("p", goToPreviousPage); + keyboardHandler.on("j", goToNextPage); + keyboardHandler.on("n", goToNextPage); keyboardHandler.on("h", () => goToPage("previous")); keyboardHandler.on("l", () => goToPage("next")); keyboardHandler.on("z t", scrollToCurrentItem); @@ -32,10 +32,10 @@ document.addEventListener("DOMContentLoaded", () => { keyboardHandler.on("s", () => handleSaveEntry()); keyboardHandler.on("d", handleFetchOriginalContent); keyboardHandler.on("f", () => handleBookmark()); - keyboardHandler.on("F", goToFeed); + keyboardHandler.on("F", goToFeedPage); keyboardHandler.on("R", handleRefreshAllFeeds); keyboardHandler.on("?", showKeyboardShortcuts); - keyboardHandler.on("+", goToAddSubscription); + keyboardHandler.on("+", goToAddSubscriptionPage); keyboardHandler.on("#", unsubscribeFromFeed); keyboardHandler.on("/", () => goToPage("search")); keyboardHandler.on("a", () => {