diff --git a/frontend/app/puzzle-list/page.tsx b/frontend/app/puzzle-list/page.tsx index 8706070..c130fb9 100644 --- a/frontend/app/puzzle-list/page.tsx +++ b/frontend/app/puzzle-list/page.tsx @@ -1,11 +1,12 @@ 'use client'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState, useMemo } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import FilterBar from '@/components/puzzles/FilterBar'; import { usePuzzles } from '@/hooks/usePuzzles'; import type { PuzzleDifficulty, PuzzleFilters } from '@/lib/types/puzzles'; import { Puzzle as PuzzleIcon, Clock, Zap } from 'lucide-react'; +import Pagination from '@/components/ui/Pagination'; // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -20,6 +21,8 @@ const VALID_DIFFICULTIES: PuzzleDifficulty[] = [ 'ALL', 'BEGINNER', 'INTERMEDIATE', 'ADVANCED', 'EXPERT', ]; +const ITEMS_PER_PAGE_OPTIONS = [10, 20, 50]; + function parseFiltersFromParams(params: URLSearchParams): PuzzleFilters { const category = params.get('category') ?? ''; const rawDiff = (params.get('difficulty') ?? '').toUpperCase() as PuzzleDifficulty; @@ -27,6 +30,15 @@ function parseFiltersFromParams(params: URLSearchParams): PuzzleFilters { return { categoryId: category, difficulty }; } +function parsePaginationFromParams(params: URLSearchParams) { + const page = parseInt(params.get('page') ?? '1', 10); + const itemsPerPage = parseInt(params.get('itemsPerPage') ?? '10', 10); + return { + currentPage: Math.max(1, page), + itemsPerPage: ITEMS_PER_PAGE_OPTIONS.includes(itemsPerPage) ? itemsPerPage : 10, + }; +} + // ─── Puzzle Card ───────────────────────────────────────────────────────────── interface PuzzleCardProps { @@ -88,24 +100,64 @@ function PuzzleListContent() { parseFiltersFromParams(searchParams) ); + const [pagination, setPagination] = useState(() => + parsePaginationFromParams(searchParams) + ); + // Keep local state in sync if the browser URL changes externally (back/forward) useEffect(() => { setFilters(parseFiltersFromParams(searchParams)); + setPagination(parsePaginationFromParams(searchParams)); }, [searchParams]); + // Sync filters & pagination → URL + const updateURL = useCallback( + (nextFilters: PuzzleFilters, nextPagination: { currentPage: number; itemsPerPage: number }) => { + const params = new URLSearchParams(); + if (nextFilters.categoryId) params.set('category', nextFilters.categoryId); + if (nextFilters.difficulty !== 'ALL') params.set('difficulty', nextFilters.difficulty); + if (nextPagination.currentPage > 1) params.set('page', nextPagination.currentPage.toString()); + if (nextPagination.itemsPerPage !== 10) params.set('itemsPerPage', nextPagination.itemsPerPage.toString()); + + const query = params.toString(); + router.push(query ? `/puzzle-list?${query}` : '/puzzle-list', { scroll: false }); + }, + [router], + ); + // Sync filters → URL const handleFiltersChange = useCallback( (next: PuzzleFilters) => { setFilters(next); + // Reset to page 1 when filters change + const newPagination = { ...pagination, currentPage: 1 }; + setPagination(newPagination); + updateURL(next, newPagination); + }, + [pagination, updateURL], + ); - const params = new URLSearchParams(); - if (next.categoryId) params.set('category', next.categoryId); - if (next.difficulty !== 'ALL') params.set('difficulty', next.difficulty); + // Handle pagination changes + const handlePageChange = useCallback( + (page: number) => { + const newPagination = { ...pagination, currentPage: page }; + setPagination(newPagination); + updateURL(filters, newPagination); + + // Scroll to top smoothly + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, + [filters, pagination, updateURL], + ); - const query = params.toString(); - router.push(query ? `/puzzle-list?${query}` : '/puzzle-list', { scroll: false }); + // Handle items per page change + const handleItemsPerPageChange = useCallback( + (itemsPerPage: number) => { + const newPagination = { itemsPerPage, currentPage: 1 }; + setPagination(newPagination); + updateURL(filters, newPagination); }, - [router], + [filters, updateURL], ); // Build query params for usePuzzles — omit 'ALL' (no filter) @@ -116,6 +168,27 @@ function PuzzleListContent() { const { data: puzzles, isLoading, isError, error } = usePuzzles(puzzleQuery); + // Calculate pagination + const paginatedData = useMemo(() => { + if (!puzzles) return []; + + const startIndex = (pagination.currentPage - 1) * pagination.itemsPerPage; + const endIndex = startIndex + pagination.itemsPerPage; + return puzzles.slice(startIndex, endIndex); + }, [puzzles, pagination.currentPage, pagination.itemsPerPage]); + + const totalPages = useMemo(() => { + if (!puzzles) return 0; + return Math.ceil(puzzles.length / pagination.itemsPerPage); + }, [puzzles, pagination.itemsPerPage]); + + // Adjust current page if it exceeds total pages (e.g., after filtering) + useEffect(() => { + if (totalPages > 0 && pagination.currentPage > totalPages) { + handlePageChange(totalPages); + } + }, [totalPages, pagination.currentPage, handlePageChange]); + return ( <> {/* Filter bar */} @@ -125,9 +198,33 @@ function PuzzleListContent() { {/* Results count */} {!isLoading && !isError && puzzles && ( -

- {puzzles.length} puzzle{puzzles.length !== 1 ? 's' : ''} found -

+
+

+ {puzzles.length} puzzle{puzzles.length !== 1 ? 's' : ''} found + {totalPages > 1 && ( + + Showing {((pagination.currentPage - 1) * pagination.itemsPerPage) + 1}- + {Math.min(pagination.currentPage * pagination.itemsPerPage, puzzles.length)} of {puzzles.length} + + )} +

+ {totalPages > 1 && ( +
+ Items per page: + +
+ )} +
)} {/* Loading skeleton */} @@ -157,26 +254,37 @@ function PuzzleListContent() { {!isLoading && !isError && puzzles?.length === 0 && (
-

No puzzles match your filters

-

Try adjusting the difficulty or category

+

No puzzles found

+

Try clearing your filters or adjusting your search.

)} {/* Puzzle grid */} {!isLoading && !isError && puzzles && puzzles.length > 0 && ( -
- {puzzles.map((puzzle) => ( - +
+ {paginatedData.map((puzzle) => ( + + ))} +
+ + {/* Pagination */} + {totalPages > 1 && ( + - ))} -
+ )} + )} ); diff --git a/frontend/components/ui/Pagination.tsx b/frontend/components/ui/Pagination.tsx new file mode 100644 index 0000000..598c283 --- /dev/null +++ b/frontend/components/ui/Pagination.tsx @@ -0,0 +1,166 @@ +'use client'; + +import React, { useMemo } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; + +interface PaginationProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; +} + +const ITEMS_PER_PAGE_OPTIONS = [10, 20, 50]; + +const Pagination: React.FC = ({ + currentPage, + totalPages, + onPageChange, +}) => { + // Generate page numbers to display with ellipsis + const pageNumbers = useMemo(() => { + if (totalPages <= 7) { + return Array.from({ length: totalPages }, (_, i) => i + 1); + } + + const pages: (number | string)[] = []; + + // Always show first page + pages.push(1); + + if (currentPage <= 4) { + // Show pages 2-5 when current is near start + for (let i = 2; i <= 5; i++) { + pages.push(i); + } + pages.push('...'); + pages.push(totalPages); + } else if (currentPage >= totalPages - 3) { + // Show ellipsis and last 4 pages when current is near end + pages.push('...'); + for (let i = totalPages - 4; i <= totalPages; i++) { + pages.push(i); + } + } else { + // Show ellipsis on both sides with current page in middle + pages.push('...'); + for (let i = currentPage - 1; i <= currentPage + 1; i++) { + pages.push(i); + } + pages.push('...'); + pages.push(totalPages); + } + + return pages; + }, [currentPage, totalPages]); + + const handlePrevious = () => { + if (currentPage > 1) { + onPageChange(currentPage - 1); + } + }; + + const handleNext = () => { + if (currentPage < totalPages) { + onPageChange(currentPage + 1); + } + }; + + const handlePageClick = (page: number) => { + onPageChange(page); + }; + + if (totalPages <= 1) { + return null; + } + + return ( +
+ {/* Page Navigation */} +
+ {/* Previous Button */} + + + {/* Page Numbers */} +
+ {pageNumbers.map((page, index) => ( + page === '...' ? ( + + ... + + ) : ( + + ) + ))} +
+ + {/* Next Button */} + +
+ + {/* Items per page selector */} +
+ Items per page: + +
+
+ ); +}; + +export default Pagination; diff --git a/package-lock.json b/package-lock.json index b8ad7f0..2765a97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,8 +16,8 @@ "dependencies": { "@nestjs/common": "^11.1.14", "@nestjs/core": "^11.1.14", - "framer-motion": "^12.34.3", "@tanstack/react-query": "^5.90.21", + "framer-motion": "^12.34.3", "minimatch": "^10.1.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", @@ -243,7 +243,7 @@ }, "backend/node_modules/@swc/core": { "version": "1.13.5", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "peer": true, @@ -4637,6 +4637,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4653,6 +4654,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4669,6 +4671,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4685,6 +4688,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4701,6 +4705,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4717,6 +4722,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4733,6 +4739,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4749,6 +4756,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4765,6 +4773,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4781,6 +4790,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4794,7 +4804,7 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@swc/helpers": { @@ -4810,7 +4820,7 @@ "version": "0.1.25", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3"