Improve grid

This commit is contained in:
jeffvli
2022-10-28 13:02:55 -07:00
parent 2352c136a1
commit 00710125d3
4 changed files with 193 additions and 129 deletions
@@ -1,9 +1,16 @@
import React from 'react'; import React from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { Button, UnstyledButton, UnstyledButtonProps } from '@mantine/core'; import { Box, UnstyledButton, Group, UnstyledButtonProps } from '@mantine/core';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { RiPlayFill } from 'react-icons/ri'; import {
import { Play } from '../../../types'; RiPlayFill,
RiMore2Fill,
RiHeartFill,
RiHeartLine,
} from 'react-icons/ri';
import { Button } from '@/renderer/components/button';
import { DropdownMenu } from '@/renderer/components/dropdown-menu';
import { Play } from '@/renderer/types';
type PlayButtonType = UnstyledButtonProps & type PlayButtonType = UnstyledButtonProps &
React.ComponentPropsWithoutRef<'button'>; React.ComponentPropsWithoutRef<'button'>;
@@ -14,23 +21,24 @@ const PlayButton = styled(UnstyledButton)<PlayButtonType>`
justify-content: center; justify-content: center;
width: 50px; width: 50px;
height: 50px; height: 50px;
border: 1px solid var(--primary-color); background-color: rgb(255, 255, 255);
border-radius: 50%; border-radius: 50%;
cursor: default;
opacity: 0.8; opacity: 0.8;
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
transition: scale 0.2s ease-in;
&:hover { &:hover {
opacity: 1; opacity: 1;
scale: 1.1;
} }
svg { svg {
fill: #000; fill: rgb(0, 0, 0);
stroke: #000; stroke: rgb(0, 0, 0);
} }
`; `;
const GridCardControlsContainer = styled.div` const GridCardControlsContainer = styled(Box)`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -48,36 +56,45 @@ const TopControls = styled(ControlsRow)`
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
padding: 0.5rem;
`; `;
const CenterControls = styled(ControlsRow)` const CenterControls = styled(ControlsRow)`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0.5rem;
`; `;
const BottomControls = styled(ControlsRow)` const BottomControls = styled(ControlsRow)`
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
justify-content: space-between; justify-content: space-between;
padding: 1rem 0.5rem;
`;
const FavoriteWrapper = styled.span<{ isFavorite: boolean }>`
svg {
fill: ${(props) => props.isFavorite && 'var(--primary-color)'};
}
`; `;
export const GridCardControls = ({ export const GridCardControls = ({
itemData, itemData,
itemType,
handlePlayQueueAdd, handlePlayQueueAdd,
cardControls,
}: any) => { }: any) => {
return ( return (
<GridCardControlsContainer> <GridCardControlsContainer>
<TopControls /> <TopControls />
<CenterControls animate={{ opacity: 1 }} initial={{ opacity: 0 }}> <CenterControls animate={{ opacity: 1 }} initial={{ opacity: 0 }} />
<BottomControls>
<PlayButton <PlayButton
onClick={() => { onClick={() => {
handlePlayQueueAdd({ handlePlayQueueAdd({
byItemType: { byItemType: {
endpoint: cardControls.endpoint, id: itemData.id,
id: itemData[cardControls.idProperty], type: itemType,
type: cardControls.type,
}, },
play: Play.NOW, play: Play.NOW,
}); });
@@ -85,36 +102,32 @@ export const GridCardControls = ({
> >
<RiPlayFill size={25} /> <RiPlayFill size={25} />
</PlayButton> </PlayButton>
</CenterControls> <Group spacing="xs">
<BottomControls> <Button disabled p={5} variant="subtle">
<Button <FavoriteWrapper isFavorite={itemData?.isFavorite}>
onClick={() => { {itemData?.isFavorite ? (
handlePlayQueueAdd({ <RiHeartFill size={20} />
byItemType: { ) : (
endpoint: cardControls.endpoint, <RiHeartLine color="white" size={20} />
id: itemData[cardControls.idProperty], )}
type: cardControls.type, </FavoriteWrapper>
}, </Button>
play: Play.NEXT, <DropdownMenu withinPortal position="bottom-start">
}); <DropdownMenu.Target>
}} <Button p={5} variant="subtle">
> <RiMore2Fill color="white" size={20} />
NEXT </Button>
</Button> </DropdownMenu.Target>
<Button <DropdownMenu.Dropdown>
onClick={() => { <DropdownMenu.Item>Play next</DropdownMenu.Item>
handlePlayQueueAdd({ <DropdownMenu.Item>Play later</DropdownMenu.Item>
byItemType: { <DropdownMenu.Divider />
endpoint: cardControls.endpoint, <DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
id: itemData[cardControls.idProperty], <DropdownMenu.Divider />
type: cardControls.type, <DropdownMenu.Item disabled>Refresh metadata</DropdownMenu.Item>
}, </DropdownMenu.Dropdown>
play: Play.LAST, </DropdownMenu>
}); </Group>
}}
>
LATER
</Button>
</BottomControls> </BottomControls>
</GridCardControlsContainer> </GridCardControlsContainer>
); );
@@ -1,12 +1,11 @@
import React from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { Skeleton } from '@mantine/core'; import { Skeleton } from '@mantine/core';
import { motion } from 'framer-motion'; import { GridCardControls } from '@/renderer/components/virtual-grid/grid-card-controls';
import { fadeIn } from '@/renderer/styles';
import { CardRow } from '../../types'; import { CardRow } from '../../types';
import { Text } from '../text'; import { Text } from '../text';
import { GridCardControls } from './grid-card-controls';
const CardWrapper = styled(motion.div)<{ const CardWrapper = styled.div<{
itemGap: number; itemGap: number;
itemHeight: number; itemHeight: number;
itemWidth: number; itemWidth: number;
@@ -15,11 +14,20 @@ const CardWrapper = styled(motion.div)<{
width: ${({ itemWidth }) => `${itemWidth}px`}; width: ${({ itemWidth }) => `${itemWidth}px`};
height: ${({ itemHeight }) => `${itemHeight}px`}; height: ${({ itemHeight }) => `${itemHeight}px`};
margin: ${({ itemGap }) => `0 ${itemGap / 2}px`}; margin: ${({ itemGap }) => `0 ${itemGap / 2}px`};
filter: drop-shadow(0 4px 4px #000);
transition: border 0.2s ease-in-out; transition: border 0.2s ease-in-out;
user-select: none; user-select: none;
pointer-events: auto; // https://github.com/bvaughn/react-window/issues/128#issuecomment-460166682 pointer-events: auto; // https://github.com/bvaughn/react-window/issues/128#issuecomment-460166682
&:hover div {
opacity: 1;
}
&:hover * {
&::before {
opacity: 0.5;
}
}
&:focus-visible { &:focus-visible {
outline: 1px solid #fff; outline: 1px solid #fff;
} }
@@ -37,8 +45,8 @@ const StyledCard = styled.div`
`; `;
const ImageSection = styled.div` const ImageSection = styled.div`
position: relative;
width: 100%; width: 100%;
height: 100%;
`; `;
interface ImageProps { interface ImageProps {
@@ -46,31 +54,42 @@ interface ImageProps {
src: string; src: string;
} }
// const Image = styled(motion.div).attrs((props: ImageProps) => ({ const Image = styled.div<ImageProps>`
// style: { ${fadeIn};
// background: `url(${props.src})`,
// backgroundPosition: 'center',
// backgroundSize: 'cover',
// },
// }))<ImageProps>`
// height: ${({ height }) => `${height}px`};
// background-position: center;
// background-size: cover;
// border: 0;
// `;
const Image = styled(motion.div)<ImageProps>`
height: ${({ height }) => `${height}px`}; height: ${({ height }) => `${height}px`};
background: ${({ src }) => `url(${src})`}; background-image: ${({ src }) => `url(${src})`};
background-position: center; background-position: center;
background-size: cover; background-size: cover;
border: 0; border: 0;
border-radius: 5px;
transition: background-image 0.5s ease-in-out;
&::before {
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: 100%;
height: 100%;
background: linear-gradient(
0deg,
rgba(0, 0, 0, 100%) 35%,
rgba(0, 0, 0, 0%) 100%
);
opacity: 0;
transition: all 0.2s ease-in-out;
content: '';
user-select: none;
}
`; `;
const ControlsContainer = styled.div` const ControlsContainer = styled.div`
display: block; position: absolute;
bottom: 0;
z-index: 50;
width: 100%; width: 100%;
height: 100%; opacity: 0;
transition: all 0.2s ease-in-out;
`; `;
const DetailSection = styled.div` const DetailSection = styled.div`
@@ -94,6 +113,7 @@ export const GridCard = ({ data, index, style }: any) => {
handlePlayQueueAdd, handlePlayQueueAdd,
cardRows, cardRows,
itemData, itemData,
itemType,
} = data; } = data;
const startIndex = index * columnCount; const startIndex = index * columnCount;
@@ -101,40 +121,67 @@ export const GridCard = ({ data, index, style }: any) => {
const cards = []; const cards = [];
for (let i = startIndex; i <= stopIndex; i += 1) { for (let i = startIndex; i <= stopIndex; i += 1) {
cards.push( if (itemData[i]) {
<React.Fragment key={`card-${i}-${index}`}> cards.push(
<CardWrapper <CardWrapper
key={`card-${i}-${index}`}
itemGap={itemGap} itemGap={itemGap}
itemHeight={itemHeight} itemHeight={itemHeight}
itemWidth={itemWidth} itemWidth={itemWidth}
> >
<Skeleton visible={!itemData[i]}> <StyledCard>
<StyledCard> <ImageSection style={{ height: `${itemWidth}px` }}>
<ImageSection> <Image height={itemWidth} src={itemData[i]?.imageUrl} />
<Image height={itemWidth} src={itemData[i]?.imageUrl}> <ControlsContainer>
<ControlsContainer> <GridCardControls
<GridCardControls cardControls={cardControls}
cardControls={cardControls} handlePlayQueueAdd={handlePlayQueueAdd}
handlePlayQueueAdd={handlePlayQueueAdd} itemData={itemData[i]}
itemData={itemData[i]} itemType={itemType}
/> />
</ControlsContainer> </ControlsContainer>
</Image> </ImageSection>
</ImageSection> <DetailSection>
<DetailSection> {cardRows.map((row: CardRow) => (
{cardRows.map((row: CardRow) => ( <Row>
<Row key={`row-${row.prop}`}> <Text overflow="hidden" to={row.route?.route} weight={500}>
<Text overflow="hidden" weight={500}> {itemData[i] && itemData[i][row.prop]}
{itemData[i] && itemData[i][row.prop]} </Text>
</Text> </Row>
</Row> ))}
))} </DetailSection>
</DetailSection> </StyledCard>
</StyledCard>
</Skeleton>
</CardWrapper> </CardWrapper>
</React.Fragment> );
); } else {
cards.push(
<CardWrapper
key={`card-${i}-${index}`}
itemGap={itemGap}
itemHeight={itemHeight}
itemWidth={itemWidth}
>
<StyledCard>
<Skeleton visible radius="sm">
<ImageSection style={{ height: `${itemWidth}px` }} />
</Skeleton>
<DetailSection>
{cardRows.map((row: CardRow, index: number) => (
<Skeleton
key={`row-${row.prop}`}
my={2}
radius="md"
visible={!itemData[i]}
width={!itemData[i] ? (index > 0 ? '50%' : '90%') : '100%'}
>
<Row />
</Skeleton>
))}
</DetailSection>
</StyledCard>
</CardWrapper>
);
}
} }
return ( return (
@@ -1,14 +1,15 @@
import { Ref, useMemo } from 'react'; import { Ref, useMemo } from 'react';
import styled from '@emotion/styled';
import { FixedSizeList, FixedSizeListProps } from 'react-window'; import { FixedSizeList, FixedSizeListProps } from 'react-window';
import { GridCard } from '@/renderer/components/virtual-grid/grid-card'; import { GridCard } from '@/renderer/components/virtual-grid/grid-card';
import { usePlayQueueHandler } from '@/renderer/features/player/hooks/use-playqueue-handler'; import { usePlayQueueHandler } from '@/renderer/features/player/hooks/use-playqueue-handler';
import { CardRow } from '@/renderer/types'; import { CardRow, LibraryItem } from '@/renderer/types';
export const VirtualGridWrapper = ({ export const VirtualGridWrapper = ({
refInstance, refInstance,
cardControls,
cardRows, cardRows,
itemGap, itemGap,
itemType,
itemWidth, itemWidth,
itemHeight, itemHeight,
itemCount, itemCount,
@@ -17,12 +18,12 @@ export const VirtualGridWrapper = ({
itemData, itemData,
...rest ...rest
}: Omit<FixedSizeListProps, 'ref' | 'itemSize' | 'children'> & { }: Omit<FixedSizeListProps, 'ref' | 'itemSize' | 'children'> & {
cardControls: any;
cardRows: CardRow[]; cardRows: CardRow[];
columnCount: number; columnCount: number;
itemData: any[]; itemData: any[];
itemGap: number; itemGap: number;
itemHeight: number; itemHeight: number;
itemType: LibraryItem;
itemWidth: number; itemWidth: number;
refInstance: Ref<any>; refInstance: Ref<any>;
rowCount: number; rowCount: number;
@@ -31,7 +32,6 @@ export const VirtualGridWrapper = ({
const memo = useMemo( const memo = useMemo(
() => ({ () => ({
cardControls,
cardRows, cardRows,
columnCount, columnCount,
handlePlayQueueAdd, handlePlayQueueAdd,
@@ -39,11 +39,12 @@ export const VirtualGridWrapper = ({
itemData, itemData,
itemGap, itemGap,
itemHeight, itemHeight,
itemType,
itemWidth, itemWidth,
}), }),
[ [
cardControls,
cardRows, cardRows,
itemType,
columnCount, columnCount,
handlePlayQueueAdd, handlePlayQueueAdd,
itemCount, itemCount,
@@ -67,3 +68,13 @@ export const VirtualGridWrapper = ({
</FixedSizeList> </FixedSizeList>
); );
}; };
export const VirtualGridContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`;
export const VirtualGridAutoSizerContainer = styled.div`
flex: 1;
`;
@@ -3,31 +3,31 @@ import debounce from 'lodash/debounce';
import { FixedSizeListProps } from 'react-window'; import { FixedSizeListProps } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader'; import InfiniteLoader from 'react-window-infinite-loader';
import { VirtualGridWrapper } from '@/renderer/components/virtual-grid/virtual-grid-wrapper'; import { VirtualGridWrapper } from '@/renderer/components/virtual-grid/virtual-grid-wrapper';
import { CardRow } from '@/renderer/types'; import { CardRow, LibraryItem } from '@/renderer/types';
interface VirtualGridProps interface VirtualGridProps
extends Omit<FixedSizeListProps, 'children' | 'itemSize'> { extends Omit<FixedSizeListProps, 'children' | 'itemSize'> {
cardControls: any;
cardRows: CardRow[]; cardRows: CardRow[];
itemGap?: number; fetchFn: (options: {
columnCount: number;
skip: number;
take: number;
}) => Promise<any>;
itemGap: number;
itemSize: number; itemSize: number;
itemType: LibraryItem;
minimumBatchSize?: number; minimumBatchSize?: number;
query: ({ serverId }: { serverId: string }, props: any) => Promise<any>;
queryParams?: Record<string, any>;
serverId: string;
} }
export const VirtualInfiniteGrid = ({ export const VirtualInfiniteGrid = ({
itemCount, itemCount,
itemGap, itemGap,
itemSize, itemSize,
cardControls, itemType,
cardRows, cardRows,
minimumBatchSize, minimumBatchSize,
query, fetchFn,
queryParams,
height, height,
serverId,
width, width,
}: VirtualGridProps) => { }: VirtualGridProps) => {
const [itemData, setItemData] = useState<any[]>([]); const [itemData, setItemData] = useState<any[]>([]);
@@ -36,7 +36,7 @@ export const VirtualInfiniteGrid = ({
const { itemHeight, rowCount, columnCount } = useMemo(() => { const { itemHeight, rowCount, columnCount } = useMemo(() => {
const itemsPerRow = Math.floor( const itemsPerRow = Math.floor(
(Number(width) - itemGap! + 3) / (itemSize! + itemGap! + 2) (Number(width) - itemGap + 3) / (itemSize! + itemGap + 2)
); );
return { return {
@@ -60,20 +60,17 @@ export const VirtualInfiniteGrid = ({
const start = startIndex * columnCount; const start = startIndex * columnCount;
const end = stopIndex * columnCount + columnCount; const end = stopIndex * columnCount + columnCount;
const t = await query( const items = await fetchFn({
{ serverId }, columnCount,
{ skip: start,
skip: start, take: end - start,
take: end - start, });
...queryParams,
}
);
const newData: any[] = [...itemData]; const newData: any[] = [...itemData];
let itemIndex = 0; let itemIndex = 0;
for (let rowIndex = start; rowIndex < end; rowIndex += 1) { for (let rowIndex = start; rowIndex < end; rowIndex += 1) {
newData[rowIndex] = t.data[itemIndex]; newData[rowIndex] = items.data[itemIndex];
itemIndex += 1; itemIndex += 1;
} }
@@ -85,36 +82,34 @@ export const VirtualInfiniteGrid = ({
useEffect(() => { useEffect(() => {
if (loader.current) { if (loader.current) {
listRef.current.scrollTo(0); listRef.current.scrollTo(0);
loader.current.resetloadMoreItemsCache(true); loader.current.resetloadMoreItemsCache(false);
setItemData(() => []); setItemData(() => []);
loadMoreItems(0, minimumBatchSize! * 2); loadMoreItems(0, minimumBatchSize! * 2);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [minimumBatchSize, queryParams, setItemData]); }, [minimumBatchSize, fetchFn]);
return ( return (
<InfiniteLoader <InfiniteLoader
ref={loader} ref={loader}
isItemLoaded={(index) => isItemLoaded(index)} isItemLoaded={(index) => isItemLoaded(index)}
itemCount={itemCount || 0} itemCount={itemCount || 0}
loadMoreItems={(startIndex, stopIndex) => loadMoreItems={debouncedLoadMoreItems}
debouncedLoadMoreItems(startIndex, stopIndex)
}
minimumBatchSize={minimumBatchSize} minimumBatchSize={minimumBatchSize}
threshold={30} threshold={30}
> >
{({ onItemsRendered, ref: infiniteLoaderRef }) => ( {({ onItemsRendered, ref: infiniteLoaderRef }) => (
<VirtualGridWrapper <VirtualGridWrapper
useIsScrolling useIsScrolling
cardControls={cardControls}
cardRows={cardRows} cardRows={cardRows}
columnCount={columnCount} columnCount={columnCount}
height={height} height={height}
itemCount={itemCount || 0} itemCount={itemCount || 0}
itemData={itemData} itemData={itemData}
itemGap={itemGap!} itemGap={itemGap}
itemHeight={itemHeight! + itemGap! / 2} itemHeight={itemHeight + itemGap / 2}
itemType={itemType}
itemWidth={itemSize} itemWidth={itemSize}
refInstance={(list) => { refInstance={(list) => {
infiniteLoaderRef(list); infiniteLoaderRef(list);
@@ -130,7 +125,5 @@ export const VirtualInfiniteGrid = ({
}; };
VirtualInfiniteGrid.defaultProps = { VirtualInfiniteGrid.defaultProps = {
itemGap: 10,
minimumBatchSize: 20, minimumBatchSize: 20,
queryParams: {},
}; };