mirror of
https://github.com/antebudimir/feishin.git
synced 2026-01-01 10:23:33 +00:00
Add files
This commit is contained in:
commit
e87c814068
266 changed files with 63938 additions and 0 deletions
335
src/renderer/components/virtual-grid/grid-card/default-card.tsx
Normal file
335
src/renderer/components/virtual-grid/grid-card/default-card.tsx
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
import React from 'react';
|
||||
import { Center } from '@mantine/core';
|
||||
import { RiAlbumFill } from 'react-icons/ri';
|
||||
import { generatePath, useNavigate } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { SimpleImg } from 'react-simple-img';
|
||||
import type { ListChildComponentProps } from 'react-window';
|
||||
import styled from 'styled-components';
|
||||
import { Text } from '/@/renderer/components/text';
|
||||
import type { LibraryItem, CardRow, CardRoute, Play } from '/@/renderer/types';
|
||||
import GridCardControls from './grid-card-controls';
|
||||
import { Skeleton } from '/@/renderer/components/skeleton';
|
||||
|
||||
const CardWrapper = styled.div<{
|
||||
itemGap: number;
|
||||
itemHeight: number;
|
||||
itemWidth: number;
|
||||
link?: boolean;
|
||||
}>`
|
||||
flex: ${({ itemWidth }) => `0 0 ${itemWidth - 12}px`};
|
||||
width: ${({ itemWidth }) => `${itemWidth}px`};
|
||||
height: ${({ itemHeight, itemGap }) => `${itemHeight - 12 - itemGap}px`};
|
||||
margin: ${({ itemGap }) => `0 ${itemGap / 2}px`};
|
||||
padding: 12px 12px 0;
|
||||
background: var(--card-default-bg);
|
||||
border-radius: var(--card-default-radius);
|
||||
cursor: ${({ link }) => link && 'pointer'};
|
||||
transition: border 0.2s ease-in-out, background 0.2s ease-in-out;
|
||||
user-select: none;
|
||||
pointer-events: auto; // https://github.com/bvaughn/react-window/issues/128#issuecomment-460166682
|
||||
|
||||
&:hover {
|
||||
background: var(--card-default-bg-hover);
|
||||
}
|
||||
|
||||
&: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: var(--card-default-radius);
|
||||
`;
|
||||
|
||||
const ImageSection = styled.div<{ size?: number }>`
|
||||
position: relative;
|
||||
width: ${({ size }) => size && `${size - 24}px`};
|
||||
height: ${({ size }) => size && `${size - 24}px`};
|
||||
border-radius: var(--card-default-radius);
|
||||
|
||||
&::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 Image = styled(SimpleImg)`
|
||||
border-radius: var(--card-default-radius);
|
||||
box-shadow: 2px 2px 10px 10px rgba(0, 0, 0, 20%);
|
||||
`;
|
||||
|
||||
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: 22px;
|
||||
padding: 0 0.2rem;
|
||||
overflow: hidden;
|
||||
color: ${({ $secondary }) => ($secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
interface BaseGridCardProps {
|
||||
columnIndex: number;
|
||||
controls: {
|
||||
cardRows: CardRow[];
|
||||
itemType: LibraryItem;
|
||||
playButtonBehavior: Play;
|
||||
route: CardRoute;
|
||||
};
|
||||
data: any;
|
||||
listChildProps: Omit<ListChildComponentProps, 'data' | 'style'>;
|
||||
sizes: {
|
||||
itemGap: number;
|
||||
itemHeight: number;
|
||||
itemWidth: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const DefaultCard = ({
|
||||
listChildProps,
|
||||
data,
|
||||
columnIndex,
|
||||
controls,
|
||||
sizes,
|
||||
}: BaseGridCardProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { index } = listChildProps;
|
||||
const { itemGap, itemHeight, itemWidth } = sizes;
|
||||
const { itemType, cardRows, route } = controls;
|
||||
|
||||
const cardSize = itemWidth - 24;
|
||||
|
||||
if (data) {
|
||||
return (
|
||||
<CardWrapper
|
||||
key={`card-${columnIndex}-${index}`}
|
||||
link
|
||||
itemGap={itemGap}
|
||||
itemHeight={itemHeight}
|
||||
itemWidth={itemWidth}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
generatePath(
|
||||
route.route,
|
||||
route.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]: data[slug.idProperty],
|
||||
};
|
||||
}, {}),
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<StyledCard>
|
||||
<ImageSection size={itemWidth}>
|
||||
{data?.imageUrl ? (
|
||||
<Image
|
||||
animationDuration={0.3}
|
||||
height={cardSize}
|
||||
imgStyle={{ objectFit: 'cover' }}
|
||||
placeholder="var(--card-default-bg)"
|
||||
src={data?.imageUrl}
|
||||
width={cardSize}
|
||||
/>
|
||||
) : (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-default-radius)',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<RiAlbumFill
|
||||
color="var(--placeholder-fg)"
|
||||
size={35}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
<ControlsContainer>
|
||||
<GridCardControls
|
||||
itemData={data}
|
||||
itemType={itemType}
|
||||
/>
|
||||
</ControlsContainer>
|
||||
</ImageSection>
|
||||
<DetailSection>
|
||||
{cardRows.map((row: CardRow, index: number) => {
|
||||
if (row.arrayProperty && row.route) {
|
||||
return (
|
||||
<Row
|
||||
key={`row-${row.property}-${columnIndex}`}
|
||||
$secondary={index > 0}
|
||||
>
|
||||
{data[row.property].map((item: any, itemIndex: number) => (
|
||||
<React.Fragment key={`${data.id}-${item.id}`}>
|
||||
{itemIndex > 0 && (
|
||||
<Text
|
||||
$noSelect
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
padding: '0 2px 0 1px',
|
||||
}}
|
||||
>
|
||||
,
|
||||
</Text>
|
||||
)}{' '}
|
||||
<Text
|
||||
$link
|
||||
$noSelect
|
||||
$secondary={index > 0}
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size={index > 0 ? 'xs' : 'md'}
|
||||
to={generatePath(
|
||||
row.route!.route,
|
||||
row.route!.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]: data[slug.idProperty],
|
||||
};
|
||||
}, {}),
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{row.arrayProperty && item[row.arrayProperty]}
|
||||
</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
if (row.arrayProperty) {
|
||||
return (
|
||||
<Row key={`row-${row.property}-${columnIndex}`}>
|
||||
{data[row.property].map((item: any) => (
|
||||
<Text
|
||||
key={`${data.id}-${item.id}`}
|
||||
$noSelect
|
||||
$secondary={index > 0}
|
||||
overflow="hidden"
|
||||
size={index > 0 ? 'xs' : 'md'}
|
||||
>
|
||||
{row.arrayProperty && item[row.arrayProperty]}
|
||||
</Text>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Row key={`row-${row.property}-${columnIndex}`}>
|
||||
{row.route ? (
|
||||
<Text
|
||||
$link
|
||||
$noSelect
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
to={generatePath(
|
||||
row.route.route,
|
||||
row.route.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]: data[slug.idProperty],
|
||||
};
|
||||
}, {}),
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{data && data[row.property]}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
$noSelect
|
||||
$secondary={index > 0}
|
||||
overflow="hidden"
|
||||
size={index > 0 ? 'xs' : 'md'}
|
||||
>
|
||||
{data && data[row.property]}
|
||||
</Text>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</DetailSection>
|
||||
</StyledCard>
|
||||
</CardWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CardWrapper
|
||||
key={`card-${columnIndex}-${index}`}
|
||||
itemGap={itemGap}
|
||||
itemHeight={itemHeight}
|
||||
itemWidth={itemWidth + 12}
|
||||
>
|
||||
<StyledCard>
|
||||
<Skeleton
|
||||
visible
|
||||
radius="sm"
|
||||
>
|
||||
<ImageSection size={itemWidth} />
|
||||
</Skeleton>
|
||||
<DetailSection>
|
||||
{cardRows.map((row: CardRow, index: number) => (
|
||||
<Skeleton
|
||||
key={`row-${row.property}-${columnIndex}`}
|
||||
height={20}
|
||||
my={2}
|
||||
radius="md"
|
||||
visible={!data}
|
||||
width={!data ? (index > 0 ? '50%' : '90%') : '100%'}
|
||||
>
|
||||
<Row />
|
||||
</Skeleton>
|
||||
))}
|
||||
</DetailSection>
|
||||
</StyledCard>
|
||||
</CardWrapper>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
import type { MouseEvent } from 'react';
|
||||
import React from 'react';
|
||||
import type { UnstyledButtonProps } from '@mantine/core';
|
||||
import { Group } from '@mantine/core';
|
||||
import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
import { _Button } from '/@/renderer/components/button';
|
||||
import { DropdownMenu } from '/@/renderer/components/dropdown-menu';
|
||||
import type { LibraryItem } from '/@/renderer/types';
|
||||
import { Play } from '/@/renderer/types';
|
||||
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
|
||||
type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>;
|
||||
|
||||
const PlayButton = styled.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 linear;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
scale: 1.1;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: rgb(0, 0, 0);
|
||||
stroke: rgb(0, 0, 0);
|
||||
}
|
||||
`;
|
||||
|
||||
const SecondaryButton = styled(_Button)`
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
transition: scale 0.2s linear;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
scale: 1.1;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const GridCardControlsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const ControlsRow = styled.div`
|
||||
width: 100%;
|
||||
height: calc(100% / 3);
|
||||
`;
|
||||
|
||||
// const TopControls = styled(ControlsRow)`
|
||||
// display: flex;
|
||||
// align-items: flex-start;
|
||||
// justify-content: space-between;
|
||||
// padding: 0.5rem;
|
||||
// `;
|
||||
|
||||
// const CenterControls = styled(ControlsRow)`
|
||||
// display: flex;
|
||||
// align-items: center;
|
||||
// justify-content: center;
|
||||
// padding: 0.5rem;
|
||||
// `;
|
||||
|
||||
const BottomControls = styled(ControlsRow)`
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 0.5rem;
|
||||
`;
|
||||
|
||||
const FavoriteWrapper = styled.span<{ isFavorite: boolean }>`
|
||||
svg {
|
||||
fill: ${(props) => props.isFavorite && 'var(--primary-color)'};
|
||||
}
|
||||
`;
|
||||
|
||||
const PLAY_TYPES = [
|
||||
{
|
||||
label: 'Play',
|
||||
play: Play.NOW,
|
||||
},
|
||||
{
|
||||
label: 'Play last',
|
||||
play: Play.LAST,
|
||||
},
|
||||
{
|
||||
label: 'Play next',
|
||||
play: Play.NEXT,
|
||||
},
|
||||
];
|
||||
|
||||
export const GridCardControls = ({
|
||||
itemData,
|
||||
itemType,
|
||||
}: {
|
||||
itemData: any;
|
||||
itemType: LibraryItem;
|
||||
}) => {
|
||||
const playButtonBehavior = useSettingsStore((state) => state.player.playButtonBehavior);
|
||||
|
||||
const handlePlay = (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
import('/@/renderer/features/player/utils/handle-playqueue-add').then((fn) => {
|
||||
fn.handlePlayQueueAdd({
|
||||
byItemType: {
|
||||
id: itemData.id,
|
||||
type: itemType,
|
||||
},
|
||||
play: playType || playButtonBehavior,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<GridCardControlsContainer>
|
||||
{/* <TopControls /> */}
|
||||
{/* <CenterControls /> */}
|
||||
<BottomControls>
|
||||
<PlayButton onClick={handlePlay}>
|
||||
<RiPlayFill size={25} />
|
||||
</PlayButton>
|
||||
<Group spacing="xs">
|
||||
<SecondaryButton
|
||||
disabled
|
||||
p={5}
|
||||
sx={{ svg: { fill: 'white !important' } }}
|
||||
variant="subtle"
|
||||
>
|
||||
<FavoriteWrapper isFavorite={itemData?.isFavorite}>
|
||||
{itemData?.isFavorite ? (
|
||||
<RiHeartFill size={20} />
|
||||
) : (
|
||||
<RiHeartLine
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
</FavoriteWrapper>
|
||||
</SecondaryButton>
|
||||
<DropdownMenu
|
||||
withinPortal
|
||||
position="bottom-start"
|
||||
>
|
||||
<DropdownMenu.Target>
|
||||
<SecondaryButton
|
||||
p={5}
|
||||
sx={{ svg: { fill: 'white !important' } }}
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<RiMore2Fill
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
</SecondaryButton>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => (
|
||||
<DropdownMenu.Item
|
||||
key={`playtype-${type.play}`}
|
||||
onClick={(e: MouseEvent<HTMLButtonElement>) => handlePlay(e, type.play)}
|
||||
>
|
||||
{type.label}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
|
||||
<DropdownMenu.Item disabled>Refresh metadata</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
</BottomControls>
|
||||
</GridCardControlsContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default GridCardControls;
|
||||
61
src/renderer/components/virtual-grid/grid-card/index.tsx
Normal file
61
src/renderer/components/virtual-grid/grid-card/index.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { memo } from 'react';
|
||||
import type { ListChildComponentProps } from 'react-window';
|
||||
import { areEqual } from 'react-window';
|
||||
import { DefaultCard } from '/@/renderer/components/virtual-grid/grid-card/default-card';
|
||||
import { PosterCard } from '/@/renderer/components/virtual-grid/grid-card/poster-card';
|
||||
import type { GridCardData } from '/@/renderer/types';
|
||||
import { CardDisplayType } from '/@/renderer/types';
|
||||
|
||||
export const GridCard = memo(({ data, index, style }: ListChildComponentProps) => {
|
||||
const {
|
||||
itemHeight,
|
||||
itemWidth,
|
||||
columnCount,
|
||||
itemGap,
|
||||
itemCount,
|
||||
cardRows,
|
||||
itemData,
|
||||
itemType,
|
||||
playButtonBehavior,
|
||||
route,
|
||||
display,
|
||||
} = data as GridCardData;
|
||||
const cards = [];
|
||||
const startIndex = index * columnCount;
|
||||
const stopIndex = Math.min(itemCount - 1, startIndex + columnCount - 1);
|
||||
|
||||
const View = display === CardDisplayType.CARD ? DefaultCard : PosterCard;
|
||||
|
||||
for (let i = startIndex; i <= stopIndex; i += 1) {
|
||||
cards.push(
|
||||
<View
|
||||
key={`card-${i}-${index}`}
|
||||
columnIndex={i}
|
||||
controls={{
|
||||
cardRows,
|
||||
itemType,
|
||||
playButtonBehavior,
|
||||
route,
|
||||
}}
|
||||
data={itemData[i]}
|
||||
listChildProps={{ index }}
|
||||
sizes={{ itemGap, itemHeight, itemWidth }}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
>
|
||||
{cards}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}, areEqual);
|
||||
329
src/renderer/components/virtual-grid/grid-card/poster-card.tsx
Normal file
329
src/renderer/components/virtual-grid/grid-card/poster-card.tsx
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
import React from 'react';
|
||||
import { Center } from '@mantine/core';
|
||||
import { RiAlbumFill } from 'react-icons/ri';
|
||||
import { generatePath } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { SimpleImg } from 'react-simple-img';
|
||||
import type { ListChildComponentProps } from 'react-window';
|
||||
import styled from 'styled-components';
|
||||
import { Skeleton } from '/@/renderer/components/skeleton';
|
||||
import { Text } from '/@/renderer/components/text';
|
||||
import type { LibraryItem, CardRow, CardRoute, Play } from '/@/renderer/types';
|
||||
import GridCardControls from './grid-card-controls';
|
||||
|
||||
const CardWrapper = styled.div<{
|
||||
itemGap: number;
|
||||
itemHeight: number;
|
||||
itemWidth: number;
|
||||
}>`
|
||||
flex: ${({ itemWidth }) => `0 0 ${itemWidth}px`};
|
||||
width: ${({ itemWidth }) => `${itemWidth}px`};
|
||||
height: ${({ itemHeight, itemGap }) => `${itemHeight - itemGap}px`};
|
||||
margin: ${({ itemGap }) => `0 ${itemGap / 2}px`};
|
||||
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;
|
||||
background: var(--card-poster-bg);
|
||||
border-radius: var(--card-poster-radius);
|
||||
|
||||
&:hover {
|
||||
background: var(--card-poster-bg-hover);
|
||||
}
|
||||
`;
|
||||
|
||||
const ImageSection = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-radius: var(--card-poster-radius);
|
||||
|
||||
&::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(SimpleImg)<ImageProps>`
|
||||
border: 0;
|
||||
border-radius: var(--card-poster-radius);
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
}
|
||||
`;
|
||||
|
||||
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: 22px;
|
||||
padding: 0 0.2rem;
|
||||
overflow: hidden;
|
||||
color: ${({ $secondary }) => ($secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
interface BaseGridCardProps {
|
||||
columnIndex: number;
|
||||
controls: {
|
||||
cardRows: CardRow[];
|
||||
itemType: LibraryItem;
|
||||
playButtonBehavior: Play;
|
||||
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) => {
|
||||
if (data) {
|
||||
return (
|
||||
<CardWrapper
|
||||
key={`card-${columnIndex}-${listChildProps.index}`}
|
||||
itemGap={sizes.itemGap}
|
||||
itemHeight={sizes.itemHeight}
|
||||
itemWidth={sizes.itemWidth}
|
||||
>
|
||||
<StyledCard>
|
||||
<Link
|
||||
tabIndex={0}
|
||||
to={generatePath(
|
||||
controls.route.route,
|
||||
controls.route.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]: data[slug.idProperty],
|
||||
};
|
||||
}, {}),
|
||||
)}
|
||||
>
|
||||
<ImageSection style={{ height: `${sizes.itemWidth}px` }}>
|
||||
{data?.imageUrl ? (
|
||||
<Image
|
||||
animationDuration={0.3}
|
||||
height={sizes.itemWidth}
|
||||
importance="auto"
|
||||
placeholder="var(--card-default-bg)"
|
||||
src={data?.imageUrl}
|
||||
width={sizes.itemWidth}
|
||||
/>
|
||||
) : (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-poster-radius)',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<RiAlbumFill
|
||||
color="var(--placeholder-fg)"
|
||||
size={35}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
<ControlsContainer>
|
||||
<GridCardControls
|
||||
itemData={data}
|
||||
itemType={controls.itemType}
|
||||
/>
|
||||
</ControlsContainer>
|
||||
</ImageSection>
|
||||
</Link>
|
||||
<DetailSection>
|
||||
{controls.cardRows.map((row: CardRow, index: number) => {
|
||||
if (row.arrayProperty && row.route) {
|
||||
return (
|
||||
<Row
|
||||
key={`row-${row.property}-${columnIndex}`}
|
||||
$secondary={index > 0}
|
||||
>
|
||||
{data[row.property].map((item: any, itemIndex: number) => (
|
||||
<React.Fragment key={`${data.id}-${item.id}`}>
|
||||
{itemIndex > 0 && (
|
||||
<Text
|
||||
$noSelect
|
||||
size={index > 0 ? 'xs' : 'md'}
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
padding: '0 2px 0 1px',
|
||||
}}
|
||||
>
|
||||
,
|
||||
</Text>
|
||||
)}{' '}
|
||||
<Text
|
||||
$link
|
||||
$noSelect
|
||||
$secondary={index > 0}
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size={index > 0 ? 'xs' : 'md'}
|
||||
to={generatePath(
|
||||
row.route!.route,
|
||||
row.route!.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]: data[slug.idProperty],
|
||||
};
|
||||
}, {}),
|
||||
)}
|
||||
>
|
||||
{row.arrayProperty && item[row.arrayProperty]}
|
||||
</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
if (row.arrayProperty) {
|
||||
return (
|
||||
<Row key={`row-${row.property}-${columnIndex}`}>
|
||||
{data[row.property].map((item: any) => (
|
||||
<Text
|
||||
key={`${data.id}-${item.id}`}
|
||||
$noSelect
|
||||
$secondary={index > 0}
|
||||
overflow="hidden"
|
||||
size={index > 0 ? 'xs' : 'md'}
|
||||
>
|
||||
{row.arrayProperty && item[row.arrayProperty]}
|
||||
</Text>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Row key={`row-${row.property}-${columnIndex}`}>
|
||||
{row.route ? (
|
||||
<Text
|
||||
$link
|
||||
$noSelect
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size={index > 0 ? 'xs' : 'md'}
|
||||
to={generatePath(
|
||||
row.route.route,
|
||||
row.route.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]: data[slug.idProperty],
|
||||
};
|
||||
}, {}),
|
||||
)}
|
||||
>
|
||||
{data && data[row.property]}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
$noSelect
|
||||
$secondary={index > 0}
|
||||
overflow="hidden"
|
||||
size={index > 0 ? 'xs' : 'md'}
|
||||
>
|
||||
{data && data[row.property]}
|
||||
</Text>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</DetailSection>
|
||||
</StyledCard>
|
||||
</CardWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CardWrapper
|
||||
key={`card-${columnIndex}-${listChildProps.index}`}
|
||||
itemGap={sizes.itemGap}
|
||||
itemHeight={sizes.itemHeight}
|
||||
itemWidth={sizes.itemWidth}
|
||||
>
|
||||
<StyledCard>
|
||||
<Skeleton
|
||||
visible
|
||||
radius="sm"
|
||||
>
|
||||
<ImageSection style={{ height: `${sizes.itemWidth}px` }} />
|
||||
</Skeleton>
|
||||
<DetailSection>
|
||||
{controls.cardRows.map((row: CardRow, index: number) => (
|
||||
<Skeleton
|
||||
key={`row-${row.property}-${columnIndex}`}
|
||||
height={20}
|
||||
my={2}
|
||||
radius="md"
|
||||
visible={!data}
|
||||
width={!data ? (index > 0 ? '50%' : '90%') : '100%'}
|
||||
>
|
||||
<Row />
|
||||
</Skeleton>
|
||||
))}
|
||||
</DetailSection>
|
||||
</StyledCard>
|
||||
</CardWrapper>
|
||||
);
|
||||
};
|
||||
2
src/renderer/components/virtual-grid/index.ts
Normal file
2
src/renderer/components/virtual-grid/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './virtual-grid-wrapper';
|
||||
export * from './virtual-infinite-grid';
|
||||
110
src/renderer/components/virtual-grid/virtual-grid-wrapper.tsx
Normal file
110
src/renderer/components/virtual-grid/virtual-grid-wrapper.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import type { Ref } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import memoize from 'memoize-one';
|
||||
import type { FixedSizeListProps } from 'react-window';
|
||||
import { FixedSizeList } from 'react-window';
|
||||
import styled from 'styled-components';
|
||||
import { GridCard } from '/@/renderer/components/virtual-grid/grid-card';
|
||||
import type { CardRow, LibraryItem, CardDisplayType, CardRoute } from '/@/renderer/types';
|
||||
|
||||
const createItemData = memoize(
|
||||
(
|
||||
cardRows,
|
||||
columnCount,
|
||||
display,
|
||||
itemCount,
|
||||
itemData,
|
||||
itemGap,
|
||||
itemHeight,
|
||||
itemType,
|
||||
itemWidth,
|
||||
route,
|
||||
) => ({
|
||||
cardRows,
|
||||
columnCount,
|
||||
display,
|
||||
itemCount,
|
||||
itemData,
|
||||
itemGap,
|
||||
itemHeight,
|
||||
itemType,
|
||||
itemWidth,
|
||||
route,
|
||||
}),
|
||||
);
|
||||
|
||||
const createScrollHandler = memoize((onScroll) => debounce(onScroll, 250));
|
||||
|
||||
export const VirtualGridWrapper = ({
|
||||
refInstance,
|
||||
cardRows,
|
||||
itemGap,
|
||||
itemType,
|
||||
itemWidth,
|
||||
display,
|
||||
itemHeight,
|
||||
itemCount,
|
||||
columnCount,
|
||||
rowCount,
|
||||
initialScrollOffset,
|
||||
itemData,
|
||||
route,
|
||||
onScroll,
|
||||
...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 memoizedItemData = createItemData(
|
||||
cardRows,
|
||||
columnCount,
|
||||
display,
|
||||
itemCount,
|
||||
itemData,
|
||||
itemGap,
|
||||
itemHeight,
|
||||
itemType,
|
||||
itemWidth,
|
||||
route,
|
||||
);
|
||||
|
||||
const memoizedOnScroll = createScrollHandler(onScroll);
|
||||
|
||||
return (
|
||||
<FixedSizeList
|
||||
ref={refInstance}
|
||||
{...rest}
|
||||
initialScrollOffset={initialScrollOffset}
|
||||
itemCount={rowCount}
|
||||
itemData={memoizedItemData}
|
||||
itemSize={itemHeight}
|
||||
overscanCount={5}
|
||||
onScroll={memoizedOnScroll}
|
||||
>
|
||||
{GridCard}
|
||||
</FixedSizeList>
|
||||
);
|
||||
};
|
||||
|
||||
VirtualGridWrapper.defaultProps = {
|
||||
route: undefined,
|
||||
};
|
||||
|
||||
export const VirtualGridContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
export const VirtualGridAutoSizerContainer = styled.div`
|
||||
flex: 1;
|
||||
`;
|
||||
154
src/renderer/components/virtual-grid/virtual-infinite-grid.tsx
Normal file
154
src/renderer/components/virtual-grid/virtual-infinite-grid.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import type { FixedSizeListProps } from 'react-window';
|
||||
import InfiniteLoader from 'react-window-infinite-loader';
|
||||
import { VirtualGridWrapper } from '/@/renderer/components/virtual-grid/virtual-grid-wrapper';
|
||||
import type { CardRoute, CardRow, LibraryItem } from '/@/renderer/types';
|
||||
import { CardDisplayType } from '/@/renderer/types';
|
||||
|
||||
interface VirtualGridProps extends Omit<FixedSizeListProps, 'children' | 'itemSize'> {
|
||||
cardRows: CardRow[];
|
||||
display?: CardDisplayType;
|
||||
fetchFn: (options: { columnCount: number; skip: number; take: number }) => Promise<any>;
|
||||
itemGap: number;
|
||||
itemSize: number;
|
||||
itemType: LibraryItem;
|
||||
minimumBatchSize?: number;
|
||||
refresh?: any; // Pass in any value to refresh the grid when changed
|
||||
route?: CardRoute;
|
||||
}
|
||||
|
||||
const constrainWidth = (width: number) => {
|
||||
if (width < 1920) {
|
||||
return width;
|
||||
}
|
||||
|
||||
return 1920;
|
||||
};
|
||||
|
||||
export const VirtualInfiniteGrid = ({
|
||||
itemCount,
|
||||
itemGap,
|
||||
itemSize,
|
||||
itemType,
|
||||
cardRows,
|
||||
route,
|
||||
onScroll,
|
||||
display,
|
||||
minimumBatchSize,
|
||||
fetchFn,
|
||||
initialScrollOffset,
|
||||
height,
|
||||
width,
|
||||
refresh,
|
||||
}: VirtualGridProps) => {
|
||||
const [itemData, setItemData] = useState<any[]>([]);
|
||||
const listRef = useRef<any>(null);
|
||||
const loader = useRef<InfiniteLoader>(null);
|
||||
|
||||
const { itemHeight, rowCount, columnCount } = useMemo(() => {
|
||||
const itemsPerRow = Math.floor(
|
||||
(constrainWidth(Number(width)) - itemGap + 3) / (itemSize! + itemGap + 2),
|
||||
);
|
||||
|
||||
return {
|
||||
columnCount: itemsPerRow,
|
||||
itemHeight: itemSize! + cardRows.length * 22 + itemGap,
|
||||
itemWidth: itemSize! + itemGap,
|
||||
rowCount: Math.ceil(itemCount / itemsPerRow),
|
||||
};
|
||||
}, [cardRows.length, itemCount, itemGap, itemSize, width]);
|
||||
|
||||
const isItemLoaded = useCallback(
|
||||
(index: number) => {
|
||||
const itemIndex = index * columnCount;
|
||||
|
||||
return itemData[itemIndex] !== undefined;
|
||||
},
|
||||
[columnCount, itemData],
|
||||
);
|
||||
|
||||
const loadMoreItems = useCallback(
|
||||
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 data = await fetchFn({
|
||||
columnCount,
|
||||
skip: start,
|
||||
take: end - start,
|
||||
});
|
||||
|
||||
const newData: any[] = [...itemData];
|
||||
|
||||
let itemIndex = 0;
|
||||
for (let rowIndex = start; rowIndex < end; rowIndex += 1) {
|
||||
newData[rowIndex] = data.items[itemIndex];
|
||||
itemIndex += 1;
|
||||
}
|
||||
|
||||
setItemData(newData);
|
||||
},
|
||||
[columnCount, fetchFn, itemData],
|
||||
);
|
||||
|
||||
const debouncedLoadMoreItems = debounce(loadMoreItems, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (loader.current) {
|
||||
listRef.current.scrollTo(0);
|
||||
loader.current.resetloadMoreItemsCache(false);
|
||||
setItemData(() => []);
|
||||
|
||||
loadMoreItems(0, minimumBatchSize! * 2);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [minimumBatchSize, fetchFn, refresh]);
|
||||
|
||||
return (
|
||||
<InfiniteLoader
|
||||
ref={loader}
|
||||
isItemLoaded={(index) => isItemLoaded(index)}
|
||||
itemCount={itemCount || 0}
|
||||
loadMoreItems={debouncedLoadMoreItems}
|
||||
minimumBatchSize={minimumBatchSize}
|
||||
threshold={30}
|
||||
>
|
||||
{({ onItemsRendered, ref: infiniteLoaderRef }) => (
|
||||
<VirtualGridWrapper
|
||||
cardRows={cardRows}
|
||||
columnCount={columnCount}
|
||||
display={display || CardDisplayType.CARD}
|
||||
height={height}
|
||||
initialScrollOffset={initialScrollOffset}
|
||||
itemCount={itemCount || 0}
|
||||
itemData={itemData}
|
||||
itemGap={itemGap}
|
||||
itemHeight={itemHeight + itemGap / 2}
|
||||
itemType={itemType}
|
||||
itemWidth={itemSize}
|
||||
refInstance={(list) => {
|
||||
infiniteLoaderRef(list);
|
||||
listRef.current = list;
|
||||
}}
|
||||
route={route}
|
||||
rowCount={rowCount}
|
||||
width={width}
|
||||
onItemsRendered={onItemsRendered}
|
||||
onScroll={onScroll}
|
||||
/>
|
||||
)}
|
||||
</InfiniteLoader>
|
||||
);
|
||||
};
|
||||
|
||||
VirtualInfiniteGrid.defaultProps = {
|
||||
display: CardDisplayType.CARD,
|
||||
minimumBatchSize: 20,
|
||||
refresh: undefined,
|
||||
route: undefined,
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue