diff --git a/frontend/src/App.css b/frontend/src/App.css index 0cbead653..95dbcd78c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -226,7 +226,7 @@ } .thin-scrollbar::-webkit-scrollbar-thumb:hover { - background-color: var(--muted-foreground); + background-color: var(--muted-foreground); } .no-select { diff --git a/frontend/src/components/FaceCollections.tsx b/frontend/src/components/FaceCollections.tsx index 582348204..58e257f52 100644 --- a/frontend/src/components/FaceCollections.tsx +++ b/frontend/src/components/FaceCollections.tsx @@ -1,11 +1,11 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate } from 'react-router'; import { Card, CardContent } from '@/components/ui/card'; import { PersonAvatar } from '@/components/PersonAvatar'; import { getPersonName, getPhotoCountText } from '@/utils/personUtils'; import { Button } from '@/components/ui/button'; -import { Users } from 'lucide-react'; +import { ChevronLeft, ChevronRight, Users } from 'lucide-react'; import { MultiPersonSearchDialog } from '@/components/Dialog/MultiPersonSearchDialog'; import { RootState } from '@/app/store'; import { setClusters } from '@/features/faceClustersSlice'; @@ -20,10 +20,15 @@ interface FaceCollectionsProps { ) => void; } +// One row at xl:grid-cols-8. Keeps the card height fixed on every page, +// not just the default view. +const PAGE_SIZE = 8; + export function FaceCollections({ onSearchActivated }: FaceCollectionsProps) { const navigate = useNavigate(); const dispatch = useDispatch(); const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false); + const [page, setPage] = useState(0); const { clusters } = useSelector((state: RootState) => state.faceClusters); @@ -39,6 +44,24 @@ export function FaceCollections({ onSearchActivated }: FaceCollectionsProps) { } }, [clustersData, clustersSuccess, dispatch]); + // Highest face_count first, so the most prominent people show up on page 1. + const sortedClusters = useMemo( + () => + [...(clusters ?? [])].sort( + (a: Cluster, b: Cluster) => (b.face_count ?? 0) - (a.face_count ?? 0), + ), + [clusters], + ); + + // Clamp page in case the cluster list shrinks (e.g. after a delete) while + // the user is on a later page. + const totalPages = Math.max(1, Math.ceil(sortedClusters.length / PAGE_SIZE)); + const currentPage = Math.min(page, totalPages - 1); + + useEffect(() => { + setPage((previousPage) => Math.min(previousPage, totalPages - 1)); + }, [totalPages]); + const handlePersonClick = (clusterId: string) => { navigate(`/person/${clusterId}`); }; @@ -57,6 +80,12 @@ export function FaceCollections({ onSearchActivated }: FaceCollectionsProps) { ); } + const hasMultiplePages = totalPages > 1; + const visibleClusters = sortedClusters.slice( + currentPage * PAGE_SIZE, + (currentPage + 1) * PAGE_SIZE, + ); + return ( @@ -78,7 +107,7 @@ export function FaceCollections({ onSearchActivated }: FaceCollectionsProps) { to see all their photos.

- {clusters.map((cluster: any) => ( + {visibleClusters.map((cluster: Cluster) => (
))} + {Array.from({ length: PAGE_SIZE - visibleClusters.length }).map( + (_, index) => ( +
+
+
+

 

+

 

+
+
+ ), + )}
+ {hasMultiplePages && ( +
+ + + Page {currentPage + 1} of {totalPages} + + +
+ )} { await waitFor(() => expect(mockGetMemoryStatus).toHaveBeenCalled()); const callsAfterMount = mockGetMemoryStatus.mock.calls.length; - await new Promise((resolve) => setTimeout(resolve, 2500)); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 2500)); + }); // An idle page should sit still rather than poll a settled scheduler. expect(mockGetMemoryStatus.mock.calls.length).toBe(callsAfterMount);