diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2785366..783a055 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import theme from 'antd/es/theme'; import Badge from 'antd/es/badge'; import Empty from 'antd/es/empty'; import Alert from 'antd/es/alert'; +import Pagination from 'antd/es/pagination'; import { ShoppingCartOutlined } from '@ant-design/icons'; import { Link, useLocation, useSearchParams } from 'react-router-dom'; import { Item } from './api'; @@ -18,6 +19,14 @@ import ItemCard from './components/ItemCard'; import BrandMark from './components/BrandMark'; import FilterBar from './components/filters/FilterBar'; import { chipsFor } from './components/filters/dimension'; +import { + PAGE_SIZE_OPTIONS, + clampPage, + pageFromSearchParams, + pageSlice, + readStoredPageSize, + writeStoredPageSize +} from './pagination'; import { availabilityDimension, categoryDimension, @@ -40,7 +49,14 @@ const { Title } = Typography; type CatalogueProps = Readonly<{ failed: boolean; loading: boolean; + /** One page of items, not the whole result set — see `total`. */ items: Item[]; + /** How many items match the filters altogether, across every page. */ + total: number; + page: number; + pageSize: number; + onPageChange: (page: number) => void; + onPageSizeChange: (size: number) => void; filtered: boolean; needsFavoritesAuth: boolean; onRetry: () => void; @@ -58,6 +74,11 @@ function Catalogue({ failed, loading, items, + total, + page, + pageSize, + onPageChange, + onPageSizeChange, filtered, needsFavoritesAuth, onRetry, @@ -98,13 +119,32 @@ function Catalogue({ } return ( - - {items.map(item => ( - - - - ))} - + <> + + {items.map(item => ( + + + + ))} + + onPageSizeChange(size)} + // Deliberately off: the jump box earns its place on a table of + // thousands of rows, not on a catalogue somebody is browsing (#269). + showQuickJumper={false} + // Shown even when everything fits on one page, because the count is a + // requirement in its own right and hiding the control would hide it. + hideOnSinglePage={false} + showTotal={(count) => `${count} ${count === 1 ? 'item' : 'items'}`} + /> + ); } @@ -163,6 +203,42 @@ export default function App() { const { items, loading, failed, options, needsFavoritesAuth, reload, retry, filterKey } = useCatalogue(filters, openAuthModal); + /** + * The page size is a preference, not view state, so it lives in storage + * rather than in the URL. Putting it in the URL would mean sharing a link to + * an item also imposed your page size on whoever opened it, which is not + * yours to decide for them. Read through a lazy initialiser so a storage + * that throws is not hit on every render. + */ + const [pageSize, setPageSize] = useState(() => + readStoredPageSize(typeof window === 'undefined' ? null : window.localStorage) + ); + + const choosePageSize = useCallback((size: number) => { + setPageSize(size); + writeStoredPageSize(typeof window === 'undefined' ? null : window.localStorage, size); + }, []); + + // Clamped against what there actually is, so a shared link to a page that no + // longer exists shows the last page rather than an empty grid. + const page = clampPage(pageFromSearchParams(searchParams), items.length, pageSize); + const visibleItems = useMemo(() => pageSlice(items, page, pageSize), [items, page, pageSize]); + + const goToPage = useCallback( + (next: number) => { + const params = new URLSearchParams(searchParams); + // Page one is the absence of the parameter, so the plain catalogue URL + // stays clean and two links to the same first page are the same string. + if (next <= 1) params.delete('page'); + else params.set('page', String(next)); + // push, not replace: paging is navigation, and the back button should + // return to the page you came from. Filters use replace for the opposite + // reason — dragging a slider must not bury the previous view. + setSearchParams(params); + }, + [searchParams, setSearchParams] + ); + const applyFilters = useCallback( (next: ItemFilters) => { // replace, not push: dragging a slider shouldn't bury the previous page @@ -264,7 +340,12 @@ export default function App() {