diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index 50b27eaf..6abd9444 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -126,6 +126,7 @@ "marked": "^15.0.12", "nanoid": "^5.1.6", "next": "16.1.6", + "next-view-transitions": "^0.3.5", "nunjucks": "^3.2.4", "overlayscrollbars": "^2.11.4", "overlayscrollbars-react": "^0.5.6", diff --git a/apps/readest-app/src/app/layout.tsx b/apps/readest-app/src/app/layout.tsx index 470b4fd5..bf450dec 100644 --- a/apps/readest-app/src/app/layout.tsx +++ b/apps/readest-app/src/app/layout.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { ViewTransitions } from 'next-view-transitions'; import { EnvProvider } from '@/context/EnvContext'; import Providers from '@/components/Providers'; @@ -72,9 +73,11 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - - {children} - + + + {children} + + ); diff --git a/apps/readest-app/src/app/library/components/Bookshelf.tsx b/apps/readest-app/src/app/library/components/Bookshelf.tsx index c3170697..1fc7afb6 100644 --- a/apps/readest-app/src/app/library/components/Bookshelf.tsx +++ b/apps/readest-app/src/app/library/components/Bookshelf.tsx @@ -54,6 +54,7 @@ interface BookshelfProps { handleBookDelete: (book: Book, syncBooks?: boolean) => Promise; handleSetSelectMode: (selectMode: boolean) => void; handleShowDetailsBook: (book: Book) => void; + handleLibraryNavigation: (targetGroup: string) => void; handlePushLibrary: () => Promise; booksTransferProgress: { [key: string]: number | null }; } @@ -69,6 +70,7 @@ const Bookshelf: React.FC = ({ handleBookDelete, handleSetSelectMode, handleShowDetailsBook, + handleLibraryNavigation, handlePushLibrary, booksTransferProgress, }) => { @@ -426,6 +428,7 @@ const Bookshelf: React.FC = ({ handleBookDelete={handleBookDelete} handleSetSelectMode={handleSetSelectMode} handleShowDetailsBook={handleShowDetailsBook} + handleLibraryNavigation={handleLibraryNavigation} handleUpdateReadingStatus={handleUpdateReadingStatus} transferProgress={ 'hash' in item ? booksTransferProgress[(item as Book).hash] || null : null diff --git a/apps/readest-app/src/app/library/components/BookshelfItem.tsx b/apps/readest-app/src/app/library/components/BookshelfItem.tsx index 8245dcd6..f57f7509 100644 --- a/apps/readest-app/src/app/library/components/BookshelfItem.tsx +++ b/apps/readest-app/src/app/library/components/BookshelfItem.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx'; import { useCallback } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { navigateToLibrary, navigateToReader, showReaderWindow } from '@/utils/nav'; +import { useTransitionRouter } from 'next-view-transitions'; +import { navigateToReader, showReaderWindow } from '@/utils/nav'; import { useEnv } from '@/context/EnvContext'; import { useLibraryStore } from '@/store/libraryStore'; import { useSettingsStore } from '@/store/settingsStore'; @@ -99,6 +99,7 @@ interface BookshelfItemProps { handleBookDelete: (book: Book, syncBooks?: boolean) => Promise; handleSetSelectMode: (selectMode: boolean) => void; handleShowDetailsBook: (book: Book) => void; + handleLibraryNavigation: (targetGroup: string) => void; handleUpdateReadingStatus: (book: Book, status: ReadingStatus | undefined) => void; } @@ -116,11 +117,11 @@ const BookshelfItem: React.FC = ({ handleBookDownload, handleSetSelectMode, handleShowDetailsBook, + handleLibraryNavigation, handleUpdateReadingStatus, }) => { const _ = useTranslation(); - const router = useRouter(); - const searchParams = useSearchParams(); + const router = useTransitionRouter(); const { envConfig, appService } = useEnv(); const { settings } = useSettingsStore(); const { updateBook } = useLibraryStore(); @@ -179,15 +180,11 @@ const BookshelfItem: React.FC = ({ if (isSelectMode) { toggleSelection(group.id); } else { - const params = new URLSearchParams(searchParams?.toString()); - params.set('group', group.id); - setTimeout(() => { - navigateToLibrary(router, `${params.toString()}`); - }, 0); + handleLibraryNavigation(group.id); } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [isSelectMode, searchParams], + [isSelectMode, handleLibraryNavigation], ); const bookContextMenuHandler = async (book: Book) => { diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index 0011ccc2..41f168b1 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -4,7 +4,8 @@ import clsx from 'clsx'; import * as React from 'react'; import { MdChevronRight } from 'react-icons/md'; import { useState, useRef, useEffect, Suspense, useCallback } from 'react'; -import { ReadonlyURLSearchParams, useRouter, useSearchParams } from 'next/navigation'; +import { ReadonlyURLSearchParams, useSearchParams } from 'next/navigation'; +import { useTransitionRouter } from 'next-view-transitions'; import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from 'overlayscrollbars-react'; import 'overlayscrollbars/overlayscrollbars.css'; @@ -82,7 +83,7 @@ const LibraryPageWithSearchParams = () => { }; const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchParams | null }) => { - const router = useRouter(); + const router = useTransitionRouter(); const { envConfig, appService } = useEnv(); const { token, user } = useAuth(); const { @@ -134,6 +135,53 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP const containerRef: React.MutableRefObject = useRef(null); const pageRef = useRef(null); + const getScrollKey = (group: string) => `library-scroll-${group || 'all'}`; + + const saveScrollPosition = (group: string) => { + const viewport = osRef.current?.osInstance()?.elements().viewport; + if (viewport) { + const scrollTop = viewport.scrollTop; + sessionStorage.setItem(getScrollKey(group), scrollTop.toString()); + } + }; + + const restoreScrollPosition = useCallback((group: string) => { + const savedPosition = sessionStorage.getItem(getScrollKey(group)); + if (savedPosition) { + const scrollTop = parseInt(savedPosition, 10); + const viewport = osRef.current?.osInstance()?.elements().viewport; + if (viewport) { + viewport.scrollTop = scrollTop; + } + } + }, []); + + // Unified navigation function that handles scroll position and direction + const handleLibraryNavigation = useCallback( + (targetGroup: string) => { + const currentGroup = searchParams?.get('group') || ''; + + // Save current scroll position BEFORE navigation + saveScrollPosition(currentGroup); + + // Detect and set navigation direction + const direction = currentGroup && !targetGroup ? 'back' : 'forward'; + document.documentElement.setAttribute('data-nav-direction', direction); + + // Build query params + const params = new URLSearchParams(searchParams?.toString()); + if (targetGroup) { + params.set('group', targetGroup); + } else { + params.delete('group'); + } + + navigateToLibrary(router, `${params.toString()}`); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [searchParams, router], + ); + useTheme({ systemUIVisible: true, appThemeColor: 'base-200' }); useUICSS(); @@ -390,6 +438,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP setCurrentGroupPath(groupName); }, [libraryBooks, searchParams, getGroupName]); + useEffect(() => { + const group = searchParams?.get('group') || ''; + restoreScrollPosition(group); + }, [searchParams, restoreScrollPosition]); + // Track current series/author group for navigation header useEffect(() => { const groupId = searchParams?.get('group') || ''; @@ -746,19 +799,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP }; const handleNavigateToPath = (path: string | undefined) => { - const group = path ? getGroupId(path) : ''; - const params = new URLSearchParams(searchParams?.toString()); - if (group) { - params.set('group', group); - } else { - params.delete('group'); - } + const group = path ? getGroupId(path) || '' : ''; setIsSelectAll(false); setIsSelectNone(false); - navigateToLibrary(router, `${params.toString()}`); - setTimeout(() => { - setCurrentGroupPath(path); - }, 300); + handleLibraryNavigation(group); }; if (!appService || !insets || checkOpenWithBooks || checkLastOpenBooks) { @@ -890,6 +934,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP handleBookDelete={handleBookDelete('both')} handleSetSelectMode={handleSetSelectMode} handleShowDetailsBook={handleShowDetailsBook} + handleLibraryNavigation={handleLibraryNavigation} booksTransferProgress={booksTransferProgress} handlePushLibrary={pushLibrary} /> diff --git a/apps/readest-app/src/styles/globals.css b/apps/readest-app/src/styles/globals.css index 0db70268..bdf0db9e 100644 --- a/apps/readest-app/src/styles/globals.css +++ b/apps/readest-app/src/styles/globals.css @@ -631,3 +631,100 @@ foliate-fxl { animation: settings-highlight-pulse 0.5s ease-in-out 2; border-radius: 8px; } + +/* View Transitions for library navigation */ +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 0.3s; + animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + +html[data-nav-direction='forward'] ::view-transition-old(root) { + animation-name: slide-fade-out-left; +} + +html[data-nav-direction='forward'] ::view-transition-new(root) { + animation-name: slide-fade-in-right; +} + +html[data-nav-direction='back'] ::view-transition-old(root) { + animation-name: slide-fade-out-right; +} + +html[data-nav-direction='back'] ::view-transition-new(root) { + animation-name: slide-fade-in-left; +} + +/* Forward animations */ +@keyframes slide-fade-out-left { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(-30px); + } +} + +@keyframes slide-fade-in-right { + from { + opacity: 0; + transform: translateX(30px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +/* Back animations */ +@keyframes slide-fade-out-right { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(30px); + } +} + +@keyframes slide-fade-in-left { + from { + opacity: 0; + transform: translateX(-30px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +/* Simpler fade for reduced motion preference */ +@media (prefers-reduced-motion: reduce) { + ::view-transition-old(root), + ::view-transition-new(root) { + animation-duration: 0.15s; + } + + ::view-transition-old(root) { + animation-name: simple-fade-out !important; + } + + ::view-transition-new(root) { + animation-name: simple-fade-in !important; + } + + @keyframes simple-fade-out { + to { + opacity: 0; + } + } + + @keyframes simple-fade-in { + from { + opacity: 0; + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c27b6a01..e05d4c0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -269,6 +269,9 @@ importers: next: specifier: 16.1.6 version: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next-view-transitions: + specifier: ^0.3.5 + version: 0.3.5(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) nunjucks: specifier: ^3.2.4 version: 3.2.4(chokidar@3.6.0) @@ -6341,6 +6344,13 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + next-view-transitions@0.3.5: + resolution: {integrity: sha512-yP8OPNydLmKpmE94hLurLGEzPsUy1uyl9iSv8oxaC2JwhSXTD86SVwk1NMMQT7Ado4kMENDJ7fNBIXHy3GU/Lg==} + peerDependencies: + next: '>=14.0.0' + react: '>=18.2.0 || ^19.0.0' + react-dom: '>=18.2.0 || ^19.0.0' + next@16.1.6: resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} engines: {node: '>=20.9.0'} @@ -15296,6 +15306,12 @@ snapshots: neo-async@2.6.2: {} + next-view-transitions@0.3.5(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@next/env': 16.1.6