Add filter functionality for infinite album list

This commit is contained in:
jeffvli
2022-07-30 07:03:10 -07:00
parent fa9cf2efda
commit b9a171b096
6 changed files with 347 additions and 207 deletions
@@ -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})`,
},
}))<ImageProps>`
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}
>
<StyledCard>
<ImageSection>
<Image
animate={{
opacity: 1,
}}
height={itemWidth}
initial={{
opacity: 0,
}}
src={itemData[i]?.image}
transition={{
duration: 0.5,
ease: 'anticipate',
}}
>
{!isScrolling && (
<Skeleton visible={!itemData[i]}>
<StyledCard>
<ImageSection>
<Image height={itemWidth} src={itemData[i]?.image}>
<ControlsContainer>
<GridCardControls
cardControls={cardControls}
handlePlayQueueAdd={handlePlayQueueAdd}
itemData={itemData[i]}
/>
{!isScrolling && (
<GridCardControls
cardControls={cardControls}
handlePlayQueueAdd={handlePlayQueueAdd}
itemData={itemData[i]}
/>
)}
</ControlsContainer>
)}
</Image>
</ImageSection>
<DetailSection>
{cardRows.map((row: CardRow) => (
<Row key={`row-${row.prop}`}>
<Text overflow="hidden" weight={500}>
{itemData[i] && itemData[i][row.prop]}
</Text>
</Row>
))}
</DetailSection>
</StyledCard>
</Image>
</ImageSection>
<DetailSection>
{cardRows.map((row: CardRow) => (
<Row key={`row-${row.prop}`}>
<Text overflow="hidden" weight={500}>
{itemData[i] && itemData[i][row.prop]}
</Text>
</Row>
))}
</DetailSection>
</StyledCard>
</Skeleton>
</CardWrapper>
);
}
@@ -14,11 +14,13 @@ export const VirtualGridWrapper = ({
itemCount,
columnCount,
rowCount,
itemData,
...rest
}: Omit<FixedSizeListProps, 'ref' | 'itemSize' | 'children'> & {
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 (
<FixedSizeList
style={{ scrollBehavior: 'smooth' }}
{...rest}
ref={refInstance}
initialScrollOffset={0}
{...rest}
itemCount={rowCount}
itemData={itemData}
itemData={memo}
itemSize={itemHeight + itemGap}
overscanCount={10}
>
@@ -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<FixedSizeListProps, 'children' | 'itemSize'> {
cardControls: any;
cardRows: CardRow[];
itemGap?: number;
@@ -20,119 +16,113 @@ interface VirtualGridProps
queryParams?: Record<string, any>;
}
export const VirtualInfiniteGrid = forwardRef(
(
{
itemCount,
itemGap,
itemSize,
cardControls,
cardRows,
minimumBatchSize,
query,
queryParams,
}: VirtualGridProps,
ref: Ref<InfiniteLoader>
) => {
const [itemData, setItemData] = useState<any[]>([]);
export const VirtualInfiniteGrid = ({
itemCount,
itemGap,
itemSize,
cardControls,
cardRows,
minimumBatchSize,
query,
queryParams,
height,
width,
}: VirtualGridProps) => {
const [itemData, setItemData] = useState<any[]>([]);
const listRef = useRef<any>(null);
const loader = useRef<InfiniteLoader>(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<void>((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 (
<AutoSizer>
{({ 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 (
<InfiniteLoader
ref={ref}
isItemLoaded={(index) => isItemLoaded(index, columnCount)}
itemCount={itemCount || 0}
loadMoreItems={(startIndex, stopIndex) =>
debouncedLoadMoreItems(
startIndex,
stopIndex,
pageItemLimit,
columnCount
)
}
minimumBatchSize={minimumBatchSize}
threshold={10}
>
{({ onItemsRendered, ref: infiniteLoaderRef }) => (
<VirtualGridWrapper
useIsScrolling
cardControls={cardControls}
cardRows={cardRows}
columnCount={columnCount}
height={height}
itemCount={itemCount || 0}
itemData={itemData}
itemGap={itemGap!}
itemHeight={itemHeight!}
itemWidth={itemSize}
refInstance={infiniteLoaderRef}
rowCount={rowCount}
width={width}
onItemsRendered={onItemsRendered}
/>
)}
</InfiniteLoader>
);
}}
</AutoSizer>
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 (
<InfiniteLoader
ref={loader}
isItemLoaded={(index) => isItemLoaded(index)}
itemCount={itemCount || 0}
loadMoreItems={(startIndex, stopIndex) =>
debouncedLoadMoreItems(startIndex, stopIndex)
}
minimumBatchSize={minimumBatchSize}
threshold={30}
>
{({ onItemsRendered, ref: infiniteLoaderRef }) => (
<VirtualGridWrapper
useIsScrolling
cardControls={cardControls}
cardRows={cardRows}
columnCount={columnCount}
height={height}
itemCount={itemCount || 0}
itemData={itemData}
itemGap={itemGap!}
itemHeight={itemHeight!}
itemWidth={itemSize}
refInstance={(list) => {
infiniteLoaderRef(list);
listRef.current = list;
}}
rowCount={rowCount}
width={width}
onItemsRendered={onItemsRendered}
/>
)}
</InfiniteLoader>
);
};
VirtualInfiniteGrid.defaultProps = {
itemGap: 10,
@@ -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<ViewType>;
menuProps: MenuProps;
type: ViewType;
}
export const ViewTypeButton = ({
type,
menuProps,
handler,
}: ViewTypeButtonProps) => {
return (
<Menu {...menuProps}>
<Menu.Target>
<ActionIcon variant="transparent">
{type === ViewType.Grid ? (
<LayoutGrid />
) : type === ViewType.Detail ? (
<LayoutList />
) : (
<Table />
)}
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
icon={<LayoutGrid size={14} />}
onClick={() => handler(ViewType.Grid)}
>
Grid
</Menu.Item>
<Menu.Item
icon={<LayoutList size={14} />}
onClick={() => handler(ViewType.Detail)}
>
Detail
</Menu.Item>
<Menu.Item
icon={<Table size={14} />}
onClick={() => handler(ViewType.Table)}
>
Table
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
@@ -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 }) =>
@@ -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<InfiniteLoader>(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 (
<AnimatedPage>
{albums && (
<VirtualInfiniteGrid
ref={infiniteLoaderRef}
cardControls={{
endpoint: albumsApi.getAlbum,
idProperty: 'id',
type: Item.Album,
}}
cardRows={[
{
align: 'center',
prop: 'name',
route: {
prop: 'id',
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
},
},
{
align: 'center',
prop: 'year',
},
]}
itemCount={albums.pagination.totalEntries}
itemGap={20}
itemSize={180}
query={albumsApi.getAlbums}
queryParams={params}
/>
)}
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Group mb={10} position="apart">
<Menu position="bottom-start">
<Menu.Target>
<Button variant="subtle">
{
FILTERS.find((filter) => filter.value === filters.sortBy)
?.name
}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
rightSection={<CaretDown size={12} />}
onClick={() => setFilters({ sortBy: AlbumSort.TITLE })}
>
Title
</Menu.Item>
<Menu.Item
rightSection={<CaretDown size={12} />}
onClick={() => setFilters({ sortBy: AlbumSort.YEAR })}
>
Year
</Menu.Item>
<Menu.Item
rightSection={<CaretDown size={12} />}
onClick={() => setFilters({ sortBy: AlbumSort.RATING })}
>
Rating
</Menu.Item>
<Menu.Item
rightSection={<CaretDown size={12} />}
onClick={() => setFilters({ sortBy: AlbumSort.DATE_RELEASED })}
>
Date Released
</Menu.Item>
<Menu.Item
rightSection={<CaretDown size={12} />}
onClick={() => setFilters({ sortBy: AlbumSort.DATE_ADDED })}
>
Date Added
</Menu.Item>
<Menu.Item
rightSection={<CaretDown size={12} />}
onClick={() =>
setFilters({ sortBy: AlbumSort.DATE_ADDED_REMOTE })
}
>
Date Added (Remote)
</Menu.Item>
</Menu.Dropdown>
</Menu>
<ViewTypeButton
handler={setViewType}
menuProps={{ position: 'bottom-end' }}
type={viewType}
/>
</Group>
<div style={{ flex: 1 }}>
{albums && (
<AutoSizer>
{({ height, width }) => (
<VirtualInfiniteGrid
cardControls={{
endpoint: albumsApi.getAlbum,
idProperty: 'id',
type: Item.Album,
}}
cardRows={[
{
align: 'center',
prop: 'name',
route: {
prop: 'id',
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
},
},
{
align: 'center',
prop: 'year',
},
]}
height={height}
itemCount={albums.pagination.totalEntries}
itemGap={20}
itemSize={180}
minimumBatchSize={100}
query={albumsApi.getAlbums}
queryParams={filters}
width={width}
/>
)}
</AutoSizer>
)}
</div>
</div>
</AnimatedPage>
);
};