Add sticky table header to detail pages

This commit is contained in:
jeffvli 2023-01-06 11:46:17 -08:00
parent b59c86f78f
commit 4c275ea878
3 changed files with 242 additions and 233 deletions

View file

@ -5,11 +5,12 @@ import {
getColumnDefs, getColumnDefs,
GridCarousel, GridCarousel,
TextTitle, TextTitle,
useFixedTableHeader,
VirtualTable, VirtualTable,
} from '/@/renderer/components'; } from '/@/renderer/components';
import { CellContextMenuEvent, ColDef } from '@ag-grid-community/core'; import { CellContextMenuEvent, ColDef } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Group, Stack } from '@mantine/core'; import { Box, Group, Stack } from '@mantine/core';
import { useSetState } from '@mantine/hooks'; import { useSetState } from '@mantine/hooks';
import sortBy from 'lodash/sortBy'; import sortBy from 'lodash/sortBy';
import { RiHeartLine, RiMoreFill } from 'react-icons/ri'; import { RiHeartLine, RiMoreFill } from 'react-icons/ri';
@ -29,19 +30,16 @@ import { AlbumListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { usePlayQueueAdd } from '/@/renderer/features/player'; import { usePlayQueueAdd } from '/@/renderer/features/player';
const ContentContainer = styled.div` const ContentContainer = styled.div`
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-width: 1920px; max-width: 1920px;
padding: 1rem 2rem; padding: 1rem 2rem 5rem;
overflow: hidden; overflow: hidden;
.ag-theme-alpine-dark { .ag-theme-alpine-dark {
--ag-header-background-color: rgba(0, 0, 0, 0%); --ag-header-background-color: rgba(0, 0, 0, 0%);
} }
.ag-header-container {
z-index: 1000;
}
`; `;
interface AlbumDetailContentProps { interface AlbumDetailContentProps {
@ -170,9 +168,13 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
}); });
}; };
const { intersectRef, tableContainerRef } = useFixedTableHeader();
return ( return (
<ContentContainer> <ContentContainer>
<Group <Group
ref={intersectRef}
className="test"
pb="2rem" pb="2rem"
pt="1rem" pt="1rem"
spacing="lg" spacing="lg"
@ -210,34 +212,36 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
</DropdownMenu> </DropdownMenu>
</Group> </Group>
</Group> </Group>
<VirtualTable <Box ref={tableContainerRef}>
ref={tableRef} <VirtualTable
animateRows ref={tableRef}
detailRowAutoHeight animateRows
maintainColumnOrder detailRowAutoHeight
suppressCellFocus maintainColumnOrder
suppressCopyRowsToClipboard suppressCellFocus
suppressLoadingOverlay suppressCopyRowsToClipboard
suppressMoveWhenRowDragging suppressLoadingOverlay
suppressPaginationPanel suppressMoveWhenRowDragging
suppressRowDrag suppressPaginationPanel
suppressScrollOnNewData suppressRowDrag
columnDefs={columnDefs} suppressScrollOnNewData
defaultColDef={defaultColumnDefs} columnDefs={columnDefs}
enableCellChangeFlash={false} defaultColDef={defaultColumnDefs}
getRowId={(data) => data.data.id} enableCellChangeFlash={false}
rowData={detailQuery.data?.songs} getRowId={(data) => data.data.id}
rowHeight={60} rowData={detailQuery.data?.songs}
rowSelection="multiple" rowHeight={60}
onCellContextMenu={handleContextMenu} rowSelection="multiple"
onGridReady={(params) => { onCellContextMenu={handleContextMenu}
params.api.setDomLayout('autoHeight'); onGridReady={(params) => {
params.api.sizeColumnsToFit(); params.api.setDomLayout('autoHeight');
}} params.api.sizeColumnsToFit();
onGridSizeChanged={(params) => { }}
params.api.sizeColumnsToFit(); onGridSizeChanged={(params) => {
}} params.api.sizeColumnsToFit();
/> }}
/>
</Box>
<Stack <Stack
ref={cq.ref} ref={cq.ref}
mt="5rem" mt="5rem"

View file

@ -1,18 +1,39 @@
import { CellContextMenuEvent, ColDef } from '@ag-grid-community/core'; import { CellContextMenuEvent, ColDef } from '@ag-grid-community/core';
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Group } from '@mantine/core'; import { Box, Group } from '@mantine/core';
import { closeAllModals, openModal } from '@mantine/modals';
import { useQueryClient } from '@tanstack/react-query';
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
import { MutableRefObject, useMemo } from 'react'; import { MutableRefObject, useMemo, useRef } from 'react';
import { generatePath, useParams } from 'react-router'; import { RiMoreFill } from 'react-icons/ri';
import { generatePath, useNavigate, useParams } from 'react-router';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import { LibraryItem } from '/@/renderer/api/types'; import { api } from '/@/renderer/api';
import { Button, getColumnDefs, Text, VirtualTable } from '/@/renderer/components'; import { queryKeys } from '/@/renderer/api/query-keys';
import { LibraryItem, SortOrder, UserListQuery, UserListSort } from '/@/renderer/api/types';
import {
Button,
ConfirmModal,
DropdownMenu,
getColumnDefs,
MotionGroup,
toast,
useFixedTableHeader,
VirtualTable,
} from '/@/renderer/components';
import { openContextMenu } from '/@/renderer/features/context-menu'; import { openContextMenu } from '/@/renderer/features/context-menu';
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { UpdatePlaylistForm } from '/@/renderer/features/playlists/components/update-playlist-form';
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { usePlaylistSongListInfinite } from '/@/renderer/features/playlists/queries/playlist-song-list-query'; import { usePlaylistSongListInfinite } from '/@/renderer/features/playlists/queries/playlist-song-list-query';
import { PlayButton, PLAY_TYPES } from '/@/renderer/features/shared';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useSongListStore } from '/@/renderer/store'; import { useCurrentServer, useSongListStore } from '/@/renderer/store';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { Play } from '/@/renderer/types';
const ContentContainer = styled.div` const ContentContainer = styled.div`
display: flex; display: flex;
@ -24,10 +45,6 @@ const ContentContainer = styled.div`
.ag-theme-alpine-dark { .ag-theme-alpine-dark {
--ag-header-background-color: rgba(0, 0, 0, 0%); --ag-header-background-color: rgba(0, 0, 0, 0%);
} }
.ag-header-container {
z-index: 1000;
}
`; `;
interface PlaylistDetailContentProps { interface PlaylistDetailContentProps {
@ -35,8 +52,14 @@ interface PlaylistDetailContentProps {
} }
export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps) => { export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps) => {
const navigate = useNavigate();
const { playlistId } = useParams() as { playlistId: string }; const { playlistId } = useParams() as { playlistId: string };
const page = useSongListStore(); const page = useSongListStore();
const handlePlayQueueAdd = usePlayQueueAdd();
const detailQuery = usePlaylistDetail({ id: playlistId });
const playButtonBehavior = usePlayButtonBehavior();
const queryClient = useQueryClient();
const server = useCurrentServer();
const playlistSongsQueryInfinite = usePlaylistSongListInfinite( const playlistSongsQueryInfinite = usePlaylistSongListInfinite(
{ {
@ -44,7 +67,7 @@ export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps)
limit: 50, limit: 50,
startIndex: 0, startIndex: 0,
}, },
{ keepPreviousData: false }, { cacheTime: 0, keepPreviousData: false },
); );
const handleLoadMore = () => { const handleLoadMore = () => {
@ -94,41 +117,178 @@ export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps)
[playlistSongsQueryInfinite.data?.pages], [playlistSongsQueryInfinite.data?.pages],
); );
console.log('playlistSongData', playlistSongData);
const { intersectRef, tableContainerRef } = useFixedTableHeader();
const deletePlaylistMutation = useDeletePlaylist();
const handleDeletePlaylist = () => {
deletePlaylistMutation.mutate(
{ query: { id: playlistId } },
{
onError: (err) => {
toast.error({
message: err.message,
title: 'Error deleting playlist',
});
},
onSuccess: () => {
toast.success({
message: `${detailQuery?.data?.name} was successfully deleted`,
title: 'Playlist deleted',
});
closeAllModals();
navigate(AppRoute.PLAYLISTS);
},
},
);
};
const openDeletePlaylist = () => {
openModal({
children: (
<ConfirmModal
loading={deletePlaylistMutation.isLoading}
onConfirm={handleDeletePlaylist}
>
Are you sure you want to delete this playlist?
</ConfirmModal>
),
title: 'Delete playlist',
});
};
const handlePlay = (playType?: Play) => {
handlePlayQueueAdd?.({
byItemType: {
id: [playlistId],
type: LibraryItem.PLAYLIST,
},
play: playType || playButtonBehavior,
});
};
const openUpdatePlaylistModal = async () => {
const query: UserListQuery = {
sortBy: UserListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
};
const users = await queryClient.fetchQuery({
queryFn: ({ signal }) => api.controller.getUserList({ query, server, signal }),
queryKey: queryKeys.users.list(server?.id || '', query),
});
const normalizedUsers = api.normalize.userList(users, server);
openModal({
children: (
<UpdatePlaylistForm
body={{
comment: detailQuery?.data?.description || undefined,
genres: detailQuery?.data?.genres,
name: detailQuery?.data?.name,
ndParams: {
owner: detailQuery?.data?.owner || undefined,
ownerId: detailQuery?.data?.ownerId || undefined,
public: detailQuery?.data?.public || false,
rules: detailQuery?.data?.rules || undefined,
sync: detailQuery?.data?.sync || undefined,
},
}}
query={{ id: playlistId }}
users={normalizedUsers.items}
onCancel={closeAllModals}
/>
),
title: 'Edit playlist',
});
};
const loadMoreRef = useRef<HTMLButtonElement | null>(null);
return ( return (
<ContentContainer> <ContentContainer>
<VirtualTable
ref={tableRef}
animateRows
detailRowAutoHeight
maintainColumnOrder
suppressCellFocus
suppressCopyRowsToClipboard
suppressLoadingOverlay
suppressMoveWhenRowDragging
suppressPaginationPanel
suppressRowDrag
suppressScrollOnNewData
columnDefs={columnDefs}
defaultColDef={defaultColumnDefs}
enableCellChangeFlash={false}
getRowId={(data) => data.data.uniqueId}
rowData={playlistSongData}
rowHeight={60}
rowSelection="multiple"
onCellContextMenu={handleContextMenu}
onGridReady={(params) => {
params.api.setDomLayout('autoHeight');
params.api.sizeColumnsToFit();
}}
onGridSizeChanged={(params) => {
params.api.sizeColumnsToFit();
}}
/>
<Group <Group
ref={intersectRef}
maw="1920px"
p="1rem"
position="apart"
>
<Group>
<PlayButton onClick={() => handlePlay()} />
<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 onClick={openUpdatePlaylistModal}>Edit playlist</DropdownMenu.Item>
<DropdownMenu.Item onClick={openDeletePlaylist}>Delete playlist</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
<Button
compact
component={Link}
to={generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId })}
variant="subtle"
>
View full playlist
</Button>
</Group>
</Group>
<Box ref={tableContainerRef}>
<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={playlistSongData}
rowHeight={60}
rowSelection="multiple"
onCellContextMenu={handleContextMenu}
onGridReady={(params) => {
params.api.setDomLayout('autoHeight');
params.api.sizeColumnsToFit();
}}
onGridSizeChanged={(params) => {
params.api.sizeColumnsToFit();
}}
/>
</Box>
<MotionGroup
p="2rem" p="2rem"
position="center" position="center"
onViewportEnter={handleLoadMore}
> >
<Button <Button
ref={loadMoreRef}
compact compact
disabled={!playlistSongsQueryInfinite.hasNextPage} disabled={!playlistSongsQueryInfinite.hasNextPage}
loading={playlistSongsQueryInfinite.isFetchingNextPage} loading={playlistSongsQueryInfinite.isFetchingNextPage}
@ -137,16 +297,7 @@ export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps)
> >
Load more Load more
</Button> </Button>
<Text>or</Text> </MotionGroup>
<Button
compact
component={Link}
to={generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId })}
variant="subtle"
>
View full playlist
</Button>
</Group>
</ContentContainer> </ContentContainer>
); );
}; };

View file

@ -1,24 +1,12 @@
import { forwardRef, Fragment, Ref } from 'react'; import { forwardRef, Fragment, Ref } from 'react';
import { Group, Stack } from '@mantine/core'; import { Group, Stack } from '@mantine/core';
import { closeAllModals, openModal } from '@mantine/modals'; import { useParams } from 'react-router';
import { useQueryClient } from '@tanstack/react-query'; import { Text } from '/@/renderer/components';
import { RiMoreFill } from 'react-icons/ri';
import { generatePath, useNavigate, useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { DropdownMenu, Button, ConfirmModal, toast, Text } from '/@/renderer/components';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { UpdatePlaylistForm } from './update-playlist-form';
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query'; import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { LibraryHeader, PlayButton, PLAY_TYPES } from '/@/renderer/features/shared'; import { LibraryHeader } from '/@/renderer/features/shared';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { Play } from '/@/renderer/types';
import { formatDurationString } from '/@/renderer/utils'; import { formatDurationString } from '/@/renderer/utils';
import { UserListSort, SortOrder, UserListQuery, LibraryItem } from '/@/renderer/api/types'; import { LibraryItem } from '/@/renderer/api/types';
import { useCurrentServer } from '/@/renderer/store';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
interface PlaylistDetailHeaderProps { interface PlaylistDetailHeaderProps {
background: string; background: string;
@ -31,99 +19,8 @@ export const PlaylistDetailHeader = forwardRef(
{ background, imageUrl, imagePlaceholderUrl }: PlaylistDetailHeaderProps, { background, imageUrl, imagePlaceholderUrl }: PlaylistDetailHeaderProps,
ref: Ref<HTMLDivElement>, ref: Ref<HTMLDivElement>,
) => { ) => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { playlistId } = useParams() as { playlistId: string }; const { playlistId } = useParams() as { playlistId: string };
const detailQuery = usePlaylistDetail({ id: playlistId }); const detailQuery = usePlaylistDetail({ id: playlistId });
const handlePlayQueueAdd = usePlayQueueAdd();
const playButtonBehavior = usePlayButtonBehavior();
const server = useCurrentServer();
const handlePlay = (playType?: Play) => {
handlePlayQueueAdd?.({
byItemType: {
id: [playlistId],
type: LibraryItem.PLAYLIST,
},
play: playType || playButtonBehavior,
});
};
const openUpdatePlaylistModal = async () => {
const query: UserListQuery = {
sortBy: UserListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
};
const users = await queryClient.fetchQuery({
queryFn: ({ signal }) => api.controller.getUserList({ query, server, signal }),
queryKey: queryKeys.users.list(server?.id || '', query),
});
const normalizedUsers = api.normalize.userList(users, server);
openModal({
children: (
<UpdatePlaylistForm
body={{
comment: detailQuery?.data?.description || undefined,
genres: detailQuery?.data?.genres,
name: detailQuery?.data?.name,
ndParams: {
owner: detailQuery?.data?.owner || undefined,
ownerId: detailQuery?.data?.ownerId || undefined,
public: detailQuery?.data?.public || false,
rules: detailQuery?.data?.rules || undefined,
sync: detailQuery?.data?.sync || undefined,
},
}}
query={{ id: playlistId }}
users={normalizedUsers.items}
onCancel={closeAllModals}
/>
),
title: 'Edit playlist',
});
};
const deletePlaylistMutation = useDeletePlaylist();
const handleDeletePlaylist = () => {
deletePlaylistMutation.mutate(
{ query: { id: playlistId } },
{
onError: (err) => {
toast.error({
message: err.message,
title: 'Error deleting playlist',
});
},
onSuccess: () => {
toast.success({
message: `${detailQuery?.data?.name} was successfully deleted`,
title: 'Playlist deleted',
});
closeAllModals();
navigate(AppRoute.PLAYLISTS);
},
},
);
};
const openDeletePlaylist = () => {
openModal({
children: (
<ConfirmModal
loading={deletePlaylistMutation.isLoading}
onConfirm={handleDeletePlaylist}
>
Are you sure you want to delete this playlist?
</ConfirmModal>
),
title: 'Delete playlist',
});
};
const metadataItems = [ const metadataItems = [
{ {
@ -160,49 +57,6 @@ export const PlaylistDetailHeader = forwardRef(
<Text lineClamp={3}>{detailQuery?.data?.description}</Text> <Text lineClamp={3}>{detailQuery?.data?.description}</Text>
</Stack> </Stack>
</LibraryHeader> </LibraryHeader>
<Group
maw="1920px"
p="1rem"
position="apart"
>
<Group>
<PlayButton onClick={() => handlePlay()} />
<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 onClick={openUpdatePlaylistModal}>
Edit playlist
</DropdownMenu.Item>
<DropdownMenu.Item onClick={openDeletePlaylist}>Delete playlist</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
<Button
compact
component={Link}
to={generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId })}
variant="subtle"
>
View full playlist
</Button>
</Group>
</Group>
</Stack> </Stack>
); );
}, },