diff --git a/package.json b/package.json index f87d186..4056f0f 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-progress": "^1.1.1", "@radix-ui/react-scroll-area": "^1.2.0-rc.7", "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-slot": "^1.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e03015..45c6cfe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@radix-ui/react-label': specifier: ^2.1.0 version: 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-progress': + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-scroll-area': specifier: ^1.2.0-rc.7 version: 1.2.0-rc.7(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1350,6 +1353,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-compose-refs@1.1.1': + resolution: {integrity: sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-context@1.1.0': resolution: {integrity: sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==} peerDependencies: @@ -1359,6 +1371,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.1': + resolution: {integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.1.1': resolution: {integrity: sha512-zysS+iU4YP3STKNS6USvFVqI4qqx8EpiwmT5TuCApVEBca+eRCbONi4EgzfNSuVnOXvC5UPHHMjs8RXO6DH9Bg==} peerDependencies: @@ -1516,6 +1537,32 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.0.1': + resolution: {integrity: sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.1': + resolution: {integrity: sha512-6diOawA84f/eMxFHcWut0aE1C2kyE9dOyCTQOMRR2C/qPiXz/X0SaiA/RLbapQaXUCmy0/hLMf9meSccD1N0pA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.1.0': resolution: {integrity: sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==} peerDependencies: @@ -1564,6 +1611,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.1.1': + resolution: {integrity: sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-switch@1.1.0': resolution: {integrity: sha512-OBzy5WAj641k0AOSpKQtreDMe+isX0MQJ1IVyF03ucdF3DunOnROVrjWs8zsXUxC3zfZ6JL9HFVCUlMghz9dJw==} peerDependencies: @@ -4121,6 +4177,7 @@ packages: sudo-prompt@9.2.1: resolution: {integrity: sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} @@ -5771,12 +5828,24 @@ snapshots: optionalDependencies: '@types/react': 18.3.5 + '@radix-ui/react-compose-refs@1.1.1(@types/react@18.3.5)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.5 + '@radix-ui/react-context@1.1.0(@types/react@18.3.5)(react@18.3.1)': dependencies: react: 18.3.1 optionalDependencies: '@types/react': 18.3.5 + '@radix-ui/react-context@1.1.1(@types/react@18.3.5)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.5 + '@radix-ui/react-dialog@1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -5939,6 +6008,25 @@ snapshots: '@types/react': 18.3.5 '@types/react-dom': 18.3.0 + '@radix-ui/react-primitive@2.0.1(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.1.1(@types/react@18.3.5)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.5 + '@types/react-dom': 18.3.0 + + '@radix-ui/react-progress@1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-context': 1.1.1(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.5 + '@types/react-dom': 18.3.0 + '@radix-ui/react-roving-focus@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -6009,6 +6097,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.5 + '@radix-ui/react-slot@1.1.1(@types/react@18.3.5)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.5)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.5 + '@radix-ui/react-switch@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 diff --git a/src/api/backend/search/search.ts b/src/api/backend/search/search.ts index 6bb0ea1..66ab548 100644 --- a/src/api/backend/search/search.ts +++ b/src/api/backend/search/search.ts @@ -35,3 +35,18 @@ export const useGetBooksQueryWithExternalDownloads = (params: SearchParams) => { enabled: params.query !== "", }); }; + +export const useGetBooksByMd5sQuery = (md5s: string[]) => { + return useQuery({ + queryKey: ["search", md5s], + queryFn: async () => { + const books: BookItem[] = []; + for (const md5 of md5s) { + const response = await getBooks({ query: md5, lang: "all", limit: 1 }); + books.push(response.results[0]); + } + return books; + }, + enabled: md5s.length > 0, + }); +}; diff --git a/src/api/backend/types.ts b/src/api/backend/types.ts index 19fd7f7..019bd7d 100644 --- a/src/api/backend/types.ts +++ b/src/api/backend/types.ts @@ -1,18 +1,24 @@ import { ExternalDownloadLink } from "./downloads/types"; export interface BookItem { - authors: string; - book_content: string; + author: string; book_filetype: string; book_image: string; book_lang: string; + book_length: string; book_size: string; - book_source: string; + cid: string; description: string; + external_cover_url: string; + id: number; + isbn: string; link: string; md5: string; - publication: string[]; + other_titles: string; + publisher: string; + series: string; title: string; + year: string; } export interface BookItemWithExternalDownloads extends BookItem { diff --git a/src/components/books/book-item.tsx b/src/components/books/book-item.tsx index d83eb9f..4fec5b1 100644 --- a/src/components/books/book-item.tsx +++ b/src/components/books/book-item.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { BookItem, BookItemWithExternalDownloads } from "@/api/backend/types"; import { Card, CardContent } from "../ui/card"; import PlaceholderImage from "@/assets/placeholder.png"; @@ -9,13 +9,25 @@ import { BookmarkButton } from "./bookmark"; import { BookDownloadButton } from "./download-button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog"; import { ScrollArea } from "../ui/scroll-area"; +import { Progress } from "../ui/progress"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; +import { useReadingProgressStore } from "@/stores/progress"; type BookItemProps = BookItemWithExternalDownloads | BookItem; export function BookItemCard(props: BookItemProps) { const [isReaderOpen, setIsReaderOpen] = useState(false); + const findReadingProgress = useReadingProgressStore((state) => state.findReadingProgress); const isEpub = Boolean(props.link?.toLowerCase().endsWith(".epub")); + + const progress = useMemo(() => { + const progress = findReadingProgress(props.md5); + if (progress && progress.totalPages > 0) { + return (progress.currentPage / progress.totalPages) * 100; + } + }, [props.md5, findReadingProgress]); + return ( @@ -24,7 +36,7 @@ export function BookItemCard(props: BookItemProps) {
-
+
setIsReaderOpen(true)} /> + {progress != null && ( + + + + + + +

Progress: {progress!.toFixed(2)}%

+
+
+
+ )}

{props.title}

-

By {props.authors}

+

By {props.author}

{props.description}

-

{props.book_content}

File size: {props.book_size}

File type: {props.book_filetype}

MD5: {props.md5}

{"externalDownloads" in props && } - {isEpub && } + {isEpub && }
@@ -118,11 +141,10 @@ export function BookItemDialog(props: BookItemProps) { {props.title} - By {props.authors} + By {props.author}
-

{props.book_content}

File size: {props.book_size}

File type: {props.book_filetype}

MD5: {props.md5}

@@ -132,7 +154,7 @@ export function BookItemDialog(props: BookItemProps) { {"externalDownloads" in props && } - {isEpub && } + {isEpub && } diff --git a/src/components/epub-reader/epub-reader.tsx b/src/components/epub-reader/epub-reader.tsx index a263490..c2d58e9 100644 --- a/src/components/epub-reader/epub-reader.tsx +++ b/src/components/epub-reader/epub-reader.tsx @@ -1,7 +1,7 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Button } from "../ui/button"; -import { AArrowDown, AArrowUp, BookOpen, DownloadIcon, X } from "lucide-react"; +import { AArrowDown, AArrowUp, BookOpen, DownloadIcon, X, Loader2 } from "lucide-react"; import { ThemeToggle } from "../layout/theme-toggle"; import { useSettingsStore } from "@/stores/settings"; import { saveAs } from "@/lib/saveAs"; @@ -13,10 +13,12 @@ import { NavItem } from "epubjs"; import { EpubView, EpubViewInstance } from "./epub-view"; import { cn } from "@/lib/utils"; import { useSwipeable } from "react-swipeable"; +import { useReadingProgressStore } from "@/stores/progress"; interface EpubReaderProps { title: string; link: string; + md5: string; open: boolean; setIsOpen: (isOpen: boolean) => void; } @@ -25,13 +27,18 @@ export function EpubReader(props: EpubReaderProps) { const readerRef = useRef(null); const renditionRef = useRef(null); + const findReadingProgress = useReadingProgressStore((state) => state.findReadingProgress); + const setReadingProgress = useReadingProgressStore((state) => state.setReadingProgress); + const [toc, setToc] = useState([]); const [location, setLocation] = useState(1); const [fontSize, setFontSize] = useState(16); const [page, setPage] = useState({ - current: 1, - total: 1, + current: 0, + total: 0, }); + const [loading, setLoading] = useState(true); + const [progress, setProgress] = useState(0); const theme = useSettingsStore((state) => state.theme); @@ -64,6 +71,59 @@ export function EpubReader(props: EpubReaderProps) { } }, [fontSize]); + const handleProgress = (loaded: number, total: number) => { + setProgress(Math.round((loaded / total) * 100)); + }; + + const handleLocationChanged = useCallback( + async (loc: string) => { + if (renditionRef.current) { + if (!renditionRef.current.book.locations.length()) { + await renditionRef.current.book.locations.generate(1600); + } + /* @ts-expect-error missing epub types */ + const currentPage = renditionRef.current.book.locations.locationFromCfi(loc) + 1; + /* @ts-expect-error missing epub types */ + const totalPages = renditionRef.current.book.locations.total; + setPage({ + current: currentPage, + total: totalPages, + }); + + if (currentPage > 0 && totalPages > 0) { + setReadingProgress({ + md5: props.md5, + currentPage, + totalPages, + location: loc, + }); + } + } + }, + [props.md5, setReadingProgress], + ); + + const handleRendition = useCallback( + (rendition: Rendition) => { + rendition.themes.override("color", theme === "dark" ? "#fff" : "#050505"); + rendition.themes.override("background", theme === "dark" ? "#050505" : "#fff"); + renditionRef.current = rendition; + const eventsToStopLoading = ["rendered", "relocated", "displayError", "displayed", "layout", "started"]; + eventsToStopLoading.forEach((event) => { + rendition.on(event, () => setLoading(false)); + }); + rendition.on("loading", (loaded: number, total: number) => handleProgress(loaded, total)); + }, + [theme], + ); + + useEffect(() => { + const readingProgress = findReadingProgress(props.md5); + if (readingProgress) { + setLocation(readingProgress.location); + } + }, [findReadingProgress, props.md5]); + return ( @@ -104,36 +164,23 @@ export function EpubReader(props: EpubReaderProps) {
{/* Hack to have swipe events for the iframe */}
+ {loading && ( +
+ +

{progress}%

+
+ )}
- { - setLocation(loc); - setPage({ - current: renditionRef.current?.location.start.displayed.page || 1, - total: renditionRef.current?.location.start.displayed.total || 1, - }); - }} - getRendition={(rendition) => { - rendition.themes.override("color", theme === "dark" ? "#fff" : "#050505"); - rendition.themes.override("background", theme === "dark" ? "#050505" : "#fff"); - renditionRef.current = rendition; - }} - /> +
- - {page.current}/{page.total} - + {page.current === 0 || page.total === 0 ? : `${page.current}/${page.total}`}
diff --git a/src/components/epub-reader/epub-view.tsx b/src/components/epub-reader/epub-view.tsx index eda2479..0cafbf3 100644 --- a/src/components/epub-reader/epub-view.tsx +++ b/src/components/epub-reader/epub-view.tsx @@ -6,11 +6,10 @@ import type { BookOptions } from "epubjs/types/book"; export type IEpubViewProps = { url: string | ArrayBuffer; epubInitOptions?: Partial; - location: string | number | null; + location: string | number; locationChanged(value: string): void; tocChanged?(value: NavItem[]): void; getRendition?(rendition: Rendition): void; - handleKeyUp?(): void; }; export interface EpubViewInstance { @@ -18,7 +17,7 @@ export interface EpubViewInstance { prevPage: () => void; } -export const EpubView = forwardRef(({ url, epubInitOptions = {}, location, locationChanged, tocChanged, getRendition, handleKeyUp }, ref) => { +export const EpubView = forwardRef(({ url, epubInitOptions = {}, location, locationChanged, tocChanged, getRendition }, ref) => { const viewerRef = useRef(null); const bookRef = useRef(null); const renditionRef = useRef(null); @@ -40,17 +39,13 @@ export const EpubView = forwardRef(({ url, epu const handleKeys = useCallback( (event: KeyboardEvent) => { - if (handleKeyUp) { - handleKeyUp(); - return; - } if (event.key === "ArrowRight") { nextPage(); } else if (event.key === "ArrowLeft") { prevPage(); } }, - [handleKeyUp, prevPage, nextPage], + [prevPage, nextPage], ); const registerEvents = useCallback( diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 0000000..a38446f --- /dev/null +++ b/src/components/ui/progress.tsx @@ -0,0 +1,13 @@ +import * as React from "react"; +import * as ProgressPrimitive from "@radix-ui/react-progress"; + +import { cn } from "@/lib/utils"; + +const Progress = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, value, ...props }, ref) => ( + + + +)); +Progress.displayName = ProgressPrimitive.Root.displayName; + +export { Progress }; diff --git a/src/routes/lists.tsx b/src/routes/lists.tsx index 449fd3b..a415057 100644 --- a/src/routes/lists.tsx +++ b/src/routes/lists.tsx @@ -1,7 +1,10 @@ +import { useGetBooksByMd5sQuery } from "@/api/backend/search/search"; import { BookList } from "@/components/books/book-list"; import { NavLink } from "@/components/ui/nav-link"; import { useBookmarksStore } from "@/stores/bookmarks"; +import { useReadingProgressStore } from "@/stores/progress"; import { createFileRoute } from "@tanstack/react-router"; +import { Loader2 } from "lucide-react"; export const Route = createFileRoute("/lists")({ component: Lists, @@ -9,9 +12,14 @@ export const Route = createFileRoute("/lists")({ export function Lists() { const bookmarks = useBookmarksStore((state) => state.bookmarks); + const readingProgress = useReadingProgressStore((state) => state.readingProgress) + .filter((p) => p.totalPages > 0) + .filter((p) => p.currentPage < p.totalPages); + + const { data, isLoading, isError } = useGetBooksByMd5sQuery(readingProgress.map((p) => p.md5)); return ( -
+
{bookmarks.length > 0 ? (

Your Bookmarks

@@ -27,6 +35,19 @@ export function Lists() {
+ +
+ {data?.length && data?.length > 0 &&

Reading Progress

} + {readingProgress.length === 0 && ( +
+

No Reading Progress

+

Start reading some books and your progress will show up here.

+
+ )} + {isLoading && } + {data?.length && data?.length > 0 && } + {isError &&

Failed to fetch reading progress

} +
); } diff --git a/src/stores/progress.ts b/src/stores/progress.ts new file mode 100644 index 0000000..15a185c --- /dev/null +++ b/src/stores/progress.ts @@ -0,0 +1,40 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface ProgressItem { + md5: string; + currentPage: number; + totalPages: number; + location: string; +} + +interface ReadingProgressStoreState { + readingProgress: ProgressItem[]; + findReadingProgress: (md5: string) => ProgressItem | undefined; + setReadingProgress: (progressItem: ProgressItem) => void; + removeReadingProgress: (md5: string) => void; +} + +export const useReadingProgressStore = create()( + persist( + (set, get) => ({ + readingProgress: [], + findReadingProgress: (md5) => get().readingProgress.find((p) => p.md5 === md5), + setReadingProgress: (progressItem) => + set((state) => { + const index = state.readingProgress.findIndex((p) => p.md5 === progressItem.md5); + if (index === -1) { + return { readingProgress: [...state.readingProgress, progressItem] }; + } + const newReadingProgress = [...state.readingProgress]; + newReadingProgress[index] = progressItem; + return { readingProgress: newReadingProgress }; + }), + removeReadingProgress: (md5) => set((state) => ({ readingProgress: state.readingProgress.filter((p) => p.md5 !== md5) })), + }), + { + name: "BR::progress", + getStorage: () => localStorage, + }, + ), +);