Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 132 additions & 24 deletions frontend/app/puzzle-list/page.tsx
Original file line number Diff line number Diff line change
@@ -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 ────────────────────────────────────────────────────────────────

Expand All @@ -20,13 +21,24 @@ 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;
const difficulty = VALID_DIFFICULTIES.includes(rawDiff) ? rawDiff : 'ALL';
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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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 */}
Expand All @@ -125,9 +198,33 @@ function PuzzleListContent() {

{/* Results count */}
{!isLoading && !isError && puzzles && (
<p className="mb-4 text-xs text-slate-500">
{puzzles.length} puzzle{puzzles.length !== 1 ? 's' : ''} found
</p>
<div className="mb-4 flex items-center justify-between">
<p className="text-xs text-slate-500">
{puzzles.length} puzzle{puzzles.length !== 1 ? 's' : ''} found
{totalPages > 1 && (
<span className="ml-2">
Showing {((pagination.currentPage - 1) * pagination.itemsPerPage) + 1}-
{Math.min(pagination.currentPage * pagination.itemsPerPage, puzzles.length)} of {puzzles.length}
</span>
)}
</p>
{totalPages > 1 && (
<div className="flex items-center gap-2 text-sm text-slate-400">
<span>Items per page:</span>
<select
value={pagination.itemsPerPage}
onChange={(e) => handleItemsPerPageChange(parseInt(e.target.value, 10))}
className="bg-slate-800 border border-slate-600 rounded px-2 py-1 text-slate-300 focus:outline-none focus:border-[#3B82F6]/40"
>
{ITEMS_PER_PAGE_OPTIONS.map(option => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
)}
</div>
)}

{/* Loading skeleton */}
Expand Down Expand Up @@ -157,26 +254,37 @@ function PuzzleListContent() {
{!isLoading && !isError && puzzles?.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-center gap-2">
<PuzzleIcon className="h-10 w-10 text-slate-600" />
<p className="font-medium text-slate-400">No puzzles match your filters</p>
<p className="text-xs text-slate-600">Try adjusting the difficulty or category</p>
<p className="font-medium text-slate-400">No puzzles found</p>
<p className="text-xs text-slate-600">Try clearing your filters or adjusting your search.</p>
</div>
)}

{/* Puzzle grid */}
{!isLoading && !isError && puzzles && puzzles.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{puzzles.map((puzzle) => (
<PuzzleCard
key={puzzle.id}
id={puzzle.id}
title={puzzle.title}
description={puzzle.description}
difficulty={puzzle.difficulty}
type={puzzle.type}
timeLimit={puzzle.timeLimit}
<>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{paginatedData.map((puzzle) => (
<PuzzleCard
key={puzzle.id}
id={puzzle.id}
title={puzzle.title}
description={puzzle.description}
difficulty={puzzle.difficulty}
type={puzzle.type}
timeLimit={puzzle.timeLimit}
/>
))}
</div>

{/* Pagination */}
{totalPages > 1 && (
<Pagination
currentPage={pagination.currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
))}
</div>
)}
</>
)}
</>
);
Expand Down
166 changes: 166 additions & 0 deletions frontend/components/ui/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -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<PaginationProps> = ({
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 (
<div className="flex flex-col items-center gap-4 mt-8">
{/* Page Navigation */}
<div className="flex items-center gap-2">
{/* Previous Button */}
<button
onClick={handlePrevious}
disabled={currentPage === 1}
className={`
flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg border transition-all duration-200
${currentPage === 1
? 'text-slate-500 border-slate-700 cursor-not-allowed'
: 'text-slate-300 border-slate-600 hover:border-[#3B82F6]/40 hover:text-white hover:bg-white/[0.05]'
}
`}
aria-label="Previous page"
>
<ChevronLeft size={16} />
Previous
</button>

{/* Page Numbers */}
<div className="flex items-center gap-1">
{pageNumbers.map((page, index) => (
page === '...' ? (
<span key={`ellipsis-${index}`} className="px-2 text-slate-500">
...
</span>
) : (
<button
key={page}
onClick={() => handlePageClick(page as number)}
className={`
px-3 py-2 text-sm font-medium rounded-lg border transition-all duration-200
${currentPage === page
? 'bg-[#3B82F6] text-white border-[#3B82F6]'
: 'text-slate-300 border-slate-600 hover:border-[#3B82F6]/40 hover:text-white hover:bg-white/[0.05]'
}
`}
aria-label={`Go to page ${page}`}
aria-current={currentPage === page ? 'page' : undefined}
>
{page}
</button>
)
))}
</div>

{/* Next Button */}
<button
onClick={handleNext}
disabled={currentPage === totalPages}
className={`
flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg border transition-all duration-200
${currentPage === totalPages
? 'text-slate-500 border-slate-700 cursor-not-allowed'
: 'text-slate-300 border-slate-600 hover:border-[#3B82F6]/40 hover:text-white hover:bg-white/[0.05]'
}
`}
aria-label="Next page"
>
Next
<ChevronRight size={16} />
</button>
</div>

{/* Items per page selector */}
<div className="flex items-center gap-2 text-sm text-slate-400">
<span>Items per page:</span>
<select
value={ITEMS_PER_PAGE_OPTIONS[0]} // This will be handled by parent component
onChange={(e) => {
// This will be handled by parent component
const newItemsPerPage = parseInt(e.target.value);
// Trigger page change with items per page info
onPageChange(currentPage); // Parent will handle items per page change
}}
className="bg-slate-800 border border-slate-600 rounded px-2 py-1 text-slate-300 focus:outline-none focus:border-[#3B82F6]/40"
>
{ITEMS_PER_PAGE_OPTIONS.map(option => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
</div>
);
};

export default Pagination;
Loading