feat: album multi select using select
Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { useAlbumSelection } from './useAlbumSelection'
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user