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() {