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:
@@ -1,6 +1,6 @@
|
||||
import { fetchBooks, searchBooks, fetchLibraries, BookDto, LibraryDto, getBookCoverUrl } from "../../lib/api";
|
||||
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";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -13,6 +13,8 @@ export default async function BooksPage({
|
||||
const searchParamsAwaited = await searchParams;
|
||||
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
|
||||
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([
|
||||
fetchLibraries().catch(() => [] as LibraryDto[])
|
||||
@@ -25,7 +27,7 @@ export default async function BooksPage({
|
||||
|
||||
if (searchQuery) {
|
||||
// Mode recherche
|
||||
const searchResponse = await searchBooks(searchQuery, libraryId).catch(() => null);
|
||||
const searchResponse = await searchBooks(searchQuery, libraryId, limit).catch(() => null);
|
||||
if (searchResponse) {
|
||||
searchResults = searchResponse.hits.map(hit => ({
|
||||
id: hit.id,
|
||||
@@ -45,10 +47,15 @@ export default async function BooksPage({
|
||||
totalHits = searchResponse.estimated_total_hits;
|
||||
}
|
||||
} else {
|
||||
// Mode liste
|
||||
const booksPage = await fetchBooks(libraryId).catch(() => ({ items: [] as BookDto[], next_cursor: null }));
|
||||
// Mode liste avec pagination
|
||||
const booksPage = await fetchBooks(libraryId, undefined, cursor, limit).catch(() => ({
|
||||
items: [] as BookDto[],
|
||||
next_cursor: null,
|
||||
prev_cursor: null
|
||||
}));
|
||||
books = booksPage.items;
|
||||
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 => ({
|
||||
@@ -56,6 +63,9 @@ export default async function BooksPage({
|
||||
coverUrl: getBookCoverUrl(book.id)
|
||||
}));
|
||||
|
||||
const hasNextPage = !!nextCursor;
|
||||
const hasPrevPage = !!cursor; // Si on a un cursor, on peut revenir en arrière (simplifié)
|
||||
|
||||
return (
|
||||
<>
|
||||
<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} />
|
||||
|
||||
{/* Pagination */}
|
||||
{!searchQuery && nextCursor && (
|
||||
<div className="flex justify-center mt-8">
|
||||
<form>
|
||||
<input type="hidden" name="library" value={libraryId || ""} />
|
||||
<input type="hidden" name="cursor" value={nextCursor} />
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-3 bg-primary text-white font-medium rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
📥 Load more
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{!searchQuery && (
|
||||
<CursorPagination
|
||||
hasNextPage={hasNextPage}
|
||||
hasPrevPage={hasPrevPage}
|
||||
pageSize={limit}
|
||||
currentCount={displayBooks.length}
|
||||
nextCursor={nextCursor}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { JobProgress } from "./JobProgress";
|
||||
import { StatusBadge, Button } from "./ui";
|
||||
import { StatusBadge, Button, MiniProgressBar } from "./ui";
|
||||
|
||||
interface JobRowProps {
|
||||
job: {
|
||||
@@ -12,14 +12,27 @@ interface JobRowProps {
|
||||
type: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
finished_at: 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;
|
||||
highlighted?: boolean;
|
||||
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(
|
||||
highlighted || job.status === "running" || job.status === "pending"
|
||||
);
|
||||
@@ -29,6 +42,24 @@ export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps)
|
||||
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 (
|
||||
<>
|
||||
<tr className={highlighted ? 'bg-primary-soft/50' : 'hover:bg-muted/5'}>
|
||||
@@ -65,8 +96,30 @@ export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps)
|
||||
)}
|
||||
</div>
|
||||
</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">
|
||||
{new Date(job.created_at).toLocaleString()}
|
||||
{duration}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted">
|
||||
{formatDate(job.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -90,7 +143,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel }: JobRowProps)
|
||||
</tr>
|
||||
{showProgress && (job.status === "running" || job.status === "pending") && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-3 bg-muted/5">
|
||||
<td colSpan={8} className="px-4 py-3 bg-muted/5">
|
||||
<JobProgress
|
||||
jobId={job.id}
|
||||
onComplete={handleComplete}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { JobRow } from "./JobRow";
|
||||
import { MiniProgressBar } from "./ui";
|
||||
|
||||
interface Job {
|
||||
id: string;
|
||||
@@ -9,7 +10,18 @@ interface Job {
|
||||
type: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
finished_at: 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 {
|
||||
@@ -18,6 +30,36 @@ interface JobsListProps {
|
||||
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) {
|
||||
const [jobs, setJobs] = useState(initialJobs);
|
||||
|
||||
@@ -53,7 +95,6 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Update local state to reflect cancellation
|
||||
setJobs(jobs.map(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">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">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">Actions</th>
|
||||
</tr>
|
||||
@@ -85,6 +128,8 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
|
||||
libraryName={job.library_id ? libraries.get(job.library_id) : undefined}
|
||||
highlighted={job.id === highlightJobId}
|
||||
onCancel={handleCancel}
|
||||
formatDate={formatDate}
|
||||
formatDuration={formatDuration}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
225
apps/backoffice/app/components/ui/Pagination.tsx
Normal file
225
apps/backoffice/app/components/ui/Pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export { Button } from "./Button";
|
||||
export { Input, Select } from "./Input";
|
||||
export { FormField, FormLabel, FormInput, FormSelect, FormRow } from "./Form";
|
||||
export { PageIcon, NavIcon } from "./Icon";
|
||||
export { CursorPagination, OffsetPagination } from "./Pagination";
|
||||
|
||||
@@ -31,6 +31,12 @@
|
||||
--color-primary: hsl(194 76% 62%);
|
||||
--color-primary-soft: hsl(210 34% 24%);
|
||||
--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 */
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
</nav>
|
||||
|
||||
{/* 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}
|
||||
</main>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -19,6 +19,15 @@ export type IndexJobDto = {
|
||||
finished_at: string | null;
|
||||
error_opt: string | null;
|
||||
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 = {
|
||||
|
||||
2
apps/backoffice/next-env.d.ts
vendored
2
apps/backoffice/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <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
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
Reference in New Issue
Block a user