Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@
}

.thin-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: var(--muted-foreground);
background-color: var(--muted-foreground);
}

.no-select {
Expand Down
77 changes: 74 additions & 3 deletions frontend/src/components/FaceCollections.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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

Expand All @@ -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);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
useEffect(() => {
setPage((previousPage) => Math.min(previousPage, totalPages - 1));
}, [totalPages]);

const handlePersonClick = (clusterId: string) => {
navigate(`/person/${clusterId}`);
};
Expand All @@ -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 (
<Card className="border-primary/20 w-full">
<CardContent>
Expand All @@ -78,7 +107,7 @@ export function FaceCollections({ onSearchActivated }: FaceCollectionsProps) {
to see all their photos.
</p>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8">
{clusters.map((cluster: any) => (
{visibleClusters.map((cluster: Cluster) => (
<div
key={cluster.cluster_id}
className="hover:bg-accent flex cursor-pointer flex-col items-center gap-2 rounded-lg p-4 transition-colors dark:hover:bg-white/10"
Expand All @@ -96,7 +125,49 @@ export function FaceCollections({ onSearchActivated }: FaceCollectionsProps) {
</div>
</div>
))}
{Array.from({ length: PAGE_SIZE - visibleClusters.length }).map(
(_, index) => (
<div
key={`placeholder-${index}`}
className="flex flex-col items-center gap-2 rounded-lg p-4"
inert
>
<div className="w-16 md:h-20 md:w-20" />
<div className="text-center">
<p className="font-medium">&nbsp;</p>
<p className="text-muted-foreground text-xs">&nbsp;</p>
</div>
</div>
),
)}
</div>
{hasMultiplePages && (
<div className="mt-4 flex items-center justify-center gap-4">
<Button
variant="ghost"
size="sm"
className="cursor-pointer"
aria-label="Previous page"
disabled={currentPage === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-muted-foreground text-sm">
Page {currentPage + 1} of {totalPages}
</span>
<Button
variant="ghost"
size="sm"
className="cursor-pointer"
aria-label="Next page"
disabled={currentPage === totalPages - 1}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<MultiPersonSearchDialog
open={isSearchDialogOpen}
onOpenChange={setIsSearchDialogOpen}
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/hooks/__tests__/useRefreshMemories.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@ describe('useRefreshMemories', () => {
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);
Expand Down
Loading