diff --git a/locales/template/en.po b/locales/template/en.po index 56936015f..994a4caee 100644 --- a/locales/template/en.po +++ b/locales/template/en.po @@ -1073,6 +1073,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:" @@ -1100,6 +1109,9 @@ msgstr "B: toggle bookmark" msgid "N: toggle auto next page" msgstr "N: toggle auto next page" +msgid "shift+Left/Right: go to first page/last page" +msgstr "shift+Left/Right: go to first page/last page" + msgid "G: go to page number" msgstr "G: go to page number" diff --git a/locales/template/zh.po b/locales/template/zh.po index 9bd2fbdc2..41908f087 100644 --- a/locales/template/zh.po +++ b/locales/template/zh.po @@ -943,6 +943,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 "其他键盘快捷键:" @@ -967,6 +976,9 @@ msgstr "F:切换全屏模式" msgid "B: toggle bookmark" msgstr "B:切换书签" +msgid "shift+Left/Right: go to first page/last page" +msgstr "shift+箭头键:跳到首尾页" + msgid "To return to the archive index, touch the arrow pointing down or use Backspace." msgstr "要返回档案索引,请点击向下的箭头或使用退格键。" diff --git a/public/css/lrr.css b/public/css/lrr.css index c70b92fc5..398c3b3ba 100644 --- a/public/css/lrr.css +++ b/public/css/lrr.css @@ -998,3 +998,28 @@ body.infinite-scroll .fullscreen-infinite img { text-overflow: ellipsis; white-space: nowrap; } + +/* 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; + } +} diff --git a/public/js/mod/index.js b/public/js/mod/index.js index 03ec8ff89..528afab9a 100644 --- a/public/js/mod/index.js +++ b/public/js/mod/index.js @@ -9,7 +9,7 @@ import I18N from "i18n"; import * as marked from "marked"; import DOMPurify from "dompurify"; -let selectedCategory = ""; +export let selectedCategory = ""; let awesomplete = {}; let carouselInitialized = false; let swiper = {}; @@ -57,6 +57,11 @@ export function initializeAll() { if (id) toggleArchiveSelection(id); }); + // Mark carousel-originated reader links so Reader can decide whether to enable cross-archive navigation + $(document).on("click.carousel-navstate", ".swiper-wrapper .swiper-slide a[href*='/reader?id=']", () => { + sessionStorage.setItem("navigationState", "carousel"); + }); + // 0 = List view // 1 = Thumbnail view // List view is at 0 but became the non-default state later so here's some legacy weirdness @@ -1043,6 +1048,7 @@ export function handleContextMenu(option, id) { break; } case "read": + sessionStorage.setItem("navigationState", window.contextMenuSource === "carousel" ? "carousel" : "datatables"); LRR.openInNewTab(new LRR.ApiURL(`/reader?id=${id}`)); break; case "download": diff --git a/public/js/mod/index_datatables.js b/public/js/mod/index_datatables.js index 636b762d0..0a34544e5 100644 --- a/public/js/mod/index_datatables.js +++ b/public/js/mod/index_datatables.js @@ -34,6 +34,13 @@ export function initializeAll() { doSearch(); }); + // Mark datatables-originated reader links so Reader can decide whether to enable cross-archive navigation. + // Excludes anything inside the carousel; that's tagged separately in Index.initializeAll. + $(document).on("click.datatables-navstate", "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 $(window).on("popstate", () => { @@ -67,6 +74,9 @@ export function initializeAll() { data: "tags", className: "tags itd", name: "tags", orderable: false, render: renderTags, }); + // Store the page size in localStorage for use in the reader + localStorage.setItem("datatablesPageSize", Index.pageSize.toString()); + // Datatables configuration dataTable = $(".datatables").DataTable({ serverSide: true, @@ -116,6 +126,10 @@ export function doSearch(page) { // This allows for the regular search bar to be used in conjunction with categories. dataTable.column(".tags.itd").search(Index.selectedCategory); + // Store search parameters in localStorage for archive navigation + localStorage.setItem("currentSearch", currentSearch); + localStorage.setItem("selectedCategory", Index.selectedCategory); + // Update search input field $("#search-input").val(currentSearch); dataTable.search(currentSearch); @@ -298,6 +312,21 @@ export function drawCallback() { $(".itg").show(); } + // Store archive IDs in localStorage in the order they appear in the table, + // so the Reader can navigate to neighbors without re-querying. + const archiveIds = []; + const archives = 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 stale neighbors 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 (isComingFromPopstate) { // But don't fire this if we're coming from popstate diff --git a/public/js/mod/server.js b/public/js/mod/server.js index a073efb78..0a2398bd8 100644 --- a/public/js/mod/server.js +++ b/public/js/mod/server.js @@ -83,7 +83,11 @@ export function callAPIBody(endpoint, method, body, successMessage, errorMessage */ export function checkJobStatus(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.json()) .then((data) => { if (data.error) throw new Error(data.error); diff --git a/public/js/reader.js b/public/js/reader.js index 5433527d0..6c50c05df 100644 --- a/public/js/reader.js +++ b/public/js/reader.js @@ -16,6 +16,8 @@ let showingSinglePage = true; let pageThumbnails = []; let preloadedImg = {}; let preloadedSizes = {}; +let archiveIndex = -1; +let archiveIds = []; let spaceScroll = { timeout: null, animationId: null }; //Spacebar Scroll Config let scrollConfig = { @@ -52,7 +54,7 @@ let markers = []; let overlayFiltered = false; let pageNaviState = true; -export function initializeAll(trackProgressLocally, authenticateProgress) { +export async function initializeAll(trackProgressLocally, authenticateProgress) { state.trackProgressLocally = trackProgressLocally; state.authenticateProgress = authenticateProgress; @@ -85,7 +87,11 @@ export function initializeAll(trackProgressLocally, authenticateProgress) { $(document).on("click.auto-next-page", "#auto-next-page-apply", registerAutoNextPage); $(document).on("click.close-overlay", "#overlay-shade", LRR.closeOverlay); - $(document).on("click.toggle-full-screen", "#toggle-full-screen", () => toggleFullScreen()); + $(document).on("click.toggle-full-screen", "#toggle-full-screen", (e) => { + e.preventDefault(); + e.stopPropagation(); + toggleFullScreen(); + }); $(document).on("click.toggle-auto-next-page", ".toggle-auto-next-page", toggleAutoNextPage); $(document).on("click.toggle-archive-overlay", "#toggle-archive-overlay", toggleArchiveOverlay); $(document).on("click.toggle-settings-overlay", "#toggle-settings-overlay", toggleSettingsOverlay); @@ -268,6 +274,15 @@ export function initializeAll(trackProgressLocally, authenticateProgress) { force = params.get("force_reload") !== null; currentPage = (+params.get("p") || 1) - 1; + // Set up archive navigation state from the entry source (datatables vs carousel vs direct nav) + await setupArchiveNavigation(); + + // Intercept return-to-index so we can re-apply the search/page state the user came from + $(document).on("click.return-to-index", "#return-to-index", (e) => { + e.preventDefault(); + returnToIndex(); + }); + // Remove the "new" tag with an api call (archives only; tanks don't have an isnew flag) if (!id.startsWith("TANK_")) Server.callAPI(`/api/archives/${id}/isnew`, "DELETE", null, I18N.ReaderErrorClearingNew, null); @@ -555,6 +570,12 @@ export function loadImages() { } if (showOverlayByDefault) { toggleArchiveOverlay(); } + + // Resume slideshow if it was active before cross-archive navigation + if (sessionStorage.getItem("autoNextPage") === "true") { + sessionStorage.removeItem("autoNextPage"); + startAutoNextPage(); + } }; const onFinally = () => { @@ -718,7 +739,8 @@ function handleShortcuts(e) { } switch (e.which) { case 8: // backspace - document.location.href = $("#return-to-index").attr("href"); + e.preventDefault(); + returnToIndex(); break; case 27: // escape LRR.closeOverlay(); @@ -727,19 +749,35 @@ function handleShortcuts(e) { spaceScrollProcessInput(e); break; case 37: // left arrow - changePage(-1, true); + if (e.shiftKey) { + changePage("first", true); + } else { + changePage(-1, true); + } break; case 39: // right arrow - changePage(1, true); + if (e.shiftKey) { + changePage("last", true); + } else { + changePage(1, true); + } break; case 65: // a - changePage(-1, true); + if (e.shiftKey) { + changePage("first", true); + } else { + changePage(-1, true); + } break; case 66: // b toggleBookmark(e); break; case 68: // d - changePage(1, true); + if (e.shiftKey) { + changePage("last", true); + } else { + changePage(1, true); + } break; case 70: // f toggleFullScreen(); @@ -774,6 +812,7 @@ function handleShortcuts(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 83: // s @@ -781,6 +820,12 @@ function handleShortcuts(e) { addStamp(); } break; + case 219: // [ + readPreviousArchive(); + break; + case 221: // ] + readNextArchive(); + break; default: break; } @@ -1546,16 +1591,25 @@ function startAutoNextPage() { if (autoNextPageCountdown <= 0) { clearInterval(autoNextPageCountdownTaskId); - if (mangaMode) - changePage(-1); - else - changePage(1); - - const continueNextPage = mangaMode ? currentPage > 0 : currentPage < maxPage; - if (continueNextPage) { - startAutoNextPage(); - } else { + const atLastPage = mangaMode ? currentPage === 0 : currentPage === maxPage; + + if (atLastPage) { + // At archive boundary: attempt cross-archive navigation. + // readNextArchive/readPreviousArchive persists slideshow state + // to sessionStorage; loadImages on the new page resumes it. + if (archiveIds.length > 0) { + if (mangaMode) + readPreviousArchive(); + else + readNextArchive(); + } stopAutoNextPage(); + } else { + if (mangaMode) + changePage(-1); + else + changePage(1); + startAutoNextPage(); } return; } @@ -1818,7 +1872,11 @@ function generateThumbnails() { }; const fetchThumbsForArc = function(arc) { - fetch(new LRR.ApiURL(`/api/archives/${arc.id}/files/thumbnails`), { method: "POST" }) + fetch(new LRR.ApiURL(`/api/archives/${arc.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! @@ -1879,9 +1937,17 @@ function changePage(targetPage, resetAuto = false) { } let destination; if (targetPage === "first") { - destination = mangaMode ? maxPage : 0; + const firstPage = mangaMode ? maxPage : 0; + if (currentPage === firstPage) { + return readPreviousArchive(); + } + destination = firstPage; } else if (targetPage === "last") { - destination = mangaMode ? 0 : maxPage; + const lastPage = mangaMode ? 0 : maxPage; + if (currentPage === lastPage) { + return readNextArchive(); + } + destination = lastPage; } else { let offset = targetPage; if (doublePageMode && !showingSinglePage && currentPage > 0) { @@ -1889,11 +1955,19 @@ function changePage(targetPage, resetAuto = false) { } destination = currentPage + (mangaMode ? -offset : offset); } - goToPage(destination); + if (destination < 0) { + return readPreviousArchive(); + } else if (destination > maxPage) { + return readNextArchive(); + } + return goToPage(destination); } function handlePaginator() { switch (this.getAttribute("value")) { + case "outermost-left": + readPreviousArchive(); + break; case "outer-left": changePage("first", true); break; @@ -1906,6 +1980,9 @@ function handlePaginator() { case "outer-right": changePage("last", true); break; + case "outermost-right": + readNextArchive(); + break; default: break; } @@ -1915,6 +1992,209 @@ function getFilename(index) { return new URLSearchParams(pages[index].split("?")[1]).get("path"); } +/** + * 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 {Promise} - whether archive navigation state was set up + */ +async function setupArchiveNavigation() { + const navigationState = sessionStorage.getItem("navigationState"); + const currArchiveIdsJson = localStorage.getItem("currArchiveIds"); + const referrer = document.referrer; + const isDirectNavigation = !referrer || !referrer.includes(window.location.host); + if (isDirectNavigation) { + archiveIds = []; + sessionStorage.removeItem("navigationState"); + return false; + } else if (navigationState === "datatables" && currArchiveIdsJson) { + try { + const ids = JSON.parse(currArchiveIdsJson); + archiveIds = ids; + archiveIndex = ids.indexOf(id); + if (archiveIndex !== -1) { + if (archiveIndex === 0) { + const previousArchives = await loadPreviousDatatablesArchives(); + if (previousArchives) { + localStorage.setItem("previousArchiveIds", JSON.stringify(previousArchives)); + } + } + if (archiveIndex === ids.length - 1) { + const nextArchives = await loadNextDatatablesArchives(); + if (nextArchives) { + localStorage.setItem("nextArchiveIds", JSON.stringify(nextArchives)); + } + } + } + } catch (error) { + console.error("Error setting up archive navigation state:", error); + return false; + } + } + return true; +} + +async function loadPreviousDatatablesArchives() { + if (localStorage.getItem("previousArchiveIds")) { + return JSON.parse(localStorage.getItem("previousArchiveIds")); + } + const currentDTPage = parseInt(localStorage.getItem("currDatatablesPage") || "1", 10); + if (currentDTPage <= 1) return null; + return loadDatatablesArchives(currentDTPage - 1); +} + +async function loadNextDatatablesArchives() { + if (localStorage.getItem("nextArchiveIds")) { + return JSON.parse(localStorage.getItem("nextArchiveIds")); + } + const currentDTPage = parseInt(localStorage.getItem("currDatatablesPage") || "1", 10); + return loadDatatablesArchives(currentDTPage + 1); +} + +function readPreviousArchive() { + const isIphone = /iPhone/.test(navigator.userAgent); + if (!isIphone && fscreen.inFullscreen()) { + return; + } + if (archiveIds.length > 0) { + let previousArchiveId; + if (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 currentDTPage = parseInt(localStorage.getItem("currDatatablesPage") || "1", 10); + localStorage.setItem("currDatatablesPage", currentDTPage - 1); + } else { + LRR.toast({ text: "This is the first archive" }); + return; + } + } else { + previousArchiveId = archiveIds[archiveIndex - 1]; + } + if (autoNextPage) { + sessionStorage.setItem("autoNextPage", "true"); + } + const newUrl = new LRR.ApiURL(`/reader?id=${previousArchiveId}`).toString(); + window.location.replace(newUrl); + } else { + LRR.toast({ text: "This is the first archive" }); + } +} + +function readNextArchive() { + const isIphone = /iPhone/.test(navigator.userAgent); + if (!isIphone && fscreen.inFullscreen()) { + return; + } + if (archiveIds.length > 0) { + let nextArchiveId; + if (archiveIndex === 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 currentDTPage = parseInt(localStorage.getItem("currDatatablesPage") || "1", 10); + localStorage.setItem("currDatatablesPage", currentDTPage + 1); + } else { + LRR.toast({ text: "This is the last archive" }); + return; + } + } else { + nextArchiveId = archiveIds[archiveIndex + 1]; + } + if (autoNextPage) { + sessionStorage.setItem("autoNextPage", "true"); + } + const newUrl = new LRR.ApiURL(`/reader?id=${nextArchiveId}`).toString(); + window.location.replace(newUrl); + } else { + LRR.toast({ text: "This is the last archive" }); + } +} + +/** + * Loads the archives for the given datatables page so the Reader can navigate + * between archives across DT page boundaries without re-rendering the index. + * + * @param {number} datatablesPage - The page number to load. + * @returns {Promise|null>} - The list of archive IDs, or null on error + */ +async function loadDatatablesArchives(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)}`; + + // Mirrors the sortby resolution from index_datatables.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); + + try { + const response = await fetch(searchUrl.toString(), { + method: "GET", + headers: { Accept: "application/json" }, + }); + if (!response.ok) { + console.error("Failed to fetch archive list:", response.status, response.statusText); + return null; + } + const data = await response.json(); + if (data && data.data && data.data.length > 0) { + return data.data.map(archive => archive.arcid); + } + return null; + } catch (error) { + console.error("Failed to fetch archive list:", error); + return null; + } +} + +/** + * Return to the index page with state preservation. Navigates to the DT page, + * search filter, category, and sort order that were active when the user + * entered reader mode, updated by any cross-DT archive navigation. + */ +function returnToIndex() { + const indexSearchQuery = localStorage.getItem("currentSearch") || ""; + const indexSelectedCategory = localStorage.getItem("selectedCategory") || ""; + const indexSort = localStorage.getItem("indexSort") || 0; + const indexOrder = localStorage.getItem("indexOrder") || "asc"; + const currentDTPage = 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 (currentDTPage !== "1") params.append("p", currentDTPage); + const queryString = params.toString(); + if (queryString) { + returnUrl += "?" + queryString; + } + window.location.href = new LRR.ApiURL(returnUrl).toString(); +} + /** * Toggles the visibility of the base-overlay div that's in the given selector. * @param {string} selector diff --git a/templates/index.html.tt2 b/templates/index.html.tt2 index 28b02e340..3a9bf6b60 100644 --- a/templates/index.html.tt2 +++ b/templates/index.html.tt2 @@ -227,6 +227,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) => { const id = $trigger.attr("id"); const isTankoubon = id && id.startsWith("TANK_"); diff --git a/templates/reader.html.tt2 b/templates/reader.html.tt2 index 5ee863857..52d55ddaf 100644 --- a/templates/reader.html.tt2 +++ b/templates/reader.html.tt2 @@ -32,7 +32,7 @@ const trackProgressLocally = "[% use_local %]" === "1"; const authenticateProgress = "[% auth_progress %]" === "1"; - Reader.initializeAll(trackProgressLocally, authenticateProgress); + await Reader.initializeAll(trackProgressLocally, authenticateProgress); @@ -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:") %] @@ -284,6 +290,7 @@ [% BLOCK arrows %]
    + @@ -294,6 +301,7 @@ +
    [% END %]