Update grid card styles and props

This commit is contained in:
jeffvli
2022-11-05 03:11:51 -07:00
parent ae53b17214
commit e014ac0a4b
8 changed files with 524 additions and 255 deletions
@@ -1,220 +0,0 @@
import styled from '@emotion/styled';
import { Center, Skeleton } from '@mantine/core';
import { RiAlbumFill } from 'react-icons/ri';
import { Link, generatePath } from 'react-router-dom';
import { Text } from '@/renderer/components/text';
import { GridCardControls } from '@/renderer/components/virtual-grid/grid-card-controls';
import { AppRoute } from '@/renderer/router/routes';
import { fadeIn } from '@/renderer/styles';
import { CardRow } from '@/renderer/types';
const CardWrapper = styled.div<{
itemGap: number;
itemHeight: number;
itemWidth: number;
}>`
flex: ${({ itemWidth }) => `0 0 ${itemWidth}px`};
width: ${({ itemWidth }) => `${itemWidth}px`};
height: ${({ itemHeight }) => `${itemHeight}px`};
margin: ${({ itemGap }) => `0 ${itemGap / 2}px`};
transition: border 0.2s ease-in-out;
user-select: none;
pointer-events: auto; // https://github.com/bvaughn/react-window/issues/128#issuecomment-460166682
&:hover div {
opacity: 1;
}
&:hover * {
&::before {
opacity: 0.5;
}
}
&:focus-visible {
outline: 1px solid #fff;
}
`;
const StyledCard = styled.div`
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
height: 100%;
padding: 0;
border-radius: 3px;
`;
const ImageSection = styled.div`
position: relative;
width: 100%;
`;
interface ImageProps {
height: number;
src: string;
}
const Image = styled.div<ImageProps>`
${fadeIn};
height: ${({ height }) => `${height}px`};
background-image: ${({ src }) => `url(${src})`};
background-position: center;
background-size: cover;
border: 0;
border-radius: 5px;
&::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`
position: absolute;
bottom: 0;
z-index: 50;
width: 100%;
opacity: 0;
transition: all 0.2s ease-in-out;
`;
const DetailSection = styled.div`
display: flex;
flex-direction: column;
`;
const Row = styled.div`
height: 25px;
padding: 0 0.2rem;
`;
export const GridCard = ({ data, index, style }: any) => {
const {
itemHeight,
itemWidth,
columnCount,
itemGap,
itemCount,
cardControls,
handlePlayQueueAdd,
cardRows,
itemData,
itemType,
} = data;
const startIndex = index * columnCount;
const stopIndex = Math.min(itemCount - 1, startIndex + columnCount - 1);
const cards = [];
for (let i = startIndex; i <= stopIndex; i += 1) {
if (itemData[i]) {
cards.push(
<CardWrapper
key={`card-${i}-${index}`}
itemGap={itemGap}
itemHeight={itemHeight}
itemWidth={itemWidth}
>
<StyledCard>
<Link
tabIndex={0}
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: itemData[i]?.id,
})}
>
<ImageSection style={{ height: `${itemWidth}px` }}>
{itemData[i]?.imageUrl ? (
<Image height={itemWidth} src={itemData[i]?.imageUrl} />
) : (
<Center
sx={{
background: 'var(--placeholder-bg)',
borderRadius: '5px',
height: '100%',
}}
>
<RiAlbumFill color="var(--placeholder-fg)" size={35} />
</Center>
)}
<ControlsContainer>
<GridCardControls
cardControls={cardControls}
handlePlayQueueAdd={handlePlayQueueAdd}
itemData={itemData[i]}
itemType={itemType}
/>
</ControlsContainer>
</ImageSection>
</Link>
<DetailSection>
{cardRows.map((row: CardRow) => (
<Row key={row.prop}>
<Text overflow="hidden">
{itemData[i] && itemData[i][row.prop]}
</Text>
</Row>
))}
</DetailSection>
</StyledCard>
</CardWrapper>
);
} 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 (
<div
style={{
...style,
alignItems: 'center',
display: 'flex',
justifyContent: 'start',
}}
>
{cards}
</div>
);
};
@@ -1,6 +1,6 @@
import React from 'react';
import React, { MouseEvent } from 'react';
import styled from '@emotion/styled';
import { Box, UnstyledButton, Group, UnstyledButtonProps } from '@mantine/core';
import { Group, UnstyledButtonProps } from '@mantine/core';
import { motion } from 'framer-motion';
import {
RiPlayFill,
@@ -15,30 +15,25 @@ import { Play } from '@/renderer/types';
type PlayButtonType = UnstyledButtonProps &
React.ComponentPropsWithoutRef<'button'>;
const PlayButton = styled(UnstyledButton)<PlayButtonType>`
const PlayButton = styled(motion.button)<PlayButtonType>`
display: flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
background-color: rgb(255, 255, 255);
border: none;
border-radius: 50%;
opacity: 0.8;
transition: opacity 0.2s ease-in-out;
transition: scale 0.2s ease-in;
&:hover {
opacity: 1;
scale: 1.1;
}
svg {
fill: rgb(0, 0, 0);
stroke: rgb(0, 0, 0);
}
`;
const GridCardControlsContainer = styled(Box)`
const GridCardControlsContainer = styled.div`
display: flex;
flex-direction: column;
align-items: center;
@@ -90,7 +85,11 @@ export const GridCardControls = ({
<CenterControls animate={{ opacity: 1 }} initial={{ opacity: 0 }} />
<BottomControls>
<PlayButton
onClick={() => {
animate={{ opacity: 0.6 }}
whileHover={{ opacity: 1, scale: 1.1 }}
whileTap={{ scale: 1 }}
onClick={(e) => {
e.preventDefault();
handlePlayQueueAdd({
byItemType: {
id: itemData.id,
@@ -114,13 +113,43 @@ export const GridCardControls = ({
</Button>
<DropdownMenu withinPortal position="bottom-start">
<DropdownMenu.Target>
<Button p={5} variant="subtle">
<Button
p={5}
variant="subtle"
onClick={(e) => e.preventDefault()}
>
<RiMore2Fill color="white" size={20} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item>Play next</DropdownMenu.Item>
<DropdownMenu.Item>Play later</DropdownMenu.Item>
<DropdownMenu.Item
onClick={(e: MouseEvent) => {
e.preventDefault();
handlePlayQueueAdd({
byItemType: {
id: itemData.id,
type: itemType,
},
play: Play.LAST,
});
}}
>
Play later
</DropdownMenu.Item>
<DropdownMenu.Item
onClick={(e: MouseEvent) => {
e.preventDefault();
handlePlayQueueAdd({
byItemType: {
id: itemData.id,
type: itemType,
},
play: Play.NEXT,
});
}}
>
Play next
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
<DropdownMenu.Divider />
@@ -0,0 +1,61 @@
import { ListChildComponentProps } from 'react-window';
import { PosterCard } from '@/renderer/components/virtual-grid/grid-card/poster-card';
import { GridCardData } from '@/renderer/types';
export const GridCard = ({
data,
index,
style,
isScrolling,
}: ListChildComponentProps) => {
const {
itemHeight,
itemWidth,
columnCount,
itemGap,
itemCount,
cardControls,
handlePlayQueueAdd,
cardRows,
itemData,
itemType,
route,
display,
} = data as GridCardData;
const startIndex = index * columnCount;
const stopIndex = Math.min(itemCount - 1, startIndex + columnCount - 1);
const cards = [];
for (let i = startIndex; i <= stopIndex; i += 1) {
cards.push(
<PosterCard
key={`card-${i}-${index}`}
columnIndex={i}
controls={{
cardControls,
cardRows,
handlePlayQueueAdd,
itemType,
route,
}}
data={itemData[i]}
listChildProps={{ index, isScrolling }}
sizes={{ itemGap, itemHeight, itemWidth }}
/>
);
}
return (
<div
style={{
...style,
alignItems: 'center',
display: 'flex',
justifyContent: 'start',
}}
>
{cards}
</div>
);
};
@@ -0,0 +1,338 @@
import styled from '@emotion/styled';
import { Center, Skeleton } from '@mantine/core';
import { RiAlbumFill } from 'react-icons/ri';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import { ListChildComponentProps } from 'react-window';
import { Text } from '@/renderer/components/text';
import { GridCardControls } from '@/renderer/components/virtual-grid/grid-card/grid-card-controls';
import { fadeIn } from '@/renderer/styles';
import {
PlayQueueAddOptions,
LibraryItem,
CardRow,
CardRoute,
} from '@/renderer/types';
const CardWrapper = styled.div<{
itemGap: number;
itemHeight: number;
itemWidth: number;
}>`
flex: ${({ itemWidth }) => `0 0 ${itemWidth}px`};
width: ${({ itemWidth }) => `${itemWidth}px`};
height: ${({ itemHeight }) => `${itemHeight}px`};
margin: ${({ itemGap }) => `0 ${itemGap / 2}px`};
transition: border 0.2s ease-in-out;
user-select: none;
pointer-events: auto; // https://github.com/bvaughn/react-window/issues/128#issuecomment-460166682
&:hover div {
opacity: 1;
}
&:hover * {
&::before {
opacity: 0.5;
}
}
&:focus-visible {
outline: 1px solid #fff;
}
`;
const StyledCard = styled.div`
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
height: 100%;
padding: 0;
border-radius: 5px;
`;
const ImageSection = styled.div`
position: relative;
width: 100%;
border-radius: 5px;
&::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;
}
`;
interface ImageProps {
height: number;
isLoading?: boolean;
}
const Image = styled.img<ImageProps>`
${fadeIn};
width: ${({ height }) => `${height}px`};
height: ${({ height }) => `${height}px`};
object-fit: cover;
border: 0;
border-radius: 2px;
`;
const ControlsContainer = styled.div`
position: absolute;
bottom: 0;
z-index: 50;
width: 100%;
opacity: 0;
transition: all 0.2s ease-in-out;
`;
const DetailSection = styled.div`
display: flex;
flex-direction: column;
`;
const Row = styled.div<{ secondary?: boolean }>`
width: 100%;
max-width: 100%;
height: 25px;
padding: 0 0.2rem;
overflow: hidden;
color: ${({ secondary }) =>
secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)'};
white-space: nowrap;
text-overflow: ellipsis;
`;
interface BaseGridCardProps {
columnIndex: number;
controls: {
cardControls: any[];
cardRows: CardRow[];
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
itemType: LibraryItem;
route?: CardRoute;
};
data: any;
listChildProps: Omit<ListChildComponentProps, 'data' | 'style'>;
sizes: {
itemGap: number;
itemHeight: number;
itemWidth: number;
};
}
export const PosterCard = ({
listChildProps,
data,
columnIndex,
controls,
sizes,
}: BaseGridCardProps) => {
const { isScrolling, index } = listChildProps;
const { itemGap, itemHeight, itemWidth } = sizes;
const { cardControls, handlePlayQueueAdd, itemType, cardRows, route } =
controls;
if (data) {
return (
<CardWrapper
key={`card-${columnIndex}-${index}`}
itemGap={itemGap}
itemHeight={itemHeight}
itemWidth={itemWidth}
>
<StyledCard>
{route ? (
<Link
tabIndex={0}
to={generatePath(
route.route,
route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {})
)}
>
<ImageSection style={{ height: `${itemWidth}px` }}>
{data?.imageUrl ? (
<Image height={itemWidth} src={data?.imageUrl} />
) : (
<Center
sx={{
background: 'var(--placeholder-bg)',
borderRadius: '5px',
height: '100%',
}}
>
<RiAlbumFill color="var(--placeholder-fg)" size={35} />
</Center>
)}
<ControlsContainer>
{!isScrolling && (
<GridCardControls
cardControls={cardControls}
handlePlayQueueAdd={handlePlayQueueAdd}
itemData={data}
itemType={itemType}
/>
)}
</ControlsContainer>
</ImageSection>
</Link>
) : (
<ImageSection style={{ height: `${itemWidth}px` }}>
{data?.imageUrl ? (
<Image height={itemWidth} src={data?.imageUrl} />
) : (
<Center
sx={{
background: 'var(--placeholder-bg)',
borderRadius: '5px',
height: '100%',
}}
>
<RiAlbumFill color="var(--placeholder-fg)" size={35} />
</Center>
)}
<ControlsContainer>
{!isScrolling && (
<GridCardControls
cardControls={cardControls}
handlePlayQueueAdd={handlePlayQueueAdd}
itemData={data}
itemType={itemType}
/>
)}
</ControlsContainer>
</ImageSection>
)}
<DetailSection>
{cardRows.map((row: CardRow, index: number) => {
if (row.arrayProperty) {
if (row.route) {
return (
<Row secondary={index > 0}>
{data[row.property].map(
(item: any, itemIndex: number) => (
<>
{itemIndex > 0 && (
<Text
sx={{
display: 'inline-block',
padding: '0 2px 0 1px',
}}
>
,
</Text>
)}{' '}
<Text
link
component={Link}
overflow="hidden"
secondary={index > 0}
to={generatePath(
row.route!.route,
row.route!.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {})
)}
>
{row.arrayProperty && item[row.arrayProperty]}
</Text>
</>
)
)}
</Row>
);
}
return (
<Row>
{data[row.property].map((item: any) => (
<Text overflow="hidden" secondary={index > 0}>
{row.arrayProperty && item[row.arrayProperty]}
</Text>
))}
</Row>
);
}
return (
<Row key={row.property}>
{row.route ? (
<Text
link
component={Link}
overflow="hidden"
to={generatePath(
row.route.route,
row.route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {})
)}
>
{data && data[row.property]}
</Text>
) : (
<Text overflow="hidden" secondary={index > 0}>
{data && data[row.property]}
</Text>
)}
</Row>
);
})}
</DetailSection>
</StyledCard>
</CardWrapper>
);
}
return (
<CardWrapper
key={`card-${columnIndex}-${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.property}`}
my={2}
radius="md"
visible={!data}
width={!data ? (index > 0 ? '50%' : '90%') : '100%'}
>
<Row />
</Skeleton>
))}
</DetailSection>
</StyledCard>
</CardWrapper>
);
};
@@ -3,7 +3,12 @@ import styled from '@emotion/styled';
import { FixedSizeList, FixedSizeListProps } from 'react-window';
import { GridCard } from '@/renderer/components/virtual-grid/grid-card';
import { usePlayQueueHandler } from '@/renderer/features/player/hooks/use-playqueue-handler';
import { CardRow, LibraryItem } from '@/renderer/types';
import {
CardRow,
LibraryItem,
CardDisplayType,
CardRoute,
} from '@/renderer/types';
export const VirtualGridWrapper = ({
refInstance,
@@ -16,16 +21,19 @@ export const VirtualGridWrapper = ({
columnCount,
rowCount,
itemData,
route,
...rest
}: Omit<FixedSizeListProps, 'ref' | 'itemSize' | 'children'> & {
cardRows: CardRow[];
columnCount: number;
display: CardDisplayType;
itemData: any[];
itemGap: number;
itemHeight: number;
itemType: LibraryItem;
itemWidth: number;
refInstance: Ref<any>;
route?: CardRoute;
rowCount: number;
}) => {
const handlePlayQueueAdd = usePlayQueueHandler();
@@ -41,6 +49,7 @@ export const VirtualGridWrapper = ({
itemHeight,
itemType,
itemWidth,
route,
}),
[
cardRows,
@@ -51,6 +60,7 @@ export const VirtualGridWrapper = ({
itemData,
itemGap,
itemHeight,
route,
itemWidth,
]
);
@@ -59,16 +69,21 @@ export const VirtualGridWrapper = ({
<FixedSizeList
ref={refInstance}
{...rest}
useIsScrolling
itemCount={rowCount}
itemData={memo}
itemSize={itemHeight + itemGap}
overscanCount={10}
overscanCount={5}
>
{GridCard}
</FixedSizeList>
);
};
VirtualGridWrapper.defaultProps = {
route: undefined,
};
export const VirtualGridContainer = styled.div`
display: flex;
flex-direction: column;
@@ -3,11 +3,17 @@ import debounce from 'lodash/debounce';
import { FixedSizeListProps } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
import { VirtualGridWrapper } from '@/renderer/components/virtual-grid/virtual-grid-wrapper';
import { CardRow, LibraryItem } from '@/renderer/types';
import {
CardDisplayType,
CardRoute,
CardRow,
LibraryItem,
} from '@/renderer/types';
interface VirtualGridProps
extends Omit<FixedSizeListProps, 'children' | 'itemSize'> {
cardRows: CardRow[];
display?: CardDisplayType;
fetchFn: (options: {
columnCount: number;
skip: number;
@@ -17,6 +23,8 @@ interface VirtualGridProps
itemSize: number;
itemType: LibraryItem;
minimumBatchSize?: number;
refresh?: any; // Pass in any value to refresh the grid when changed
route?: CardRoute;
}
export const VirtualInfiniteGrid = ({
@@ -25,10 +33,13 @@ export const VirtualInfiniteGrid = ({
itemSize,
itemType,
cardRows,
route,
display,
minimumBatchSize,
fetchFn,
height,
width,
refresh,
}: VirtualGridProps) => {
const [itemData, setItemData] = useState<any[]>([]);
const listRef = useRef<any>(null);
@@ -88,7 +99,7 @@ export const VirtualInfiniteGrid = ({
loadMoreItems(0, minimumBatchSize! * 2);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [minimumBatchSize, fetchFn]);
}, [minimumBatchSize, fetchFn, refresh]);
return (
<InfiniteLoader
@@ -104,6 +115,7 @@ export const VirtualInfiniteGrid = ({
useIsScrolling
cardRows={cardRows}
columnCount={columnCount}
display={display || CardDisplayType.CARD}
height={height}
itemCount={itemCount || 0}
itemData={itemData}
@@ -115,6 +127,7 @@ export const VirtualInfiniteGrid = ({
infiniteLoaderRef(list);
listRef.current = list;
}}
route={route}
rowCount={rowCount}
width={width}
onItemsRendered={onItemsRendered}
@@ -125,5 +138,8 @@ export const VirtualInfiniteGrid = ({
};
VirtualInfiniteGrid.defaultProps = {
display: CardDisplayType.CARD,
minimumBatchSize: 20,
refresh: undefined,
route: undefined,
};
@@ -3,8 +3,8 @@ import { api } from '@/renderer/api';
import { queryKeys } from '@/renderer/api/query-keys';
import { useServerCredential } from '@/renderer/features/shared';
import { useAuthStore, usePlayerStore } from '@/renderer/store';
import { LibraryItem, Play } from '@/renderer/types';
import { mpvPlayer } from '../utils/mpvPlayer';
import { LibraryItem, Play, PlayQueueAddOptions } from '@/renderer/types';
import { mpvPlayer } from '../utils/mpv-player';
export const usePlayQueueHandler = () => {
const queryClient = useQueryClient();
@@ -12,14 +12,7 @@ export const usePlayQueueHandler = () => {
const { serverToken, isImageTokenRequired } = useServerCredential();
const addToQueue = usePlayerStore((state) => state.addToQueue);
const handlePlayQueueAdd = async (options: {
byData?: any[];
byItemType?: {
id: string;
type: LibraryItem;
};
play: Play;
}) => {
const handlePlayQueueAdd = async (options: PlayQueueAddOptions) => {
if (options.byData) {
// dispatchSongsToQueue(options.byData, options.play);
}
@@ -65,6 +58,7 @@ export const usePlayQueueHandler = () => {
mpvPlayer.setQueueNext(playerData);
} else {
mpvPlayer.setQueue(playerData);
mpvPlayer.play();
}
}
};
+43 -7
View File
@@ -1,12 +1,24 @@
import { AppRoute } from './router/routes';
export interface CardRow {
align?: 'left' | 'center' | 'right';
prop: string;
route?: {
prop: string;
route: AppRoute | string;
};
export type RouteSlug = {
idProperty: string;
slugProperty: string;
};
export type CardRoute = {
route: AppRoute | string;
slugs?: RouteSlug[];
};
export type CardRow = {
arrayProperty?: string;
property: string;
route?: CardRoute;
};
export enum CardDisplayType {
CARD = 'card',
POSTER = 'poster',
}
export enum LibraryItem {
@@ -74,3 +86,27 @@ export enum SortOrder {
ASC = 'asc',
DESC = 'desc',
}
export type PlayQueueAddOptions = {
byData?: any[];
byItemType?: {
id: string;
type: LibraryItem;
};
play: Play;
};
export type GridCardData = {
cardControls: any;
cardRows: CardRow[];
columnCount: number;
display: CardDisplayType;
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
itemCount: number;
itemData: any[];
itemGap: number;
itemHeight: number;
itemType: LibraryItem;
itemWidth: number;
route?: CardRoute;
};