diff --git a/locales/template/en.po b/locales/template/en.po index 8e98be2eb..23363cc1b 100644 --- a/locales/template/en.po +++ b/locales/template/en.po @@ -947,6 +947,15 @@ msgstr "Your keyboard arrows (and the spacebar)" msgid "Touching the left/right side of the image." msgstr "Touching the left/right side of the image." +msgid "When reading an archive from search results, you can also navigate between archives using:" +msgstr "When reading an archive from search results, you can also navigate between archives using:" + +msgid "The square bracket keys" +msgstr "The square bracket keys" + +msgid "Reading past the first/last page" +msgstr "Reading past the first/last page" + msgid "Other keyboard shortcuts:" msgstr "Other keyboard shortcuts:" @@ -968,6 +977,9 @@ msgstr "R: open a random archive." msgid "F: toggle fullscreen mode" msgstr "F: toggle fullscreen mode" +msgid "shift+L/R: go to first page/last page" +msgstr "shift+L/R: go to first page/last page" + msgid "B: toggle bookmark" msgstr "B: toggle bookmark" diff --git a/locales/template/zh.po b/locales/template/zh.po index 0a1244636..dfa3e5351 100644 --- a/locales/template/zh.po +++ b/locales/template/zh.po @@ -913,6 +913,15 @@ msgstr "键盘方向键(和空格键)" msgid "Touching the left/right side of the image." msgstr "点击图像的左/右侧。" +msgid "When reading an archive from search results, you can also navigate between archives using:" +msgstr "从搜索结果阅读时,您也可以尝试以下方式读前后档案:" + +msgid "The square bracket keys" +msgstr "方括号键" + +msgid "Reading past the first/last page" +msgstr "跨书翻页" + msgid "Other keyboard shortcuts:" msgstr "其他键盘快捷键:" @@ -934,6 +943,9 @@ msgstr "R:打开一个随机档案。" msgid "F: toggle fullscreen mode" msgstr "F:切换全屏模式" +msgid "shift+L/R: go to first page/last page" +msgstr "shift+箭头键:跳到首尾页" + msgid "B: toggle bookmark" msgstr "B:切换书签" diff --git a/public/css/lrr.css b/public/css/lrr.css index c51540a39..a50a902c9 100644 --- a/public/css/lrr.css +++ b/public/css/lrr.css @@ -833,4 +833,29 @@ div.fullscreen img { body.infinite-scroll .fullscreen-infinite img { margin: 0 auto !important; +} + +/* Prevent absolute options from blocking the paginator */ +@media (max-width: 768px) { + .absolute-options { + position: fixed !important; + z-index: 100; + } + + .absolute-left { + bottom: 15px !important; + left: 10px !important; + top: auto !important; + } + + .absolute-right { + bottom: 15px !important; + right: 10px !important; + top: auto !important; + } + + /* Ensure the paginator isn't blocked */ + .sn { + margin-bottom: 60px !important; + } } \ No newline at end of file diff --git a/public/js/index.js b/public/js/index.js index b8987d84f..f48d4f8cd 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -35,6 +35,9 @@ Index.initializeAll = function () { $(document).on("click.title-bookmark-icon", ".title-bookmark-icon", Index.toggleBookmarkStatusByIcon); $(document).on("keydown.quick-search", Index.handleQuickSearch); $(document).on("keydown.escape-overlay", Index.handleEscapeKey); + $(document).on("click", ".swiper-wrapper .swiper-slide a[href*='/reader?id=']", function() { + sessionStorage.setItem('navigationState', 'carousel'); + }); // 0 = List view // 1 = Thumbnail view @@ -773,6 +776,13 @@ Index.handleContextMenu = function (option, id) { }); break; case "read": + // Use the source that was stored when the context menu was opened + if (window.contextMenuSource === 'carousel') { + sessionStorage.setItem('navigationState', 'carousel'); + } else { + sessionStorage.setItem('navigationState', 'datatables'); + } + LRR.openInNewTab(new LRR.apiURL(`/reader?id=${id}`)); break; case "download": diff --git a/public/js/index_datatables.js b/public/js/index_datatables.js index fd2fcfbb9..35287bc42 100644 --- a/public/js/index_datatables.js +++ b/public/js/index_datatables.js @@ -31,6 +31,15 @@ IndexTable.initializeAll = function () { IndexTable.currentSearch = $(e.target).attr("search"); IndexTable.doSearch(); }); + + // Add click handler for datatables items to mark them as datatables navigation + // Exclude any elements that are inside .swiper-wrapper (carousel) + $(document).on("click", "a[href*='/reader?id=']", function() { + if ($(this).closest(".swiper-wrapper").length > 0) { + return; + } + sessionStorage.setItem('navigationState', 'datatables'); + }); // Add a listen event to window.popstate to update the search accordingly // if the user goes back using browser history @@ -65,6 +74,9 @@ IndexTable.initializeAll = function () { data: "tags", className: "tags itd", name: "tags", orderable: false, render: IndexTable.renderTags, }); + // Store the page size in localStorage for use in the reader + localStorage.setItem('datatablesPageSize', Index.pageSize.toString()); + // Datatables configuration IndexTable.dataTable = $(".datatables").DataTable({ serverSide: true, @@ -105,6 +117,10 @@ IndexTable.doSearch = function (page) { // This allows for the regular search bar to be used in conjunction with categories. IndexTable.dataTable.column(".tags.itd").search(Index.selectedCategory); + // Store search parameters in localStorage for archive navigation + localStorage.setItem('currentSearch', IndexTable.currentSearch); + localStorage.setItem('selectedCategory', Index.selectedCategory); + // Update search input field $("#search-input").val(IndexTable.currentSearch); IndexTable.dataTable.search(IndexTable.currentSearch); @@ -269,6 +285,20 @@ IndexTable.drawCallback = function () { $(".itg").show(); } + // Store archive IDs in localStorage in the order they appear in the table + const archiveIds = []; + const archives = IndexTable.dataTable.rows().data(); + for (let i = 0; i < archives.length; i++) { + archiveIds.push(archives[i].arcid); + } + localStorage.setItem('currArchiveIds', JSON.stringify(archiveIds)); + localStorage.setItem('currDatatablesPage', pageInfo.page + 1); + + // Clear previous/next archive IDs when changing pages manually + // to avoid navigation issues when using the browser back button + localStorage.removeItem('previousArchiveIds'); + localStorage.removeItem('nextArchiveIds'); + // Update url to contain all search parameters, and push it to the history if (IndexTable.isComingFromPopstate) { // But don't fire this if we're coming from popstate diff --git a/public/js/reader.js b/public/js/reader.js index d35c37dd1..881cbb562 100644 --- a/public/js/reader.js +++ b/public/js/reader.js @@ -24,6 +24,9 @@ Reader.autoNextPage = false; Reader.autoNextPageCountdownTaskId = undefined; Reader.autoNextPageCountdown = 0; +Reader.archiveIndex = -1; +Reader.archiveIds = []; + Reader.initializeAll = function () { Reader.initializeSettings(); Reader.applyContainerWidth(); @@ -53,7 +56,11 @@ Reader.initializeAll = function () { $(document).on("click.auto-next-page", "#auto-next-page-apply", Reader.registerAutoNextPage); $(document).on("click.close-overlay", "#overlay-shade", LRR.closeOverlay); - $(document).on("click.toggle-full-screen", "#toggle-full-screen", () => Reader.handleFullScreen(true)); + $(document).on("click.toggle-full-screen", "#toggle-full-screen", (e) => { + e.preventDefault(); + e.stopPropagation(); + Reader.handleFullScreen(true); + }); $(document).on("click.toggle-auto-next-page", ".toggle-auto-next-page", Reader.toggleAutoNextPage); $(document).on("click.toggle-archive-overlay", "#toggle-archive-overlay", Reader.toggleArchiveOverlay); $(document).on("click.toggle-settings-overlay", "#toggle-settings-overlay", Reader.toggleSettingsOverlay); @@ -113,6 +120,12 @@ Reader.initializeAll = function () { Reader.goToPage(pageNumber); }); + // When user hits browser back, return to index. + window.addEventListener("popstate", (event) => { + event.preventDefault(); + Reader.returnToIndex(); + }); + // Apply full-screen utility // F11 Fullscreen is totally another "Fullscreen", so its support is beyong consideration. if (!window.fscreen.fullscreenEnabled) { @@ -129,6 +142,9 @@ Reader.initializeAll = function () { Reader.force = params.get("force_reload") !== null; Reader.currentPage = (+params.get("p") || 1) - 1; + // Set up archive navigation state + Reader.setupArchiveNavigation(); + // Remove the "new" tag with an api call Server.callAPI(`/api/archives/${Reader.id}/isnew`, "DELETE", null, I18N.ReaderErrorClearingNew, null); @@ -182,8 +198,61 @@ Reader.initializeAll = function () { // Fetch "bookmark" category ID and setup icon Reader.loadBookmarkStatus(); + + // Changing archives in reader mode cause datatables search info to be lost, so we need to + // get it out from localStorage when calling return to index. + $(document).on("click.return-to-index", "#return-to-index", (e) => { + e.preventDefault(); + Reader.returnToIndex(); + }); }; +/** + * Determine if current page qualifies for, and sets up, archive navigation state. + * While in reader mode, navigation state is only supported if user enters reader from index datatables, + * or if user is already in reader mode with navigation support and switches to a different archive via + * readNextArchive() or readPreviousArchive(). + * + * If users enters from carousel or by pasting URL, navigation is not supported + * + * @returns {boolean} - whether archive navigation state was set up + */ +Reader.setupArchiveNavigation = function () { + const navigationState = sessionStorage.getItem('navigationState'); + const currArchiveIdsJson = localStorage.getItem('currArchiveIds'); + const referrer = document.referrer; + const isDirectNavigation = !referrer || !referrer.includes(window.location.host) + if (isDirectNavigation) { + Reader.archiveIds = []; + sessionStorage.removeItem('navigationState'); + return false; + } else if (navigationState === 'datatables' && currArchiveIdsJson) { + try { + const archiveIds = JSON.parse(currArchiveIdsJson); + Reader.archiveIds = archiveIds; + Reader.archiveIndex = archiveIds.indexOf(Reader.id); + if (Reader.archiveIndex !== -1) { + if (Reader.archiveIndex === 0) { + const previousArchives = Reader.loadPreviousDatatablesArchives(); + if (previousArchives) { + localStorage.setItem('previousArchiveIds', JSON.stringify(previousArchives)); + } + } + if (Reader.archiveIndex === archiveIds.length - 1) { + const nextArchives = Reader.loadNextDatatablesArchives(); + if (nextArchives) { + localStorage.setItem('nextArchiveIds', JSON.stringify(nextArchives)); + } + } + } + } catch (error) { + console.error("Error setting up archive navigation state:", error); + return false; + } + } + return true; +} + /** * Adds a removable category flag to the categories section within archive overview. */ @@ -379,7 +448,8 @@ Reader.handleShortcuts = function (e) { } switch (e.keyCode) { case 8: // backspace - document.location.href = $("#return-to-index").attr("href"); + e.preventDefault(); + Reader.returnToIndex(); break; case 27: // escape LRR.closeOverlay(); @@ -477,19 +547,35 @@ Reader.handleShortcuts = function (e) { } break; case 37: // left arrow - Reader.changePage(-1); + if (e.shiftKey) { + Reader.changePage("first"); + } else { + Reader.changePage(-1); + } break; case 39: // right arrow - Reader.changePage(1); + if (e.shiftKey) { + Reader.changePage("last"); + } else { + Reader.changePage(1); + } break; case 65: // a - Reader.changePage(-1); + if (e.shiftKey) { + Reader.changePage("first"); + } else { + Reader.changePage(-1); + } break; case 66: // b Reader.toggleBookmark(e); break; case 68: // d - Reader.changePage(1); + if (e.shiftKey) { + Reader.changePage("last"); + } else { + Reader.changePage(1); + } break; case 70: // f Reader.toggleFullScreen(); @@ -514,8 +600,15 @@ Reader.handleShortcuts = function (e) { break; case 82: // r if (e.ctrlKey || e.shiftKey || e.metaKey) { break; } + sessionStorage.removeItem('navigationState'); document.location.href = new LRR.apiURL("/random"); break; + case 219: // [ + Reader.readPreviousArchive(); + break; + case 221: // ] + Reader.readNextArchive(); + break; default: break; } @@ -1036,7 +1129,11 @@ Reader.initializeArchiveOverlay = function () { } }; - fetch(new LRR.apiURL(`/api/archives/${Reader.id}/files/thumbnails`), { method: "POST" }) + fetch(new LRR.apiURL(`/api/archives/${Reader.id}/files/thumbnails`), { + method: "POST", + mode: 'same-origin', + credentials: 'same-origin' + }) .then((response) => { if (response.status === 200) { // Thumbnails are already generated, there's nothing to do. Very nice! @@ -1071,8 +1168,14 @@ Reader.changePage = function(targetPage) { } let destination; if (targetPage === "first") { + if (Reader.currentPage === 0) { + return Reader.readPreviousArchive(); + } destination = Reader.mangaMode ? Reader.maxPage : 0; } else if (targetPage === "last") { + if (Reader.currentPage === Reader.maxPage) { + return Reader.readNextArchive(); + } destination = Reader.mangaMode ? 0 : Reader.maxPage; } else { let offset = targetPage; @@ -1081,11 +1184,20 @@ Reader.changePage = function(targetPage) { } destination = Reader.currentPage + (Reader.mangaMode ? -offset : offset); } - Reader.goToPage(destination); + if (destination < 0) { + return Reader.readPreviousArchive(); + } else if (destination > Reader.maxPage) { + return Reader.readNextArchive(); + } else { + return Reader.goToPage(destination); + } }; Reader.handlePaginator = function () { switch (this.getAttribute("value")) { + case "outermost-left": + Reader.readPreviousArchive(); + break; case "outer-left": Reader.changePage("first"); break; @@ -1098,7 +1210,191 @@ Reader.handlePaginator = function () { case "outer-right": Reader.changePage("last"); break; + case "outermost-right": + Reader.readNextArchive(); + break; default: break; } }; + +Reader.loadPreviousDatatablesArchives = function () { + if (localStorage.getItem('previousArchiveIds')) { + return JSON.parse(localStorage.getItem('previousArchiveIds')); + } + const currentPage = parseInt(localStorage.getItem('currDatatablesPage') || '1', 10); + if (currentPage <= 1) return null; + const prevDTPage = currentPage - 1; + return Reader.loadDatatablesArchives(prevDTPage); +} + +Reader.loadNextDatatablesArchives = function () { + if (localStorage.getItem('nextArchiveIds')) { + return JSON.parse(localStorage.getItem('nextArchiveIds')); + } + const currentPage = parseInt(localStorage.getItem('currDatatablesPage') || '1', 10); + const nextDTPage = currentPage + 1; + return Reader.loadDatatablesArchives(nextDTPage); +} + +// TODO: this can probably be refactored to Reader.changeArchive like in Reader.changePage +// but also it might be fine as is for readability +// when at start or end of list, perform list shifting +// if no previous/next list is available, this means we're at the first or last archive +// iPhone doesn't support fullscreen API (actually ios doesn't in general but iPhone is where +// it really breaks down). +Reader.readPreviousArchive = function () { + const isIphone = /iPhone/.test(navigator.userAgent); + if (!isIphone && window.fscreen.inFullscreen()) { + return; + } + if (Reader.archiveIds.length > 0) { + let previousArchiveId; + if (Reader.archiveIndex === 0) { + const previousArchiveIdsJson = localStorage.getItem('previousArchiveIds'); + const currArchiveIdsJson = localStorage.getItem('currArchiveIds'); + if (previousArchiveIdsJson && currArchiveIdsJson) { + const previousArchiveIds = JSON.parse(previousArchiveIdsJson); + localStorage.removeItem('previousArchiveIds'); + localStorage.setItem('currArchiveIds', previousArchiveIdsJson); + localStorage.setItem('nextArchiveIds', currArchiveIdsJson); + previousArchiveId = previousArchiveIds[previousArchiveIds.length - 1]; + const currentPage = parseInt(localStorage.getItem('currDatatablesPage') || '1', 10); + localStorage.setItem('currDatatablesPage', currentPage - 1); + } else { + LRR.toast({"text": "This is the first archive"}); + return; + } + } else { + previousArchiveId = Reader.archiveIds[Reader.archiveIndex - 1]; + } + const newUrl = new LRR.apiURL(`/reader?id=${previousArchiveId}`).toString(); + history.pushState({navigation: 'archive'}, '', newUrl); + window.location.href = newUrl; + } else { + LRR.toast({"text": "This is the first archive"}); + } +} + +Reader.readNextArchive = function () { + const isIphone = /iPhone/.test(navigator.userAgent); + if (!isIphone && window.fscreen.inFullscreen()) { + return; + } + if (Reader.archiveIds.length > 0) { + let nextArchiveId; + if (Reader.archiveIndex === Reader.archiveIds.length - 1) { + const nextArchiveIdsJson = localStorage.getItem('nextArchiveIds'); + const currArchiveIdsJson = localStorage.getItem('currArchiveIds'); + if (nextArchiveIdsJson && currArchiveIdsJson) { + const nextArchiveIds = JSON.parse(nextArchiveIdsJson); + localStorage.removeItem('nextArchiveIds'); + localStorage.setItem('currArchiveIds', nextArchiveIdsJson); + localStorage.setItem('previousArchiveIds', currArchiveIdsJson); + nextArchiveId = nextArchiveIds[0]; + const currentPage = parseInt(localStorage.getItem('currDatatablesPage') || '1', 10); + localStorage.setItem('currDatatablesPage', currentPage + 1); + } else { + LRR.toast({"text": "This is the last archive"}); + return; + } + } else { + nextArchiveId = Reader.archiveIds[Reader.archiveIndex + 1]; + } + const newUrl = new LRR.apiURL(`/reader?id=${nextArchiveId}`).toString(); + history.pushState({navigation: 'archive'}, '', newUrl); + window.location.href = newUrl; + } else { + LRR.toast({"text": "This is the last archive"}); + } +} + +/** + * Loads the archives for the given datatables page. + * This is to support archive navigation between datatables pages; in order to do that + * we need to know everything about the filter used to produce the datatables pages. + * + * E.g., if we have two filters F, G, then the archives loaded under F on page 3 may be + * different from the archives loaded under G on page 3. + * + * We would also need to store the filter somewhere (localStorage), so it survives page + * changes, history rewrites, etc. + * + * @param {number} datatablesPage - The page number to load. + * @returns {Array} - The list of archive IDs + */ +Reader.loadDatatablesArchives = function (datatablesPage) { + const indexSearchQuery = localStorage.getItem('currentSearch') || ''; + const indexSelectedCategory = localStorage.getItem('selectedCategory') || ''; + const datatablesPageSize = parseInt(localStorage.getItem('datatablesPageSize') || '100', 10); + const indexSort = localStorage.getItem('indexSort') || '0'; + const indexOrder = localStorage.getItem('indexOrder') || 'asc'; + let searchUrlStr = `/api/search?start=${(datatablesPage-1) * datatablesPageSize}`; + if (indexSearchQuery) searchUrlStr += `&filter=${encodeURIComponent(indexSearchQuery)}`; + if (indexSelectedCategory) searchUrlStr += `&category=${encodeURIComponent(indexSelectedCategory)}`; + + // See IndexTable.drawCallback + if (indexSort && indexSort !== '0') { + const sortby = indexSort >= 1 ? localStorage[`customColumn${indexSort}`] || `Header ${indexSort}` : "title"; + searchUrlStr += `&sortby=${sortby}`; + searchUrlStr += `&order=${indexOrder}`; + } + const searchUrl = new LRR.apiURL(searchUrlStr); + console.debug("Using Search API URL:", searchUrl.toString()); + let archives = null; + + // See the result of this API here to get an idea of what the response looks like: + // tools/Documentation/api-documentation/search-api.md + $.ajax({ + url: searchUrl.toString(), + type: 'GET', + async: false, + dataType: 'json', + success: function(data) { + if (data && data.data && data.data.length > 0) { + archives = data.data.map(archive => archive.arcid); + } + }, + error: function(xhr, _status, error) { + console.error('Failed to fetch archive list:', error); + console.error('Response:', xhr.responseText); + } + }); + return archives; +} + +/** + * Return to the index page, preserving the search filter from when + * the user went from index to reader. + * + * If for some reason history is unavailable, fall back with search + * parameters. + */ +Reader.returnToIndex = function () { + // Check if we have more than one entry in the history stack + // This checks if we can go back in browser history + if (window.history.length > 1) { + history.back(); + return; + } + // Fallback logic + const indexSearchQuery = localStorage.getItem('currentSearch') || ''; + const indexSelectedCategory = localStorage.getItem('selectedCategory') || ''; + const indexSort = localStorage.getItem('indexSort') || 0; + const indexOrder = localStorage.getItem('indexOrder') || 'asc'; + const currentPage = localStorage.getItem('currDatatablesPage') || '1'; + let returnUrl = '/'; + const params = new URLSearchParams(); + if (indexSearchQuery) params.append('q', indexSearchQuery); + if (indexSelectedCategory) params.append('c', indexSelectedCategory); + if (indexSort) params.append('sort', indexSort); + if (indexOrder !== 'asc') params.append('sortdir', indexOrder); + if (currentPage !== '1') params.append('p', currentPage); + const queryString = params.toString(); + if (queryString) { + returnUrl += '?' + queryString; + } + const indexUrl = new LRR.apiURL(returnUrl).toString(); + history.pushState({navigation: 'index'}, '', indexUrl); + window.location.href = indexUrl; +} diff --git a/public/js/server.js b/public/js/server.js index b537433a3..78320b1f8 100644 --- a/public/js/server.js +++ b/public/js/server.js @@ -82,7 +82,11 @@ Server.callAPIBody = function (endpoint, method, body, successMessage, errorMess */ Server.checkJobStatus = function (jobId, useDetail, callback, failureCallback, progressCallback = null) { let endpoint = new LRR.apiURL(useDetail ? `/api/minion/${jobId}/detail` : `/api/minion/${jobId}`); - fetch(endpoint, { method: "GET" }) + fetch(endpoint, { + method: "GET", + mode: 'same-origin', + credentials: 'same-origin' + }) .then((response) => (response.ok ? response.json() : { success: 0, error: I18N.GenericReponseError })) .then((data) => { if (data.error) throw new Error(data.error); diff --git a/templates/index.html.tt2 b/templates/index.html.tt2 index a8bed1a37..86f22737e 100644 --- a/templates/index.html.tt2 +++ b/templates/index.html.tt2 @@ -217,6 +217,16 @@ // Initialize context menu $.contextMenu({ selector: '.context-menu', + events: { + show: function(options) { + const $trigger = $(this); + if ($trigger.closest('.swiper-wrapper').length > 0) { + window.contextMenuSource = 'carousel'; + } else { + window.contextMenuSource = 'datatables'; + } + } + }, build: ($trigger, e) => { return { callback: function (key, options) { diff --git a/templates/reader.html.tt2 b/templates/reader.html.tt2 index 5f1ecc738..967f44a48 100644 --- a/templates/reader.html.tt2 +++ b/templates/reader.html.tt2 @@ -172,6 +172,11 @@
  • [% c.lh("Your keyboard arrows (and the spacebar)") %]
  • [% c.lh("Touching the left/right side of the image.") %]
  • + [% c.lh("When reading an archive from search results, you can also navigate between archives using:") %] +
    [% c.lh("Other keyboard shortcuts:") %]
    [% c.lh("To return to the archive index, touch the arrow pointing down or use Backspace.") %] @@ -277,6 +283,7 @@ [% BLOCK arrows %]
    + @@ -287,6 +294,7 @@ +
    [% END %]