Add initial home route

This commit is contained in:
jeffvli
2022-12-14 19:17:27 -08:00
parent acf873f0d6
commit c1c70ff576
8 changed files with 743 additions and 13 deletions
@@ -0,0 +1,302 @@
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 styled from 'styled-components';
import { Text } from '/@/components/text';
import type { LibraryItem, CardRow, CardRoute, Play } from '/@/types';
import { Skeleton } from '/@/components/skeleton';
import CardControls from '/@/features/shared/components/card-controls';
const CardWrapper = styled.div<{
link?: boolean;
}>`
padding: 1rem;
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;
&: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`
position: relative;
display: flex;
justify-content: center;
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 {
controls: {
cardRows: CardRow[];
itemType: LibraryItem;
playButtonBehavior: Play;
route: CardRoute;
};
data: any;
loading?: boolean;
size: number;
}
export const AlbumCard = ({ loading, size, data, controls }: BaseGridCardProps) => {
const navigate = useNavigate();
const { itemType, cardRows, route } = controls;
if (loading) {
return (
<CardWrapper>
<StyledCard style={{ alignItems: 'center', display: 'flex' }}>
<Skeleton
visible
height={size}
radius="sm"
width={size}
>
<ImageSection />
</Skeleton>
<DetailSection style={{ width: '100%' }}>
{cardRows.map((row: CardRow, index: number) => (
<Skeleton
visible
height={15}
my={3}
radius="md"
width={!data ? (index > 0 ? '50%' : '90%') : '100%'}
>
<Row />
</Skeleton>
))}
</DetailSection>
</StyledCard>
</CardWrapper>
);
}
return (
<CardWrapper
link
onClick={() =>
navigate(
generatePath(
route.route,
route.slugs?.reduce((acc, slug) => {
return {
...acc,
[slug.slugProperty]: data[slug.idProperty],
};
}, {}),
),
)
}
>
<StyledCard>
<ImageSection>
{data?.imageUrl ? (
<Image
animationDuration={0.3}
height={size}
imgStyle={{ objectFit: 'cover' }}
placeholder="var(--card-default-bg)"
src={data?.imageUrl}
width={size}
/>
) : (
<Center
sx={{
background: 'var(--placeholder-bg)',
borderRadius: 'var(--card-default-radius)',
height: `${size}px`,
width: `${size}px`,
}}
>
<RiAlbumFill
color="var(--placeholder-fg)"
size={35}
/>
</Center>
)}
<ControlsContainer>
<CardControls
itemData={data}
itemType={itemType}
/>
</ControlsContainer>
</ImageSection>
<DetailSection>
{cardRows.map((row: CardRow, index: number) => {
if (row.arrayProperty && row.route) {
return (
<Row
key={`row-${row.property}-${index}`}
$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}`}>
{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}`}>
{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>
);
};
@@ -0,0 +1 @@
export * from './album-card';
@@ -0,0 +1,131 @@
import { createContext, useContext } from 'react';
import { Group, Stack } from '@mantine/core';
import { motion } from 'framer-motion';
import { RiArrowLeftSFill, RiArrowRightSFill } from 'react-icons/ri';
import { AlbumCard, Button } from '/@/components';
import { AppRoute } from '/@/router/routes';
import type { CardRow } from '/@/types';
import { LibraryItem, Play } from '/@/types';
import styled from 'styled-components';
interface GridCarouselProps {
cardRows: CardRow[];
children: React.ReactElement;
containerWidth: number;
data: any[] | undefined;
loading?: boolean;
pagination?: {
handleNextPage?: () => void;
handlePreviousPage?: () => void;
hasNextPage?: boolean;
hasPreviousPage?: boolean;
itemsPerPage?: number;
};
}
const GridCarouselContext = createContext<any>(null);
export const GridCarousel = ({
data,
loading,
cardRows,
pagination,
children,
containerWidth,
}: GridCarouselProps) => {
const gridHeight = (containerWidth * 1.2 - 36) / (pagination?.itemsPerPage || 4);
const imageSize = gridHeight * 0.66;
const providerValue = { cardRows, data, gridHeight, imageSize, loading, pagination };
return (
<GridCarouselContext.Provider value={providerValue}>
<Stack>
{children}
{data && (
<Carousel
cardRows={cardRows}
data={data}
/>
)}
</Stack>
</GridCarouselContext.Provider>
);
};
const GridContainer = styled.div<{ height: number; itemsPerPage: number }>`
display: grid;
grid-auto-rows: 0;
grid-gap: 18px;
grid-template-rows: 1fr;
grid-template-columns: repeat(${(props) => props.itemsPerPage || 4}, minmax(0, 1fr));
height: ${(props) => props.height}px;
`;
const Carousel = ({ data, cardRows }: any) => {
const { loading, pagination, gridHeight, imageSize } = useContext(GridCarouselContext);
return (
<motion.div
animate={{
opacity: loading ? 0.5 : 1,
scale: loading ? 0.95 : 1,
}}
>
<GridContainer
height={gridHeight}
itemsPerPage={pagination.itemsPerPage}
>
{data?.map((item: any) => (
<AlbumCard
controls={{
cardRows,
itemType: LibraryItem.ALBUM,
playButtonBehavior: Play.NOW,
route: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
},
}}
data={item}
loading={loading}
size={imageSize}
/>
))}
</GridContainer>
</motion.div>
);
};
interface TitleProps {
children?: React.ReactNode;
}
const Title = ({ children }: TitleProps) => {
const { pagination } = useContext(GridCarouselContext);
return (
<Group position="apart">
{children}
<Group>
<Button
compact
disabled={pagination?.hasPreviousPage === false}
variant="default"
onClick={pagination?.handlePreviousPage}
>
<RiArrowLeftSFill size={20} />
</Button>
<Button
compact
variant="default"
onClick={pagination?.handleNextPage}
>
<RiArrowRightSFill size={20} />
</Button>
</Group>
</Group>
);
};
GridCarousel.Title = Title;
GridCarousel.Carousel = Carousel;
@@ -23,3 +23,5 @@ export * from './virtual-table';
export * from './skeleton';
export * from './page-header';
export * from './text-title';
export * from './grid-carousel';
export * from './card';
@@ -1,10 +1,18 @@
import { motion } from 'framer-motion';
import { useEffect, useRef } from 'react';
import styled from 'styled-components';
import { useShouldPadTitlebar } from '/@/hooks';
const Container = styled(motion.div)``;
const Container = styled(motion.div)<{ $useOpacity?: boolean; height?: string }>`
z-index: 100;
width: 100%;
height: ${(props) => props.height || '55px'};
opacity: ${(props) => props.$useOpacity && 'var(--header-opacity)'};
transition: opacity 0.3s ease-in-out;
`;
const Header = styled(motion.div)<{ $padRight?: boolean }>`
height: 100%;
margin-right: ${(props) => props.$padRight && '170px'};
padding: 1rem;
-webkit-app-region: drag;
@@ -16,21 +24,29 @@ const Header = styled(motion.div)<{ $padRight?: boolean }>`
interface PageHeaderProps {
backgroundColor?: string;
children: React.ReactNode;
children?: React.ReactNode;
height?: string;
useOpacity?: boolean;
}
export const PageHeader = ({ backgroundColor, children }: PageHeaderProps) => {
export const PageHeader = ({ height, backgroundColor, useOpacity, children }: PageHeaderProps) => {
const ref = useRef(null);
const padRight = useShouldPadTitlebar();
console.log('padRight :>> ', padRight);
useEffect(() => {
const rootElement = document.querySelector(':root') as HTMLElement;
rootElement?.style?.setProperty('--header-opacity', '0');
}, []);
return (
<Container
ref={ref}
$useOpacity={useOpacity}
animate={{
backgroundColor,
transition: { duration: 1.5 },
}}
initial={{ backgroundColor: 'transparent', color: 'white' }}
height={height}
>
<Header $padRight={padRight}>{children}</Header>
</Container>
@@ -1,5 +0,0 @@
const DashboardRoute = () => {
return <></>;
};
export default DashboardRoute;
@@ -0,0 +1,283 @@
import { Box, Stack } from '@mantine/core';
import { AlbumListSort, SortOrder } from '/@/api/types';
import { GridCarousel, PageHeader, ScrollArea, TextTitle } from '/@/components';
import { useAlbumList } from '/@/features/albums';
import { useRecentlyPlayed } from '/@/features/home/queries/recently-played-query';
import { AnimatedPage, useContainerQuery } from '/@/features/shared';
import { AppRoute } from '/@/router/routes';
import { useSetState } from '@mantine/hooks';
import { throttle } from 'lodash';
const HomeRoute = () => {
const rootElement = document.querySelector(':root') as HTMLElement;
const cq = useContainerQuery();
const itemsPerPage = cq.isXl ? 9 : cq.isLg ? 7 : cq.isMd ? 5 : cq.isSm ? 4 : 3;
const [pagination, setPagination] = useSetState({
mostPlayed: 0,
random: 0,
recentlyAdded: 0,
recentlyPlayed: 0,
});
const random = useAlbumList(
{
limit: itemsPerPage,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,
startIndex: pagination.random * itemsPerPage,
},
{
keepPreviousData: true,
staleTime: 0,
},
);
const recentlyPlayed = useRecentlyPlayed(
{
limit: itemsPerPage,
sortBy: AlbumListSort.RECENTLY_PLAYED,
sortOrder: SortOrder.ASC,
startIndex: pagination.recentlyPlayed * itemsPerPage,
},
{
keepPreviousData: true,
staleTime: 0,
},
);
const recentlyAdded = useAlbumList(
{
limit: itemsPerPage,
sortBy: AlbumListSort.RECENTLY_ADDED,
sortOrder: SortOrder.ASC,
startIndex: pagination.recentlyAdded * itemsPerPage,
},
{
keepPreviousData: true,
staleTime: 0,
},
);
const mostPlayed = useAlbumList(
{
limit: itemsPerPage,
sortBy: AlbumListSort.PLAY_COUNT,
sortOrder: SortOrder.DESC,
startIndex: pagination.mostPlayed * itemsPerPage,
},
{
keepPreviousData: true,
staleTime: 0,
},
);
const handleNextPage = (key: 'mostPlayed' | 'random' | 'recentlyAdded' | 'recentlyPlayed') => {
setPagination({
[key]: pagination[key as keyof typeof pagination] + 1,
});
};
const handlePreviousPage = (
key: 'mostPlayed' | 'random' | 'recentlyAdded' | 'recentlyPlayed',
) => {
setPagination({
[key]: pagination[key as keyof typeof pagination] - 1,
});
};
const handleScroll = (position: { x: number; y: number }) => {
if (position.y <= 15) {
return rootElement?.style?.setProperty('--header-opacity', '0');
}
return rootElement?.style?.setProperty('--header-opacity', '1');
};
const throttledScroll = throttle(handleScroll, 200);
return (
<>
<AnimatedPage>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<PageHeader
useOpacity
backgroundColor="var(--sidebar-bg)"
/>
<ScrollArea
mb="1rem"
mt="-1.5rem"
px="1rem"
sx={{
height: '100%',
overflow: 'auto',
}}
onScrollPositionChange={throttledScroll}
>
<Box
ref={cq.ref}
sx={{
height: '100%',
maxWidth: '1920px',
width: '100%',
}}
>
<Stack spacing={35}>
<GridCarousel
cardRows={[
{
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
},
},
{
arrayProperty: 'name',
property: 'albumArtists',
route: {
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
]}
containerWidth={cq.width}
data={random?.data?.items}
loading={random.isLoading || random.isFetching}
pagination={{
handleNextPage: () => handleNextPage('random'),
handlePreviousPage: () => handlePreviousPage('random'),
hasPreviousPage: pagination.random > 0,
itemsPerPage,
}}
>
<GridCarousel.Title>
<TextTitle
fw="bold"
order={3}
>
Explore from your library
</TextTitle>
</GridCarousel.Title>
</GridCarousel>
<GridCarousel
cardRows={[
{
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
},
},
{
arrayProperty: 'name',
property: 'albumArtists',
route: {
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
]}
containerWidth={cq.width}
data={recentlyPlayed?.data?.items}
loading={recentlyPlayed.isLoading || recentlyPlayed.isFetching}
pagination={{
handleNextPage: () => handleNextPage('recentlyPlayed'),
handlePreviousPage: () => handlePreviousPage('recentlyPlayed'),
hasPreviousPage: pagination.recentlyPlayed > 0,
itemsPerPage,
}}
>
<GridCarousel.Title>
<TextTitle
fw="bold"
order={3}
>
Recently played
</TextTitle>
</GridCarousel.Title>
</GridCarousel>
<GridCarousel
cardRows={[
{
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
},
},
{
arrayProperty: 'name',
property: 'albumArtists',
route: {
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
]}
containerWidth={cq.width}
data={recentlyAdded?.data?.items}
loading={recentlyAdded.isLoading || recentlyAdded.isFetching}
pagination={{
handleNextPage: () => handleNextPage('recentlyAdded'),
handlePreviousPage: () => handlePreviousPage('recentlyAdded'),
hasPreviousPage: pagination.recentlyAdded > 0,
itemsPerPage,
}}
>
<GridCarousel.Title>
<TextTitle
fw="bold"
order={3}
>
Newly added releases
</TextTitle>
</GridCarousel.Title>
</GridCarousel>
<GridCarousel
cardRows={[
{
property: 'name',
route: {
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
},
},
{
arrayProperty: 'name',
property: 'albumArtists',
route: {
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL,
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
},
},
]}
containerWidth={cq.width}
data={mostPlayed?.data?.items}
loading={mostPlayed.isLoading || mostPlayed.isFetching}
pagination={{
handleNextPage: () => handleNextPage('mostPlayed'),
handlePreviousPage: () => handlePreviousPage('mostPlayed'),
hasPreviousPage: pagination.mostPlayed > 0,
itemsPerPage,
}}
>
<GridCarousel.Title>
<TextTitle
fw="bold"
order={3}
>
Most played
</TextTitle>
</GridCarousel.Title>
</GridCarousel>
</Stack>
</Box>
</ScrollArea>
</Box>
</AnimatedPage>
</>
);
};
export default HomeRoute;
+3 -3
View File
@@ -11,7 +11,7 @@ import { AppRoute } from './routes';
import { RouteErrorBoundary } from '/@/features/action-required';
import { TitlebarOutlet } from '/@/router/titlebar-outlet';
const DashboardRoute = lazy(() => import('/@/features/dashboard/routes/DashboardRoute'));
const HomeRoute = lazy(() => import('/@/features/home/routes/home-route'));
const NowPlayingRoute = lazy(() => import('/@/features/now-playing/routes/now-playing-route'));
@@ -35,10 +35,10 @@ export const AppRouter = () => {
<Route element={<DefaultLayout />}>
<Route
index
element={<DashboardRoute />}
element={<HomeRoute />}
/>
<Route
element={<DashboardRoute />}
element={<HomeRoute />}
path={AppRoute.HOME}
/>
<Route