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"