From cf59b4fa3f35c3dcdddb62f07eb99d5c96471458 Mon Sep 17 00:00:00 2001
From: Varun Raj
Date: Tue, 29 Jul 2025 10:01:59 +0530
Subject: [PATCH] feat: album multi select using select
Signed-off-by: Varun Raj
---
src/components/albums/list/AlbumThumbnail.tsx | 24 +--
src/components/shared/FloatingBar.tsx | 6 +-
src/hooks/index.ts | 1 +
src/hooks/useAlbumSelection.ts | 68 +++++++++
src/pages/albums/index.tsx | 138 +++++++++++-------
5 files changed, 176 insertions(+), 61 deletions(-)
create mode 100644 src/hooks/index.ts
create mode 100644 src/hooks/useAlbumSelection.ts
diff --git a/src/components/albums/list/AlbumThumbnail.tsx b/src/components/albums/list/AlbumThumbnail.tsx
index e8019be..b2973fe 100644
--- a/src/components/albums/list/AlbumThumbnail.tsx
+++ b/src/components/albums/list/AlbumThumbnail.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo, useState } from 'react'
+import React, { useMemo } from 'react'
import Link from 'next/link';
import { humanizeBytes, humanizeNumber, pluralize } from '@/helpers/string.helper';
@@ -12,16 +12,20 @@ import { Calendar, Camera } from 'lucide-react';
interface IAlbumThumbnailProps {
album: IAlbum;
- onSelect: (checked: boolean) => void;
+ onSelect: (albumId: string, isShiftClick?: boolean) => void;
selected: boolean;
}
-export default function AlbumThumbnail({ album, onSelect, selected }: IAlbumThumbnailProps) {
- const [isSelected, setIsSelected] = useState(selected);
+export default function AlbumThumbnail({ album, onSelect, selected }: IAlbumThumbnailProps) {
const numberOfDays = useMemo(() => {
return differenceInDays(album.lastPhotoDate, album.firstPhotoDate);
}, [album.firstPhotoDate, album.lastPhotoDate]);
+ const handleCheckboxChange = (event: React.MouseEvent) => {
+ const isShiftClick = event.shiftKey;
+ onSelect(album.id, isShiftClick);
+ };
+
return (
@@ -54,15 +58,15 @@ export default function AlbumThumbnail({ album, onSelect, selected }: IAlbumThum
{numberOfDays === 0 ? '1 day' : `${numberOfDays.toLocaleString() } days`}
-
+
-
{album.albumName}
+
{album.albumName}
-
{humanizeNumber(album.faceCount)} {
+
{humanizeNumber(album.faceCount)} {
pluralize(album.faceCount, 'person', 'people')
}
-
{humanizeBytes(parseInt(album.size))}
+
{humanizeBytes(parseInt(album.size))}
diff --git a/src/components/shared/FloatingBar.tsx b/src/components/shared/FloatingBar.tsx
index 701d509..81aea89 100644
--- a/src/components/shared/FloatingBar.tsx
+++ b/src/components/shared/FloatingBar.tsx
@@ -1,12 +1,14 @@
+import clsx from 'clsx';
import React from 'react'
interface FloatingBarProps {
children: React.ReactNode;
+ className?: string;
}
-export default function FloatingBar({ children }: FloatingBarProps) {
+export default function FloatingBar({ children, className }: FloatingBarProps) {
return (
-
+
{children}
)
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
new file mode 100644
index 0000000..7714555
--- /dev/null
+++ b/src/hooks/index.ts
@@ -0,0 +1 @@
+export { useAlbumSelection } from './useAlbumSelection'
\ No newline at end of file
diff --git a/src/hooks/useAlbumSelection.ts b/src/hooks/useAlbumSelection.ts
new file mode 100644
index 0000000..3e6189c
--- /dev/null
+++ b/src/hooks/useAlbumSelection.ts
@@ -0,0 +1,68 @@
+import { useState, useCallback } from 'react'
+import { IAlbum } from '@/types/album'
+
+/**
+ * Custom hook for album selection with shift-click support
+ * @param albums - Array of albums to manage selection for
+ * @returns Selection state and methods
+ */
+export const useAlbumSelection = (albums: IAlbum[]) => {
+ const [selectedIds, setSelectedIds] = useState
>(new Set())
+ const [lastSelectedId, setLastSelectedId] = useState(null)
+
+ const selectAlbum = useCallback((albumId: string, isShiftClick: boolean = false) => {
+ if (isShiftClick && lastSelectedId && lastSelectedId !== albumId) {
+ // Handle shift-click range selection
+ const currentIndex = albums.findIndex(album => album.id === albumId)
+ const lastIndex = albums.findIndex(album => album.id === lastSelectedId)
+
+ if (currentIndex !== -1 && lastIndex !== -1) {
+ const startIndex = Math.min(currentIndex, lastIndex)
+ const endIndex = Math.max(currentIndex, lastIndex)
+
+ const newSelectedIds = new Set(selectedIds)
+ for (let i = startIndex; i <= endIndex; i++) {
+ newSelectedIds.add(albums[i].id)
+ }
+ setSelectedIds(newSelectedIds)
+ }
+ } else {
+ // Toggle single selection
+ const newSelectedIds = new Set(selectedIds)
+ if (newSelectedIds.has(albumId)) {
+ newSelectedIds.delete(albumId)
+ } else {
+ newSelectedIds.add(albumId)
+ }
+ setSelectedIds(newSelectedIds)
+ }
+
+ setLastSelectedId(albumId)
+ }, [albums, selectedIds, lastSelectedId])
+
+ const selectAll = useCallback(() => {
+ setSelectedIds(new Set(albums.map(album => album.id)))
+ }, [albums])
+
+ const clearSelection = useCallback(() => {
+ setSelectedIds(new Set())
+ setLastSelectedId(null)
+ }, [])
+
+ const isSelected = useCallback((albumId: string) => selectedIds.has(albumId), [selectedIds])
+
+ const selectedCount = selectedIds.size
+ const isAllSelected = albums.length > 0 && selectedCount === albums.length
+ const isPartiallySelected = selectedCount > 0 && selectedCount < albums.length
+
+ return {
+ selectedIds: Array.from(selectedIds),
+ selectedCount,
+ isSelected,
+ selectAlbum,
+ selectAll,
+ clearSelection,
+ isAllSelected,
+ isPartiallySelected
+ }
+}
\ No newline at end of file
diff --git a/src/pages/albums/index.tsx b/src/pages/albums/index.tsx
index 1cc340b..570d6e0 100644
--- a/src/pages/albums/index.tsx
+++ b/src/pages/albums/index.tsx
@@ -4,7 +4,7 @@ import Loader from '@/components/ui/loader'
import { useConfig } from '@/contexts/ConfigContext'
import { deleteAlbums, listAlbums } from '@/handlers/api/album.handler'
import { IAlbum } from '@/types/album'
-import React, { useEffect, useMemo, useRef, useState } from 'react'
+import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import AlbumThumbnail from '@/components/albums/list/AlbumThumbnail'
import { Button } from '@/components/ui/button'
import { Select, SelectItem, SelectContent, SelectTrigger, SelectValue } from '@/components/ui/select'
@@ -14,6 +14,8 @@ import { Input } from '@/components/ui/input'
import AlbumShareDialog, { IAlbumShareDialogRef } from '@/components/albums/share/AlbumShareDialog'
import { AlertDialog } from '@/components/ui/alert-dialog'
import toast from 'react-hot-toast'
+import { useAlbumSelection } from '@/hooks'
+import FloatingBar from '@/components/shared/FloatingBar'
const SORT_BY_OPTIONS = [
{ value: 'lastPhotoDate', label: 'Last Photo Date' },
@@ -25,6 +27,7 @@ const SORT_BY_OPTIONS = [
{ value: 'albumSize', label: 'Album Size' },
{ value: 'faceCount', label: 'Number of People' },
]
+
export default function AlbumListPage() {
const router = useRouter()
const [search, setSearch] = useState('')
@@ -33,7 +36,6 @@ export default function AlbumListPage() {
const [albums, setAlbums] = useState([])
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
- const [selectedAlbumsIds, setSelectedAlbumsIds] = useState([])
const albumShareDialogRef = useRef(null);
const [deleting, setDeleting] = useState(false)
@@ -41,6 +43,19 @@ export default function AlbumListPage() {
const searchedAlbums = useMemo(() => albums.filter((album) => album.albumName.toLowerCase().includes(search.toLowerCase())), [albums, search])
+ const {
+ selectedIds,
+ selectedCount,
+ isSelected,
+ selectAlbum,
+ selectAll,
+ clearSelection,
+ isAllSelected,
+ isPartiallySelected
+ } = useAlbumSelection(searchedAlbums)
+
+ const selectedAlbums = useMemo(() => albums.filter((album) => selectedIds.includes(album.id)), [albums, selectedIds])
+
const fetchAlbums = async () => {
setLoading(true)
listAlbums({
@@ -56,27 +71,16 @@ export default function AlbumListPage() {
})
}
- const selectedAlbums = useMemo(() => albums.filter((album) => selectedAlbumsIds.includes(album.id)), [albums, selectedAlbumsIds])
-
useEffect(() => {
fetchAlbums()
}, [sortBy, sortOrder])
-
- const handleSelect = (checked: boolean, albumId: string) => {
- if (checked) {
- setSelectedAlbumsIds([...selectedAlbumsIds, albumId])
- } else {
- setSelectedAlbumsIds(selectedAlbumsIds.filter((id) => id !== albumId))
- }
- }
-
const handleDeleteAlbums = async () => {
setDeleting(true)
- return deleteAlbums(selectedAlbumsIds).then(() => {
- setSelectedAlbumsIds([])
- setAlbums(albums.filter((album) => !selectedAlbumsIds.includes(album.id)))
- toast.success(`Deleted ${selectedAlbumsIds.length} albums`)
+ return deleteAlbums(selectedIds).then(() => {
+ clearSelection()
+ setAlbums(albums.filter((album) => !selectedIds.includes(album.id)))
+ toast.success(`Deleted ${selectedCount} albums`)
}).catch((error) => {
toast.error(error.message)
}).finally(() => {
@@ -97,48 +101,30 @@ export default function AlbumListPage() {
handleSelect(checked, album.id)}
+ selected={isSelected(album.id)}
+ onSelect={selectAlbum}
/>
))}
)
}
return (
-
+