diff --git a/packages/renderer/src/components/card/album-card.tsx b/packages/renderer/src/components/card/album-card.tsx new file mode 100644 index 000000000..a26065dae --- /dev/null +++ b/packages/renderer/src/components/card/album-card.tsx @@ -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 ( + + + + + + + {cardRows.map((row: CardRow, index: number) => ( + 0 ? '50%' : '90%') : '100%'} + > + + + ))} + + + + ); + } + + return ( + + navigate( + generatePath( + route.route, + route.slugs?.reduce((acc, slug) => { + return { + ...acc, + [slug.slugProperty]: data[slug.idProperty], + }; + }, {}), + ), + ) + } + > + + + {data?.imageUrl ? ( + + ) : ( +
+ +
+ )} + + + +
+ + {cardRows.map((row: CardRow, index: number) => { + if (row.arrayProperty && row.route) { + return ( + 0} + > + {data[row.property].map((item: any, itemIndex: number) => ( + + {itemIndex > 0 && ( + + , + + )}{' '} + 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]} + + + ))} + + ); + } + + if (row.arrayProperty) { + return ( + + {data[row.property].map((item: any) => ( + 0} + overflow="hidden" + size={index > 0 ? 'xs' : 'md'} + > + {row.arrayProperty && item[row.arrayProperty]} + + ))} + + ); + } + + return ( + + {row.route ? ( + { + return { + ...acc, + [slug.slugProperty]: data[slug.idProperty], + }; + }, {}), + )} + onClick={(e) => e.stopPropagation()} + > + {data && data[row.property]} + + ) : ( + 0} + overflow="hidden" + size={index > 0 ? 'xs' : 'md'} + > + {data && data[row.property]} + + )} + + ); + })} + +
+
+ ); +}; diff --git a/packages/renderer/src/components/card/index.tsx b/packages/renderer/src/components/card/index.tsx new file mode 100644 index 000000000..fe08e1c16 --- /dev/null +++ b/packages/renderer/src/components/card/index.tsx @@ -0,0 +1 @@ +export * from './album-card'; diff --git a/packages/renderer/src/components/grid-carousel/index.tsx b/packages/renderer/src/components/grid-carousel/index.tsx new file mode 100644 index 000000000..12f3f4216 --- /dev/null +++ b/packages/renderer/src/components/grid-carousel/index.tsx @@ -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(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 ( + + + {children} + {data && ( + + )} + + + ); +}; + +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 ( + + + {data?.map((item: any) => ( + + ))} + + + ); +}; + +interface TitleProps { + children?: React.ReactNode; +} + +const Title = ({ children }: TitleProps) => { + const { pagination } = useContext(GridCarouselContext); + + return ( + + {children} + + + + + + ); +}; + +GridCarousel.Title = Title; +GridCarousel.Carousel = Carousel; diff --git a/packages/renderer/src/components/index.ts b/packages/renderer/src/components/index.ts index f0757916a..14405745a 100644 --- a/packages/renderer/src/components/index.ts +++ b/packages/renderer/src/components/index.ts @@ -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'; diff --git a/packages/renderer/src/components/page-header/index.tsx b/packages/renderer/src/components/page-header/index.tsx index 1a540fb90..626547206 100644 --- a/packages/renderer/src/components/page-header/index.tsx +++ b/packages/renderer/src/components/page-header/index.tsx @@ -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 (
{children}
diff --git a/packages/renderer/src/features/dashboard/routes/DashboardRoute.tsx b/packages/renderer/src/features/dashboard/routes/DashboardRoute.tsx deleted file mode 100644 index bb605fad3..000000000 --- a/packages/renderer/src/features/dashboard/routes/DashboardRoute.tsx +++ /dev/null @@ -1,5 +0,0 @@ -const DashboardRoute = () => { - return <>; -}; - -export default DashboardRoute; diff --git a/packages/renderer/src/features/home/routes/home-route.tsx b/packages/renderer/src/features/home/routes/home-route.tsx new file mode 100644 index 000000000..333445ef4 --- /dev/null +++ b/packages/renderer/src/features/home/routes/home-route.tsx @@ -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 ( + <> + + + + + + + handleNextPage('random'), + handlePreviousPage: () => handlePreviousPage('random'), + hasPreviousPage: pagination.random > 0, + itemsPerPage, + }} + > + + + Explore from your library + + + + handleNextPage('recentlyPlayed'), + handlePreviousPage: () => handlePreviousPage('recentlyPlayed'), + hasPreviousPage: pagination.recentlyPlayed > 0, + itemsPerPage, + }} + > + + + Recently played + + + + handleNextPage('recentlyAdded'), + handlePreviousPage: () => handlePreviousPage('recentlyAdded'), + hasPreviousPage: pagination.recentlyAdded > 0, + itemsPerPage, + }} + > + + + Newly added releases + + + + handleNextPage('mostPlayed'), + handlePreviousPage: () => handlePreviousPage('mostPlayed'), + hasPreviousPage: pagination.mostPlayed > 0, + itemsPerPage, + }} + > + + + Most played + + + + + + + + + + ); +}; + +export default HomeRoute; diff --git a/packages/renderer/src/router/app-router.tsx b/packages/renderer/src/router/app-router.tsx index 6c7a149c5..c5bb507d9 100644 --- a/packages/renderer/src/router/app-router.tsx +++ b/packages/renderer/src/router/app-router.tsx @@ -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 = () => { }> } + element={} /> } + element={} path={AppRoute.HOME} />