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 { 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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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 { 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";
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
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" />
|
||||||
/// <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.
|
||||||
|
|||||||
Reference in New Issue
Block a user