feat: local store read progress for later sync
This commit is contained in:
@@ -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" });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
31
src/lib/services/client-offlinebook.service.ts
Normal file
31
src/lib/services/client-offlinebook.service.ts
Normal 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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user