From fef75f123d79428e5ddcac2a79c31702917f71a0 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Thu, 30 Jan 2025 01:47:42 +0100 Subject: [PATCH] sync: pull to refresh library (#271) --- .../src/app/library/hooks/useBooksSync.ts | 9 +- apps/readest-app/src/app/library/page.tsx | 10 +- .../readest-app/src/hooks/usePullToRefresh.ts | 116 ++++++++++++++++++ apps/readest-app/src/services/appService.ts | 1 + apps/readest-app/src/services/constants.ts | 2 +- apps/readest-app/src/styles/globals.css | 21 ++++ 6 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 apps/readest-app/src/hooks/usePullToRefresh.ts diff --git a/apps/readest-app/src/app/library/hooks/useBooksSync.ts b/apps/readest-app/src/app/library/hooks/useBooksSync.ts index 14d23e1a..39184a59 100644 --- a/apps/readest-app/src/app/library/hooks/useBooksSync.ts +++ b/apps/readest-app/src/app/library/hooks/useBooksSync.ts @@ -13,12 +13,17 @@ export const useBooksSync = () => { const { syncedBooks, syncBooks, lastSyncedAtBooks } = useSync(); const syncBooksPullingRef = useRef(false); + const pullLibrary = async () => { + if (!user) return; + syncBooks([], 'pull'); + }; + useEffect(() => { if (!user) return; if (syncBooksPullingRef.current) return; syncBooksPullingRef.current = true; - syncBooks([], 'pull'); + pullLibrary(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -98,4 +103,6 @@ export const useBooksSync = () => { updateLibrary(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [syncedBooks]); + + return { pullLibrary }; }; diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index dc91d404..38b9f4df 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -21,14 +21,15 @@ import { useTheme } from '@/hooks/useTheme'; import { useTranslation } from '@/hooks/useTranslation'; import { useLibraryStore } from '@/store/libraryStore'; import { useSettingsStore } from '@/store/settingsStore'; +import { usePullToRefresh } from '@/hooks/usePullToRefresh'; import { useDemoBooks } from './hooks/useDemoBooks'; +import { useBooksSync } from './hooks/useBooksSync'; import { AboutWindow } from '@/components/AboutWindow'; import { Toast } from '@/components/Toast'; import Spinner from '@/components/Spinner'; import LibraryHeader from './components/LibraryHeader'; import Bookshelf from './components/Bookshelf'; -import { useBooksSync } from './hooks/useBooksSync'; const LibraryPage = () => { const router = useRouter(); @@ -49,8 +50,11 @@ const LibraryPage = () => { const [libraryLoaded, setLibraryLoaded] = useState(false); const [isSelectMode, setIsSelectMode] = useState(false); const demoBooks = useDemoBooks(); + const containerRef = useRef(null); - useBooksSync(); + const { pullLibrary } = useBooksSync(); + + usePullToRefresh(containerRef, pullLibrary); useEffect(() => { updateAppTheme('base-200'); @@ -310,7 +314,7 @@ const LibraryPage = () => { )} {libraryLoaded && (libraryBooks.length > 0 ? ( -
+
, onTrigger: () => void) => { + useEffect(() => { + const el = ref.current; + if (!el) return; + + el.addEventListener('touchstart', handleTouchStart); + + function handleTouchStart(startEvent: TouchEvent) { + const el = ref.current; + if (!el) return; + + const initialY = startEvent.touches[0]!.clientY; + + el.addEventListener('touchmove', handleTouchMove); + el.addEventListener('touchend', handleTouchEnd); + + function handleTouchMove(moveEvent: TouchEvent) { + const el = ref.current; + if (!el) return; + + const currentY = moveEvent.touches[0]!.clientY; + const dy = currentY - initialY; + + if (dy < 0) return; + const parentEl = el.parentNode as HTMLDivElement; + if (dy > TRIGGER_THRESHOLD) { + flipArrow(parentEl); + } else if (dy > SHOW_INDICATOR_THRESHOLD) { + addPullIndicator(parentEl); + } else { + removePullIndicator(parentEl); + } + + el.style.transform = `translateY(${appr(dy)}px)`; + } + + function addPullIndicator(el: HTMLDivElement) { + const indicator = el.querySelector('.pull-indicator'); + if (indicator) { + if (indicator.classList.contains('flip')) { + indicator.classList.remove('flip'); + } + return; + } + + const pullIndicator = document.createElement('div'); + pullIndicator.className = 'pull-indicator text-gray-500'; + pullIndicator.innerHTML = ` + + + + `; + el.appendChild(pullIndicator); + } + + function removePullIndicator(el: HTMLDivElement) { + const pullIndicator = el.querySelector('.pull-indicator'); + if (pullIndicator) { + pullIndicator.remove(); + } + } + + function flipArrow(el: HTMLDivElement) { + const pullIndicator = el.querySelector('.pull-indicator'); + if (pullIndicator && !pullIndicator.classList.contains('flip')) { + pullIndicator.classList.add('flip'); + } + } + + function handleTouchEnd(endEvent: TouchEvent) { + const el = ref.current; + if (!el) return; + + el.style.transform = 'translateY(0)'; + removePullIndicator(el.parentNode as HTMLDivElement); + + el.style.transition = 'transform 0.2s'; + + const y = endEvent.changedTouches[0]!.clientY; + const dy = y - initialY; + if (dy > TRIGGER_THRESHOLD) { + onTrigger(); + } + + el.addEventListener('transitionend', onTransitionEnd); + + el.removeEventListener('touchmove', handleTouchMove); + el.removeEventListener('touchend', handleTouchEnd); + } + + function onTransitionEnd() { + const el = ref.current; + if (!el) return; + + el.style.transition = ''; + el.removeEventListener('transitionend', onTransitionEnd); + } + } + + return () => { + el.removeEventListener('touchstart', handleTouchStart); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ref.current]); +}; diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index 874ad6de..15af3383 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -225,6 +225,7 @@ export abstract class BaseAppService implements AppService { } } if (uploaded) { + book.deletedAt = null; book.updatedAt = Date.now(); book.uploadedAt = Date.now(); book.downloadedAt = Date.now(); diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index 754c507c..312e9c6e 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -371,7 +371,7 @@ export const DOWNLOAD_READEST_URL = 'https://readest.com?utm_source=readest_web' export const READEST_WEB_BASE_URL = 'https://web.readest.com'; export const SYNC_PROGRESS_INTERVAL_SEC = 60; -export const SYNC_NOTES_INTERVAL_SEC = 60; +export const SYNC_NOTES_INTERVAL_SEC = 10; export const SYNC_BOOKS_INTERVAL_SEC = 10; export const CHECK_UPDATE_INTERVAL_SEC = 24 * 60 * 60; diff --git a/apps/readest-app/src/styles/globals.css b/apps/readest-app/src/styles/globals.css index bc1e5c53..83c67027 100644 --- a/apps/readest-app/src/styles/globals.css +++ b/apps/readest-app/src/styles/globals.css @@ -209,3 +209,24 @@ foliate-view { .scroll-container.hidden-scrollbar { scrollbar-width: none; } + +.pull-indicator { + position: fixed; + top: 64px; + width: 100%; + background-color: transparent; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-lighter); + transition: transform 0.2s ease-in-out; + z-index: 10; +} + +.pull-indicator svg { + transition: transform 0.2s ease-in-out; +} + +.pull-indicator.flip svg { + transform: rotate(180deg); +}