diff --git a/src/components/albums/AlbumTableColumns.tsx b/src/components/albums/AlbumTableColumns.tsx new file mode 100644 index 0000000..2109247 --- /dev/null +++ b/src/components/albums/AlbumTableColumns.tsx @@ -0,0 +1,236 @@ +import { ColumnDef } from "@tanstack/react-table" +import { IAlbum } from "@/types/album" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { ArrowUpDown, MoreHorizontal, Calendar, Camera, Users, HardDrive } from "lucide-react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { formatDate } from "@/helpers/date.helper" +import { humanizeBytes, humanizeNumber, pluralize } from "@/helpers/string.helper" +import { differenceInDays } from "date-fns" +import Link from "next/link" +import { ASSET_THUMBNAIL_PATH } from "@/config/routes" +import LazyImage from "@/components/ui/lazy-image" + +export const albumColumns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "albumThumbnailAssetId", + header: "Thumbnail", + cell: ({ row }) => { + const album = row.original + return ( +
+ +
+ ) + }, + enableSorting: false, + }, + { + accessorKey: "albumName", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const album = row.original + return ( + { + // Ensure the link click is not blocked by table events + e.stopPropagation() + }} + > + {album.albumName} + + ) + }, + }, + { + accessorKey: "assetCount", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const count = row.getValue("assetCount") as number + return
{humanizeNumber(count)}
+ }, + }, + { + accessorKey: "faceCount", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const count = row.getValue("faceCount") as number + return
{humanizeNumber(count)} {pluralize(count, 'person', 'people')}
+ }, + }, + { + accessorKey: "size", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const size = row.getValue("size") as string + return
{humanizeBytes(parseInt(size))}
+ }, + }, + { + accessorKey: "firstPhotoDate", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const album = row.original + const numberOfDays = differenceInDays(album.lastPhotoDate, album.firstPhotoDate) + + return ( +
+
+ {album.firstPhotoDate ? formatDate(album.firstPhotoDate.toString(), 'MMM d, yyyy') : ''} - {album.lastPhotoDate ? formatDate(album.lastPhotoDate.toString(), 'MMM d, yyyy') : ''} +
+
+ {numberOfDays === 0 ? '1 day' : `${numberOfDays.toLocaleString()} days`} +
+
+ ) + }, + }, + { + accessorKey: "createdAt", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const date = row.getValue("createdAt") as string + return
{formatDate(date, 'MMM d, yyyy')}
+ }, + }, + { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + const album = row.original + + return ( + + + + + + Actions + + + View Album + + + + + Share Album + + + Edit Album + + + + Delete Album + + + + ) + }, + }, +] \ No newline at end of file diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx new file mode 100644 index 0000000..8fd8bb6 --- /dev/null +++ b/src/components/ui/data-table.tsx @@ -0,0 +1,242 @@ +"use client" + +import * as React from "react" +import { + ColumnDef, + ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + useReactTable, + VisibilityState, +} from "@tanstack/react-table" +import { ArrowUpDown, ChevronDown, MoreHorizontal } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Input } from "@/components/ui/input" +import { + TableRoot, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" + +interface DataTableProps { + columns: ColumnDef[] + data: TData[] + onRowSelectionChange?: (selectedIds: string[]) => void + selectedIds?: string[] + getRowId?: (row: TData) => string + searchValue?: string + onSearchChange?: (value: string) => void +} + +export function DataTable({ + columns, + data, + onRowSelectionChange, + selectedIds = [], + getRowId, + searchValue, + onSearchChange, +}: DataTableProps) { + const [sorting, setSorting] = React.useState([]) + const [columnFilters, setColumnFilters] = React.useState([]) + const [columnVisibility, setColumnVisibility] = React.useState({}) + + // Initialize row selection from selectedIds + const initialRowSelection = React.useMemo(() => { + const selection: Record = {} + selectedIds.forEach(id => { + selection[id] = true + }) + return selection + }, [selectedIds]) + + const [rowSelection, setRowSelection] = React.useState>(initialRowSelection) + + const table = useReactTable({ + data, + columns, + getRowId: getRowId || ((row: any) => row.id), + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection, + }, + // Add these options to prevent potential issues + enableRowSelection: true, + enableMultiRowSelection: true, + enableSorting: true, + enableFilters: true, + enableColumnFilters: true, + enablePagination: true, + }) + + // Sync external selection with table selection + React.useEffect(() => { + const newRowSelection: Record = {} + selectedIds.forEach((id) => { + newRowSelection[id] = true + }) + setRowSelection(newRowSelection) + }, [selectedIds]) + + // Sync external search with table filter + React.useEffect(() => { + if (searchValue !== undefined) { + table.getColumn("albumName")?.setFilterValue(searchValue) + } + }, [searchValue, table]) + + // Notify parent of selection changes + React.useEffect(() => { + const selectedRowIds = Object.keys(rowSelection).filter(id => rowSelection[id]) + onRowSelectionChange?.(selectedRowIds) + }, [rowSelection, onRowSelectionChange]) + + return ( +
+
+ + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ) + })} + + +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + { + // Prevent row click from interfering with link clicks + if (e.target instanceof HTMLAnchorElement) { + e.stopPropagation() + } + }} + > + {row.getVisibleCells().map((cell) => ( + { + // Allow link clicks to propagate + if (e.target instanceof HTMLAnchorElement) { + e.stopPropagation() + } + }} + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + + +
+
+
+ {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} row(s) selected. +
+
+ + +
+
+
+ ) +} \ No newline at end of file diff --git a/src/pages/albums/index.tsx b/src/pages/albums/index.tsx index 570d6e0..87eceb2 100644 --- a/src/pages/albums/index.tsx +++ b/src/pages/albums/index.tsx @@ -9,13 +9,15 @@ import AlbumThumbnail from '@/components/albums/list/AlbumThumbnail' import { Button } from '@/components/ui/button' import { Select, SelectItem, SelectContent, SelectTrigger, SelectValue } from '@/components/ui/select' import { useRouter } from 'next/router' -import { Share, SortAsc, SortDesc, Trash } from 'lucide-react' +import { Share, SortAsc, SortDesc, Trash, Grid3X3, Table as TableIcon } from 'lucide-react' 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' +import { DataTable } from '@/components/ui/data-table' +import { albumColumns } from '@/components/albums/AlbumTableColumns' const SORT_BY_OPTIONS = [ { value: 'lastPhotoDate', label: 'Last Photo Date' }, @@ -31,6 +33,7 @@ const SORT_BY_OPTIONS = [ export default function AlbumListPage() { const router = useRouter() const [search, setSearch] = useState('') + const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid') const { query, pathname } = router const { sortBy, sortOrder } = query as { sortBy: string, sortOrder: string } const [albums, setAlbums] = useState([]) @@ -38,6 +41,9 @@ export default function AlbumListPage() { const [errorMessage, setErrorMessage] = useState('') const albumShareDialogRef = useRef(null); const [deleting, setDeleting] = useState(false) + + // Separate selection state for table view + const [tableSelectedIds, setTableSelectedIds] = useState([]) const selectedSortBy = useMemo(() => SORT_BY_OPTIONS.find((option) => option.value === sortBy), [sortBy]) @@ -56,6 +62,11 @@ export default function AlbumListPage() { const selectedAlbums = useMemo(() => albums.filter((album) => selectedIds.includes(album.id)), [albums, selectedIds]) + // Use appropriate selection state based on view mode + const currentSelectedIds = viewMode === 'table' ? tableSelectedIds : selectedIds + const currentSelectedCount = currentSelectedIds.length + const currentSelectedAlbums = useMemo(() => albums.filter((album) => currentSelectedIds.includes(album.id)), [albums, currentSelectedIds]) + const fetchAlbums = async () => { setLoading(true) listAlbums({ @@ -77,10 +88,14 @@ export default function AlbumListPage() { const handleDeleteAlbums = async () => { setDeleting(true) - return deleteAlbums(selectedIds).then(() => { - clearSelection() - setAlbums(albums.filter((album) => !selectedIds.includes(album.id))) - toast.success(`Deleted ${selectedCount} albums`) + return deleteAlbums(currentSelectedIds).then(() => { + if (viewMode === 'table') { + setTableSelectedIds([]) + } else { + clearSelection() + } + setAlbums(albums.filter((album) => !currentSelectedIds.includes(album.id))) + toast.success(`Deleted ${currentSelectedCount} albums`) }).catch((error) => { toast.error(error.message) }).finally(() => { @@ -88,6 +103,29 @@ export default function AlbumListPage() { }) } + const handleTableSelectionChange = (selectedRowIds: string[]) => { + setTableSelectedIds(selectedRowIds) + } + + const handleSelectAll = () => { + if (viewMode === 'table') { + setTableSelectedIds(searchedAlbums.map(album => album.id)) + } else { + selectAll() + } + } + + const handleClearSelection = () => { + if (viewMode === 'table') { + setTableSelectedIds([]) + } else { + clearSelection() + } + } + + const isAllSelectedCurrent = searchedAlbums.length > 0 && currentSelectedCount === searchedAlbums.length + const isPartiallySelectedCurrent = currentSelectedCount > 0 && currentSelectedCount < searchedAlbums.length + const renderContent = () => { if (loading) { return @@ -95,6 +133,23 @@ export default function AlbumListPage() { else if (errorMessage) { return
{errorMessage}
} + + if (viewMode === 'table') { + return ( +
+ row.id} + searchValue={search} + onSearchChange={setSearch} + /> +
+ ) + } + return (
{searchedAlbums.map((album) => ( @@ -108,15 +163,16 @@ export default function AlbumListPage() {
) } + return (
Manage Albums - {selectedCount > 0 && ( + {currentSelectedCount > 0 && ( - ({selectedCount} selected) + ({currentSelectedCount} selected) )} @@ -125,6 +181,25 @@ export default function AlbumListPage() {
setSearch(e.target.value)} /> +
+ + +
+