feat: local store read progress for later sync

This commit is contained in:
Julien Froidefond
2025-03-01 11:37:34 +01:00
parent 13492cea84
commit a3d0094cec
11 changed files with 93 additions and 43 deletions

View File

@@ -4,6 +4,7 @@ import { getErrorMessage } from "@/utils/errors";
import { LibraryService } from "@/lib/services/library.service"; import { LibraryService } from "@/lib/services/library.service";
import { HomeService } from "@/lib/services/home.service"; import { HomeService } from "@/lib/services/home.service";
import { SeriesService } from "@/lib/services/series.service"; import { SeriesService } from "@/lib/services/series.service";
import { revalidatePath } from "next/cache";
export async function POST( export async function POST(
request: Request, request: Request,
@@ -13,13 +14,17 @@ export async function POST(
const { libraryId, seriesId } = params; const { libraryId, seriesId } = params;
await HomeService.invalidateHomeCache(); await HomeService.invalidateHomeCache();
revalidatePath("/");
if (libraryId) { if (libraryId) {
await LibraryService.invalidateLibrarySeriesCache(libraryId); await LibraryService.invalidateLibrarySeriesCache(libraryId);
revalidatePath(`/library/${libraryId}`);
} }
if (seriesId) { if (seriesId) {
await SeriesService.invalidateSeriesBooksCache(seriesId); await SeriesService.invalidateSeriesBooksCache(seriesId);
await SeriesService.invalidateSeriesCache(seriesId); await SeriesService.invalidateSeriesCache(seriesId);
revalidatePath(`/series/${seriesId}`);
} }
return NextResponse.json({ message: "🧹 Cache vidé avec succès" }); return NextResponse.json({ message: "🧹 Cache vidé avec succès" });

View File

@@ -4,6 +4,8 @@ import { ChevronLeft, ChevronRight } from "lucide-react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { Cover } from "@/components/ui/cover"; import { Cover } from "@/components/ui/cover";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
import { KomgaBook } from "@/types/komga";
interface BaseItem { interface BaseItem {
id: string; id: string;
@@ -18,12 +20,12 @@ interface OptimizedSeries extends BaseItem {
} }
interface OptimizedBook extends BaseItem { interface OptimizedBook extends BaseItem {
readProgress:{ readProgress: {
page: number page: number;
} };
media: { media: {
pagesCount: number; pagesCount: number;
} };
metadata: { metadata: {
title: string; title: string;
number?: string; number?: string;
@@ -124,19 +126,27 @@ function MediaCard({ item, onClick }: MediaCardProps) {
onClick={onClick} onClick={onClick}
className="flex-shrink-0 w-[200px] relative flex flex-col rounded-lg border bg-card text-card-foreground shadow-sm hover:bg-accent hover:text-accent-foreground transition-colors overflow-hidden cursor-pointer" className="flex-shrink-0 w-[200px] relative flex flex-col rounded-lg border bg-card text-card-foreground shadow-sm hover:bg-accent hover:text-accent-foreground transition-colors overflow-hidden cursor-pointer"
> >
{/* Image de couverture */}
<div className="relative aspect-[2/3] bg-muted"> <div className="relative aspect-[2/3] bg-muted">
<Cover {isSeries ? (
type={isSeries ? "series" : "book"} <Cover
id={item.id} type={isSeries ? "series" : "book"}
alt={`Couverture de ${title}`} id={item.id}
quality={100} alt={`Couverture de ${title}`}
readBooks={isSeries ? item.booksReadCount : undefined} quality={100}
totalBooks={isSeries ? item.booksCount : undefined} readBooks={item.booksReadCount}
isCompleted={isSeries ? item.booksCount === item.booksReadCount : undefined} totalBooks={item.booksCount}
currentPage={isSeries ? undefined : item.readProgress?.page} isCompleted={item.booksCount === item.booksReadCount}
totalPages={isSeries ? undefined : item.media?.pagesCount} />
/> ) : (
<Cover
type="book"
id={item.id}
alt={`Couverture de ${title}`}
quality={100}
currentPage={ClientOfflineBookService.getCurrentPage(item as KomgaBook)}
totalPages={item.media?.pagesCount}
/>
)}
{/* Overlay avec les informations au survol */} {/* Overlay avec les informations au survol */}
<div className="absolute inset-0 bg-black/60 opacity-0 hover:opacity-100 transition-opacity duration-200 flex flex-col justify-end p-3"> <div className="absolute inset-0 bg-black/60 opacity-0 hover:opacity-100 transition-opacity duration-200 flex flex-col justify-end p-3">
<h3 className="font-medium text-sm text-white line-clamp-2">{title}</h3> <h3 className="font-medium text-sm text-white line-clamp-2">{title}</h3>

View File

@@ -3,6 +3,7 @@
import { KomgaBook } from "@/types/komga"; import { KomgaBook } from "@/types/komga";
import { BookReader } from "./BookReader"; import { BookReader } from "./BookReader";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
interface ClientBookWrapperProps { interface ClientBookWrapperProps {
book: KomgaBook; book: KomgaBook;
@@ -12,10 +13,11 @@ interface ClientBookWrapperProps {
export function ClientBookWrapper({ book, pages }: ClientBookWrapperProps) { export function ClientBookWrapper({ book, pages }: ClientBookWrapperProps) {
const router = useRouter(); const router = useRouter();
const handleCloseReader = () => { const handleCloseReader = (currentPage: number) => {
fetch(`/api/komga/cache/clear/${book.libraryId}/${book.seriesId}`, { fetch(`/api/komga/cache/clear/${book.libraryId}/${book.seriesId}`, {
method: "POST", method: "POST",
}); });
ClientOfflineBookService.setCurrentPage(book, currentPage);
router.back(); router.back();
}; };

View File

@@ -101,7 +101,7 @@ export const ControlButtons = ({
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onClose(); onClose(currentPage);
}} }}
className={cn( className={cn(
"absolute top-4 right-4 p-2 rounded-full bg-background/50 hover:bg-background/80 transition-all duration-300 z-30", "absolute top-4 right-4 p-2 rounded-full bg-background/50 hover:bg-background/80 transition-all duration-300 z-30",

View File

@@ -1,11 +1,12 @@
import { useState, useCallback, useEffect, useRef } from "react"; import { useState, useCallback, useEffect, useRef } from "react";
import { KomgaBook } from "@/types/komga"; import { KomgaBook } from "@/types/komga";
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
interface UsePageNavigationProps { interface UsePageNavigationProps {
book: KomgaBook; book: KomgaBook;
pages: number[]; pages: number[];
isDoublePage: boolean; isDoublePage: boolean;
onClose?: () => void; onClose?: (currentPage: number) => void;
direction: "ltr" | "rtl"; direction: "ltr" | "rtl";
} }
@@ -16,7 +17,7 @@ export const usePageNavigation = ({
onClose, onClose,
direction, direction,
}: UsePageNavigationProps) => { }: UsePageNavigationProps) => {
const [currentPage, setCurrentPage] = useState(book.readProgress?.page || 1); const [currentPage, setCurrentPage] = useState(ClientOfflineBookService.getCurrentPage(book));
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [secondPageLoading, setSecondPageLoading] = useState(true); const [secondPageLoading, setSecondPageLoading] = useState(true);
const [zoomLevel, setZoomLevel] = useState(1); const [zoomLevel, setZoomLevel] = useState(1);
@@ -37,6 +38,7 @@ export const usePageNavigation = ({
const syncReadProgress = useCallback( const syncReadProgress = useCallback(
async (page: number) => { async (page: number) => {
try { try {
ClientOfflineBookService.setCurrentPage(book, page);
const completed = page === pages.length; const completed = page === pages.length;
await fetch(`/api/komga/books/${book.id}/read-progress`, { await fetch(`/api/komga/books/${book.id}/read-progress`, {
method: "PATCH", method: "PATCH",
@@ -239,7 +241,7 @@ export const usePageNavigation = ({
} }
} else if (e.key === "Escape" && onClose) { } else if (e.key === "Escape" && onClose) {
e.preventDefault(); e.preventDefault();
onClose(); onClose(currentPage);
} }
}; };
@@ -269,6 +271,7 @@ export const usePageNavigation = ({
if (timeoutRef.current) { if (timeoutRef.current) {
clearTimeout(timeoutRef.current); clearTimeout(timeoutRef.current);
syncReadProgress(currentPageRef.current); syncReadProgress(currentPageRef.current);
ClientOfflineBookService.removeCurrentPage(book);
} }
}; };
}, [syncReadProgress]); }, [syncReadProgress]);

View File

@@ -12,7 +12,7 @@ export interface PageCache {
export interface BookReaderProps { export interface BookReaderProps {
book: KomgaBook; book: KomgaBook;
pages: number[]; pages: number[];
onClose?: () => void; onClose?: (currentPage: number) => void;
} }
export interface ThumbnailProps { export interface ThumbnailProps {
@@ -39,7 +39,7 @@ export interface ControlButtonsProps {
onPreviousPage: () => void; onPreviousPage: () => void;
onNextPage: () => void; onNextPage: () => void;
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
onClose?: () => void; onClose?: (currentPage: number) => void;
currentPage: number; currentPage: number;
totalPages: number; totalPages: number;
isDoublePage: boolean; isDoublePage: boolean;

View File

@@ -8,6 +8,7 @@ import { MarkAsUnreadButton } from "@/components/ui/mark-as-unread-button";
import { BookOfflineButton } from "@/components/ui/book-offline-button"; import { BookOfflineButton } from "@/components/ui/book-offline-button";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
interface BookGridProps { interface BookGridProps {
books: KomgaBook[]; books: KomgaBook[];
@@ -31,10 +32,12 @@ const getReadingStatusInfo = (book: KomgaBook, t: (key: string, options?: any) =
}; };
} }
if (book.readProgress.page > 0) { const currentPage = ClientOfflineBookService.getCurrentPage(book);
if (currentPage > 0) {
return { return {
label: t("books.status.progress", { label: t("books.status.progress", {
current: book.readProgress.page, current: currentPage,
total: book.media.pagesCount, total: book.media.pagesCount,
}), }),
className: "bg-blue-500/10 text-blue-500", className: "bg-blue-500/10 text-blue-500",
@@ -102,7 +105,8 @@ export function BookGrid({ books, onBookClick }: BookGridProps) {
{localBooks.map((book) => { {localBooks.map((book) => {
const statusInfo = getReadingStatusInfo(book, t); const statusInfo = getReadingStatusInfo(book, t);
const isRead = book.readProgress?.completed || false; const isRead = book.readProgress?.completed || false;
const currentPage = book.readProgress?.page || 0; const hasReadProgress = book.readProgress !== null;
const currentPage = ClientOfflineBookService.getCurrentPage(book);
return ( return (
<div <div
@@ -138,10 +142,9 @@ export function BookGrid({ books, onBookClick }: BookGridProps) {
className="bg-white/90 hover:bg-white text-black shadow-sm" className="bg-white/90 hover:bg-white text-black shadow-sm"
/> />
)} )}
{isRead && ( {hasReadProgress && (
<MarkAsUnreadButton <MarkAsUnreadButton
bookId={book.id} bookId={book.id}
isRead={isRead}
onSuccess={() => handleMarkAsUnread(book.id)} onSuccess={() => handleMarkAsUnread(book.id)}
className="bg-white/90 hover:bg-white text-black shadow-sm" className="bg-white/90 hover:bg-white text-black shadow-sm"
/> />

View File

@@ -44,7 +44,7 @@ export function Cover(props: CoverProps) {
} = props; } = props;
const imageUrl = getImageUrl(type, id); const imageUrl = getImageUrl(type, id);
const showProgress = () => { const showProgress = () => {
if (type === "book") { if (type === "book") {
const { currentPage, totalPages } = props; const { currentPage, totalPages } = props;
@@ -52,7 +52,7 @@ export function Cover(props: CoverProps) {
<ProgressBar progress={currentPage} total={totalPages} type="book" /> <ProgressBar progress={currentPage} total={totalPages} type="book" />
) : null; ) : null;
} }
if (type === "series") { if (type === "series") {
const { readBooks, totalBooks } = props; const { readBooks, totalBooks } = props;
return readBooks && totalBooks && readBooks > 0 && !isCompleted ? ( return readBooks && totalBooks && readBooks > 0 && !isCompleted ? (

View File

@@ -1,8 +1,9 @@
"use client"; "use client";
import { CheckCircle2 } from "lucide-react"; import { BookCheck } from "lucide-react";
import { Button } from "./button"; import { Button } from "./button";
import { useToast } from "./use-toast"; import { useToast } from "./use-toast";
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
interface MarkAsReadButtonProps { interface MarkAsReadButtonProps {
bookId: string; bookId: string;
@@ -24,6 +25,7 @@ export function MarkAsReadButton({
const handleMarkAsRead = async (e: React.MouseEvent) => { const handleMarkAsRead = async (e: React.MouseEvent) => {
e.stopPropagation(); // Empêcher la propagation au parent e.stopPropagation(); // Empêcher la propagation au parent
try { try {
ClientOfflineBookService.removeCurrentPageById(bookId);
const response = await fetch(`/api/komga/books/${bookId}/read-progress`, { const response = await fetch(`/api/komga/books/${bookId}/read-progress`, {
method: "PATCH", method: "PATCH",
headers: { headers: {
@@ -60,7 +62,7 @@ export function MarkAsReadButton({
disabled={isRead} disabled={isRead}
aria-label="Marquer comme lu" aria-label="Marquer comme lu"
> >
<CheckCircle2 className="h-5 w-5" /> <BookCheck className="h-5 w-5" />
</Button> </Button>
); );
} }

View File

@@ -1,27 +1,22 @@
"use client"; "use client";
import { XCircle } from "lucide-react"; import { BookX } from "lucide-react";
import { Button } from "./button"; import { Button } from "./button";
import { useToast } from "./use-toast"; import { useToast } from "./use-toast";
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
interface MarkAsUnreadButtonProps { interface MarkAsUnreadButtonProps {
bookId: string; bookId: string;
isRead: boolean;
onSuccess?: () => void; onSuccess?: () => void;
className?: string; className?: string;
} }
export function MarkAsUnreadButton({ export function MarkAsUnreadButton({ bookId, onSuccess, className }: MarkAsUnreadButtonProps) {
bookId,
isRead = false,
onSuccess,
className,
}: MarkAsUnreadButtonProps) {
const { toast } = useToast(); const { toast } = useToast();
const handleMarkAsUnread = async (e: React.MouseEvent) => { const handleMarkAsUnread = async (e: React.MouseEvent) => {
e.stopPropagation(); // Empêcher la propagation au parent e.stopPropagation(); // Empêcher la propagation au parent
try { try {
ClientOfflineBookService.removeCurrentPageById(bookId);
const response = await fetch(`/api/komga/books/${bookId}/read-progress`, { const response = await fetch(`/api/komga/books/${bookId}/read-progress`, {
method: "DELETE", method: "DELETE",
}); });
@@ -51,10 +46,9 @@ export function MarkAsUnreadButton({
size="icon" size="icon"
onClick={handleMarkAsUnread} onClick={handleMarkAsUnread}
className={`h-8 w-8 p-0 rounded-br-lg rounded-tl-lg ${className}`} className={`h-8 w-8 p-0 rounded-br-lg rounded-tl-lg ${className}`}
disabled={!isRead}
aria-label="Marquer comme non lu" aria-label="Marquer comme non lu"
> >
<XCircle className="h-5 w-5" /> <BookX className="h-5 w-5" />
</Button> </Button>
); );
} }

View File

@@ -0,0 +1,31 @@
import { KomgaBook } from "@/types/komga";
export class ClientOfflineBookService {
static setCurrentPage(book: KomgaBook, page: number) {
localStorage.setItem(`${book.id}-page`, page.toString());
}
static getCurrentPage(book: KomgaBook) {
const readProgressPage = book.readProgress?.page || 0;
if (typeof localStorage !== "undefined") {
const cPageLS = localStorage.getItem(`${book.id}-page`) || "0";
const currentPage = parseInt(cPageLS);
if (currentPage < readProgressPage) {
return readProgressPage;
}
return currentPage;
} else {
return readProgressPage;
}
}
static removeCurrentPage(book: KomgaBook) {
localStorage.removeItem(`${book.id}-page`);
}
static removeCurrentPageById(bookId: string) {
localStorage.removeItem(`${bookId}-page`);
}
}