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))}
>