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 ( - +
+ Manage Albums + {selectedCount > 0 && ( + + ({selectedCount} selected) + + )} + + } rightComponent={
- {!!selectedAlbumsIds.length && ( - <> - - - - - - - )} setSearch(e.target.value)} /> +