From b9161aceb267938f2a47b8aa8d3ea058f7e7fe87 Mon Sep 17 00:00:00 2001 From: Akshaj Tiwari Date: Tue, 4 Aug 2026 23:16:24 +0530 Subject: [PATCH 1/3] minimize face collection shown at once --- frontend/src/components/FaceCollections.tsx | 58 ++++++++++++++++++- .../__tests__/useRefreshMemories.test.tsx | 6 +- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/FaceCollections.tsx b/frontend/src/components/FaceCollections.tsx index 582348204..f09615843 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,20 @@ 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: any, b: any) => (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); + const handlePersonClick = (clusterId: string) => { navigate(`/person/${clusterId}`); }; @@ -57,6 +76,12 @@ export function FaceCollections({ onSearchActivated }: FaceCollectionsProps) { ); } + const hasMultiplePages = totalPages > 1; + const visibleClusters = sortedClusters.slice( + currentPage * PAGE_SIZE, + (currentPage + 1) * PAGE_SIZE, + ); + return ( @@ -78,7 +103,7 @@ export function FaceCollections({ onSearchActivated }: FaceCollectionsProps) { to see all their photos.

- {clusters.map((cluster: any) => ( + {visibleClusters.map((cluster: any) => (
))}
+ {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); @@ -163,4 +165,4 @@ describe('useRefreshMemories', () => { // react-query passes a context object as a second argument. expect(mockGenerateMemories.mock.calls[0][0]).toEqual({ force: true }); }); -}); +}); \ No newline at end of file From cedd9396758be2e95b806dcbac10ed467ccbd7b9 Mon Sep 17 00:00:00 2001 From: Akshaj Tiwari Date: Tue, 4 Aug 2026 23:28:54 +0530 Subject: [PATCH 2/3] linter fixes --- frontend/src/App.css | 2 +- frontend/src/components/FaceCollections.tsx | 4 +--- frontend/src/hooks/__tests__/useRefreshMemories.test.tsx | 6 +++--- 3 files changed, 5 insertions(+), 7 deletions(-) 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 f09615843..aead8d21a 100644 --- a/frontend/src/components/FaceCollections.tsx +++ b/frontend/src/components/FaceCollections.tsx @@ -141,9 +141,7 @@ export function FaceCollections({ onSearchActivated }: FaceCollectionsProps) { size="sm" className="cursor-pointer" disabled={currentPage === totalPages - 1} - onClick={() => - setPage((p) => Math.min(totalPages - 1, p + 1)) - } + onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} > diff --git a/frontend/src/hooks/__tests__/useRefreshMemories.test.tsx b/frontend/src/hooks/__tests__/useRefreshMemories.test.tsx index 42783073a..80439bf1b 100644 --- a/frontend/src/hooks/__tests__/useRefreshMemories.test.tsx +++ b/frontend/src/hooks/__tests__/useRefreshMemories.test.tsx @@ -125,8 +125,8 @@ describe('useRefreshMemories', () => { const callsAfterMount = mockGetMemoryStatus.mock.calls.length; await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 2500)); - }); + 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); @@ -165,4 +165,4 @@ describe('useRefreshMemories', () => { // react-query passes a context object as a second argument. expect(mockGenerateMemories.mock.calls[0][0]).toEqual({ force: true }); }); -}); \ No newline at end of file +}); From dac58c983e30b1d0346699aac8b8a201550e9a34 Mon Sep 17 00:00:00 2001 From: Akshaj Tiwari Date: Wed, 5 Aug 2026 09:41:37 +0530 Subject: [PATCH 3/3] review coderabbit comments --- frontend/src/components/FaceCollections.tsx | 25 +++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/FaceCollections.tsx b/frontend/src/components/FaceCollections.tsx index aead8d21a..58e257f52 100644 --- a/frontend/src/components/FaceCollections.tsx +++ b/frontend/src/components/FaceCollections.tsx @@ -48,7 +48,7 @@ export function FaceCollections({ onSearchActivated }: FaceCollectionsProps) { const sortedClusters = useMemo( () => [...(clusters ?? [])].sort( - (a: any, b: any) => (b.face_count ?? 0) - (a.face_count ?? 0), + (a: Cluster, b: Cluster) => (b.face_count ?? 0) - (a.face_count ?? 0), ), [clusters], ); @@ -58,6 +58,10 @@ export function FaceCollections({ onSearchActivated }: FaceCollectionsProps) { 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}`); }; @@ -103,7 +107,7 @@ export function FaceCollections({ onSearchActivated }: FaceCollectionsProps) { to see all their photos.

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

 

+

 

+
+
+ ), + )}
{hasMultiplePages && (
@@ -128,6 +147,7 @@ export function FaceCollections({ onSearchActivated }: FaceCollectionsProps) { variant="ghost" size="sm" className="cursor-pointer" + aria-label="Previous page" disabled={currentPage === 0} onClick={() => setPage((p) => Math.max(0, p - 1))} > @@ -140,6 +160,7 @@ export function FaceCollections({ onSearchActivated }: FaceCollectionsProps) { variant="ghost" size="sm" className="cursor-pointer" + aria-label="Next page" disabled={currentPage === totalPages - 1} onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} >