diff --git a/internal/ui/static/js/app.js b/internal/ui/static/js/app.js index e53fdbf85d9..910b6ea42dd 100644 --- a/internal/ui/static/js/app.js +++ b/internal/ui/static/js/app.js @@ -148,12 +148,17 @@ function findEntry(element) { * * @param {Element} parentElement The parent element to insert the icon label into. * @param {string} iconLabelText The text to display in the icon label. + * @param {boolean} clearParentTextcontent If true, clear the parent's text content before appending the icon label. * @returns {void} */ -function insertIconLabelElement(parentElement, iconLabelText) { +function insertIconLabelElement(parentElement, iconLabelText, clearParentTextcontent = true) { const span = document.createElement('span'); span.classList.add('icon-label'); span.textContent = iconLabelText; + + if (clearParentTextcontent) { + parentElement.textContent = ''; + } parentElement.appendChild(span); } @@ -323,35 +328,25 @@ function goToListItem(offset) { * 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() { +async function handleEntryShareAction() { const link = document.querySelector(':is(a, button)[data-share-status]'); if (link.dataset.shareStatus === "shared") { const title = document.querySelector(".entry-header > h1 > a"); - await triggerWebShare(title, link.href); - } -} + const url = link.href; -/** - * 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); + 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); + } } } @@ -458,7 +453,7 @@ function initializeFormHandlers() { /** * Show the keyboard shortcuts modal. */ -function showKeyboardShortcuts() { +function showKeyboardShortcutsAction() { const template = document.getElementById("keyboard-shortcuts"); ModalHandler.open(template.content, "dialog-title"); } @@ -466,7 +461,7 @@ function showKeyboardShortcuts() { /** * Mark all visible entries on the current page as read. */ -function markPageAsRead() { +function markPageAsReadAction() { const items = getVisibleEntries(); if (items.length === 0) return; @@ -552,7 +547,7 @@ function toggleEntryStatus(element, toasting) { } link.replaceChildren(iconElement.content.cloneNode(true)); - insertIconLabelElement(link, label); + insertIconLabelElement(link, label, false); link.dataset.value = newStatus; if (element.classList.contains("item-status-" + currentStatus)) { @@ -566,27 +561,12 @@ function toggleEntryStatus(element, toasting) { }); } -/** - * Mark the entry as read if it is currently unread. - * - * @param {Element} element The entry element to mark as read. - */ -function markEntryAsRead(element) { - if (element.classList.contains("item-status-unread")) { - element.classList.remove("item-status-unread"); - element.classList.add("item-status-read"); - - const entryID = parseInt(element.dataset.id, 10); - updateEntriesStatus([entryID], "read"); - } -} - /** * Handle the refresh of all feeds. * * This function redirects the user to the URL specified in the data-refresh-all-feeds-url attribute of the body element. */ -function handleRefreshAllFeeds() { +function handleRefreshAllFeedsAction() { const refreshAllFeedsUrl = document.body.dataset.refreshAllFeedsUrl; if (refreshAllFeedsUrl) { window.location.href = refreshAllFeedsUrl; @@ -614,38 +594,22 @@ function updateEntriesStatus(entryIDs, status, callback) { /** * Handle save entry from list view and entry view. * - * @param {Element} element + * @param {Element|null} element - The element that triggered the save action (optional). */ -function handleSaveEntry(element) { - const toasting = !element; +function handleSaveEntryAction(element = null) { const currentEntry = findEntry(element); - if (currentEntry) { - saveEntry(currentEntry.querySelector(":is(a, button)[data-save-entry]"), toasting); - } -} + if (!currentEntry) return; -/** - * Save the entry by sending an Ajax request to the server. - * - * @param {Element} element The element that triggered the save action. - * @param {boolean} toasting If true, show a toast notification after saving the entry. - * @return {void} - */ -function saveEntry(element, toasting) { - if (!element || element.dataset.completed) { - return; - } + const buttonElement = currentEntry.querySelector(":is(a, button)[data-save-entry]"); + if (!buttonElement || buttonElement.dataset.completed) return; - element.textContent = ""; - insertIconLabelElement(element, element.dataset.labelLoading); + insertIconLabelElement(buttonElement, buttonElement.dataset.labelLoading); - sendPOSTRequest(element.dataset.saveUrl).then(() => { - element.textContent = ""; - insertIconLabelElement(element, element.dataset.labelDone); - element.dataset.completed = "true"; - if (toasting) { - const iconElement = document.querySelector("template#icon-save"); - showToast(element.dataset.toastDone, iconElement); + sendPOSTRequest(buttonElement.dataset.saveUrl).then(() => { + insertIconLabelElement(buttonElement, buttonElement.dataset.labelDone); + buttonElement.dataset.completed = "true"; + if (!element) { + showToast(buttonElement.dataset.toastDone, document.querySelector("template#icon-save")); } }); } @@ -655,26 +619,13 @@ function saveEntry(element, toasting) { * * @param {Element} element - The element that triggered the bookmark action. */ -function handleBookmark(element) { - const toasting = !element; +function handleBookmarkAction(element) { const currentEntry = findEntry(element); - if (currentEntry) { - toggleBookmark(currentEntry, toasting); - } -} + if (!currentEntry) return; -/** - * Toggle the bookmark status of an entry. - * - * @param {Element} parentElement - The parent element containing the bookmark button. - * @param {boolean} toasting - Whether to show a toast notification. - * @returns {void} - */ -function toggleBookmark(parentElement, toasting) { - const buttonElement = parentElement.querySelector(":is(a, button)[data-toggle-bookmark]"); + const buttonElement = currentEntry.querySelector(":is(a, button)[data-toggle-bookmark]"); if (!buttonElement) return; - buttonElement.textContent = ""; insertIconLabelElement(buttonElement, buttonElement.dataset.labelLoading); sendPOSTRequest(buttonElement.dataset.bookmarkUrl).then(() => { @@ -685,14 +636,14 @@ function toggleBookmark(parentElement, toasting) { const iconElement = document.querySelector(isStarred ? "template#icon-star" : "template#icon-unstar"); const label = isStarred ? buttonElement.dataset.labelStar : buttonElement.dataset.labelUnstar; - if (toasting) { + buttonElement.replaceChildren(iconElement.content.cloneNode(true)); + insertIconLabelElement(buttonElement, label, false); + buttonElement.dataset.value = newStarStatus; + + if (!element) { const toastKey = isStarred ? "toastUnstar" : "toastStar"; showToast(buttonElement.dataset[toastKey], iconElement); } - - buttonElement.replaceChildren(iconElement.content.cloneNode(true)); - insertIconLabelElement(buttonElement, label); - buttonElement.dataset.value = newStarStatus; }); } @@ -701,7 +652,7 @@ function toggleBookmark(parentElement, toasting) { * * @returns {void} */ -function handleFetchOriginalContent() { +function handleFetchOriginalContentAction() { if (isListView()) return; const buttonElement = document.querySelector(":is(a, button)[data-fetch-content-entry]"); @@ -709,7 +660,6 @@ function handleFetchOriginalContent() { const previousElement = buttonElement.cloneNode(true); - buttonElement.textContent = ""; insertIconLabelElement(buttonElement, buttonElement.dataset.labelLoading); sendPOSTRequest(buttonElement.dataset.fetchContentUrl).then((response) => { @@ -734,27 +684,60 @@ function handleFetchOriginalContent() { * @param {boolean} openLinkInCurrentTab - Whether to open the link in the current tab. * @returns {void} */ -function openOriginalLink(openLinkInCurrentTab) { +function openOriginalLinkAction(openLinkInCurrentTab) { + if (isEntryView()) { + openOriginalLinkFromEntryView(openLinkInCurrentTab); + } else if (isListView()) { + openOriginalLinkFromListView(); + } +} + +/** + * Open the original link from entry view. + * + * @param {boolean} openLinkInCurrentTab - Whether to open the link in the current tab. + * @returns {void} + */ +function openOriginalLinkFromEntryView(openLinkInCurrentTab) { const entryLink = document.querySelector(".entry h1 a"); - if (entryLink) { - if (openLinkInCurrentTab) { - window.location.href = entryLink.getAttribute("href"); - } else { - openNewTab(entryLink.getAttribute("href")); - } - return; + if (!entryLink) return; + + const url = entryLink.getAttribute("href"); + if (openLinkInCurrentTab) { + window.location.href = url; + } else { + openNewTab(url); } +} + +/** + * Open the original link from list view. + * + * @returns {void} + */ +function openOriginalLinkFromListView() { + const currentItem = document.querySelector(".current-item"); + const originalLink = currentItem?.querySelector(":is(a, button)[data-original-link]"); - const currentItemOriginalLink = document.querySelector(".current-item :is(a, button)[data-original-link]"); - if (currentItemOriginalLink) { - openNewTab(currentItemOriginalLink.getAttribute("href")); + if (!currentItem || !originalLink) return; - const 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(':is(a, button)[data-page=starred]').href) { - goToListItem(1); - } - markEntryAsRead(currentItem); + // Open the link + openNewTab(originalLink.getAttribute("href")); + + // Don't navigate or mark as read on starred page + const isStarredPage = document.location.href === document.querySelector(':is(a, button)[data-page=starred]').href; + if (isStarredPage) return; + + // Navigate to next item + goToListItem(1); + + // Mark as read if currently unread + if (currentItem.classList.contains("item-status-unread")) { + currentItem.classList.remove("item-status-unread"); + currentItem.classList.add("item-status-read"); + + const entryID = parseInt(currentItem.dataset.id, 10); + updateEntriesStatus([entryID], "read"); } } @@ -764,7 +747,7 @@ function openOriginalLink(openLinkInCurrentTab) { * @param {boolean} openLinkInCurrentTab - Whether to open the link in the current tab. * @returns {void} */ -function openCommentLink(openLinkInCurrentTab) { +function openCommentLinkAction(openLinkInCurrentTab) { const entryLink = document.querySelector(isListView() ? ".current-item :is(a, button)[data-comments-link]" : ":is(a, button)[data-comments-link]"); if (entryLink) { @@ -782,7 +765,7 @@ function openCommentLink(openLinkInCurrentTab) { * If the current view is a list view, it will navigate to the link of the currently selected item. * If the current view is an entry view, it will navigate to the link of the entry. */ -function openSelectedItem() { +function openSelectedItemAction() { const currentItemLink = document.querySelector(".current-item .item-title a"); if (currentItemLink) { window.location.href = currentItemLink.getAttribute("href"); @@ -792,7 +775,7 @@ function openSelectedItem() { /** * Unsubscribe from the feed of the currently selected item. */ -function unsubscribeFromFeed() { +function handleRemoveFeedAction() { const unsubscribeLink = document.querySelector("[data-action=remove-feed]"); if (unsubscribeLink) { sendPOSTRequest(unsubscribeLink.dataset.url).then(() => { @@ -804,7 +787,7 @@ function unsubscribeFromFeed() { /** * Scroll the page to the currently selected item. */ -function scrollToCurrentItem() { +function scrollToCurrentItemAction() { const currentItem = document.querySelector(".current-item"); if (currentItem) { scrollPageTo(currentItem, true); @@ -1135,32 +1118,32 @@ function initializeKeyboardShortcuts() { keyboardHandler.on("n", goToNextPage); keyboardHandler.on("h", () => goToPage("previous")); keyboardHandler.on("l", () => goToPage("next")); - keyboardHandler.on("z t", scrollToCurrentItem); + keyboardHandler.on("z t", scrollToCurrentItemAction); // Item actions - keyboardHandler.on("o", openSelectedItem); - keyboardHandler.on("Enter", () => openSelectedItem()); - keyboardHandler.on("v", () => openOriginalLink(false)); - keyboardHandler.on("V", () => openOriginalLink(true)); - keyboardHandler.on("c", () => openCommentLink(false)); - keyboardHandler.on("C", () => openCommentLink(true)); + keyboardHandler.on("o", openSelectedItemAction); + keyboardHandler.on("Enter", () => openSelectedItemAction()); + keyboardHandler.on("v", () => openOriginalLinkAction(false)); + keyboardHandler.on("V", () => openOriginalLinkAction(true)); + keyboardHandler.on("c", () => openCommentLinkAction(false)); + keyboardHandler.on("C", () => openCommentLinkAction(true)); // Entry management 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("A", markPageAsReadAction); + keyboardHandler.on("s", () => handleSaveEntryAction()); + keyboardHandler.on("d", handleFetchOriginalContentAction); + keyboardHandler.on("f", () => handleBookmarkAction()); // Feed actions keyboardHandler.on("F", goToFeedPage); - keyboardHandler.on("R", handleRefreshAllFeeds); + keyboardHandler.on("R", handleRefreshAllFeedsAction); keyboardHandler.on("+", goToAddSubscriptionPage); - keyboardHandler.on("#", unsubscribeFromFeed); + keyboardHandler.on("#", handleRemoveFeedAction); // UI actions - keyboardHandler.on("?", showKeyboardShortcuts); + keyboardHandler.on("?", showKeyboardShortcutsAction); keyboardHandler.on("Escape", () => ModalHandler.close()); keyboardHandler.on("a", () => { const enclosureElement = document.querySelector('.entry-enclosures'); @@ -1185,15 +1168,14 @@ function initializeTouchHandler() { */ function initializeClickHandlers() { // Entry actions - onClick(":is(a, button)[data-save-entry]", (event) => handleSaveEntry(event.target)); - onClick(":is(a, button)[data-toggle-bookmark]", (event) => handleBookmark(event.target)); + onClick(":is(a, button)[data-save-entry]", (event) => handleSaveEntryAction(event.target)); + onClick(":is(a, button)[data-toggle-bookmark]", (event) => handleBookmarkAction(event.target)); onClick(":is(a, button)[data-toggle-status]", (event) => handleEntryStatus("next", event.target)); - onClick(":is(a, button)[data-fetch-content-entry]", handleFetchOriginalContent); - onClick(":is(a, button)[data-share-status]", handleShare); + onClick(":is(a, button)[data-fetch-content-entry]", handleFetchOriginalContentAction); + onClick(":is(a, button)[data-share-status]", handleEntryShareAction); // Page actions with confirmation - onClick(":is(a, button)[data-action=markPageAsRead]", (event) => - handleConfirmationMessage(event.target, markPageAsRead)); + onClick(":is(a, button)[data-action=markPageAsRead]", (event) => handleConfirmationMessage(event.target, markPageAsReadAction)); // Generic confirmation handler onClick(":is(a, button)[data-confirm]", (event) => {