Add initial album detail page

This commit is contained in:
jeffvli 2022-12-29 18:45:01 -08:00
parent b2f9c73300
commit 1d82c84c9e
4 changed files with 514 additions and 0 deletions

View File

@ -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<AgGridReactType | null>;
}
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: (
<TextTitle
fw="bold"
order={3}
>
More from this artist
</TextTitle>
),
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 (
<ContentContainer>
<Group
pb="2rem"
pt="1rem"
spacing="lg"
>
<PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<Group spacing="xs">
<Button
compact
disabled
variant="subtle"
>
<RiHeartLine size={20} />
</Button>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
variant="subtle"
>
<RiMoreFill size={20} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => (
<DropdownMenu.Item
key={`playtype-${type.play}`}
onClick={() => handlePlay(type.play)}
>
{type.label}
</DropdownMenu.Item>
))}
<DropdownMenu.Divider />
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
</Group>
<VirtualTable
ref={tableRef}
animateRows
detailRowAutoHeight
maintainColumnOrder
suppressCellFocus
suppressCopyRowsToClipboard
suppressLoadingOverlay
suppressMoveWhenRowDragging
suppressPaginationPanel
suppressRowDrag
suppressScrollOnNewData
columnDefs={columnDefs}
defaultColDef={defaultColumnDefs}
enableCellChangeFlash={false}
getRowId={(data) => 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();
}}
/>
<Stack
ref={cq.ref}
mt="5rem"
>
{carousels.map((carousel, index) => (
<GridCarousel
key={`carousel-${carousel.uniqueId}-${index}`}
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={carousel.data}
loading={carousel.loading}
pagination={carousel.pagination}
uniqueId={carousel.uniqueId}
>
<GridCarousel.Title>{carousel.title}</GridCarousel.Title>
</GridCarousel>
))}
</Stack>
</ContentContainer>
);
};

View File

@ -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 (
<HeaderContainer ref={cq.ref}>
<BackgroundImage background={background} />
<BackgroundImageOverlay />
<CoverImageWrapper>
{detailQuery?.data?.imageUrl ? (
<StyledImage
alt="cover"
height={225}
src={detailQuery?.data.imageUrl}
width={225}
/>
) : (
<Center
sx={{
background: 'var(--placeholder-bg)',
borderRadius: 'var(--card-default-radius)',
height: `${80}px`,
width: `${80}px`,
}}
>
<RiAlbumFill
color="var(--placeholder-fg)"
size={35}
/>
</Center>
)}
</CoverImageWrapper>
<MetadataWrapper>
<Group>
<Text
$link
component={Link}
fw="600"
sx={{ textTransform: 'uppercase' }}
to={AppRoute.LIBRARY_ALBUMS}
>
Album
</Text>
{detailQuery?.data?.releaseYear && (
<>
<Text></Text>
<Text>{detailQuery?.data?.releaseYear}</Text>
</>
)}
</Group>
<TextTitle
fw="900"
lh="1"
mb="0.12em"
mt=".08em"
sx={{ fontSize: titleSize }}
>
{detailQuery?.data?.name}
</TextTitle>
<Group
spacing="xs"
sx={{
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
display: '-webkit-box',
overflow: 'hidden',
}}
>
{detailQuery?.data?.albumArtists.map((artist, index) => (
<Fragment key={`artist-${artist.id}`}>
{index > 0 && (
<Text
$noSelect
sx={{
display: 'inline-block',
padding: '0 0.5rem',
}}
>
</Text>
)}
<Text
$link
component={Link}
fw="600"
to={generatePath(AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
>
{artist.name}
</Text>
</Fragment>
))}
</Group>
</MetadataWrapper>
</HeaderContainer>
);
};

View File

@ -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<AgGridReactType | null>(null);
const { albumId } = useParams() as { albumId: string };
const detailQuery = useAlbumDetail({ id: albumId });
const background = useFastAverageColor(detailQuery.data?.imageUrl);
return (
<AnimatedPage>
<PageHeader
useOpacity
position="absolute"
/>
<ScrollArea
h="100%"
offsetScrollbars={false}
styles={{
scrollbar: {
marginTop: '35px',
},
}}
>
<AlbumDetailHeader background={background} />
<AlbumDetailContent tableRef={tableRef} />
</ScrollArea>
</AnimatedPage>
);
};
export default AlbumDetailRoute;

View File

@ -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={<AlbumListRoute />}
path={AppRoute.LIBRARY_ALBUMS}
/>
<Route
element={<AlbumDetailRoute />}
path={AppRoute.LIBRARY_ALBUMS_DETAIL}
/>
<Route
element={<SongListRoute />}
path={AppRoute.LIBRARY_SONGS}