feat: album multi select using select

Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
Varun Raj
2025-07-29 10:01:59 +05:30
parent 7e48837c95
commit cf59b4fa3f
5 changed files with 176 additions and 61 deletions
+14 -10
View File
@@ -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 (
<div className="border rounded-lg overflow-hidden shadow-lg relative group">
<label className="block relative ">
@@ -39,8 +43,8 @@ export default function AlbumThumbnail({ album, onSelect, selected }: IAlbumThum
{album.firstPhotoDate ? formatDate(album.firstPhotoDate?.toString(), 'MMM d, yyyy') : ''} - {album.lastPhotoDate ? formatDate(album.lastPhotoDate?.toString(), 'MMM d, yyyy') : ''}
</div>
<Checkbox
defaultChecked={isSelected}
onCheckedChange={onSelect}
checked={selected}
onClick={handleCheckboxChange}
className="absolute top-2 left-2 w-6 h-6 rounded-full border-gray-300 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
/>
</label>
@@ -54,15 +58,15 @@ export default function AlbumThumbnail({ album, onSelect, selected }: IAlbumThum
{numberOfDays === 0 ? '1 day' : `${numberOfDays.toLocaleString() } days`} <Calendar size={12} />
</p>
</div>
<div className="p-4">
<div className={`p-4 ${selected ? 'bg-blue-500' : ''}`}>
<Link href={`/albums/${album.id}`}>
<h2 className="text-md font-semibold truncate">{album.albumName}</h2>
<h2 className={`text-md font-semibold truncate ${selected ? 'text-white' : ''}`}>{album.albumName}</h2>
</Link>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-gray-700 dark:text-gray-500">{humanizeNumber(album.faceCount)} {
<p className={`text-xs ${selected ? 'text-white' : 'text-gray-700 dark:text-gray-500'}`}>{humanizeNumber(album.faceCount)} {
pluralize(album.faceCount, 'person', 'people')
} </p>
<p className="text-xs text-gray-700 dark:text-gray-500">{humanizeBytes(parseInt(album.size))}</p>
<p className={`text-xs ${selected ? 'text-white' : 'text-gray-700 dark:text-gray-500'}`}>{humanizeBytes(parseInt(album.size))}</p>
</div>
</div>
</div>
+4 -2
View File
@@ -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 (
<div className='absolute bottom-10 left-0 right-0 max-w-2xl mx-auto py-2 px-2 rounded-lg flex justify-between items-center bg-white dark:bg-zinc-900 border-t border-zinc-200 dark:border-zinc-800 border shadow-lg'>
<div className={clsx('absolute bottom-10 left-0 right-0 max-w-2xl mx-auto py-2 px-2 rounded-lg flex justify-between items-center bg-white dark:bg-zinc-900 border-t border-zinc-200 dark:border-zinc-800 border shadow-lg', className)}>
{children}
</div>
)
+1
View File
@@ -0,0 +1 @@
export { useAlbumSelection } from './useAlbumSelection'
+68
View File
@@ -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<Set<string>>(new Set())
const [lastSelectedId, setLastSelectedId] = useState<string | null>(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
}
}
+89 -49
View File
@@ -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<IAlbum[]>([])
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [selectedAlbumsIds, setSelectedAlbumsIds] = useState<string[]>([])
const albumShareDialogRef = useRef<IAlbumShareDialogRef>(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() {
<AlbumThumbnail
album={album}
key={album.id}
selected={selectedAlbumsIds.includes(album.id)}
onSelect={(checked) => handleSelect(checked, album.id)}
selected={isSelected(album.id)}
onSelect={selectAlbum}
/>
))}
</div>
)
}
return (
<PageLayout className="!p-0 !mb-0">
<PageLayout className="!p-0 !mb-0 relative">
<Header
leftComponent="Manage Albums"
leftComponent={
<div className="flex items-center gap-2">
<span>Manage Albums</span>
{selectedCount > 0 && (
<span className="text-sm text-gray-500 dark:text-gray-400">
({selectedCount} selected)
</span>
)}
</div>
}
rightComponent={
<div className="flex items-center gap-2">
{!!selectedAlbumsIds.length && (
<>
<Button
variant={"secondary"}
size={"sm"}
className="flex items-center gap-2"
onClick={() => {
albumShareDialogRef.current?.open(selectedAlbums)
}}>
<Share size={16} /> Share {selectedAlbumsIds.length} albums
</Button>
<AlertDialog
title='Delete Albums'
description='Are you sure you want to delete these albums?'
onConfirm={handleDeleteAlbums}
>
<Button
variant={"destructive"}
size={"sm"}
className="flex items-center gap-2"
disabled={deleting}
>
<Trash size={16} /> {deleting ? "Deleting..." : `Delete ${selectedAlbumsIds.length} albums`}
</Button>
</AlertDialog>
</>
)}
<Input type="text" placeholder="Search" className="w-48" onChange={(e) => setSearch(e.target.value)} />
<Select
defaultValue={selectedSortBy?.value}
onValueChange={(value) => router.push({
@@ -171,6 +157,60 @@ export default function AlbumListPage() {
}
/>
{renderContent()}
{selectedCount > 0 && (
<FloatingBar className="!fixed">
<div className="flex items-center gap-2 justify-between w-full">
<p className="text-sm text-muted-foreground">
{selectedCount} Selected
</p>
<div className="flex items-center gap-2">
{isAllSelected ? (
<Button
variant="outline"
size="sm"
onClick={clearSelection}
className="text-xs"
>
Clear Selection
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={selectAll}
className="text-xs"
>
Select All
</Button>
)}
<Button
variant="secondary"
size="sm"
className="flex items-center gap-2"
onClick={() => {
albumShareDialogRef.current?.open(selectedAlbums)
}}>
<Share size={16} /> Share
</Button>
<div className="h-[10px] w-[1px] bg-zinc-500 dark:bg-zinc-600"></div>
<AlertDialog
title='Delete Albums'
description='Are you sure you want to delete these albums?'
onConfirm={handleDeleteAlbums}
>
<Button
variant="destructive"
size="sm"
className="flex items-center gap-2"
disabled={deleting}
>
<Trash size={16} /> {deleting ? "Deleting..." : "Delete"}
</Button>
</AlertDialog>
</div>
</div>
</FloatingBar>
)}
<AlbumShareDialog
ref={albumShareDialogRef}
/>