diff --git a/apps/readest-app/src/app/api/sync/route.ts b/apps/readest-app/src/app/api/sync/route.ts new file mode 100644 index 00000000..68b38b68 --- /dev/null +++ b/apps/readest-app/src/app/api/sync/route.ts @@ -0,0 +1,209 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { PostgrestError } from '@supabase/supabase-js'; +import { supabase, createSupabaseClient } from '@/utils/supabase'; +import { BookDataRecord } from '@/types/book'; +import { transformBookConfigToDB } from '@/utils/transform'; +import { transformBookNoteToDB } from '@/utils/transform'; +import { transformBookToDB } from '@/utils/transform'; +import { SyncData, SyncResult, SyncType } from '@/libs/sync'; + +const transformsToDB = { + books: transformBookToDB, + book_notes: transformBookNoteToDB, + book_configs: transformBookConfigToDB, +}; + +const DBSyncTypeMap = { + books: 'books', + book_notes: 'notes', + book_configs: 'configs', +}; + +type TableName = keyof typeof transformsToDB; + +type DBError = { table: TableName; error: PostgrestError }; + +const getUserAndToken = async (req: NextRequest) => { + const authHeader = req.headers.get('authorization'); + if (!authHeader) return {}; + + const token = authHeader.replace('Bearer ', ''); + const { + data: { user }, + error, + } = await supabase.auth.getUser(token); + + if (error || !user) return {}; + return { user, token }; +}; + +export async function GET(req: NextRequest) { + const { user, token } = await getUserAndToken(req); + if (!user || !token) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + const supabase = createSupabaseClient(token); + + const { searchParams } = new URL(req.url); + const sinceParam = searchParams.get('since'); + const typeParam = searchParams.get('type') as SyncType | undefined; + const bookParam = searchParams.get('book'); + + if (!sinceParam) { + return NextResponse.json({ error: '"since" query parameter is required' }, { status: 400 }); + } + + const since = new Date(Number(sinceParam)); + if (isNaN(since.getTime())) { + return NextResponse.json({ error: 'Invalid "since" timestamp' }, { status: 400 }); + } + + const sinceIso = since.toISOString(); + + try { + const results: SyncResult = { books: [], configs: [], notes: [] }; + const errors: Record = { + books: null, + book_notes: null, + book_configs: null, + }; + + const queryTables = async (table: TableName) => { + let query = supabase.from(table).select('*').eq('user_id', user.id); + if (bookParam) { + query.eq('book_hash', bookParam); + } + query = query.or(`updated_at.gt.${sinceIso},deleted_at.gt.${sinceIso}`); + console.log('Querying table:', table, 'since:', sinceIso); + const { data, error } = await query; + if (error) throw { table, error } as DBError; + results[DBSyncTypeMap[table] as SyncType] = data || []; + }; + + if (!typeParam || typeParam === 'books') { + await queryTables('books').catch((err) => (errors['books'] = err)); + } + if (!typeParam || typeParam === 'configs') { + await queryTables('book_configs').catch((err) => (errors['book_configs'] = err)); + } + if (!typeParam || typeParam === 'notes') { + await queryTables('book_notes').catch((err) => (errors['book_notes'] = err)); + } + + const dbErrors = Object.values(errors).filter((err) => err !== null); + if (dbErrors.length > 0) { + console.error('Errors occurred:', dbErrors); + const errorMsg = dbErrors + .map((err) => `${err.table}: ${err.error.message || 'Unknown error'}`) + .join('; '); + return NextResponse.json({ error: errorMsg }, { status: 500 }); + } + + return NextResponse.json(results, { status: 200 }); + } catch (error: unknown) { + console.error(error); + const errorMessage = (error as PostgrestError).message || 'Unknown error'; + return NextResponse.json({ error: errorMessage }, { status: 500 }); + } +} + +export async function POST(req: NextRequest) { + const { user, token } = await getUserAndToken(req); + if (!user || !token) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + const supabase = createSupabaseClient(token); + + const body = await req.json(); + const { books = [], configs = [], notes = [] } = body as SyncData; + + const upsertRecords = async ( + table: TableName, + primaryKeys: (keyof BookDataRecord)[], + records: BookDataRecord[], + ) => { + const authoritativeRecords: BookDataRecord[] = []; + + for (const rec of records) { + const dbRec = transformsToDB[table](rec, user.id); + rec.user_id = user.id; + rec.book_hash = dbRec.book_hash; + const matchConditions: Record = { user_id: user.id }; + for (const pk of primaryKeys) { + matchConditions[pk] = rec[pk]!; + } + + const { data: serverData, error: fetchError } = await supabase + .from(table) + .select() + .match(matchConditions) + .single(); + + if (fetchError && fetchError.code !== 'PGRST116') { + return { error: fetchError.message }; + } + + if (!serverData) { + const { data: inserted, error: insertError } = await supabase + .from(table) + .insert(dbRec) + .select() + .single(); + if (insertError) return { error: insertError.message }; + authoritativeRecords.push(inserted); + } else { + const clientUpdatedAt = dbRec.updated_at ? new Date(dbRec.updated_at).getTime() : 0; + const serverUpdatedAt = serverData.updated_at + ? new Date(serverData.updated_at).getTime() + : 0; + const clientDeletedAt = dbRec.deleted_at ? new Date(dbRec.deleted_at).getTime() : 0; + const serverDeletedAt = serverData.deleted_at + ? new Date(serverData.deleted_at).getTime() + : 0; + const clientIsNewer = + clientDeletedAt > serverDeletedAt || clientUpdatedAt > serverUpdatedAt; + + if (clientIsNewer) { + const { data: updated, error: updateError } = await supabase + .from(table) + .update(dbRec) + .match(matchConditions) + .select() + .single(); + console.log('Updating record:', updated); + if (updateError) return { error: updateError.message }; + authoritativeRecords.push(updated); + } else { + authoritativeRecords.push(serverData); + } + } + } + + return { data: authoritativeRecords }; + }; + + try { + const [booksResult, configsResult, notesResult] = await Promise.all([ + upsertRecords('books', ['book_hash'], books as BookDataRecord[]), + upsertRecords('book_configs', ['book_hash'], configs as BookDataRecord[]), + upsertRecords('book_notes', ['book_hash', 'id'], notes as BookDataRecord[]), + ]); + + if (booksResult?.error) throw new Error(booksResult.error); + if (configsResult?.error) throw new Error(configsResult.error); + if (notesResult?.error) throw new Error(notesResult.error); + + return NextResponse.json( + { + books: booksResult?.data || [], + configs: configsResult?.data || [], + notes: notesResult?.data || [], + }, + { status: 200 }, + ); + } catch (error: unknown) { + console.error(error); + const errorMessage = (error as PostgrestError).message || 'Unknown error'; + return NextResponse.json({ error: errorMessage }, { status: 500 }); + } +} diff --git a/apps/readest-app/src/app/library/components/Bookshelf.tsx b/apps/readest-app/src/app/library/components/Bookshelf.tsx index 16b3df5f..6a6f04be 100644 --- a/apps/readest-app/src/app/library/components/Bookshelf.tsx +++ b/apps/readest-app/src/app/library/components/Bookshelf.tsx @@ -26,19 +26,19 @@ const generateBookshelfItems = (books: Book[]): BookshelfItem[] => { const booksGroup = acc[acc.findIndex((group) => group.name === book.group)]; if (booksGroup) { booksGroup.books.push(book); - booksGroup.lastUpdated = Math.max(acc[groupIndex]!.lastUpdated, book.lastUpdated); + booksGroup.updatedAt = Math.max(acc[groupIndex]!.updatedAt, book.updatedAt); } else { acc.push({ name: book.group, books: [book], - lastUpdated: book.lastUpdated, + updatedAt: book.updatedAt, }); } return acc; }, []); const ungroupedBooks: Book[] = groups.find((group) => group.name === UNGROUPED_NAME)?.books || []; const groupedBooks: BooksGroup[] = groups.filter((group) => group.name !== UNGROUPED_NAME); - return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.lastUpdated - a.lastUpdated); + return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.updatedAt - a.updatedAt); }; interface BookshelfProps { diff --git a/apps/readest-app/src/app/library/components/SettingsMenu.tsx b/apps/readest-app/src/app/library/components/SettingsMenu.tsx index 2100e662..15443ec4 100644 --- a/apps/readest-app/src/app/library/components/SettingsMenu.tsx +++ b/apps/readest-app/src/app/library/components/SettingsMenu.tsx @@ -8,6 +8,8 @@ import { setAboutDialogVisible } from '@/components/AboutWindow'; import { isWebAppPlatform } from '@/services/environment'; import { DOWNLOAD_READEST_URL } from '@/services/constants'; import { useAuth } from '@/context/AuthContext'; +import { useEnv } from '@/context/EnvContext'; +import { useSettingsStore } from '@/store/settingsStore'; import MenuItem from '@/components/MenuItem'; interface BookMenuProps { @@ -16,7 +18,9 @@ interface BookMenuProps { const SettingsMenu: React.FC = ({ setIsDropdownOpen }) => { const router = useRouter(); + const { envConfig } = useEnv(); const { user, logout } = useAuth(); + const { settings, setSettings, saveSettings } = useSettingsStore(); const showAboutReadest = () => { setAboutDialogVisible(true); @@ -34,6 +38,9 @@ const SettingsMenu: React.FC = ({ setIsDropdownOpen }) => { const handleUserLogout = () => { logout(); + settings.keepLogin = false; + setSettings(settings); + saveSettings(envConfig, settings); setIsDropdownOpen?.(false); }; diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index ddce582a..dd17bdaf 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -13,6 +13,7 @@ import { checkForAppUpdates } from '@/helpers/updater'; import { FILE_ACCEPT_FORMATS, SUPPORTED_FILE_EXTS } from '@/services/constants'; import { useEnv } from '@/context/EnvContext'; +import { useAuth } from '@/context/AuthContext'; import { useTheme } from '@/hooks/useTheme'; import { useLibraryStore } from '@/store/libraryStore'; import { useSettingsStore } from '@/store/settingsStore'; @@ -26,6 +27,7 @@ import { AboutWindow } from '@/components/AboutWindow'; const LibraryPage = () => { const router = useRouter(); const { envConfig, appService } = useEnv(); + const { token, user } = useAuth(); const { library: libraryBooks, setLibrary, @@ -33,7 +35,7 @@ const LibraryPage = () => { clearOpenWithBooks, } = useLibraryStore(); useTheme(); - const { setSettings } = useSettingsStore(); + const { setSettings, saveSettings } = useSettingsStore(); const [loading, setLoading] = useState(false); const isInitiating = useRef(false); const [libraryLoaded, setLibraryLoaded] = useState(false); @@ -74,6 +76,20 @@ const LibraryPage = () => { if (isInitiating.current) return; isInitiating.current = true; + const initLogin = async () => { + const appService = await envConfig.getAppService(); + const settings = await appService.loadSettings(); + if (token && user) { + if (!settings.keepLogin) { + settings.keepLogin = true; + setSettings(settings); + saveSettings(envConfig, settings); + } + } else if (settings.keepLogin) { + router.push('/auth'); + } + }; + const loadingTimeout = setTimeout(() => setLoading(true), 300); const initLibrary = async () => { const appService = await envConfig.getAppService(); @@ -104,6 +120,7 @@ const LibraryPage = () => { } }; + initLogin(); initLibrary(); return () => { clearOpenWithBooks(); diff --git a/apps/readest-app/src/app/reader/components/BookmarkToggler.tsx b/apps/readest-app/src/app/reader/components/BookmarkToggler.tsx index 670c4c00..ff49fe1e 100644 --- a/apps/readest-app/src/app/reader/components/BookmarkToggler.tsx +++ b/apps/readest-app/src/app/reader/components/BookmarkToggler.tsx @@ -38,7 +38,8 @@ const BookmarkToggler: React.FC = ({ bookKey }) => { cfi, text: truncatedText, note: '', - created: Date.now(), + createdAt: Date.now(), + updatedAt: Date.now(), }; bookmarks.push(bookmark); const updatedConfig = updateBooknotes(bookKey, bookmarks); diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index a36d5938..35f2aa56 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -1,10 +1,13 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { BookDoc, getDirection } from '@/libs/document'; -import { BookConfig, BookNote, BookSearchConfig, BookSearchResult } from '@/types/book'; +import { BookConfig } from '@/types/book'; +import { FoliateView, wrappedFoliateView } from '@/types/view'; import { useReaderStore } from '@/store/readerStore'; import { useParallelViewStore } from '@/store/parallelViewStore'; +import { useClickEvent } from '../hooks/useClickEvent'; import { useFoliateEvents } from '../hooks/useFoliateEvents'; -import { getOSPlatform } from '@/utils/misc'; +import { useProgressSync } from '../hooks/useProgressSync'; +import { useAutoHideScrollbar } from '../hooks/useAutoHideScrollbar'; import { getStyles, mountAdditionalFonts } from '@/utils/style'; import { useTheme } from '@/hooks/useTheme'; import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants'; @@ -15,57 +18,7 @@ import { handleClick, handleWheel, } from '../utils/iframeEventHandlers'; -import { eventDispatcher } from '@/utils/event'; - -export interface FoliateView extends HTMLElement { - open: (book: BookDoc) => Promise; - close: () => void; - init: (options: { lastLocation: string }) => void; - goTo: (href: string) => void; - goToFraction: (fraction: number) => void; - prev: (distance: number) => void; - next: (distance: number) => void; - goLeft: () => void; - goRight: () => void; - getCFI: (index: number, range: Range) => string; - addAnnotation: (note: BookNote, remove?: boolean) => { index: number; label: string }; - search: (config: BookSearchConfig) => AsyncGenerator; - clearSearch: () => void; - select: (target: string | number | { fraction: number }) => void; - deselect: () => void; - history: { - canGoBack: boolean; - canGoForward: boolean; - back: () => void; - forward: () => void; - clear: () => void; - }; - renderer: { - scrolled?: boolean; - viewSize: number; - setAttribute: (name: string, value: string | number) => void; - removeAttribute: (name: string) => void; - next: () => Promise; - prev: () => Promise; - goTo?: (params: { index: number; anchor: number }) => void; - setStyles?: (css: string) => void; - addEventListener: (type: string, listener: EventListener) => void; - removeEventListener: (type: string, listener: EventListener) => void; - }; -} - -const wrappedFoliateView = (originalView: FoliateView): FoliateView => { - const originalAddAnnotation = originalView.addAnnotation.bind(originalView); - originalView.addAnnotation = (note: BookNote, remove = false) => { - // transform BookNote to foliate annotation - const annotation = { - value: note.cfi, - ...note, - }; - return originalAddAnnotation(annotation, remove); - }; - return originalView; -}; +import Toast from '@/components/Toast'; const FoliateViewer: React.FC<{ bookKey: string; @@ -75,12 +28,18 @@ const FoliateViewer: React.FC<{ const containerRef = useRef(null); const viewRef = useRef(null); const isViewCreated = useRef(false); - const { getView, setView: setFoliateView, setProgress, getViewSettings } = useReaderStore(); - const { hoveredBookKey, setHoveredBookKey, setViewSettings } = useReaderStore(); + const { getView, setView: setFoliateView, setProgress } = useReaderStore(); + const { getViewSettings, setViewSettings } = useReaderStore(); const { getParallels } = useParallelViewStore(); const { themeCode } = useTheme(); - const shouldAutoHideScrollbar = ['macos', 'ios'].includes(getOSPlatform()); + const [toastMessage, setToastMessage] = useState(''); + useEffect(() => { + const timer = setTimeout(() => setToastMessage(''), 2000); + return () => clearTimeout(timer); + }, [toastMessage]); + + useProgressSync(bookKey, config, setToastMessage); const progressRelocateHandler = (event: Event) => { const detail = (event as CustomEvent).detail; @@ -88,34 +47,7 @@ const FoliateViewer: React.FC<{ setProgress(bookKey, detail.cfi, detail.tocItem, detail.section, detail.location, detail.range); }; - const handleScrollbarAutoHide = (doc: Document) => { - if (doc && doc.defaultView && doc.defaultView.frameElement) { - const iframe = doc.defaultView.frameElement as HTMLIFrameElement; - const container = iframe.parentElement?.parentElement; - if (!container) return; - - let hideScrollbarTimeout: ReturnType; - const showScrollbar = () => { - container.style.overflow = 'auto'; - container.style.scrollbarWidth = 'thin'; - }; - - const hideScrollbar = () => { - container.style.overflow = 'hidden'; - container.style.scrollbarWidth = 'none'; - requestAnimationFrame(() => { - container.style.overflow = 'auto'; - }); - }; - container.addEventListener('scroll', () => { - showScrollbar(); - clearTimeout(hideScrollbarTimeout); - hideScrollbarTimeout = setTimeout(hideScrollbar, 1000); - }); - hideScrollbar(); - } - }; - + const { shouldAutoHideScrollbar, handleScrollbarAutoHide } = useAutoHideScrollbar(); const docLoadHandler = (event: Event) => { const detail = (event as CustomEvent).detail; console.log('doc loaded:', detail); @@ -157,72 +89,7 @@ const FoliateViewer: React.FC<{ } }; - const handleTurnPage = (msg: MessageEvent | React.MouseEvent) => { - if (msg instanceof MessageEvent) { - if (msg.data && msg.data.bookKey === bookKey) { - const viewSettings = getViewSettings(bookKey)!; - if (msg.data.type === 'iframe-single-click') { - if (viewSettings.disableClick!) { - return; - } - const viewElement = containerRef.current; - if (viewElement) { - const rect = viewElement.getBoundingClientRect(); - const { screenX, screenY } = msg.data; - const consumed = eventDispatcher.dispatchSync('iframe-single-click', { - screenX, - screenY, - }); - if (!consumed) { - const centerStartX = rect.left + rect.width * 0.375; - const centerEndX = rect.left + rect.width * 0.625; - const centerStartY = rect.top + rect.height * 0.375; - const centerEndY = rect.top + rect.height * 0.625; - if ( - screenX >= centerStartX && - screenX <= centerEndX && - screenY >= centerStartY && - screenY <= centerEndY - ) { - // toggle visibility of the header bar and the footer bar - setHoveredBookKey(hoveredBookKey ? '' : bookKey); - } else if (screenX >= rect.left + rect.width / 2) { - viewRef.current?.goRight(); - } else if (screenX < rect.left + rect.width / 2) { - viewRef.current?.goLeft(); - } - } - } - } else if (msg.data.type === 'iframe-wheel' && !viewSettings.scrolled) { - const { deltaY } = msg.data; - if (deltaY > 0) { - viewRef.current?.next(1); - } else if (deltaY < 0) { - viewRef.current?.prev(1); - } - } - } - } else { - const { clientX } = msg; - const width = window.innerWidth; - const leftThreshold = width * 0.5; - const rightThreshold = width * 0.5; - if (clientX < leftThreshold) { - viewRef.current?.goLeft(); - } else if (clientX > rightThreshold) { - viewRef.current?.goRight(); - } - } - }; - - useEffect(() => { - window.addEventListener('message', handleTurnPage); - return () => { - window.removeEventListener('message', handleTurnPage); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hoveredBookKey, viewRef]); - + const { handleTurnPage } = useClickEvent(bookKey, viewRef, containerRef); useFoliateEvents(viewRef.current, { onLoad: docLoadHandler, onRelocate: progressRelocateHandler, @@ -291,11 +158,16 @@ const FoliateViewer: React.FC<{ }, []); return ( -
handleTurnPage(event)} - ref={containerRef} - /> + <> +
handleTurnPage(event)} + ref={containerRef} + /> + {toastMessage && ( + + )} + ); }; diff --git a/apps/readest-app/src/app/reader/components/FootnotePopup.tsx b/apps/readest-app/src/app/reader/components/FootnotePopup.tsx index 056a8c6c..71ec9ac5 100644 --- a/apps/readest-app/src/app/reader/components/FootnotePopup.tsx +++ b/apps/readest-app/src/app/reader/components/FootnotePopup.tsx @@ -6,7 +6,7 @@ import { useFoliateEvents } from '../hooks/useFoliateEvents'; import { useTheme } from '@/hooks/useTheme'; import { getStyles } from '@/utils/style'; import { getPopupPosition, getPosition, Position } from '@/utils/sel'; -import { FoliateView } from './FoliateViewer'; +import { FoliateView } from '@/types/view'; import { FootnoteHandler } from 'foliate-js/footnotes.js'; import Popup from '@/components/Popup'; diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx index 9197f52a..335a7839 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -225,7 +225,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { cfi, text: selection.text, note: '', - created: Date.now(), + createdAt: Date.now(), + updatedAt: Date.now(), }; const existingIndex = annotations.findIndex( @@ -260,7 +261,8 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { color, text: selection.text, note: '', - created: Date.now(), + createdAt: Date.now(), + updatedAt: Date.now(), }; const existingIndex = annotations.findIndex( (annotation) => annotation.cfi === cfi && annotation.type === 'annotation', @@ -389,7 +391,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { /> )} - {toastMessage && } + {toastMessage && }
); }; diff --git a/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx b/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx index ad21be8a..63759486 100644 --- a/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx +++ b/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx @@ -73,7 +73,8 @@ const Notebook: React.FC = ({}) => { cfi, note, text: selection.text, - created: Date.now(), + createdAt: Date.now(), + updatedAt: Date.now(), }; annotations.push(annotation); const updatedConfig = updateBooknotes(sideBarBookKey, annotations); @@ -89,7 +90,7 @@ const Notebook: React.FC = ({}) => { const { booknotes: annotations = [] } = config; const existingIndex = annotations.findIndex((item) => item.id === annotation.id); if (existingIndex === -1) return; - annotation.modified = Date.now(); + annotation.updatedAt = Date.now(); annotations[existingIndex] = annotation; const updatedConfig = updateBooknotes(sideBarBookKey, annotations); if (updatedConfig) { @@ -117,10 +118,10 @@ const Notebook: React.FC = ({}) => { const { booknotes: allNotes = [] } = config || {}; const annotationNotes = allNotes .filter((note) => note.type === 'annotation' && note.note) - .sort((a, b) => b.created - a.created); + .sort((a, b) => b.createdAt - a.createdAt); const excerptNotes = allNotes .filter((note) => note.type === 'excerpt') - .sort((a, b) => a.created - b.created); + .sort((a, b) => a.createdAt - b.createdAt); return isNotebookVisible ? ( <> diff --git a/apps/readest-app/src/app/reader/hooks/useAutoHideScrollbar.ts b/apps/readest-app/src/app/reader/hooks/useAutoHideScrollbar.ts new file mode 100644 index 00000000..9aef1e2d --- /dev/null +++ b/apps/readest-app/src/app/reader/hooks/useAutoHideScrollbar.ts @@ -0,0 +1,34 @@ +import { getOSPlatform } from '@/utils/misc'; + +export const useAutoHideScrollbar = () => { + const shouldAutoHideScrollbar = ['macos', 'ios'].includes(getOSPlatform()); + const handleScrollbarAutoHide = (doc: Document) => { + if (doc && doc.defaultView && doc.defaultView.frameElement) { + const iframe = doc.defaultView.frameElement as HTMLIFrameElement; + const container = iframe.parentElement?.parentElement; + if (!container) return; + + let hideScrollbarTimeout: ReturnType; + const showScrollbar = () => { + container.style.overflow = 'auto'; + container.style.scrollbarWidth = 'thin'; + }; + + const hideScrollbar = () => { + container.style.overflow = 'hidden'; + container.style.scrollbarWidth = 'none'; + requestAnimationFrame(() => { + container.style.overflow = 'auto'; + }); + }; + container.addEventListener('scroll', () => { + showScrollbar(); + clearTimeout(hideScrollbarTimeout); + hideScrollbarTimeout = setTimeout(hideScrollbar, 1000); + }); + hideScrollbar(); + } + }; + + return { shouldAutoHideScrollbar, handleScrollbarAutoHide }; +}; diff --git a/apps/readest-app/src/app/reader/hooks/useClickEvent.ts b/apps/readest-app/src/app/reader/hooks/useClickEvent.ts new file mode 100644 index 00000000..27909aff --- /dev/null +++ b/apps/readest-app/src/app/reader/hooks/useClickEvent.ts @@ -0,0 +1,82 @@ +import { useEffect } from 'react'; +import { FoliateView } from '@/types/view'; +import { useReaderStore } from '@/store/readerStore'; +import { eventDispatcher } from '@/utils/event'; + +export const useClickEvent = ( + bookKey: string, + viewRef: React.MutableRefObject, + containerRef: React.RefObject, +) => { + const { getViewSettings } = useReaderStore(); + const { hoveredBookKey, setHoveredBookKey } = useReaderStore(); + const handleTurnPage = (msg: MessageEvent | React.MouseEvent) => { + if (msg instanceof MessageEvent) { + if (msg.data && msg.data.bookKey === bookKey) { + const viewSettings = getViewSettings(bookKey)!; + if (msg.data.type === 'iframe-single-click') { + if (viewSettings.disableClick!) { + return; + } + const viewElement = containerRef.current; + if (viewElement) { + const rect = viewElement.getBoundingClientRect(); + const { screenX, screenY } = msg.data; + const consumed = eventDispatcher.dispatchSync('iframe-single-click', { + screenX, + screenY, + }); + if (!consumed) { + const centerStartX = rect.left + rect.width * 0.375; + const centerEndX = rect.left + rect.width * 0.625; + const centerStartY = rect.top + rect.height * 0.375; + const centerEndY = rect.top + rect.height * 0.625; + if ( + screenX >= centerStartX && + screenX <= centerEndX && + screenY >= centerStartY && + screenY <= centerEndY + ) { + // toggle visibility of the header bar and the footer bar + setHoveredBookKey(hoveredBookKey ? '' : bookKey); + } else if (screenX >= rect.left + rect.width / 2) { + viewRef.current?.goRight(); + } else if (screenX < rect.left + rect.width / 2) { + viewRef.current?.goLeft(); + } + } + } + } else if (msg.data.type === 'iframe-wheel' && !viewSettings.scrolled) { + const { deltaY } = msg.data; + if (deltaY > 0) { + viewRef.current?.next(1); + } else if (deltaY < 0) { + viewRef.current?.prev(1); + } + } + } + } else { + const { clientX } = msg; + const width = window.innerWidth; + const leftThreshold = width * 0.5; + const rightThreshold = width * 0.5; + if (clientX < leftThreshold) { + viewRef.current?.goLeft(); + } else if (clientX > rightThreshold) { + viewRef.current?.goRight(); + } + } + }; + + useEffect(() => { + window.addEventListener('message', handleTurnPage); + return () => { + window.removeEventListener('message', handleTurnPage); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hoveredBookKey, viewRef]); + + return { + handleTurnPage, + }; +}; diff --git a/apps/readest-app/src/app/reader/hooks/useFoliateEvents.ts b/apps/readest-app/src/app/reader/hooks/useFoliateEvents.ts index 8cee9bd9..b756ac3e 100644 --- a/apps/readest-app/src/app/reader/hooks/useFoliateEvents.ts +++ b/apps/readest-app/src/app/reader/hooks/useFoliateEvents.ts @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { FoliateView } from '@/app/reader/components/FoliateViewer'; +import { FoliateView } from '@/types/view'; type FoliateEventHandler = { onLoad?: (event: Event) => void; diff --git a/apps/readest-app/src/app/reader/hooks/useProgressSync.ts b/apps/readest-app/src/app/reader/hooks/useProgressSync.ts new file mode 100644 index 00000000..7e4210d6 --- /dev/null +++ b/apps/readest-app/src/app/reader/hooks/useProgressSync.ts @@ -0,0 +1,90 @@ +import { useEffect, useRef } from 'react'; +import { useAuth } from '@/context/AuthContext'; +import { useSync } from '@/hooks/useSync'; +import { BookConfig } from '@/types/book'; +import { deserializeConfig, serializeConfig } from '@/utils/serializer'; +import { useBookDataStore } from '@/store/bookDataStore'; +import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants'; +import { useReaderStore } from '@/store/readerStore'; +import { useSettingsStore } from '@/store/settingsStore'; + +export const useProgressSync = ( + bookKey: string, + config: BookConfig, + setToastMessage?: React.Dispatch>, +) => { + const { setConfig } = useBookDataStore(); + const { getView } = useReaderStore(); + const { settings } = useSettingsStore(); + const { syncedConfigs, syncConfigs } = useSync(); + const { user } = useAuth(); + const view = getView(bookKey); + + const pushConfig = (bookKey: string, config: BookConfig) => { + const bookHash = bookKey.split('-')[0]!; + const newConfig = { bookHash, ...config }; + const compressedConfig = JSON.parse( + serializeConfig(newConfig, settings.globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG), + ); + syncConfigs([compressedConfig], bookHash, 'push'); + }; + + useEffect(() => { + if (!user) return; + const bookHash = bookKey.split('-')[0]!; + syncConfigs([], bookHash, 'pull'); + return () => { + pushConfig(bookKey, config); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const lastProgressSyncTime = useRef(0); + const syncTimeoutRef = useRef | null>(null); + useEffect(() => { + if (!user) return; + const now = Date.now(); + const timeSinceLastSync = now - lastProgressSyncTime.current; + if (configSynced.current && timeSinceLastSync > SYNC_PROGRESS_INTERVAL_SEC * 1000) { + lastProgressSyncTime.current = now; + pushConfig(bookKey, config); + } else { + if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current); + syncTimeoutRef.current = setTimeout( + () => { + lastProgressSyncTime.current = Date.now(); + pushConfig(bookKey, config); + syncTimeoutRef.current = null; + }, + SYNC_PROGRESS_INTERVAL_SEC * 1000 - timeSinceLastSync, + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config]); + + // sync progress once when the book is opened + const configSynced = useRef(false); + useEffect(() => { + if (!configSynced.current && syncedConfigs?.length > 0) { + const syncedConfig = syncedConfigs.filter((c) => c.bookHash === bookKey.split('-')[0])[0]; + if (syncedConfig) { + const newConfig = deserializeConfig( + JSON.stringify(syncedConfig), + settings.globalViewSettings, + DEFAULT_BOOK_SEARCH_CONFIG, + ); + setConfig(bookKey, { ...config, ...newConfig }); + configSynced.current = true; + if ((syncedConfig.progress?.[1] ?? 0) > 0 && (config.progress?.[1] ?? 0) > 0) { + const syncedFraction = syncedConfig.progress![0] / syncedConfig.progress![1]; + const configFraction = config.progress![0] / config.progress![1]; + if (syncedFraction > configFraction) { + view?.goToFraction(syncedFraction); + setToastMessage?.('Progress synced'); + } + } + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [syncedConfigs]); +}; diff --git a/apps/readest-app/src/components/Toast.tsx b/apps/readest-app/src/components/Toast.tsx index 172f042f..3e3a6d5f 100644 --- a/apps/readest-app/src/components/Toast.tsx +++ b/apps/readest-app/src/components/Toast.tsx @@ -1,8 +1,13 @@ +import clsx from 'clsx'; import React from 'react'; -const Toast: React.FC<{ message: string }> = ({ message }) => ( -
-
+const Toast: React.FC<{ message: string; toastClass?: string; alertClass?: string }> = ({ + message, + toastClass, + alertClass, +}) => ( +
+
{message}
diff --git a/apps/readest-app/src/context/AuthContext.tsx b/apps/readest-app/src/context/AuthContext.tsx index 393f1fd2..935118c2 100644 --- a/apps/readest-app/src/context/AuthContext.tsx +++ b/apps/readest-app/src/context/AuthContext.tsx @@ -32,7 +32,6 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const fetchSession = async () => { const { data: { session }, - error, } = await supabase.auth.getSession(); if (session) { const { access_token, user } = session; @@ -40,8 +39,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { setUser(user); localStorage.setItem('token', access_token); localStorage.setItem('user', JSON.stringify(user)); - } else if (error) { - console.error('Error fetching session:', error); + } else { localStorage.removeItem('token'); localStorage.removeItem('user'); setToken(null); @@ -54,6 +52,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { }, [token]); const login = (newToken: string, newUser: User) => { + console.log('Logging in'); setToken(newToken); setUser(newUser); localStorage.setItem('token', newToken); @@ -61,6 +60,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { }; const logout = () => { + console.log('Logging out'); setToken(null); setUser(null); localStorage.removeItem('token'); diff --git a/apps/readest-app/src/context/SyncContext.tsx b/apps/readest-app/src/context/SyncContext.tsx new file mode 100644 index 00000000..0e71102a --- /dev/null +++ b/apps/readest-app/src/context/SyncContext.tsx @@ -0,0 +1,18 @@ +'use client'; + +import React, { createContext, useContext } from 'react'; +import { SyncClient } from '@/libs/sync'; + +const syncClient = new SyncClient(); + +interface SyncContextType { + syncClient: SyncClient; +} + +const SyncContext = createContext({ syncClient }); + +export const SyncProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + return {children}; +}; + +export const useSyncContext = () => useContext(SyncContext); diff --git a/apps/readest-app/src/hooks/useSync.ts b/apps/readest-app/src/hooks/useSync.ts new file mode 100644 index 00000000..e046d08d --- /dev/null +++ b/apps/readest-app/src/hooks/useSync.ts @@ -0,0 +1,194 @@ +import { useEffect, useState } from 'react'; +import { useSyncContext } from '@/context/SyncContext'; +import { SyncData, SyncOp, SyncResult, SyncType } from '@/libs/sync'; +import { useSettingsStore } from '@/store/settingsStore'; +import { useBookDataStore } from '@/store/bookDataStore'; +import { transformBookConfigFromDB } from '@/utils/transform'; +import { transformBookNoteFromDB } from '@/utils/transform'; +import { transformBookFromDB } from '@/utils/transform'; +import { DBBook, DBBookConfig, DBBookNote } from '@/types/records'; +import { Book, BookConfig, BookDataRecord, BookNote } from '@/types/book'; + +const transformsFromDB = { + books: transformBookFromDB, + notes: transformBookNoteFromDB, + configs: transformBookConfigFromDB, +}; + +const computeMaxTimestamp = (records: BookDataRecord[]): number => { + let maxTime = 0; + for (const rec of records) { + if (rec.updated_at) { + const updatedTime = new Date(rec.updated_at).getTime(); + maxTime = Math.max(maxTime, updatedTime); + } + if (rec.deleted_at) { + const deletedTime = new Date(rec.deleted_at).getTime(); + maxTime = Math.max(maxTime, deletedTime); + } + } + return maxTime; +}; + +export function useSync(bookKey?: string) { + const { settings } = useSettingsStore(); + const { getConfig } = useBookDataStore(); + + const config = bookKey ? getConfig(bookKey) : null; + + const [syncingBooks, setSyncingBooks] = useState(false); + const [syncingConfigs, setSyncingConfigs] = useState(false); + const [syncingNotes, setSyncingNotes] = useState(false); + + const [syncError, setSyncError] = useState(null); + const [lastSyncedAtBooks, setLastSyncedAtBooks] = useState( + settings.lastSyncedAtBooks ?? 0, + ); + const [lastSyncedAtConfigs, setLastSyncedAtConfigs] = useState( + config?.lastSyncedAtConfig ?? settings.lastSyncedAtConfigs ?? 0, + ); + const [lastSyncedAtNotes, setLastSyncedAtNotes] = useState( + config?.lastSyncedAtNotes ?? settings.lastSyncedAtNotes ?? 0, + ); + + const [syncing, setSyncing] = useState(false); + const [syncResult, setSyncResult] = useState({ + books: [], + configs: [], + notes: [], + }); + const [syncedBooks, setSyncedBooks] = useState([]); + const [syncedConfigs, setSyncedConfigs] = useState([]); + const [syncedNotes, setSyncedNotes] = useState([]); + + const { syncClient } = useSyncContext(); + + // bookId is for configs and notes only, if bookId is provided, only pull changes for that book + // and update the lastSyncedAt for that book in the book config + const pullChanges = async ( + type: SyncType, + since: number, + setLastSyncedAt: React.Dispatch>, + setSyncing: React.Dispatch>, + bookId?: string, + ) => { + setSyncing(true); + setSyncError(null); + + try { + const result = await syncClient.pullChanges(since, type, bookId); + const maxTime = computeMaxTimestamp(result[type]); + setLastSyncedAt(maxTime); + switch (type) { + case 'books': + settings.lastSyncedAtBooks = maxTime; + break; + case 'configs': + if (!bookId) { + settings.lastSyncedAtConfigs = maxTime; + } else if (config) { + config.lastSyncedAtConfig = maxTime; + } + break; + case 'notes': + if (!bookId) { + settings.lastSyncedAtNotes = maxTime; + } else if (config) { + config.lastSyncedAtNotes = maxTime; + } + break; + } + } catch (err: unknown) { + console.error(err); + if (err instanceof Error) { + setSyncError(err.message || `Error pulling ${type}`); + } else { + setSyncError(`Error pulling ${type}`); + } + } finally { + setSyncing(false); + } + }; + + const pushChanges = async (payload: SyncData) => { + setSyncing(true); + setSyncError(null); + + try { + const result = await syncClient.pushChanges(payload); + setSyncResult(result); + } catch (err: unknown) { + console.error(err); + if (err instanceof Error) { + setSyncError(err.message || 'Error pushing changes'); + } else { + setSyncError('Error pushing changes'); + } + } finally { + setSyncing(false); + } + }; + + const syncBooks = async (books?: Book[], op: SyncOp = 'both') => { + if ((op === 'push' || op === 'both') && books?.length) { + await pushChanges({ books }); + } + if (op === 'pull' || op === 'both') { + await pullChanges('books', lastSyncedAtBooks, setLastSyncedAtBooks, setSyncingBooks); + } + }; + + const syncConfigs = async (bookConfigs?: BookConfig[], bookId?: string, op: SyncOp = 'both') => { + if ((op === 'push' || op === 'both') && bookConfigs?.length) { + await pushChanges({ configs: bookConfigs }); + } + if (op === 'pull' || op === 'both') { + await pullChanges( + 'configs', + lastSyncedAtConfigs, + setLastSyncedAtConfigs, + setSyncingConfigs, + bookId, + ); + } + }; + + const syncNotes = async (bookNotes?: BookNote[], bookId?: string, op: SyncOp = 'both') => { + if ((op === 'push' || op === 'both') && bookNotes?.length) { + await pushChanges({ notes: bookNotes }); + } + if (op === 'pull' || op === 'both') { + await pullChanges('notes', lastSyncedAtNotes, setLastSyncedAtNotes, setSyncingNotes, bookId); + } + }; + + useEffect(() => { + if (!syncing && syncResult) { + const { books: dbBooks, configs: dbBookConfigs, notes: dbBookNotes } = syncResult; + const books = dbBooks.map((dbBook) => transformsFromDB['books'](dbBook as unknown as DBBook)); + const configs = dbBookConfigs.map((dbBookConfig) => + transformsFromDB['configs'](dbBookConfig as unknown as DBBookConfig), + ); + const notes = dbBookNotes.map((dbBookNote) => + transformsFromDB['notes'](dbBookNote as unknown as DBBookNote), + ); + if (books.length) setSyncedBooks(books); + if (configs.length) setSyncedConfigs(configs); + if (notes.length) setSyncedNotes(notes); + } + }, [syncResult, syncing]); + + return { + syncing: syncingBooks || syncingConfigs || syncingNotes, + syncError, + syncResult, + syncedBooks, + syncedConfigs, + syncedNotes, + pullChanges, + pushChanges, + syncBooks, + syncConfigs, + syncNotes, + }; +} diff --git a/apps/readest-app/src/libs/sync.ts b/apps/readest-app/src/libs/sync.ts new file mode 100644 index 00000000..5d2e5507 --- /dev/null +++ b/apps/readest-app/src/libs/sync.ts @@ -0,0 +1,79 @@ +import { supabase } from '@/utils/supabase'; +import { Book, BookConfig, BookNote, BookDataRecord } from '@/types/book'; +import { READEST_WEB_BASE_URL } from '@/services/constants'; + +const BASE_URL = process.env['NODE_ENV'] === 'production' ? READEST_WEB_BASE_URL : ''; + +export type SyncType = 'books' | 'configs' | 'notes'; +export type SyncOp = 'push' | 'pull' | 'both'; + +interface BookRecord extends BookDataRecord, Book {} +interface BookConfigRecord extends BookDataRecord, BookConfig {} +interface BookNoteRecord extends BookDataRecord, BookNote {} + +export interface SyncResult { + books: BookRecord[]; + notes: BookNoteRecord[]; + configs: BookConfigRecord[]; +} + +export interface SyncData { + books?: Partial[]; + notes?: Partial[]; + configs?: Partial[]; +} + +export class SyncClient { + /** + * Pull incremental changes since a given timestamp (in ms). + * Returns updated or deleted records since that time. + */ + async pullChanges(since: number, type?: SyncType, book?: string): Promise { + const token = await this.getAccessToken(); + if (!token) throw new Error('Not authenticated'); + + const url = `${BASE_URL}/api/sync?since=${encodeURIComponent(since)}&type=${type ?? ''}&book=${book ?? ''}`; + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(`Failed to pull changes: ${error.error || res.statusText}`); + } + + return res.json(); + } + + /** + * Push local changes to the server. + * Uses last-writer-wins logic as implemented on the server side. + */ + async pushChanges(payload: SyncData): Promise { + const token = await this.getAccessToken(); + if (!token) throw new Error('Not authenticated'); + + const res = await fetch(`${BASE_URL}/api/sync`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(`Failed to push changes: ${error.error || res.statusText}`); + } + + return res.json(); + } + + private async getAccessToken(): Promise { + const { data } = await supabase.auth.getSession(); + return data?.session?.access_token ?? null; + } +} diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index 01fd9342..d5121bfd 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -2,7 +2,7 @@ import { AppPlatform, AppService, ToastType } from '@/types/system'; import { SystemSettings } from '@/types/settings'; import { FileSystem, BaseDir } from '@/types/system'; -import { Book, BookConfig, BookContent, BookFormat, ViewSettings } from '@/types/book'; +import { Book, BookConfig, BookContent, BookFormat } from '@/types/book'; import { getDir, getFilename, @@ -26,6 +26,7 @@ import { DEFAULT_BOOK_SEARCH_CONFIG, } from './constants'; import { isValidURL } from '@/utils/misc'; +import { deserializeConfig, serializeConfig } from '@/utils/serializer'; export abstract class BaseAppService implements AppService { localBooksDir: string = ''; @@ -70,6 +71,10 @@ export abstract class BaseAppService implements AppService { settings = { version: SYSTEM_SETTINGS_VERSION, localBooksDir: await this.getInitBooksDir(), + lastSyncedAtBooks: 0, + lastSyncedAtConfigs: 0, + lastSyncedAtNotes: 0, + keepLogin: false, globalReadSettings: DEFAULT_READSETTINGS, globalViewSettings: { ...DEFAULT_BOOK_LAYOUT, @@ -126,10 +131,10 @@ export abstract class BaseAppService implements AppService { const hash = await partialMD5(fileobj); const existingBook = books.filter((b) => b.hash === hash)[0]; if (existingBook) { - if (existingBook.isRemoved) { - delete existingBook.isRemoved; + if (existingBook.deletedAt) { + delete existingBook.deletedAt; } - existingBook.lastUpdated = Date.now(); + existingBook.updatedAt = Date.now(); } const book: Book = { @@ -137,7 +142,8 @@ export abstract class BaseAppService implements AppService { format, title: formatTitle(loadedBook.metadata.title), author: formatAuthors(loadedBook.metadata.language, loadedBook.metadata.author), - lastUpdated: Date.now(), + createdAt: existingBook ? existingBook.createdAt : Date.now(), + updatedAt: Date.now(), }; if (!(await this.fs.exists(getDir(book), 'Books'))) { await this.fs.createDir(getDir(book), 'Books'); @@ -206,33 +212,22 @@ export abstract class BaseAppService implements AppService { async loadBookConfig(book: Book, settings: SystemSettings): Promise { try { const str = await this.fs.readFile(getConfigFilename(book), 'Books', 'text'); - const config = JSON.parse(str as string) as BookConfig; const { globalViewSettings } = settings; - const { viewSettings } = config; - config.viewSettings = { ...globalViewSettings, ...viewSettings }; - config.searchConfig ??= DEFAULT_BOOK_SEARCH_CONFIG; - return config; + return deserializeConfig(str as string, globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG); } catch { return INIT_BOOK_CONFIG; } } async saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings) { + let serializedConfig: string; if (settings) { - config = JSON.parse(JSON.stringify(config)); - const globalViewSettings = settings.globalViewSettings as ViewSettings; - const viewSettings = config.viewSettings as Partial; - config.viewSettings = Object.entries(viewSettings).reduce( - (acc: Partial>, [key, value]) => { - if (globalViewSettings[key as keyof ViewSettings] !== value) { - acc[key as keyof ViewSettings] = value; - } - return acc; - }, - {} as Partial>, - ) as Partial; + const { globalViewSettings } = settings; + serializedConfig = serializeConfig(config, globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG); + } else { + serializedConfig = JSON.stringify(config); } - await this.fs.writeFile(getConfigFilename(book), 'Books', JSON.stringify(config)); + await this.fs.writeFile(getConfigFilename(book), 'Books', serializedConfig); } async loadLibraryBooks(): Promise { @@ -255,6 +250,7 @@ export abstract class BaseAppService implements AppService { } else { book.coverImageUrl = this.getCoverImageUrl(book); } + book.updatedAt ??= book.lastUpdated || Date.now(); return book; }), ); diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index d390cfe5..155f506b 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -84,3 +84,7 @@ export const ONE_COLUMN_MAX_INLINE_SIZE = 9999; export const BOOK_IDS_SEPARATOR = '+'; 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 = 30; diff --git a/apps/readest-app/src/store/bookDataStore.ts b/apps/readest-app/src/store/bookDataStore.ts index e8c311ad..ffdf3439 100644 --- a/apps/readest-app/src/store/bookDataStore.ts +++ b/apps/readest-app/src/store/bookDataStore.ts @@ -17,6 +17,7 @@ interface BookData { interface BookDataState { booksData: { [id: string]: BookData }; getConfig: (key: string | null) => BookConfig | null; + setConfig: (key: string, config: BookConfig) => void; saveConfig: ( envConfig: EnvConfigType, bookKey: string, @@ -38,6 +39,20 @@ export const useBookDataStore = create((set, get) => ({ const id = key.split('-')[0]!; return get().booksData[id]?.config || null; }, + setConfig: (key: string, config: BookConfig) => { + set((state: BookDataState) => { + const id = key.split('-')[0]!; + return { + booksData: { + ...state.booksData, + [id]: { + ...state.booksData[id]!, + config, + }, + }, + }; + }); + }, saveConfig: async ( envConfig: EnvConfigType, bookKey: string, @@ -49,10 +64,10 @@ export const useBookDataStore = create((set, get) => ({ const bookIndex = library.findIndex((b) => b.hash === bookKey.split('-')[0]); if (bookIndex == -1) return; const book = library.splice(bookIndex, 1)[0]!; - book.lastUpdated = Date.now(); + book.updatedAt = Date.now(); library.unshift(book); setLibrary(library); - config.lastUpdated = Date.now(); + config.updatedAt = Date.now(); appService.saveBookConfig(book, config, settings); appService.saveLibraryBooks(library); }, @@ -67,7 +82,7 @@ export const useBookDataStore = create((set, get) => ({ ); updatedConfig = { ...book.config, - lastUpdated: Date.now(), + updatedAt: Date.now(), booknotes: dedupedBooknotes, }; return { @@ -77,7 +92,7 @@ export const useBookDataStore = create((set, get) => ({ ...book, config: { ...book.config, - lastUpdated: Date.now(), + updatedAt: Date.now(), booknotes: dedupedBooknotes, }, }, diff --git a/apps/readest-app/src/store/readerStore.ts b/apps/readest-app/src/store/readerStore.ts index 811d8e46..0ecc3208 100644 --- a/apps/readest-app/src/store/readerStore.ts +++ b/apps/readest-app/src/store/readerStore.ts @@ -2,7 +2,7 @@ import { create } from 'zustand'; import { BookContent, BookConfig, PageInfo, BookProgress, ViewSettings } from '@/types/book'; import { EnvConfigType } from '@/services/environment'; -import { FoliateView } from '@/app/reader/components/FoliateViewer'; +import { FoliateView } from '@/types/view'; import { BookDoc, DocumentLoader, SectionItem, TOCItem } from '@/libs/document'; import { updateTocCFI, updateTocID } from '@/utils/toc'; import { useSettingsStore } from './settingsStore'; @@ -182,7 +182,7 @@ export const useReaderStore = create((set, get) => ({ ...bookData, config: { ...bookData.config, - lastUpdated: Date.now(), + updatedAt: Date.now(), viewSettings, }, }, @@ -216,7 +216,7 @@ export const useReaderStore = create((set, get) => ({ const oldConfig = bookData.config; const newConfig = { ...bookData.config, - lastUpdated: Date.now(), + updatedAt: Date.now(), progress: [pageinfo.current, pageinfo.total] as [number, number], location, }; diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index f2ce94b5..b42b78f2 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -12,9 +12,13 @@ export interface Book { author: string; group?: string; tags?: string[]; - lastUpdated: number; - isRemoved?: boolean; coverImageUrl?: string | null; + + createdAt: number; + updatedAt: number; + deletedAt?: number; + + lastUpdated?: number; // deprecated in favor of updatedAt } export interface PageInfo { @@ -24,6 +28,7 @@ export interface PageInfo { } export interface BookNote { + bookHash?: string; id: string; type: BookNoteType; cfi: string; @@ -31,8 +36,10 @@ export interface BookNote { style?: HighlightStyle; color?: HighlightColor; note: string; - created: number; - modified?: number; + + createdAt: number; + updatedAt: number; + deletedAt?: number; } export interface BookLayout { @@ -107,18 +114,32 @@ export interface BookSearchResult { } export interface BookConfig { - lastUpdated: number; + bookHash?: string; progress?: [number, number]; location?: string; booknotes?: BookNote[]; - searchConfig?: BookSearchConfig; + searchConfig?: Partial; viewSettings?: Partial; + + lastSyncedAtConfig?: number; + lastSyncedAtNotes?: number; + + updatedAt: number; +} + +export interface BookDataRecord { + id: string; + book_hash: string; + user_id: string; + updated_at: number | null; + deleted_at: number | null; } export interface BooksGroup { name: string; books: Book[]; - lastUpdated: number; + + updatedAt: number; } export interface BookContent { book: Book; diff --git a/apps/readest-app/src/types/records.ts b/apps/readest-app/src/types/records.ts new file mode 100644 index 00000000..3e6f4f6b --- /dev/null +++ b/apps/readest-app/src/types/records.ts @@ -0,0 +1,42 @@ +export interface DBBook { + user_id: string; + book_hash: string; + format: string; + title: string; + author: string; + group?: string; + tags?: string; + + created_at?: string; + updated_at?: string; + deleted_at?: string | null; +} + +export interface DBBookConfig { + user_id: string; + book_hash: string; + location?: string; + progress?: string; + search_config?: string; + view_settings?: string; + + created_at?: string; + updated_at?: string; + deleted_at?: string | null; +} + +export interface DBBookNote { + user_id: string; + book_hash: string; + id: string; + type: string; + cfi: string; + text?: string; + style?: string; + color?: string; + note: string; + + created_at?: string; + updated_at?: string; + deleted_at?: string | null; +} diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts index b62b9c95..91335dc2 100644 --- a/apps/readest-app/src/types/settings.ts +++ b/apps/readest-app/src/types/settings.ts @@ -18,6 +18,11 @@ export interface ReadSettings { export interface SystemSettings { version: number; localBooksDir: string; + keepLogin: boolean; + + lastSyncedAtBooks: number; + lastSyncedAtConfigs: number; + lastSyncedAtNotes: number; globalReadSettings: ReadSettings; globalViewSettings: ViewSettings; diff --git a/apps/readest-app/src/types/view.ts b/apps/readest-app/src/types/view.ts new file mode 100644 index 00000000..c4fd0fdd --- /dev/null +++ b/apps/readest-app/src/types/view.ts @@ -0,0 +1,52 @@ +import { BookDoc } from '@/libs/document'; +import { BookNote, BookSearchConfig, BookSearchResult } from '@/types/book'; + +export interface FoliateView extends HTMLElement { + open: (book: BookDoc) => Promise; + close: () => void; + init: (options: { lastLocation: string }) => void; + goTo: (href: string) => void; + goToFraction: (fraction: number) => void; + prev: (distance: number) => void; + next: (distance: number) => void; + goLeft: () => void; + goRight: () => void; + getCFI: (index: number, range: Range) => string; + addAnnotation: (note: BookNote, remove?: boolean) => { index: number; label: string }; + search: (config: BookSearchConfig) => AsyncGenerator; + clearSearch: () => void; + select: (target: string | number | { fraction: number }) => void; + deselect: () => void; + history: { + canGoBack: boolean; + canGoForward: boolean; + back: () => void; + forward: () => void; + clear: () => void; + }; + renderer: { + scrolled?: boolean; + viewSize: number; + setAttribute: (name: string, value: string | number) => void; + removeAttribute: (name: string) => void; + next: () => Promise; + prev: () => Promise; + goTo?: (params: { index: number; anchor: number }) => void; + setStyles?: (css: string) => void; + addEventListener: (type: string, listener: EventListener) => void; + removeEventListener: (type: string, listener: EventListener) => void; + }; +} + +export const wrappedFoliateView = (originalView: FoliateView): FoliateView => { + const originalAddAnnotation = originalView.addAnnotation.bind(originalView); + originalView.addAnnotation = (note: BookNote, remove = false) => { + // transform BookNote to foliate annotation + const annotation = { + value: note.cfi, + ...note, + }; + return originalAddAnnotation(annotation, remove); + }; + return originalView; +}; diff --git a/apps/readest-app/src/utils/book.ts b/apps/readest-app/src/utils/book.ts index b07ff95a..4ec4d401 100644 --- a/apps/readest-app/src/utils/book.ts +++ b/apps/readest-app/src/utils/book.ts @@ -26,7 +26,7 @@ export const getBaseFilename = (filename: string) => { return baseName; }; export const INIT_BOOK_CONFIG: BookConfig = { - lastUpdated: 0, + updatedAt: 0, }; interface LanguageMap { diff --git a/apps/readest-app/src/utils/serializer.ts b/apps/readest-app/src/utils/serializer.ts new file mode 100644 index 00000000..f006450c --- /dev/null +++ b/apps/readest-app/src/utils/serializer.ts @@ -0,0 +1,52 @@ +import { BookConfig, BookSearchConfig, ViewSettings } from '@/types/book'; + +export const serializeConfig = ( + config: BookConfig, + globalViewSettings: ViewSettings, + defaultSearchConfig: BookSearchConfig, +): string => { + config = JSON.parse(JSON.stringify(config)); + const viewSettings = config.viewSettings as Partial; + const searchConfig = config.searchConfig as Partial; + config.viewSettings = Object.entries(viewSettings).reduce( + (acc: Partial>, [key, value]) => { + if (globalViewSettings[key as keyof ViewSettings] !== value) { + acc[key as keyof ViewSettings] = value; + } + return acc; + }, + {} as Partial>, + ) as Partial; + config.searchConfig = Object.entries(searchConfig).reduce( + (acc: Partial>, [key, value]) => { + if (defaultSearchConfig[key as keyof BookSearchConfig] !== value) { + acc[key as keyof BookSearchConfig] = value; + } + return acc; + }, + {} as Partial, + ) as Partial; + + return JSON.stringify(config); +}; + +export const deserializeConfig = ( + str: string, + globalViewSettings: ViewSettings, + defaultSearchConfig: BookSearchConfig, +): BookConfig => { + const config = JSON.parse(str) as BookConfig; + const { viewSettings, searchConfig } = config; + config.viewSettings = { ...globalViewSettings, ...viewSettings }; + config.searchConfig = { ...defaultSearchConfig, ...searchConfig }; + config.updatedAt ??= Date.now(); + return config; +}; + +export const compressConfig = ( + config: BookConfig, + globalViewSettings: ViewSettings, + defaultSearchConfig: BookSearchConfig, +): string => { + return JSON.parse(serializeConfig(config, globalViewSettings, defaultSearchConfig)); +}; diff --git a/apps/readest-app/src/utils/supabase.ts b/apps/readest-app/src/utils/supabase.ts index 695a174f..70b43211 100644 --- a/apps/readest-app/src/utils/supabase.ts +++ b/apps/readest-app/src/utils/supabase.ts @@ -6,3 +6,15 @@ const supabaseAnonKey = process.env['NEXT_PUBLIC_SUPABASE_ANON_KEY'] || process.env['NEXT_PUBLIC_DEV_SUPABASE_ANON_KEY']!; export const supabase = createClient(supabaseUrl, supabaseAnonKey); + +export const createSupabaseClient = (accessToken?: string) => { + return createClient(supabaseUrl, supabaseAnonKey, { + global: { + headers: accessToken + ? { + Authorization: `Bearer ${accessToken}`, + } + : {}, + }, + }); +}; diff --git a/apps/readest-app/src/utils/transform.ts b/apps/readest-app/src/utils/transform.ts new file mode 100644 index 00000000..e637e49d --- /dev/null +++ b/apps/readest-app/src/utils/transform.ts @@ -0,0 +1,111 @@ +import { + Book, + BookConfig, + BookFormat, + BookNote, + BookNoteType, + HighlightColor, + HighlightStyle, +} from '@/types/book'; +import { DBBookConfig, DBBook, DBBookNote } from '@/types/records'; + +export const transformBookConfigToDB = (bookConfig: unknown, userId: string): DBBookConfig => { + const { bookHash, progress, location, searchConfig, viewSettings, updatedAt } = + bookConfig as BookConfig; + + return { + user_id: userId, + book_hash: bookHash!, + location: location, + progress: progress && JSON.stringify(progress), + search_config: searchConfig && JSON.stringify(searchConfig), + view_settings: viewSettings && JSON.stringify(viewSettings), + updated_at: new Date(updatedAt).toISOString(), + }; +}; + +export const transformBookConfigFromDB = (dbBookConfig: DBBookConfig): BookConfig => { + const { book_hash, progress, location, search_config, view_settings, updated_at } = dbBookConfig; + return { + bookHash: book_hash, + location, + progress: progress && JSON.parse(progress), + searchConfig: search_config && JSON.parse(search_config), + viewSettings: view_settings && JSON.parse(view_settings), + updatedAt: new Date(updated_at!).getTime(), + } as BookConfig; +}; + +export const transformBookToDB = (book: unknown, userId: string): DBBook => { + const { hash, format, title, author, group, tags, createdAt, updatedAt, deletedAt } = + book as Book; + + return { + user_id: userId, + book_hash: hash, + format, + title, + author, + group, + tags: tags && JSON.stringify(tags), + created_at: new Date(createdAt).toISOString(), + updated_at: new Date(updatedAt).toISOString(), + deleted_at: deletedAt ? new Date(deletedAt).toISOString() : undefined, + }; +}; + +export const transformBookFromDB = (dbBook: DBBook): Book => { + const { book_hash, format, title, author, group, tags, created_at, updated_at, deleted_at } = + dbBook; + + return { + hash: book_hash, + format: format as BookFormat, + title, + author, + group, + tags: tags && JSON.parse(tags), + createdAt: new Date(created_at!).getTime(), + updatedAt: new Date(updated_at!).getTime(), + deletedAt: deleted_at ? new Date(deleted_at!).getTime() : undefined, + }; +}; + +export const transformBookNoteToDB = (bookNote: unknown, userId: string): DBBookNote => { + const { bookHash, id, type, cfi, text, style, color, note, createdAt, updatedAt, deletedAt } = + bookNote as BookNote; + + return { + user_id: userId, + book_hash: bookHash!, + id, + type, + cfi, + text, + style, + color, + note, + created_at: new Date(createdAt).toISOString(), + updated_at: new Date(updatedAt).toISOString(), + deleted_at: deletedAt ? new Date(deletedAt).toISOString() : undefined, + }; +}; + +export const transformBookNoteFromDB = (dbBookNote: DBBookNote): BookNote => { + const { book_hash, id, type, cfi, text, style, color, note, created_at, updated_at, deleted_at } = + dbBookNote; + + return { + bookHash: book_hash, + id, + type: type as BookNoteType, + cfi, + text, + style: style as HighlightStyle, + color: color as HighlightColor, + note, + createdAt: new Date(created_at!).getTime(), + updatedAt: new Date(updated_at!).getTime(), + deletedAt: deleted_at ? new Date(deleted_at!).getTime() : undefined, + }; +};