From b9a171b096607181b8a76d5224a1fd04859131e6 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sat, 30 Jul 2022 07:03:10 -0700 Subject: [PATCH] Add filter functionality for infinite album list --- .../components/virtual-grid/GridCard.tsx | 77 +++--- .../virtual-grid/VirtualGridWrapper.tsx | 18 +- .../virtual-grid/VirtualInfiniteGrid.tsx | 224 +++++++++--------- .../library/components/ViewTypeButton.tsx | 57 +++++ .../features/library/queries/getAlbums.ts | 7 +- .../library/routes/LibraryAlbumsRoute.tsx | 171 ++++++++++--- 6 files changed, 347 insertions(+), 207 deletions(-) create mode 100644 src/renderer/features/library/components/ViewTypeButton.tsx diff --git a/src/renderer/components/virtual-grid/GridCard.tsx b/src/renderer/components/virtual-grid/GridCard.tsx index e2d2aa975..beee1842a 100644 --- a/src/renderer/components/virtual-grid/GridCard.tsx +++ b/src/renderer/components/virtual-grid/GridCard.tsx @@ -1,4 +1,4 @@ -import { Card } from '@mantine/core'; +import { Card, Skeleton } from '@mantine/core'; import { motion } from 'framer-motion'; import styled from 'styled-components'; import { CardRow } from 'renderer/types'; @@ -10,7 +10,6 @@ const CardWrapper = styled(motion.div)<{ itemHeight: number; itemWidth: number; }>` - display: flex; flex: ${({ itemWidth }) => `0 0 ${itemWidth}px`}; width: ${({ itemWidth }) => `${itemWidth}px`}; height: ${({ itemHeight }) => `${itemHeight}px`}; @@ -46,9 +45,17 @@ const ImageSection = styled.div` height: 100%; `; -const Image = styled(motion.div)<{ height: number; src: string }>` +interface ImageProps { + height: number; + src: string; +} + +const Image = styled(motion.div).attrs((props: ImageProps) => ({ + style: { + background: `url(${props.src})`, + }, +}))` height: ${({ height }) => `${height}px`}; - background: ${({ src }) => `url(${src})`}; background-position: center; background-size: cover; border: 0; @@ -82,9 +89,9 @@ export const GridCard = ({ data, index, style, isScrolling }: any) => { itemGap, itemCount, cardControls, + handlePlayQueueAdd, cardRows, itemData, - handlePlayQueueAdd, } = data; const startIndex = index * columnCount; @@ -98,45 +105,33 @@ export const GridCard = ({ data, index, style, isScrolling }: any) => { itemGap={itemGap} itemHeight={itemHeight} itemWidth={itemWidth} - tabIndex={0} > - - - - {!isScrolling && ( + + + + - + {!isScrolling && ( + + )} - )} - - - - {cardRows.map((row: CardRow) => ( - - - {itemData[i] && itemData[i][row.prop]} - - - ))} - - + + + + {cardRows.map((row: CardRow) => ( + + + {itemData[i] && itemData[i][row.prop]} + + + ))} + + + ); } diff --git a/src/renderer/components/virtual-grid/VirtualGridWrapper.tsx b/src/renderer/components/virtual-grid/VirtualGridWrapper.tsx index 111192d29..3685aada7 100644 --- a/src/renderer/components/virtual-grid/VirtualGridWrapper.tsx +++ b/src/renderer/components/virtual-grid/VirtualGridWrapper.tsx @@ -14,11 +14,13 @@ export const VirtualGridWrapper = ({ itemCount, columnCount, rowCount, + itemData, ...rest }: Omit & { cardControls: any; cardRows: CardRow[]; columnCount: number; + itemData: any[]; itemGap: number; itemHeight: number; itemWidth: number; @@ -27,39 +29,37 @@ export const VirtualGridWrapper = ({ }) => { const { handlePlayQueueAdd } = usePlayQueueHandler(); - const itemData = useMemo( + const memo = useMemo( () => ({ cardControls, cardRows, columnCount, handlePlayQueueAdd, itemCount, - itemData: rest.itemData, + itemData, itemGap, itemHeight, itemWidth, }), [ - cardRows, cardControls, + cardRows, columnCount, + handlePlayQueueAdd, itemCount, - rest.itemData, + itemData, itemGap, itemHeight, itemWidth, - handlePlayQueueAdd, ] ); return ( diff --git a/src/renderer/components/virtual-grid/VirtualInfiniteGrid.tsx b/src/renderer/components/virtual-grid/VirtualInfiniteGrid.tsx index 591ce1972..39cd1ac13 100644 --- a/src/renderer/components/virtual-grid/VirtualInfiniteGrid.tsx +++ b/src/renderer/components/virtual-grid/VirtualInfiniteGrid.tsx @@ -1,16 +1,12 @@ -import { forwardRef, Ref, useState } from 'react'; +import { useState, useEffect, useRef, useMemo } from 'react'; import debounce from 'lodash/debounce'; -import AutoSizer from 'react-virtualized-auto-sizer'; import { FixedSizeListProps } from 'react-window'; import InfiniteLoader from 'react-window-infinite-loader'; import { CardRow } from 'renderer/types'; import { VirtualGridWrapper } from './VirtualGridWrapper'; interface VirtualGridProps - extends Omit< - FixedSizeListProps, - 'children' | 'itemSize' | 'height' | 'width' - > { + extends Omit { cardControls: any; cardRows: CardRow[]; itemGap?: number; @@ -20,119 +16,113 @@ interface VirtualGridProps queryParams?: Record; } -export const VirtualInfiniteGrid = forwardRef( - ( - { - itemCount, - itemGap, - itemSize, - cardControls, - cardRows, - minimumBatchSize, - query, - queryParams, - }: VirtualGridProps, - ref: Ref - ) => { - const [itemData, setItemData] = useState([]); +export const VirtualInfiniteGrid = ({ + itemCount, + itemGap, + itemSize, + cardControls, + cardRows, + minimumBatchSize, + query, + queryParams, + height, + width, +}: VirtualGridProps) => { + const [itemData, setItemData] = useState([]); + const listRef = useRef(null); + const loader = useRef(null); - const isItemLoaded = (index: number, columnCount: number) => { - const itemIndex = index * columnCount; - - return ( - itemIndex < itemData.length * columnCount && - itemData[itemIndex] !== undefined - ); - }; - - const loadMoreItems = async ( - startIndex: number, - stopIndex: number, - limit: number, - columnCount: number - ) => { - const currentPage = Math.ceil(startIndex / minimumBatchSize!); - - const t = await query({ - limit, - page: currentPage, - ...queryParams, - }); - - // Need to multiply by columnCount due to the grid layout - const start = startIndex * columnCount; - const end = (stopIndex + 1) * columnCount; - - return new Promise((resolve) => { - const newData: any[] = [...itemData]; - - let itemIndex = 0; - for (let rowIndex = start; rowIndex < end; rowIndex += 1) { - newData[rowIndex] = t?.data[itemIndex]; - itemIndex += 1; - } - - setItemData(newData); - resolve(); - }); - }; - - const debouncedLoadMoreItems = debounce(loadMoreItems, 300); - - return ( - - {({ height, width }) => { - const itemHeight = itemSize! + cardRows.length * 25; - - const columnCount = Math.floor( - (Number(width) - itemGap! + 3) / (itemSize! + itemGap! + 2) - ); - - const rowCount = Math.ceil(itemCount / columnCount); - - const pageItemLimit = columnCount * minimumBatchSize!; - - return ( - isItemLoaded(index, columnCount)} - itemCount={itemCount || 0} - loadMoreItems={(startIndex, stopIndex) => - debouncedLoadMoreItems( - startIndex, - stopIndex, - pageItemLimit, - columnCount - ) - } - minimumBatchSize={minimumBatchSize} - threshold={10} - > - {({ onItemsRendered, ref: infiniteLoaderRef }) => ( - - )} - - ); - }} - + const { itemHeight, rowCount, columnCount } = useMemo(() => { + const itemsPerRow = Math.floor( + (Number(width) - itemGap! + 3) / (itemSize! + itemGap! + 2) ); - } -); + + return { + columnCount: itemsPerRow, + itemHeight: itemSize! + cardRows.length * 25, + rowCount: Math.ceil(itemCount / itemsPerRow), + }; + }, [cardRows.length, itemCount, itemGap, itemSize, width]); + + const isItemLoaded = (index: number) => { + const itemIndex = index * columnCount; + + return itemData[itemIndex] !== undefined; + }; + + const loadMoreItems = async (startIndex: number, stopIndex: number) => { + // Fixes a caching bug(?) when switching between filters and the itemCount increases + if (startIndex === 1) return; + + // Need to multiply by columnCount due to the grid layout + const start = startIndex * columnCount; + const end = stopIndex * columnCount + columnCount; + + const t = await query({ + limit: end - start, + skip: start, + ...queryParams, + }); + + const newData: any[] = [...itemData]; + + let itemIndex = 0; + for (let rowIndex = start; rowIndex < end; rowIndex += 1) { + newData[rowIndex] = t.data[itemIndex]; + itemIndex += 1; + } + + setItemData(newData); + }; + + const debouncedLoadMoreItems = debounce(loadMoreItems, 300); + + useEffect(() => { + if (loader.current) { + listRef.current.scrollTo(0); + loader.current.resetloadMoreItemsCache(true); + setItemData(() => []); + + loadMoreItems(0, minimumBatchSize! * 2); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [minimumBatchSize, queryParams, setItemData]); + + return ( + isItemLoaded(index)} + itemCount={itemCount || 0} + loadMoreItems={(startIndex, stopIndex) => + debouncedLoadMoreItems(startIndex, stopIndex) + } + minimumBatchSize={minimumBatchSize} + threshold={30} + > + {({ onItemsRendered, ref: infiniteLoaderRef }) => ( + { + infiniteLoaderRef(list); + listRef.current = list; + }} + rowCount={rowCount} + width={width} + onItemsRendered={onItemsRendered} + /> + )} + + ); +}; VirtualInfiniteGrid.defaultProps = { itemGap: 10, diff --git a/src/renderer/features/library/components/ViewTypeButton.tsx b/src/renderer/features/library/components/ViewTypeButton.tsx new file mode 100644 index 000000000..511cb3d6c --- /dev/null +++ b/src/renderer/features/library/components/ViewTypeButton.tsx @@ -0,0 +1,57 @@ +import { Dispatch } from 'react'; +import { ActionIcon, Menu, MenuProps } from '@mantine/core'; +import { LayoutGrid, LayoutList, Table } from 'tabler-icons-react'; + +export enum ViewType { + Detail = 'detail', + Grid = 'grid', + Table = 'table', +} + +interface ViewTypeButtonProps { + handler: Dispatch; + menuProps: MenuProps; + type: ViewType; +} + +export const ViewTypeButton = ({ + type, + menuProps, + handler, +}: ViewTypeButtonProps) => { + return ( + + + + {type === ViewType.Grid ? ( + + ) : type === ViewType.Detail ? ( + + ) : ( + + )} + + + + } + onClick={() => handler(ViewType.Grid)} + > + Grid + + } + onClick={() => handler(ViewType.Detail)} + > + Detail + + } + onClick={() => handler(ViewType.Table)} + > + Table + + + + ); +}; diff --git a/src/renderer/features/library/queries/getAlbums.ts b/src/renderer/features/library/queries/getAlbums.ts index 21e42e569..be75f1eff 100644 --- a/src/renderer/features/library/queries/getAlbums.ts +++ b/src/renderer/features/library/queries/getAlbums.ts @@ -1,20 +1,21 @@ import { useInfiniteQuery, useQuery } from 'react-query'; import { queryKeys } from 'renderer/api/queryKeys'; +import { AlbumsResponse } from 'renderer/api/types'; import { albumsApi, AlbumsRequest } from '../../../api/albumsApi'; export const useAlbums = (params: AlbumsRequest) => { return useQuery({ queryFn: () => albumsApi.getAlbums(params), - queryKey: queryKeys.albums(), + queryKey: queryKeys.albums(params), }); }; export const useAlbumsInfinite = (params: AlbumsRequest) => { return useInfiniteQuery({ - getNextPageParam: (lastPage) => { + getNextPageParam: (lastPage: AlbumsResponse) => { return !!lastPage.pagination.nextPage; }, - getPreviousPageParam: (firstPage) => { + getPreviousPageParam: (firstPage: AlbumsResponse) => { return !!firstPage.pagination.prevPage; }, queryFn: ({ pageParam }) => diff --git a/src/renderer/features/library/routes/LibraryAlbumsRoute.tsx b/src/renderer/features/library/routes/LibraryAlbumsRoute.tsx index 8a9147635..bd14b6a58 100644 --- a/src/renderer/features/library/routes/LibraryAlbumsRoute.tsx +++ b/src/renderer/features/library/routes/LibraryAlbumsRoute.tsx @@ -1,58 +1,155 @@ /* eslint-disable no-plusplus */ -import { useRef } from 'react'; -import InfiniteLoader from 'react-window-infinite-loader'; +import { useState } from 'react'; +import { Button, Group, Menu } from '@mantine/core'; +import { useSetState } from '@mantine/hooks'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { CaretDown } from 'tabler-icons-react'; +import i18n from 'i18n/i18n'; import { albumsApi } from 'renderer/api/albumsApi'; import { VirtualInfiniteGrid } from 'renderer/components/virtual-grid/VirtualInfiniteGrid'; import { AnimatedPage } from 'renderer/features/shared/components/AnimatedPage'; import { AppRoute } from 'renderer/router/utils/routes'; import { Item } from 'types'; +import { ViewType, ViewTypeButton } from '../components/ViewTypeButton'; import { useAlbums } from '../queries/getAlbums'; -export const LibraryAlbumsRoute = () => { - const infiniteLoaderRef = useRef(null); +export enum AlbumSort { + DATE_ADDED = 'date_added', + DATE_ADDED_REMOTE = 'date_added_remote', + DATE_PLAYED = 'date_played', + DATE_RELEASED = 'date_released', + RANDOM = 'random', + RATING = 'rating', + TITLE = 'title', + YEAR = 'year', +} - const params = { +const FILTERS = [ + { name: i18n.t('filters.dateAdded'), value: AlbumSort.DATE_ADDED }, + { + name: i18n.t('filters.dateAddedRemote'), + value: AlbumSort.DATE_ADDED_REMOTE, + }, + { name: i18n.t('filters.datePlayed'), value: AlbumSort.DATE_PLAYED }, + { name: i18n.t('filters.dateReleased'), value: AlbumSort.DATE_RELEASED }, + { name: i18n.t('filters.random'), value: AlbumSort.RANDOM }, + { name: i18n.t('filters.rating'), value: AlbumSort.RATING }, + { name: i18n.t('filters.title'), value: AlbumSort.TITLE }, + { name: i18n.t('filters.year'), value: AlbumSort.YEAR }, +]; + +export const LibraryAlbumsRoute = () => { + const [viewType, setViewType] = useState(ViewType.Grid); + const [filters, setFilters] = useSetState({ orderBy: 'asc', - sortBy: 'title', - }; + sortBy: AlbumSort.TITLE, + }); const { data: albums } = useAlbums({ limit: 0, page: 0, - ...params, + ...filters, }); return ( - {albums && ( - - )} +
+ + + + + + + } + onClick={() => setFilters({ sortBy: AlbumSort.TITLE })} + > + Title + + } + onClick={() => setFilters({ sortBy: AlbumSort.YEAR })} + > + Year + + } + onClick={() => setFilters({ sortBy: AlbumSort.RATING })} + > + Rating + + } + onClick={() => setFilters({ sortBy: AlbumSort.DATE_RELEASED })} + > + Date Released + + } + onClick={() => setFilters({ sortBy: AlbumSort.DATE_ADDED })} + > + Date Added + + } + onClick={() => + setFilters({ sortBy: AlbumSort.DATE_ADDED_REMOTE }) + } + > + Date Added (Remote) + + + + + + +
+ {albums && ( + + {({ height, width }) => ( + + )} + + )} +
+
); };