diff --git a/src/renderer/features/albums/components/album-detail-content.tsx b/src/renderer/features/albums/components/album-detail-content.tsx new file mode 100644 index 00000000..39f217a3 --- /dev/null +++ b/src/renderer/features/albums/components/album-detail-content.tsx @@ -0,0 +1,287 @@ +import { MutableRefObject, useCallback, useMemo } from 'react'; +import { + Button, + DropdownMenu, + getColumnDefs, + GridCarousel, + TextTitle, + VirtualTable, +} from '/@/renderer/components'; +import { CellContextMenuEvent, ColDef } from '@ag-grid-community/core'; +import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; +import { Group, Stack } from '@mantine/core'; +import { useSetState } from '@mantine/hooks'; +import sortBy from 'lodash/sortBy'; +import { RiHeartLine, RiMoreFill } from 'react-icons/ri'; +import { useParams } from 'react-router'; +import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query'; +import { useSongListStore } from '/@/renderer/store'; +import styled from 'styled-components'; +import { AppRoute } from '/@/renderer/router/routes'; +import { useContainerQuery } from '/@/renderer/hooks'; +import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add'; +import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; +import { openContextMenu } from '/@/renderer/features/context-menu'; +import { LibraryItem, Play } from '/@/renderer/types'; +import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; +import { PlayButton, PLAY_TYPES } from '/@/renderer/features/shared'; +import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query'; +import { AlbumListSort, SortOrder } from '/@/renderer/api/types'; + +const ContentContainer = styled.div` + display: flex; + flex-direction: column; + max-width: 1920px; + padding: 1rem 2rem; + overflow: hidden; + + .ag-theme-alpine-dark { + --ag-header-background-color: rgba(0, 0, 0, 0%); + } + + .ag-header-container { + z-index: 1000; + } + + .ag-header-cell-resize { + top: 25%; + width: 7px; + height: 50%; + background-color: rgb(70, 70, 70, 20%); + } +`; + +interface AlbumDetailContentProps { + tableRef: MutableRefObject; +} + +export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => { + const { albumId } = useParams() as { albumId: string }; + const detailQuery = useAlbumDetail({ id: albumId }); + const cq = useContainerQuery(); + const handlePlayQueueAdd = useHandlePlayQueueAdd(); + + const page = useSongListStore(); + + const columnDefs: ColDef[] = useMemo( + () => + getColumnDefs(page.table.columns).filter((c) => c.colId !== 'album' && c.colId !== 'artist'), + [page.table.columns], + ); + + console.log('columnDefs :>> ', columnDefs); + + const defaultColumnDefs: ColDef = useMemo(() => { + return { + lockPinned: true, + lockVisible: true, + resizable: true, + }; + }, []); + + const [pagination, setPagination] = useSetState({ + artist: 0, + }); + + const handleNextPage = useCallback( + (key: 'artist') => { + setPagination({ + [key]: pagination[key as keyof typeof pagination] + 1, + }); + }, + [pagination, setPagination], + ); + + const handlePreviousPage = useCallback( + (key: 'artist') => { + setPagination({ + [key]: pagination[key as keyof typeof pagination] - 1, + }); + }, + [pagination, setPagination], + ); + + const itemsPerPage = cq.isXl ? 9 : cq.isLg ? 7 : cq.isMd ? 5 : cq.isSm ? 4 : 3; + + const artistQuery = useAlbumList( + { + jfParams: { + albumArtistIds: detailQuery?.data?.albumArtists[0]?.id, + }, + limit: itemsPerPage, + ndParams: { + artist_id: detailQuery?.data?.albumArtists[0]?.id, + }, + sortBy: AlbumListSort.YEAR, + sortOrder: SortOrder.DESC, + startIndex: pagination.artist * itemsPerPage, + }, + { + cacheTime: 1000 * 60, + enabled: detailQuery?.data?.albumArtists[0]?.id !== undefined, + keepPreviousData: true, + staleTime: 1000 * 60, + }, + ); + + const carousels = [ + { + data: artistQuery?.data?.items, + loading: artistQuery?.isLoading || artistQuery.isFetching, + pagination: { + handleNextPage: () => handleNextPage('artist'), + handlePreviousPage: () => handlePreviousPage('artist'), + hasPreviousPage: pagination.artist > 0, + itemsPerPage, + }, + title: ( + + More from this artist + + ), + uniqueId: 'mostPlayed', + }, + ]; + + const playButtonBehavior = usePlayButtonBehavior(); + + const handlePlay = async (playType?: Play) => { + handlePlayQueueAdd?.({ + byData: detailQuery?.data?.songs, + play: playType || playButtonBehavior, + }); + }; + + const handleContextMenu = (e: CellContextMenuEvent) => { + if (!e.event) return; + const clickEvent = e.event as MouseEvent; + clickEvent.preventDefault(); + + const selectedNodes = e.api.getSelectedNodes(); + const selectedIds = selectedNodes.map((node) => node.data.id); + let selectedRows = sortBy(selectedNodes, ['rowIndex']).map((node) => node.data); + + if (!selectedIds.includes(e.data.id)) { + e.api.deselectAll(); + e.node.setSelected(true); + selectedRows = [e.data]; + } + + openContextMenu({ + data: selectedRows, + menuItems: SONG_CONTEXT_MENU_ITEMS, + type: LibraryItem.SONG, + xPos: clickEvent.clientX, + yPos: clickEvent.clientY, + }); + }; + + return ( + + + handlePlay(playButtonBehavior)} /> + + + + + + + + {PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => ( + handlePlay(type.play)} + > + {type.label} + + ))} + + Add to playlist + + + + + data.data.id} + rowData={detailQuery.data?.songs} + rowHeight={60} + rowSelection="multiple" + onCellContextMenu={handleContextMenu} + onColumnResized={() => console.log('resize')} + onGridReady={(params) => { + params.api.setDomLayout('autoHeight'); + params.api.sizeColumnsToFit(); + }} + onGridSizeChanged={(params) => { + params.api.sizeColumnsToFit(); + }} + /> + + {carousels.map((carousel, index) => ( + + {carousel.title} + + ))} + + + ); +}; diff --git a/src/renderer/features/albums/components/album-detail-header.tsx b/src/renderer/features/albums/components/album-detail-header.tsx new file mode 100644 index 00000000..1c966145 --- /dev/null +++ b/src/renderer/features/albums/components/album-detail-header.tsx @@ -0,0 +1,182 @@ +import { Center, Group } from '@mantine/core'; +import { Fragment } from 'react'; +import { RiAlbumFill } from 'react-icons/ri'; +import { generatePath, useParams } from 'react-router'; +import { Link } from 'react-router-dom'; +import styled from 'styled-components'; +import { Text, TextTitle } from '/@/renderer/components'; +import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query'; +import { useContainerQuery } from '/@/renderer/hooks'; +import { AppRoute } from '/@/renderer/router/routes'; + +const HeaderContainer = styled.div` + position: relative; + display: grid; + grid-auto-columns: 1fr; + grid-template-areas: 'image info'; + grid-template-rows: 1fr; + grid-template-columns: 250px minmax(0, 1fr); + gap: 0.5rem; + width: 100%; + max-width: 100%; + height: 30vh; + min-height: 340px; + max-height: 500px; + padding: 5rem 2rem 2rem; +`; + +const CoverImageWrapper = styled.div` + z-index: 15; + display: flex; + grid-area: image; + align-items: flex-end; + justify-content: center; + height: 100%; + filter: drop-shadow(0 0 8px rgb(0, 0, 0, 50%)); +`; + +const MetadataWrapper = styled.div` + z-index: 15; + display: flex; + flex-direction: column; + grid-area: info; + justify-content: flex-end; + width: 100%; +`; + +const StyledImage = styled.img` + object-fit: cover; +`; + +const BackgroundImage = styled.div<{ background: string }>` + position: absolute; + top: 0; + z-index: 0; + width: 100%; + height: 100%; + background: ${(props) => props.background}; +`; + +const BackgroundImageOverlay = styled.div` + position: absolute; + top: 0; + left: 0; + z-index: 0; + width: 100%; + height: 100%; + background: linear-gradient(180deg, rgba(25, 26, 28, 5%), var(--main-bg)); +`; + +interface AlbumDetailHeaderProps { + background: string; +} + +export const AlbumDetailHeader = ({ background }: AlbumDetailHeaderProps) => { + const { albumId } = useParams() as { albumId: string }; + const detailQuery = useAlbumDetail({ id: albumId }); + const cq = useContainerQuery(); + + const titleSize = cq.isXl + ? '6rem' + : cq.isLg + ? '5.5rem' + : cq.isMd + ? '4.5rem' + : cq.isSm + ? '3.5rem' + : '2rem'; + + return ( + + + + + {detailQuery?.data?.imageUrl ? ( + + ) : ( +
+ +
+ )} +
+ + + + Album + + {detailQuery?.data?.releaseYear && ( + <> + + {detailQuery?.data?.releaseYear} + + )} + + + {detailQuery?.data?.name} + + + {detailQuery?.data?.albumArtists.map((artist, index) => ( + + {index > 0 && ( + + • + + )} + + {artist.name} + + + ))} + + +
+ ); +}; diff --git a/src/renderer/features/albums/routes/album-detail-route.tsx b/src/renderer/features/albums/routes/album-detail-route.tsx new file mode 100644 index 00000000..eda5d024 --- /dev/null +++ b/src/renderer/features/albums/routes/album-detail-route.tsx @@ -0,0 +1,40 @@ +import { PageHeader, ScrollArea } from '/@/renderer/components'; +import { AnimatedPage } from '/@/renderer/features/shared'; +import { useRef } from 'react'; +import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; +import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query'; +import { useParams } from 'react-router'; +import { useFastAverageColor } from '/@/renderer/hooks'; +import { AlbumDetailContent } from '/@/renderer/features/albums/components/album-detail-content'; +import { AlbumDetailHeader } from '/@/renderer/features/albums/components/album-detail-header'; + +const AlbumDetailRoute = () => { + const tableRef = useRef(null); + const { albumId } = useParams() as { albumId: string }; + const detailQuery = useAlbumDetail({ id: albumId }); + const background = useFastAverageColor(detailQuery.data?.imageUrl); + + return ( + + + + + + + + + ); +}; + +export default AlbumDetailRoute; diff --git a/src/renderer/router/app-router.tsx b/src/renderer/router/app-router.tsx index bda057b5..1ac0291f 100644 --- a/src/renderer/router/app-router.tsx +++ b/src/renderer/router/app-router.tsx @@ -7,6 +7,7 @@ import { } from 'react-router-dom'; import { AppRoute } from './routes'; import { RouteErrorBoundary } from '/@/renderer/features/action-required'; +import AlbumDetailRoute from '/@/renderer/features/albums/routes/album-detail-route'; import HomeRoute from '/@/renderer/features/home/routes/home-route'; import { DefaultLayout } from '/@/renderer/layouts'; import { AppOutlet } from '/@/renderer/router/app-outlet'; @@ -54,6 +55,10 @@ export const AppRouter = () => { element={} path={AppRoute.LIBRARY_ALBUMS} /> + } + path={AppRoute.LIBRARY_ALBUMS_DETAIL} + /> } path={AppRoute.LIBRARY_SONGS}