feat: add pagination size selection and compact view toggle in series grid

This commit is contained in:
Julien Froidefond
2025-03-28 13:51:44 +01:00
parent 7834003482
commit fcd084863a
10 changed files with 294 additions and 36 deletions

View File

@@ -13,10 +13,10 @@ import { AppError } from "@/utils/errors";
interface PageProps {
params: { libraryId: string };
searchParams: { page?: string; unread?: string; search?: string };
searchParams: { page?: string; unread?: string; search?: string; size?: string };
}
const PAGE_SIZE = 20;
const DEFAULT_PAGE_SIZE = 20;
async function refreshLibrary(libraryId: string) {
"use server";
@@ -36,7 +36,8 @@ async function getLibrarySeries(
libraryId: string,
page: number = 1,
unreadOnly: boolean = false,
search?: string
search?: string,
size: number = DEFAULT_PAGE_SIZE
) {
try {
const pageIndex = page - 1;
@@ -44,7 +45,7 @@ async function getLibrarySeries(
const series: LibraryResponse<KomgaSeries> = await LibraryService.getLibrarySeries(
libraryId,
pageIndex,
PAGE_SIZE,
size,
unreadOnly,
search
);
@@ -61,16 +62,18 @@ async function LibraryPage({ params, searchParams }: PageProps) {
const unread = (await searchParams).unread;
const search = (await searchParams).search;
const page = (await searchParams).page;
const size = (await searchParams).size;
const currentPage = page ? parseInt(page) : 1;
const pageSize = size ? parseInt(size) : DEFAULT_PAGE_SIZE;
const preferences: UserPreferences = await PreferencesService.getPreferences();
// Utiliser le paramètre d'URL s'il existe, sinon utiliser la préférence utilisateur
const unreadOnly = unread !== undefined ? unread === "true" : preferences.showOnlyUnread;
console.log(unreadOnly);
try {
const { data: series, library }: { data: LibraryResponse<KomgaSeries>; library: KomgaLibrary } =
await getLibrarySeries(libraryId, currentPage, unreadOnly, search);
await getLibrarySeries(libraryId, currentPage, unreadOnly, search, pageSize);
return (
<div className="container py-8 space-y-8">
@@ -90,7 +93,7 @@ async function LibraryPage({ params, searchParams }: PageProps) {
currentPage={currentPage}
totalPages={series.totalPages}
totalElements={series.totalElements}
pageSize={PAGE_SIZE}
pageSize={pageSize}
defaultShowOnlyUnread={preferences.showOnlyUnread}
showOnlyUnread={unreadOnly}
/>

View File

@@ -4,11 +4,18 @@ import { SeriesGrid } from "./SeriesGrid";
import { Pagination } from "@/components/ui/Pagination";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { useState, useEffect } from "react";
import { Loader2, Filter } from "lucide-react";
import { Loader2, Filter, LayoutGrid, LayoutList, LayoutTemplate } from "lucide-react";
import { cn } from "@/lib/utils";
import type { KomgaSeries } from "@/types/komga";
import { SearchInput } from "./SearchInput";
import { useTranslate } from "@/hooks/useTranslate";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface PaginatedSeriesGridProps {
series: KomgaSeries[];
@@ -34,8 +41,25 @@ export function PaginatedSeriesGrid({
const searchParams = useSearchParams();
const [isChangingPage, setIsChangingPage] = useState(false);
const [showOnlyUnread, setShowOnlyUnread] = useState(initialShowOnlyUnread);
const [isCompact, setIsCompact] = useState(searchParams.get("compact") === "true");
const { t } = useTranslate();
const updateUrlParams = async (updates: Record<string, string | null>) => {
setIsChangingPage(true);
const params = new URLSearchParams(searchParams.toString());
// Mettre à jour les paramètres
Object.entries(updates).forEach(([key, value]) => {
if (value === null) {
params.delete(key);
} else {
params.set(key, value);
}
});
await router.push(`${pathname}?${params.toString()}`);
};
// Reset loading state when series change
useEffect(() => {
setIsChangingPage(false);
@@ -49,31 +73,39 @@ export function PaginatedSeriesGrid({
// Apply default filter on initial load
useEffect(() => {
if (defaultShowOnlyUnread && !searchParams.has("unread")) {
const params = new URLSearchParams(searchParams.toString());
params.set("page", "1");
params.set("unread", "true");
router.push(`${pathname}?${params.toString()}`);
updateUrlParams({ page: "1", unread: "true" });
}
}, [defaultShowOnlyUnread, pathname, router, searchParams]);
const handlePageChange = async (page: number) => {
setIsChangingPage(true);
const params = new URLSearchParams(searchParams.toString());
params.set("page", page.toString());
params.set("unread", showOnlyUnread.toString());
await router.push(`${pathname}?${params.toString()}`);
await updateUrlParams({ page: page.toString(), compact: isCompact.toString() });
};
const handleUnreadFilter = async () => {
setIsChangingPage(true);
const params = new URLSearchParams(searchParams.toString());
params.set("page", "1");
const newUnreadState = !showOnlyUnread;
setShowOnlyUnread(newUnreadState);
params.set("unread", newUnreadState.toString());
await updateUrlParams({
page: "1",
unread: newUnreadState ? "true" : "false",
compact: isCompact.toString(),
});
};
await router.push(`${pathname}?${params.toString()}`);
const handleCompactToggle = async () => {
const newCompactState = !isCompact;
setIsCompact(newCompactState);
await updateUrlParams({
page: "1",
compact: newCompactState.toString(),
});
};
const handlePageSizeChange = async (value: string) => {
await updateUrlParams({
page: "1",
size: value,
compact: isCompact.toString(),
});
};
// Calculate start and end indices for display
@@ -92,15 +124,42 @@ export function PaginatedSeriesGrid({
return (
<div className="space-y-8">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex-1">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="w-full sm:w-auto sm:flex-1">
<SearchInput placeholder={t("series.filters.search")} />
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{getShowingText()}</p>
<Select value={pageSize.toString()} onValueChange={handlePageSizeChange}>
<SelectTrigger className="w-[80px]">
<LayoutList className="h-4 w-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
<button
onClick={handleCompactToggle}
className="inline-flex items-center gap-2 px-2 py-1.5 text-sm font-medium rounded-lg hover:bg-accent hover:text-accent-foreground whitespace-nowrap"
>
{isCompact ? (
<>
<LayoutTemplate className="h-4 w-4" />
{t("series.filters.normal")}
</>
) : (
<>
<LayoutGrid className="h-4 w-4" />
{t("series.filters.compact")}
</>
)}
</button>
<button
onClick={handleUnreadFilter}
className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg hover:bg-accent hover:text-accent-foreground whitespace-nowrap"
className="inline-flex items-center gap-2 px-2 py-1.5 text-sm font-medium rounded-lg hover:bg-accent hover:text-accent-foreground whitespace-nowrap"
>
<Filter className="h-4 w-4" />
{showOnlyUnread ? t("series.filters.showAll") : t("series.filters.unread")}
@@ -126,7 +185,7 @@ export function PaginatedSeriesGrid({
isChangingPage ? "opacity-25" : "opacity-100"
)}
>
<SeriesGrid series={series} />
<SeriesGrid series={series} isCompact={isCompact} />
</div>
</div>

View File

@@ -8,6 +8,7 @@ import { useTranslate } from "@/hooks/useTranslate";
interface SeriesGridProps {
series: KomgaSeries[];
isCompact?: boolean;
}
// Utility function to get reading status info
@@ -42,7 +43,7 @@ const getReadingStatusInfo = (series: KomgaSeries, t: (key: string, options?: an
};
};
export function SeriesGrid({ series }: SeriesGridProps) {
export function SeriesGrid({ series, isCompact = false }: SeriesGridProps) {
const router = useRouter();
const { t } = useTranslate();
@@ -55,14 +56,22 @@ export function SeriesGrid({ series }: SeriesGridProps) {
}
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
<div
className={cn(
"grid gap-4",
isCompact
? "grid-cols-3 sm:grid-cols-4 lg:grid-cols-6"
: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5"
)}
>
{series.map((series) => (
<button
key={series.id}
onClick={() => router.push(`/series/${series.id}`)}
className={cn(
"group relative aspect-[2/3] overflow-hidden rounded-lg bg-muted",
series.booksCount === series.booksReadCount && "opacity-50"
series.booksCount === series.booksReadCount && "opacity-50",
isCompact && "aspect-[3/4]"
)}
>
<SeriesCover

View File

@@ -0,0 +1,121 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
};

View File

@@ -173,10 +173,12 @@
"title": "Filters",
"showAll": "Show all",
"unread": "Unread",
"search": "Search series..."
"search": "Search series...",
"normal": "Normal",
"compact": "Compact"
},
"display": {
"showing": "Showing series {start} to {end} of {total}",
"showing": "{start}-{end} of {total}",
"page": "Page {current} of {total}"
},
"header": {

View File

@@ -173,10 +173,12 @@
"title": "Filtres",
"showAll": "Afficher tout",
"unread": "À lire",
"search": "Rechercher une série..."
"search": "Rechercher une série...",
"normal": "Affichage normal",
"compact": "Affichage compact"
},
"display": {
"showing": "Affichage des séries {start} à {end} sur {total}",
"showing": "{start}-{end} sur {total}",
"page": "Page {current} sur {total}"
},
"header": {