feat: implement view mode toggle functionality in PaginatedBookGrid and PaginatedSeriesGrid components

This commit is contained in:
Julien Froidefond
2025-11-16 08:02:37 +01:00
parent 2adc6c3f22
commit 3b24fe0f01
10 changed files with 363 additions and 20 deletions

View File

@@ -0,0 +1,36 @@
import { useDisplayPreferences } from "@/hooks/useDisplayPreferences";
import { useTranslate } from "@/hooks/useTranslate";
import { LayoutGrid, List } from "lucide-react";
import { Button } from "@/components/ui/button";
interface ViewModeButtonProps {
onToggle?: (viewMode: "grid" | "list") => void;
}
export function ViewModeButton({ onToggle }: ViewModeButtonProps) {
const { viewMode, handleViewModeToggle } = useDisplayPreferences();
const { t } = useTranslate();
const handleClick = async () => {
const newViewMode = viewMode === "grid" ? "list" : "grid";
await handleViewModeToggle(newViewMode);
onToggle?.(newViewMode);
};
const Icon = viewMode === "grid" ? List : LayoutGrid;
const label = viewMode === "grid" ? t("books.display.list") : t("books.display.grid");
return (
<Button
variant="ghost"
size="sm"
onClick={handleClick}
title={label}
className="whitespace-nowrap"
>
<Icon className="h-4 w-4" />
<span className="hidden sm:inline ml-2">{label}</span>
</Button>
);
}

View File

@@ -87,12 +87,6 @@ export function PaginatedSeriesGrid({
}); });
}; };
const handleCompactToggle = async (newCompactState: boolean) => {
await updateUrlParams({
page: "1",
compact: newCompactState.toString(),
});
};
const handlePageSizeChange = async (size: number) => { const handlePageSizeChange = async (size: number) => {
await updateUrlParams({ await updateUrlParams({
@@ -125,7 +119,7 @@ export function PaginatedSeriesGrid({
</div> </div>
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<PageSizeSelect onSizeChange={handlePageSizeChange} /> <PageSizeSelect onSizeChange={handlePageSizeChange} />
<CompactModeButton onToggle={handleCompactToggle} /> <CompactModeButton />
<UnreadFilterButton showOnlyUnread={showOnlyUnread} onToggle={handleUnreadFilter} /> <UnreadFilterButton showOnlyUnread={showOnlyUnread} onToggle={handleUnreadFilter} />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,275 @@
"use client";
import type { KomgaBook } from "@/types/komga";
import { BookCover } from "@/components/ui/book-cover";
import { useState, useEffect } from "react";
import { useTranslate } from "@/hooks/useTranslate";
import { cn } from "@/lib/utils";
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
import { formatDate } from "@/lib/utils";
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
import { Progress } from "@/components/ui/progress";
import { Calendar, FileText, User, Tag } from "lucide-react";
import { MarkAsReadButton } from "@/components/ui/mark-as-read-button";
import { MarkAsUnreadButton } from "@/components/ui/mark-as-unread-button";
import { BookOfflineButton } from "@/components/ui/book-offline-button";
interface BookListProps {
books: KomgaBook[];
onBookClick: (book: KomgaBook) => void;
}
interface BookListItemProps {
book: KomgaBook;
onBookClick: (book: KomgaBook) => void;
onSuccess: (book: KomgaBook, action: "read" | "unread") => void;
}
function BookListItem({ book, onBookClick, onSuccess }: BookListItemProps) {
const { t } = useTranslate();
const { isAccessible } = useBookOfflineStatus(book.id);
const handleClick = () => {
if (!isAccessible) return;
onBookClick(book);
};
const isRead = book.readProgress?.completed || false;
const hasReadProgress = book.readProgress !== null;
const currentPage = ClientOfflineBookService.getCurrentPage(book);
const totalPages = book.media.pagesCount;
const progressPercentage = totalPages > 0 ? (currentPage / totalPages) * 100 : 0;
const getStatusInfo = () => {
if (!book.readProgress) {
return {
label: t("books.status.unread"),
className: "bg-yellow-500/10 text-yellow-500",
};
}
if (book.readProgress.completed) {
const readDate = book.readProgress.readDate ? formatDate(book.readProgress.readDate) : null;
return {
label: readDate ? t("books.status.readDate", { date: readDate }) : t("books.status.read"),
className: "bg-green-500/10 text-green-500",
};
}
if (currentPage > 0) {
return {
label: t("books.status.progress", {
current: currentPage,
total: totalPages,
}),
className: "bg-blue-500/10 text-blue-500",
};
}
return {
label: t("books.status.unread"),
className: "bg-yellow-500/10 text-yellow-500",
};
};
const statusInfo = getStatusInfo();
const title = book.metadata.title ||
(book.metadata.number
? t("navigation.volume", { number: book.metadata.number })
: book.name);
return (
<div
className={cn(
"group relative flex gap-4 p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors",
!isAccessible && "opacity-60"
)}
>
{/* Couverture */}
<div
className={cn(
"relative w-20 h-28 sm:w-24 sm:h-36 flex-shrink-0 rounded overflow-hidden bg-muted",
isAccessible && "cursor-pointer"
)}
onClick={handleClick}
>
<BookCover
book={book}
alt={t("books.coverAlt", { title })}
showControls={false}
showOverlay={false}
className="w-full h-full"
/>
</div>
{/* Contenu */}
<div className="flex-1 min-w-0 flex flex-col gap-2">
{/* Titre et numéro */}
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h3
className={cn(
"font-semibold text-base sm:text-lg line-clamp-2",
isAccessible && "cursor-pointer hover:text-primary transition-colors"
)}
onClick={handleClick}
>
{title}
</h3>
{book.metadata.number && (
<p className="text-sm text-muted-foreground mt-1">
{t("navigation.volume", { number: book.metadata.number })}
</p>
)}
</div>
{/* Badge de statut */}
<span className={cn("px-2 py-1 rounded-full text-xs font-medium flex-shrink-0", statusInfo.className)}>
{statusInfo.label}
</span>
</div>
{/* Résumé */}
{book.metadata.summary && (
<p className="text-sm text-muted-foreground line-clamp-2 hidden sm:block">
{book.metadata.summary}
</p>
)}
{/* Métadonnées */}
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
{/* Pages */}
<div className="flex items-center gap-1">
<FileText className="h-3 w-3" />
<span>
{totalPages} {totalPages > 1 ? t("books.pages_plural") : t("books.pages")}
</span>
</div>
{/* Auteurs */}
{book.metadata.authors && book.metadata.authors.length > 0 && (
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
<span className="line-clamp-1">
{book.metadata.authors.map(a => a.name).join(", ")}
</span>
</div>
)}
{/* Date de sortie */}
{book.metadata.releaseDate && (
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>{formatDate(book.metadata.releaseDate)}</span>
</div>
)}
{/* Tags */}
{book.metadata.tags && book.metadata.tags.length > 0 && (
<div className="flex items-center gap-1">
<Tag className="h-3 w-3" />
<span className="line-clamp-1">
{book.metadata.tags.slice(0, 3).join(", ")}
{book.metadata.tags.length > 3 && ` +${book.metadata.tags.length - 3}`}
</span>
</div>
)}
</div>
{/* Barre de progression */}
{hasReadProgress && !isRead && currentPage > 0 && (
<div className="space-y-1">
<Progress value={progressPercentage} className="h-2" />
<p className="text-xs text-muted-foreground">
{Math.round(progressPercentage)}% {t("books.completed")}
</p>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 mt-auto pt-2">
{!isRead && (
<MarkAsReadButton
bookId={book.id}
pagesCount={book.media.pagesCount}
isRead={isRead}
onSuccess={() => onSuccess(book, "read")}
className="text-xs"
/>
)}
{hasReadProgress && (
<MarkAsUnreadButton
bookId={book.id}
onSuccess={() => onSuccess(book, "unread")}
className="text-xs"
/>
)}
<BookOfflineButton book={book} className="text-xs" />
</div>
</div>
</div>
);
}
export function BookList({ books, onBookClick }: BookListProps) {
const [localBooks, setLocalBooks] = useState(books);
const { t } = useTranslate();
useEffect(() => {
setLocalBooks(books);
}, [books]);
if (!localBooks.length) {
return (
<div className="text-center p-8">
<p className="text-muted-foreground whitespace-pre-line">{t("books.empty")}</p>
</div>
);
}
const handleOnSuccess = (book: KomgaBook, action: "read" | "unread") => {
if (action === "read") {
setLocalBooks(
localBooks.map((previousBook) =>
previousBook.id === book.id
? {
...previousBook,
readProgress: {
completed: true,
page: previousBook.media.pagesCount,
readDate: new Date().toISOString(),
created: new Date().toISOString(),
lastModified: new Date().toISOString(),
},
}
: previousBook
)
);
} else if (action === "unread") {
setLocalBooks(
localBooks.map((previousBook) =>
previousBook.id === book.id
? {
...previousBook,
readProgress: null,
}
: previousBook
)
);
}
};
return (
<div className="space-y-2">
{localBooks.map((book) => (
<BookListItem
key={book.id}
book={book}
onBookClick={onBookClick}
onSuccess={handleOnSuccess}
/>
))}
</div>
);
}

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { BookGrid } from "./BookGrid"; import { BookGrid } from "./BookGrid";
import { BookList } from "./BookList";
import { Pagination } from "@/components/ui/Pagination"; import { Pagination } from "@/components/ui/Pagination";
import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
@@ -9,6 +10,7 @@ import { useTranslate } from "@/hooks/useTranslate";
import { useDisplayPreferences } from "@/hooks/useDisplayPreferences"; import { useDisplayPreferences } from "@/hooks/useDisplayPreferences";
import { PageSizeSelect } from "@/components/common/PageSizeSelect"; import { PageSizeSelect } from "@/components/common/PageSizeSelect";
import { CompactModeButton } from "@/components/common/CompactModeButton"; import { CompactModeButton } from "@/components/common/CompactModeButton";
import { ViewModeButton } from "@/components/common/ViewModeButton";
import { UnreadFilterButton } from "@/components/common/UnreadFilterButton"; import { UnreadFilterButton } from "@/components/common/UnreadFilterButton";
interface PaginatedBookGridProps { interface PaginatedBookGridProps {
@@ -32,7 +34,7 @@ export function PaginatedBookGrid({
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [showOnlyUnread, setShowOnlyUnread] = useState(initialShowOnlyUnread); const [showOnlyUnread, setShowOnlyUnread] = useState(initialShowOnlyUnread);
const { isCompact, itemsPerPage } = useDisplayPreferences(); const { isCompact, itemsPerPage, viewMode } = useDisplayPreferences();
const { t } = useTranslate(); const { t } = useTranslate();
const updateUrlParams = useCallback(async ( const updateUrlParams = useCallback(async (
@@ -81,13 +83,6 @@ export function PaginatedBookGrid({
}); });
}; };
const handleCompactToggle = async (newCompactState: boolean) => {
await updateUrlParams({
page: "1",
compact: newCompactState.toString(),
});
};
const handlePageSizeChange = async (size: number) => { const handlePageSizeChange = async (size: number) => {
await updateUrlParams({ await updateUrlParams({
page: "1", page: "1",
@@ -119,12 +114,17 @@ export function PaginatedBookGrid({
<p className="text-sm text-muted-foreground text-right">{getShowingText()}</p> <p className="text-sm text-muted-foreground text-right">{getShowingText()}</p>
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<PageSizeSelect onSizeChange={handlePageSizeChange} /> <PageSizeSelect onSizeChange={handlePageSizeChange} />
<CompactModeButton onToggle={handleCompactToggle} /> <ViewModeButton />
{viewMode === "grid" && <CompactModeButton />}
<UnreadFilterButton showOnlyUnread={showOnlyUnread} onToggle={handleUnreadFilter} /> <UnreadFilterButton showOnlyUnread={showOnlyUnread} onToggle={handleUnreadFilter} />
</div> </div>
</div> </div>
<BookGrid books={books} onBookClick={handleBookClick} isCompact={isCompact} /> {viewMode === "grid" ? (
<BookGrid books={books} onBookClick={handleBookClick} isCompact={isCompact} />
) : (
<BookList books={books} onBookClick={handleBookClick} />
)}
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between"> <div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
<p className="text-sm text-muted-foreground order-2 sm:order-1"> <p className="text-sm text-muted-foreground order-2 sm:order-1">

View File

@@ -39,6 +39,11 @@ export function PreferencesProvider({
setPreferences({ setPreferences({
...defaultPreferences, ...defaultPreferences,
...data, ...data,
displayMode: {
...defaultPreferences.displayMode,
...(data.displayMode || {}),
viewMode: data.displayMode?.viewMode || defaultPreferences.displayMode.viewMode,
},
}); });
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération des préférences"); logger.error({ err: error }, "Erreur lors de la récupération des préférences");

View File

@@ -30,10 +30,25 @@ export function useDisplayPreferences() {
} }
}; };
const handleViewModeToggle = async (viewMode: "grid" | "list") => {
try {
await updatePreferences({
displayMode: {
...preferences.displayMode,
viewMode,
},
});
} catch (error) {
logger.error({ err: error }, "Erreur lors de la mise à jour du mode d'affichage");
}
};
return { return {
isCompact: preferences.displayMode.compact, isCompact: preferences.displayMode.compact,
itemsPerPage: preferences.displayMode.itemsPerPage, itemsPerPage: preferences.displayMode.itemsPerPage,
viewMode: preferences.displayMode.viewMode || "grid",
handleCompactToggle, handleCompactToggle,
handlePageSizeChange, handlePageSizeChange,
handleViewModeToggle,
}; };
} }

View File

@@ -325,9 +325,14 @@
"progress": "Page {{current}}/{{total}}", "progress": "Page {{current}}/{{total}}",
"offline": "Unavailable offline" "offline": "Unavailable offline"
}, },
"pages": "page",
"pages_plural": "pages",
"completed": "completed",
"display": { "display": {
"showing": "Showing books {start} to {end} of {total}", "showing": "Showing books {start} to {end} of {total}",
"page": "Page {current} of {total}" "page": "Page {current} of {total}",
"grid": "Grid view",
"list": "List view"
}, },
"filters": { "filters": {
"showAll": "Show all", "showAll": "Show all",

View File

@@ -323,9 +323,14 @@
"progress": "Page {{current}}/{{total}}", "progress": "Page {{current}}/{{total}}",
"offline": "Indisponible hors ligne" "offline": "Indisponible hors ligne"
}, },
"pages": "page",
"pages_plural": "pages",
"completed": "complété",
"display": { "display": {
"showing": "Affichage des tomes {start} à {end} sur {total}", "showing": "Affichage des tomes {start} à {end} sur {total}",
"page": "Page {current} sur {total}" "page": "Page {current} sur {total}",
"grid": "Vue grille",
"list": "Vue liste"
}, },
"filters": { "filters": {
"showAll": "Afficher tout", "showAll": "Afficher tout",

View File

@@ -29,11 +29,17 @@ export class PreferencesService {
return { ...defaultPreferences }; return { ...defaultPreferences };
} }
const displayMode = preferences.displayMode as UserPreferences["displayMode"];
return { return {
showThumbnails: preferences.showThumbnails, showThumbnails: preferences.showThumbnails,
cacheMode: preferences.cacheMode as "memory" | "file", cacheMode: preferences.cacheMode as "memory" | "file",
showOnlyUnread: preferences.showOnlyUnread, showOnlyUnread: preferences.showOnlyUnread,
displayMode: preferences.displayMode as UserPreferences["displayMode"], displayMode: {
...defaultPreferences.displayMode,
...displayMode,
viewMode: displayMode?.viewMode || defaultPreferences.displayMode.viewMode,
},
background: preferences.background as unknown as BackgroundPreferences, background: preferences.background as unknown as BackgroundPreferences,
komgaMaxConcurrentRequests: preferences.komgaMaxConcurrentRequests, komgaMaxConcurrentRequests: preferences.komgaMaxConcurrentRequests,
readerPrefetchCount: preferences.readerPrefetchCount, readerPrefetchCount: preferences.readerPrefetchCount,

View File

@@ -22,6 +22,7 @@ export interface UserPreferences {
displayMode: { displayMode: {
compact: boolean; compact: boolean;
itemsPerPage: number; itemsPerPage: number;
viewMode: "grid" | "list";
}; };
background: BackgroundPreferences; background: BackgroundPreferences;
komgaMaxConcurrentRequests: number; komgaMaxConcurrentRequests: number;
@@ -36,6 +37,7 @@ export const defaultPreferences: UserPreferences = {
displayMode: { displayMode: {
compact: false, compact: false,
itemsPerPage: 20, itemsPerPage: 20,
viewMode: "grid",
}, },
background: { background: {
type: "default", type: "default",