Files
stripstream-librarian/apps/backoffice/app/components/ui/Pagination.tsx
Froidefond Julien b955c2697c feat: add batch metadata jobs, series filters, and translate backoffice to French
- Add metadata_batch job type with background processing via tokio::spawn
- Auto-apply metadata only when single result at 100% confidence
- Support primary + fallback provider per library, "none" to opt out
- Add batch report/results API endpoints and job detail UI
- Add series_status and has_missing filters to both series listing pages
- Add GET /series/statuses endpoint for dynamic filter options
- Normalize series_metadata status values (migration 0036)
- Hide ComicVine provider tab when no API key configured
- Translate entire backoffice UI from English to French

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 18:26:44 +01:00

237 lines
7.2 KiB
TypeScript

"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "./Button";
import { IconButton } from "./Button";
interface CursorPaginationProps {
hasNextPage: boolean;
hasPrevPage: boolean;
pageSize: number;
currentCount: number;
pageSizeOptions?: number[];
nextCursor?: string | null;
}
export function CursorPagination({
hasNextPage,
hasPrevPage,
pageSize,
currentCount,
pageSizeOptions = [20, 50, 100],
nextCursor,
}: CursorPaginationProps) {
const router = useRouter();
const searchParams = useSearchParams();
const goToNext = () => {
if (!nextCursor) return;
const params = new URLSearchParams(searchParams);
params.set("cursor", nextCursor);
router.push(`?${params.toString()}`);
};
const goToFirst = () => {
const params = new URLSearchParams(searchParams);
params.delete("cursor");
router.push(`?${params.toString()}`);
};
const changePageSize = (size: number) => {
const params = new URLSearchParams(searchParams);
params.set("limit", size.toString());
params.delete("cursor");
router.push(`?${params.toString()}`);
};
return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-border/60">
{/* Page size selector */}
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">Afficher</span>
<select
value={pageSize.toString()}
onChange={(e) => changePageSize(Number(e.target.value))}
className="w-20 px-3 py-2 text-sm rounded-md border border-input bg-background text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 transition-colors"
>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<span className="text-sm text-muted-foreground">par page</span>
</div>
{/* Count info */}
<div className="text-sm text-muted-foreground">
Affichage de {currentCount} éléments
</div>
{/* Navigation */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={goToFirst}
disabled={!hasPrevPage}
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
Premier
</Button>
<Button
variant="outline"
size="sm"
onClick={goToNext}
disabled={!hasNextPage}
>
Suivant
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Button>
</div>
</div>
);
}
interface OffsetPaginationProps {
currentPage: number;
totalPages: number;
pageSize: number;
totalItems: number;
pageSizeOptions?: number[];
}
export function OffsetPagination({
currentPage,
totalPages,
pageSize,
totalItems,
pageSizeOptions = [20, 50, 100],
}: OffsetPaginationProps) {
const router = useRouter();
const searchParams = useSearchParams();
const goToPage = (page: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", page.toString());
router.push(`?${params.toString()}`);
};
const changePageSize = (size: number) => {
const params = new URLSearchParams(searchParams);
params.set("limit", size.toString());
params.set("page", "1");
router.push(`?${params.toString()}`);
};
const startItem = (currentPage - 1) * pageSize + 1;
const endItem = Math.min(currentPage * pageSize, totalItems);
const getPageNumbers = () => {
const pages: (number | string)[] = [];
const maxVisiblePages = 5;
if (totalPages <= maxVisiblePages) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
if (currentPage <= 3) {
for (let i = 1; i <= 4; i++) {
pages.push(i);
}
pages.push("...");
pages.push(totalPages);
} else if (currentPage >= totalPages - 2) {
pages.push(1);
pages.push("...");
for (let i = totalPages - 3; i <= totalPages; i++) {
pages.push(i);
}
} else {
pages.push(1);
pages.push("...");
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
pages.push(i);
}
pages.push("...");
pages.push(totalPages);
}
}
return pages;
};
return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 mt-8 pt-8 border-t border-border/60">
{/* Page size selector */}
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">Afficher</span>
<select
value={pageSize.toString()}
onChange={(e) => changePageSize(Number(e.target.value))}
className="w-20 px-3 py-2 text-sm rounded-md border border-input bg-background text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 transition-colors"
>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<span className="text-sm text-muted-foreground">par page</span>
</div>
{/* Page info */}
<div className="text-sm text-muted-foreground">
{startItem}-{endItem} sur {totalItems}
</div>
{/* Page navigation */}
<div className="flex items-center gap-1">
<IconButton
size="sm"
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage <= 1}
title="Page précédente"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</IconButton>
{getPageNumbers().map((page, index) => (
<span key={index}>
{page === "..." ? (
<span className="px-3 py-2 text-sm text-muted-foreground">...</span>
) : (
<Button
variant={currentPage === page ? "default" : "ghost"}
size="sm"
onClick={() => goToPage(page as number)}
className="min-w-[2.5rem]"
>
{page}
</Button>
)}
</span>
))}
<IconButton
size="sm"
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage >= totalPages}
title="Page suivante"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</IconButton>
</div>
</div>
);
}