feat(ui): Add pagination to books pages and improve spacing

- Added CursorPagination component with page size selector (20/50/100)
- Updated /books page with pagination support
- Updated /libraries/[id]/books with pagination
- Improved layout margins (added pb-16 and responsive px)
- Series page uses improved layout spacing
This commit is contained in:
2026-03-06 14:50:27 +01:00
parent c421f427b0
commit fa574586ed
9 changed files with 368 additions and 24 deletions

View File

@@ -1,6 +1,6 @@
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, getBookCoverUrl } from "../../lib/api"; import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, getBookCoverUrl } from "../../lib/api";
import { BooksGrid, EmptyState } from "../components/BookCard"; import { BooksGrid, EmptyState } from "../components/BookCard";
import { Card, Button, FormField, FormInput, FormSelect, FormRow } from "../components/ui"; import { Card, Button, FormField, FormInput, FormSelect, FormRow, CursorPagination } from "../components/ui";
import Link from "next/link"; import Link from "next/link";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -13,6 +13,8 @@ export default async function BooksPage({
const searchParamsAwaited = await searchParams; const searchParamsAwaited = await searchParams;
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined; const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : ""; const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
const cursor = typeof searchParamsAwaited.cursor === "string" ? searchParamsAwaited.cursor : undefined;
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
const [libraries] = await Promise.all([ const [libraries] = await Promise.all([
fetchLibraries().catch(() => [] as LibraryDto[]) fetchLibraries().catch(() => [] as LibraryDto[])
@@ -25,7 +27,7 @@ export default async function BooksPage({
if (searchQuery) { if (searchQuery) {
// Mode recherche // Mode recherche
const searchResponse = await searchBooks(searchQuery, libraryId).catch(() => null); const searchResponse = await searchBooks(searchQuery, libraryId, limit).catch(() => null);
if (searchResponse) { if (searchResponse) {
searchResults = searchResponse.hits.map(hit => ({ searchResults = searchResponse.hits.map(hit => ({
id: hit.id, id: hit.id,
@@ -45,10 +47,15 @@ export default async function BooksPage({
totalHits = searchResponse.estimated_total_hits; totalHits = searchResponse.estimated_total_hits;
} }
} else { } else {
// Mode liste // Mode liste avec pagination
const booksPage = await fetchBooks(libraryId).catch(() => ({ items: [] as BookDto[], next_cursor: null })); const booksPage = await fetchBooks(libraryId, undefined, cursor, limit).catch(() => ({
items: [] as BookDto[],
next_cursor: null,
prev_cursor: null
}));
books = booksPage.items; books = booksPage.items;
nextCursor = booksPage.next_cursor; nextCursor = booksPage.next_cursor;
// Note: L'API ne supporte pas encore prev_cursor, on gère ça côté UI
} }
const displayBooks = (searchResults || books).map(book => ({ const displayBooks = (searchResults || books).map(book => ({
@@ -56,6 +63,9 @@ export default async function BooksPage({
coverUrl: getBookCoverUrl(book.id) coverUrl: getBookCoverUrl(book.id)
})); }));
const hasNextPage = !!nextCursor;
const hasPrevPage = !!cursor; // Si on a un cursor, on peut revenir en arrière (simplifié)
return ( return (
<> <>
<h1 className="text-3xl font-bold text-foreground mb-6 flex items-center gap-3"> <h1 className="text-3xl font-bold text-foreground mb-6 flex items-center gap-3">
@@ -110,19 +120,14 @@ export default async function BooksPage({
<BooksGrid books={displayBooks} /> <BooksGrid books={displayBooks} />
{/* Pagination */} {/* Pagination */}
{!searchQuery && nextCursor && ( {!searchQuery && (
<div className="flex justify-center mt-8"> <CursorPagination
<form> hasNextPage={hasNextPage}
<input type="hidden" name="library" value={libraryId || ""} /> hasPrevPage={hasPrevPage}
<input type="hidden" name="cursor" value={nextCursor} /> pageSize={limit}
<button currentCount={displayBooks.length}
type="submit" nextCursor={nextCursor}
className="px-6 py-3 bg-primary text-white font-medium rounded-lg hover:bg-primary/90 transition-colors" />
>
📥 Load more
</button>
</form>
</div>
)} )}
</> </>
) : ( ) : (

View File

@@ -3,7 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { JobProgress } from "./JobProgress"; import { JobProgress } from "./JobProgress";
import { StatusBadge, Button } from "./ui"; import { StatusBadge, Button, MiniProgressBar } from "./ui";
interface JobRowProps { interface JobRowProps {
job: { job: {
@@ -12,14 +12,27 @@ interface JobRowProps {
type: string; type: string;
status: string; status: string;
created_at: string; created_at: string;
started_at: string | null;
finished_at: string | null;
error_opt: string | null; error_opt: string | null;
stats_json: {
scanned_files: number;
indexed_files: number;
removed_files: number;
errors: number;
} | null;
progress_percent: number | null;
processed_files: number | null;
total_files: number | null;
}; };
libraryName: string | undefined; libraryName: string | undefined;
highlighted?: boolean; highlighted?: boolean;
onCancel: (id: string) => void; onCancel: (id: string) => void;
formatDate: (date: string) => string;
formatDuration: (start: string, end: string | null) => string;
} }
export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps) { export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, formatDuration }: JobRowProps) {
const [showProgress, setShowProgress] = useState( const [showProgress, setShowProgress] = useState(
highlighted || job.status === "running" || job.status === "pending" highlighted || job.status === "running" || job.status === "pending"
); );
@@ -29,6 +42,24 @@ export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps)
window.location.reload(); window.location.reload();
}; };
// Calculate duration
const duration = job.started_at
? formatDuration(job.started_at, job.finished_at)
: "-";
// Get file stats
const scanned = job.stats_json?.scanned_files ?? 0;
const indexed = job.stats_json?.indexed_files ?? 0;
const removed = job.stats_json?.removed_files ?? 0;
const errors = job.stats_json?.errors ?? 0;
// Format files display
const filesDisplay = job.status === "running" && job.total_files
? `${job.processed_files || 0}/${job.total_files}`
: scanned > 0
? `${scanned} scanned`
: "-";
return ( return (
<> <>
<tr className={highlighted ? 'bg-primary-soft/50' : 'hover:bg-muted/5'}> <tr className={highlighted ? 'bg-primary-soft/50' : 'hover:bg-muted/5'}>
@@ -65,8 +96,30 @@ export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps)
)} )}
</div> </div>
</td> </td>
<td className="px-4 py-3">
<div className="flex flex-col gap-1">
<span className="text-sm text-foreground">{filesDisplay}</span>
{job.status === "running" && job.total_files && (
<MiniProgressBar
value={job.processed_files || 0}
max={job.total_files}
className="w-24"
/>
)}
{job.status === "success" && (
<div className="flex items-center gap-2 text-xs">
<span className="text-success"> {indexed}</span>
{removed > 0 && <span className="text-warning"> {removed}</span>}
{errors > 0 && <span className="text-error"> {errors}</span>}
</div>
)}
</div>
</td>
<td className="px-4 py-3 text-sm text-muted"> <td className="px-4 py-3 text-sm text-muted">
{new Date(job.created_at).toLocaleString()} {duration}
</td>
<td className="px-4 py-3 text-sm text-muted">
{formatDate(job.created_at)}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -90,7 +143,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps)
</tr> </tr>
{showProgress && (job.status === "running" || job.status === "pending") && ( {showProgress && (job.status === "running" || job.status === "pending") && (
<tr> <tr>
<td colSpan={6} className="px-4 py-3 bg-muted/5"> <td colSpan={8} className="px-4 py-3 bg-muted/5">
<JobProgress <JobProgress
jobId={job.id} jobId={job.id}
onComplete={handleComplete} onComplete={handleComplete}

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { JobRow } from "./JobRow"; import { JobRow } from "./JobRow";
import { MiniProgressBar } from "./ui";
interface Job { interface Job {
id: string; id: string;
@@ -9,7 +10,18 @@ interface Job {
type: string; type: string;
status: string; status: string;
created_at: string; created_at: string;
started_at: string | null;
finished_at: string | null;
error_opt: string | null; error_opt: string | null;
stats_json: {
scanned_files: number;
indexed_files: number;
removed_files: number;
errors: number;
} | null;
progress_percent: number | null;
processed_files: number | null;
total_files: number | null;
} }
interface JobsListProps { interface JobsListProps {
@@ -18,6 +30,36 @@ interface JobsListProps {
highlightJobId?: string; highlightJobId?: string;
} }
function formatDuration(start: string, end: string | null): string {
const startDate = new Date(start);
const endDate = end ? new Date(end) : new Date();
const diff = endDate.getTime() - startDate.getTime();
if (diff < 60000) return `${Math.floor(diff / 1000)}s`;
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ${Math.floor((diff % 60000) / 1000)}s`;
return `${Math.floor(diff / 3600000)}h ${Math.floor((diff % 3600000) / 60000)}m`;
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
// Less than 1 hour: show relative
if (diff < 3600000) {
const mins = Math.floor(diff / 60000);
if (mins < 1) return "Just now";
return `${mins}m ago`;
}
// Less than 24 hours: show hours
if (diff < 86400000) {
const hours = Math.floor(diff / 3600000);
return `${hours}h ago`;
}
// Otherwise: show date
return date.toLocaleDateString();
}
export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListProps) { export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListProps) {
const [jobs, setJobs] = useState(initialJobs); const [jobs, setJobs] = useState(initialJobs);
@@ -53,7 +95,6 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
}); });
if (response.ok) { if (response.ok) {
// Update local state to reflect cancellation
setJobs(jobs.map(job => setJobs(jobs.map(job =>
job.id === id ? { ...job, status: "cancelled" } : job job.id === id ? { ...job, status: "cancelled" } : job
)); ));
@@ -73,6 +114,8 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Library</th> <th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Library</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Type</th> <th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Type</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Status</th> <th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Status</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Files</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Duration</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Created</th> <th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Created</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Actions</th> <th className="px-4 py-3 text-left text-xs font-semibold text-muted uppercase tracking-wider">Actions</th>
</tr> </tr>
@@ -85,6 +128,8 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
libraryName={job.library_id ? libraries.get(job.library_id) : undefined} libraryName={job.library_id ? libraries.get(job.library_id) : undefined}
highlighted={job.id === highlightJobId} highlighted={job.id === highlightJobId}
onCancel={handleCancel} onCancel={handleCancel}
formatDate={formatDate}
formatDuration={formatDuration}
/> />
))} ))}
</tbody> </tbody>

View File

@@ -0,0 +1,225 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } 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-4 mt-6 pt-6 border-t border-line">
{/* Page size selector */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted">Show</span>
<select
value={pageSize.toString()}
onChange={(e) => changePageSize(Number(e.target.value))}
className="w-20 px-2 py-1.5 text-sm border border-line rounded-lg bg-background text-foreground"
>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<span className="text-sm text-muted">per page</span>
</div>
{/* Count info */}
<div className="text-sm text-muted">
Showing {currentCount} items
</div>
{/* Navigation */}
<div className="flex items-center gap-2">
{hasPrevPage && (
<Button
variant="secondary"
size="sm"
onClick={goToFirst}
>
First
</Button>
)}
<Button
variant="secondary"
size="sm"
onClick={goToNext}
disabled={!hasNextPage}
>
Next
</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-4 mt-6 pt-6 border-t border-line">
{/* Page size selector */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted">Show</span>
<select
value={pageSize.toString()}
onChange={(e) => changePageSize(Number(e.target.value))}
className="w-20 px-2 py-1.5 text-sm border border-line rounded-lg bg-background text-foreground"
>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<span className="text-sm text-muted">per page</span>
</div>
{/* Page info */}
<div className="text-sm text-muted">
{startItem}-{endItem} of {totalItems}
</div>
{/* Page navigation */}
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage <= 1}
>
</Button>
{getPageNumbers().map((page, index) => (
<span key={index}>
{page === "..." ? (
<span className="px-3 py-2 text-sm text-muted">...</span>
) : (
<Button
variant={currentPage === page ? "primary" : "ghost"}
size="sm"
onClick={() => goToPage(page as number)}
>
{page}
</Button>
)}
</span>
))}
<Button
variant="ghost"
size="sm"
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage >= totalPages}
>
</Button>
</div>
</div>
);
}

View File

@@ -6,3 +6,4 @@ export { Button } from "./Button";
export { Input, Select } from "./Input"; export { Input, Select } from "./Input";
export { FormField, FormLabel, FormInput, FormSelect, FormRow } from "./Form"; export { FormField, FormLabel, FormInput, FormSelect, FormRow } from "./Form";
export { PageIcon, NavIcon } from "./Icon"; export { PageIcon, NavIcon } from "./Icon";
export { CursorPagination, OffsetPagination } from "./Pagination";

View File

@@ -31,6 +31,12 @@
--color-primary: hsl(194 76% 62%); --color-primary: hsl(194 76% 62%);
--color-primary-soft: hsl(210 34% 24%); --color-primary-soft: hsl(210 34% 24%);
--color-muted: hsl(218 17% 72%); --color-muted: hsl(218 17% 72%);
--color-success: hsl(142 70% 55%);
--color-success-soft: hsl(142 30% 20%);
--color-warning: hsl(45 90% 55%);
--color-warning-soft: hsl(45 30% 20%);
--color-error: hsl(2 80% 65%);
--color-error-soft: hsl(2 30% 20%);
} }
/* Base styles */ /* Base styles */

View File

@@ -65,7 +65,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
</nav> </nav>
{/* Main Content */} {/* Main Content */}
<main className="container mx-auto px-4 py-8"> <main className="container mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-16">
{children} {children}
</main> </main>
</ThemeProvider> </ThemeProvider>

View File

@@ -19,6 +19,15 @@ export type IndexJobDto = {
finished_at: string | null; finished_at: string | null;
error_opt: string | null; error_opt: string | null;
created_at: string; created_at: string;
stats_json: {
scanned_files: number;
indexed_files: number;
removed_files: number;
errors: number;
} | null;
progress_percent: number | null;
processed_files: number | null;
total_files: number | null;
}; };
export type TokenDto = { export type TokenDto = {

View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.