feat: add pagination size selection and compact view toggle in series grid
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
121
src/components/ui/select.tsx
Normal file
121
src/components/ui/select.tsx
Normal 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,
|
||||
};
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user