Add ratings support (#21)

* Update rating types for multiserver support

* Add rating mutation

* Add rating support to table views

* Add rating support on playerbar

* Add hovercard component

* Handle rating from context menu

- Improve context menu components
- Allow left / right icons
- Allow nested menus

* Add selected item count

* Fix context menu auto direction

* Add transition and move portal for context menu

* Re-use context menu for all item dropdowns

* Add ratings to detail pages / double click to clear

* Bump react-query package
This commit is contained in:
Jeff 2023-02-05 05:19:01 -08:00 committed by GitHub
parent f50ec5cf31
commit 22fec8f9d3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1189 additions and 503 deletions

View file

@ -7,7 +7,7 @@ export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' },
{ disabled: true, id: 'setRating' },
{ children: true, disabled: false, id: 'setRating' },
];
export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
@ -18,7 +18,7 @@ export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ divider: true, id: 'removeFromPlaylist' },
{ id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' },
{ disabled: true, id: 'setRating' },
{ children: true, disabled: false, id: 'setRating' },
];
export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
@ -28,7 +28,7 @@ export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ id: 'removeFromFavorites' },
{ disabled: true, id: 'setRating' },
{ children: true, disabled: false, id: 'setRating' },
];
export const ARTIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
@ -38,7 +38,7 @@ export const ARTIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
{ divider: true, id: 'addToPlaylist' },
{ id: 'addToFavorites' },
{ divider: true, id: 'removeFromFavorites' },
{ disabled: true, id: 'setRating' },
{ children: true, disabled: false, id: 'setRating' },
];
export const PLAYLIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [

View file

@ -1,17 +1,45 @@
import { Divider, Group, Stack } from '@mantine/core';
import { useClickOutside, useResizeObserver, useSetState, useViewportSize } from '@mantine/hooks';
import { closeAllModals, openContextModal, openModal } from '@mantine/modals';
import { createContext, Fragment, useState } from 'react';
import { LibraryItem, ServerType } from '/@/renderer/api/types';
import { ConfirmModal, ContextMenu, ContextMenuButton, Text, toast } from '/@/renderer/components';
import { RowNode } from '@ag-grid-community/core';
import { Divider, Group, Portal, Stack } from '@mantine/core';
import {
useClickOutside,
useMergedRef,
useResizeObserver,
useSetState,
useViewportSize,
} from '@mantine/hooks';
import { closeAllModals, openContextModal, openModal } from '@mantine/modals';
import { AnimatePresence } from 'framer-motion';
import { createContext, Fragment, ReactNode, useState, useMemo, useCallback } from 'react';
import {
RiAddBoxFill,
RiAddCircleFill,
RiArrowRightSFill,
RiDeleteBinFill,
RiDislikeFill,
RiHeartFill,
RiPlayFill,
RiPlayListAddFill,
RiStarFill,
} from 'react-icons/ri';
import { AnyLibraryItems, LibraryItem, ServerType } from '/@/renderer/api/types';
import {
ConfirmModal,
ContextMenu,
ContextMenuButton,
HoverCard,
Rating,
Text,
toast,
} from '/@/renderer/components';
import {
ContextMenuItemType,
OpenContextMenuProps,
useContextMenuEvents,
} from '/@/renderer/features/context-menu/events';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { useDeletePlaylist } from '/@/renderer/features/playlists';
import { useRemoveFromPlaylist } from '/@/renderer/features/playlists/mutations/remove-from-playlist-mutation';
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
import { useCreateFavorite, useDeleteFavorite, useUpdateRating } from '/@/renderer/features/shared';
import { useCurrentServer } from '/@/renderer/store';
import { Play } from '/@/renderer/types';
@ -20,6 +48,16 @@ type ContextMenuContextProps = {
openContextMenu: (args: OpenContextMenuProps) => void;
};
type ContextMenuItem = {
children?: ContextMenuItem[];
disabled?: boolean;
id: string;
label: string | ReactNode;
leftIcon?: ReactNode;
onClick?: (...args: any) => any;
rightIcon?: ReactNode;
};
const ContextMenuContext = createContext<ContextMenuContextProps>({
closeContextMenu: () => {},
openContextMenu: (args: OpenContextMenuProps) => {
@ -34,6 +72,7 @@ export interface ContextMenuProviderProps {
export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const [opened, setOpened] = useState(false);
const clickOutsideRef = useClickOutside(() => setOpened(false));
const viewport = useViewportSize();
const server = useCurrentServer();
const serverType = server?.type;
@ -53,11 +92,16 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const openContextMenu = (args: OpenContextMenuProps) => {
const { xPos, yPos, menuItems, data, type, tableRef, dataNodes, context } = args;
const shouldReverseY = yPos + menuRect.height > viewport.height;
const shouldReverseX = xPos + menuRect.width > viewport.width;
// If the context menu dimension can't be automatically calculated, calculate it manually
// This is a hacky way since resize observer may not automatically recalculate when not rendered
const menuHeight = menuRect.height || (menuItems.length + 1) * 50;
const menuWidth = menuRect.width || 220;
const calculatedXPos = shouldReverseX ? xPos - menuRect.width : xPos;
const calculatedYPos = shouldReverseY ? yPos - menuRect.height : yPos;
const shouldReverseY = yPos + menuHeight > viewport.height;
const shouldReverseX = xPos + menuWidth > viewport.width;
const calculatedXPos = shouldReverseX ? xPos - menuWidth : xPos;
const calculatedYPos = shouldReverseY ? yPos - menuHeight : yPos;
setCtx({
context,
@ -90,44 +134,47 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
openContextMenu,
});
const handlePlay = (play: Play) => {
switch (ctx.type) {
case LibraryItem.ALBUM:
handlePlayQueueAdd?.({
byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type },
play,
});
break;
case LibraryItem.ARTIST:
handlePlayQueueAdd?.({
byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type },
play,
});
break;
case LibraryItem.ALBUM_ARTIST:
handlePlayQueueAdd?.({
byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type },
play,
});
break;
case LibraryItem.SONG:
handlePlayQueueAdd?.({ byData: ctx.data, play });
break;
case LibraryItem.PLAYLIST:
for (const item of ctx.data) {
const handlePlay = useCallback(
(play: Play) => {
switch (ctx.type) {
case LibraryItem.ALBUM:
handlePlayQueueAdd?.({
byItemType: { id: [item.id], type: ctx.type },
byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type },
play,
});
}
break;
case LibraryItem.ARTIST:
handlePlayQueueAdd?.({
byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type },
play,
});
break;
case LibraryItem.ALBUM_ARTIST:
handlePlayQueueAdd?.({
byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type },
play,
});
break;
case LibraryItem.SONG:
handlePlayQueueAdd?.({ byData: ctx.data, play });
break;
case LibraryItem.PLAYLIST:
for (const item of ctx.data) {
handlePlayQueueAdd?.({
byItemType: { id: [item.id], type: ctx.type },
play,
});
}
break;
}
};
break;
}
},
[ctx.data, ctx.type, handlePlayQueueAdd],
);
const deletePlaylistMutation = useDeletePlaylist();
const handleDeletePlaylist = () => {
const handleDeletePlaylist = useCallback(() => {
for (const item of ctx.data) {
deletePlaylistMutation?.mutate(
{ query: { id: item.id } },
@ -148,9 +195,9 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
);
}
closeAllModals();
};
}, [ctx.data, deletePlaylistMutation]);
const openDeletePlaylistModal = () => {
const openDeletePlaylistModal = useCallback(() => {
openModal({
children: (
<ConfirmModal onConfirm={handleDeletePlaylist}>
@ -170,17 +217,30 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
),
title: 'Delete playlist(s)',
});
};
}, [ctx.data, handleDeletePlaylist]);
const createFavoriteMutation = useCreateFavorite();
const deleteFavoriteMutation = useDeleteFavorite();
const handleAddToFavorites = () => {
if (!ctx.dataNodes) return;
const nodesToFavorite = ctx.dataNodes.filter((item) => !item.data.userFavorite);
const handleAddToFavorites = useCallback(() => {
if (!ctx.dataNodes && !ctx.data) return;
let itemsToFavorite: AnyLibraryItems = [];
let nodesToFavorite: RowNode<any>[] = [];
if (ctx.dataNodes) {
nodesToFavorite = ctx.dataNodes.filter((item) => !item.data.userFavorite);
} else {
itemsToFavorite = ctx.data.filter((item) => !item.userFavorite);
}
const idsToFavorite = nodesToFavorite
? nodesToFavorite.map((node) => node.data.id)
: itemsToFavorite.map((item) => item.id);
createFavoriteMutation.mutate(
{
query: {
id: nodesToFavorite.map((item) => item.data.id),
id: idsToFavorite,
type: ctx.type,
},
},
@ -192,22 +252,36 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
});
},
onSuccess: () => {
for (const node of nodesToFavorite) {
node.setData({ ...node.data, userFavorite: true });
if (ctx.dataNodes) {
for (const node of nodesToFavorite) {
node.setData({ ...node.data, userFavorite: true });
}
}
},
},
);
};
}, [createFavoriteMutation, ctx.data, ctx.dataNodes, ctx.type]);
const handleRemoveFromFavorites = () => {
if (!ctx.dataNodes) return;
const nodesToUnfavorite = ctx.dataNodes.filter((item) => item.data.userFavorite);
const handleRemoveFromFavorites = useCallback(() => {
if (!ctx.dataNodes && !ctx.data) return;
let itemsToUnfavorite: AnyLibraryItems = [];
let nodesToUnfavorite: RowNode<any>[] = [];
if (ctx.dataNodes) {
nodesToUnfavorite = ctx.dataNodes.filter((item) => !item.data.userFavorite);
} else {
itemsToUnfavorite = ctx.data.filter((item) => !item.userFavorite);
}
const idsToUnfavorite = nodesToUnfavorite
? nodesToUnfavorite.map((node) => node.data.id)
: itemsToUnfavorite.map((item) => item.id);
deleteFavoriteMutation.mutate(
{
query: {
id: nodesToUnfavorite.map((item) => item.data.id),
id: idsToUnfavorite,
type: ctx.type,
},
},
@ -219,28 +293,60 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
},
},
);
};
}, [ctx.data, ctx.dataNodes, ctx.type, deleteFavoriteMutation]);
const handleAddToPlaylist = useCallback(() => {
if (!ctx.dataNodes && !ctx.data) return;
const albumId: string[] = [];
const artistId: string[] = [];
const songId: string[] = [];
if (ctx.dataNodes) {
for (const node of ctx.dataNodes) {
switch (node.data.type) {
case LibraryItem.ALBUM:
albumId.push(node.data.id);
break;
case LibraryItem.ARTIST:
artistId.push(node.data.id);
break;
case LibraryItem.SONG:
songId.push(node.data.id);
break;
}
}
} else {
for (const item of ctx.data) {
switch (item.type) {
case LibraryItem.ALBUM:
albumId.push(item.id);
break;
case LibraryItem.ARTIST:
artistId.push(item.id);
break;
case LibraryItem.SONG:
songId.push(item.id);
break;
}
}
}
const handleAddToPlaylist = () => {
if (!ctx.dataNodes) return;
openContextModal({
innerProps: {
albumId:
ctx.type === LibraryItem.ALBUM ? ctx.dataNodes.map((node) => node.data.id) : undefined,
artistId:
ctx.type === LibraryItem.ARTIST ? ctx.dataNodes.map((node) => node.data.id) : undefined,
songId:
ctx.type === LibraryItem.SONG ? ctx.dataNodes.map((node) => node.data.id) : undefined,
albumId: albumId.length > 0 ? albumId : undefined,
artistId: artistId.length > 0 ? artistId : undefined,
songId: songId.length > 0 ? songId : undefined,
},
modal: 'addToPlaylist',
size: 'md',
title: 'Add to playlist',
});
};
}, [ctx.data, ctx.dataNodes]);
const removeFromPlaylistMutation = useRemoveFromPlaylist();
const handleRemoveFromPlaylist = () => {
const handleRemoveFromPlaylist = useCallback(() => {
const songId =
(serverType === ServerType.NAVIDROME || ServerType.JELLYFIN
? ctx.dataNodes?.map((node) => node.data.playlistItemId)
@ -284,48 +390,198 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
),
title: 'Remove song(s) from playlist',
});
};
}, [
ctx.context?.playlistId,
ctx.context?.tableRef,
ctx.dataNodes,
removeFromPlaylistMutation,
serverType,
]);
const contextMenuItems = {
addToFavorites: {
id: 'addToFavorites',
label: 'Add to favorites',
onClick: handleAddToFavorites,
const updateRatingMutation = useUpdateRating();
const handleUpdateRating = useCallback(
(rating: number) => {
if (!ctx.dataNodes || !ctx.data) return;
let uniqueServerIds: string[] = [];
let items: AnyLibraryItems = [];
if (ctx.dataNodes) {
uniqueServerIds = ctx.dataNodes.reduce((acc, node) => {
if (!acc.includes(node.data.serverId)) {
acc.push(node.data.serverId);
}
return acc;
}, [] as string[]);
} else {
uniqueServerIds = ctx.data.reduce((acc, item) => {
if (!acc.includes(item.serverId)) {
acc.push(item.serverId);
}
return acc;
}, [] as string[]);
}
for (const serverId of uniqueServerIds) {
if (ctx.dataNodes) {
items = ctx.dataNodes
.filter((node) => node.data.serverId === serverId)
.map((node) => node.data);
} else {
items = ctx.data.filter((item) => item.serverId === serverId);
}
updateRatingMutation.mutate({
_serverId: serverId,
query: {
item: items,
rating,
},
});
}
},
addToPlaylist: { id: 'addToPlaylist', label: 'Add to playlist', onClick: handleAddToPlaylist },
createPlaylist: { id: 'createPlaylist', label: 'Create playlist', onClick: () => {} },
deletePlaylist: {
id: 'deletePlaylist',
label: 'Delete playlist',
onClick: openDeletePlaylistModal,
},
play: {
id: 'play',
label: 'Play',
onClick: () => handlePlay(Play.NOW),
},
playLast: {
id: 'playLast',
label: 'Add to queue',
onClick: () => handlePlay(Play.LAST),
},
playNext: {
id: 'playNext',
label: 'Add to queue next',
onClick: () => handlePlay(Play.NEXT),
},
removeFromFavorites: {
id: 'removeFromFavorites',
label: 'Remove from favorites',
onClick: handleRemoveFromFavorites,
},
removeFromPlaylist: {
id: 'removeFromPlaylist',
label: 'Remove from playlist',
onClick: handleRemoveFromPlaylist,
},
setRating: { id: 'setRating', label: 'Set rating', onClick: () => {} },
};
[ctx.data, ctx.dataNodes, updateRatingMutation],
);
const contextMenuItems: Record<ContextMenuItemType, ContextMenuItem> = useMemo(() => {
return {
addToFavorites: {
id: 'addToFavorites',
label: 'Add to favorites',
leftIcon: <RiHeartFill size="1.1rem" />,
onClick: handleAddToFavorites,
},
addToPlaylist: {
id: 'addToPlaylist',
label: 'Add to playlist',
leftIcon: <RiPlayListAddFill size="1.1rem" />,
onClick: handleAddToPlaylist,
},
createPlaylist: { id: 'createPlaylist', label: 'Create playlist', onClick: () => {} },
deletePlaylist: {
id: 'deletePlaylist',
label: 'Delete playlist',
leftIcon: <RiDeleteBinFill size="1.1rem" />,
onClick: openDeletePlaylistModal,
},
play: {
id: 'play',
label: 'Play',
leftIcon: <RiPlayFill size="1.1rem" />,
onClick: () => handlePlay(Play.NOW),
},
playLast: {
id: 'playLast',
label: 'Add to queue',
leftIcon: <RiAddBoxFill size="1.1rem" />,
onClick: () => handlePlay(Play.LAST),
},
playNext: {
id: 'playNext',
label: 'Add to queue next',
leftIcon: <RiAddCircleFill size="1.1rem" />,
onClick: () => handlePlay(Play.NEXT),
},
removeFromFavorites: {
id: 'removeFromFavorites',
label: 'Remove from favorites',
leftIcon: <RiDislikeFill size="1.1rem" />,
onClick: handleRemoveFromFavorites,
},
removeFromPlaylist: {
id: 'removeFromPlaylist',
label: 'Remove from playlist',
leftIcon: <RiDeleteBinFill size="1.1rem" />,
onClick: handleRemoveFromPlaylist,
},
setRating: {
children: [
{
id: 'zeroStar',
label: (
<Rating
readOnly
value={0}
onClick={() => {}}
/>
),
onClick: () => handleUpdateRating(0),
},
{
id: 'oneStar',
label: (
<Rating
readOnly
value={1}
onClick={() => {}}
/>
),
onClick: () => handleUpdateRating(1),
},
{
id: 'twoStar',
label: (
<Rating
readOnly
value={2}
onClick={() => {}}
/>
),
onClick: () => handleUpdateRating(2),
},
{
id: 'threeStar',
label: (
<Rating
readOnly
value={3}
onClick={() => {}}
/>
),
onClick: () => handleUpdateRating(3),
},
{
id: 'fourStar',
label: (
<Rating
readOnly
value={4}
onClick={() => {}}
/>
),
onClick: () => handleUpdateRating(4),
},
{
id: 'fiveStar',
label: (
<Rating
readOnly
value={5}
onClick={() => {}}
/>
),
onClick: () => handleUpdateRating(5),
},
],
id: 'setRating',
label: 'Set rating',
leftIcon: <RiStarFill size="1.1rem" />,
onClick: () => {},
rightIcon: <RiArrowRightSFill size="1.2rem" />,
},
};
}, [
handleAddToFavorites,
handleAddToPlaylist,
handlePlay,
handleRemoveFromFavorites,
handleRemoveFromPlaylist,
handleUpdateRating,
openDeletePlaylistModal,
]);
const mergedRef = useMergedRef(ref, clickOutsideRef);
return (
<ContextMenuContext.Provider
@ -334,43 +590,89 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
openContextMenu,
}}
>
{opened && (
<ContextMenu
ref={ref}
minWidth={125}
xPos={ctx.xPos}
yPos={ctx.yPos}
>
<Stack
ref={clickOutsideRef}
spacing={0}
onClick={closeContextMenu}
>
{ctx.menuItems?.map((item) => {
return (
<Fragment key={`context-menu-${item.id}`}>
<ContextMenuButton
as="button"
disabled={item.disabled}
onClick={contextMenuItems[item.id as keyof typeof contextMenuItems].onClick}
>
{contextMenuItems[item.id as keyof typeof contextMenuItems].label}
</ContextMenuButton>
{item.divider && (
<Divider
key={`context-menu-divider-${item.id}`}
color="rgb(62, 62, 62)"
size="sm"
/>
)}
</Fragment>
);
})}
</Stack>
</ContextMenu>
)}
<Portal>
<AnimatePresence>
{opened && (
<ContextMenu
ref={mergedRef}
minWidth={125}
xPos={ctx.xPos}
yPos={ctx.yPos}
>
<Stack spacing={0}>
<Stack
spacing={0}
onClick={closeContextMenu}
>
{ctx.menuItems?.map((item) => {
return (
<Fragment key={`context-menu-${item.id}`}>
{item.children ? (
<HoverCard
offset={5}
position="right"
>
<HoverCard.Target>
<ContextMenuButton
disabled={item.disabled}
leftIcon={contextMenuItems[item.id].leftIcon}
rightIcon={contextMenuItems[item.id].rightIcon}
onClick={contextMenuItems[item.id].onClick}
>
{contextMenuItems[item.id].label}
</ContextMenuButton>
</HoverCard.Target>
<HoverCard.Dropdown>
<Stack spacing={0}>
{contextMenuItems[item.id].children?.map((child) => (
<>
<ContextMenuButton
key={`sub-${child.id}`}
disabled={child.disabled}
leftIcon={child.leftIcon}
rightIcon={child.rightIcon}
onClick={child.onClick}
>
{child.label}
</ContextMenuButton>
</>
))}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
) : (
<ContextMenuButton
disabled={item.disabled}
leftIcon={contextMenuItems[item.id].leftIcon}
rightIcon={contextMenuItems[item.id].rightIcon}
onClick={contextMenuItems[item.id].onClick}
>
{contextMenuItems[item.id].label}
</ContextMenuButton>
)}
{children}
{item.divider && (
<Divider
key={`context-menu-divider-${item.id}`}
color="rgb(62, 62, 62)"
size="sm"
/>
)}
</Fragment>
);
})}
</Stack>
<Divider
color="rgb(62, 62, 62)"
size="sm"
/>
<ContextMenuButton disabled>{ctx.data?.length} selected</ContextMenuButton>
</Stack>
</ContextMenu>
)}
</AnimatePresence>
{children}
</Portal>
</ContextMenuContext.Provider>
);
};

View file

@ -20,7 +20,7 @@ export type ContextMenuEvents = {
openContextMenu: (args: OpenContextMenuProps) => void;
};
export type ContextMenuItem =
export type ContextMenuItemType =
| 'play'
| 'playLast'
| 'playNext'
@ -33,9 +33,10 @@ export type ContextMenuItem =
| 'createPlaylist';
export type SetContextMenuItems = {
children?: boolean;
disabled?: boolean;
divider?: boolean;
id: ContextMenuItem;
id: ContextMenuItemType;
onClick?: () => void;
}[];

View file

@ -1,6 +1,6 @@
import { CellContextMenuEvent } from '@ag-grid-community/core';
import sortBy from 'lodash/sortBy';
import { LibraryItem } from '/@/renderer/api/types';
import { Album, AlbumArtist, Artist, LibraryItem, QueueSong, Song } from '/@/renderer/api/types';
import { openContextMenu, SetContextMenuItems } from '/@/renderer/features/context-menu/events';
export const useHandleTableContextMenu = (
@ -38,3 +38,30 @@ export const useHandleTableContextMenu = (
return handleContextMenu;
};
export const useHandleGeneralContextMenu = (
itemType: LibraryItem,
contextMenuItems: SetContextMenuItems,
context?: any,
) => {
const handleContextMenu = (
e: any,
data: Song[] | QueueSong[] | AlbumArtist[] | Artist[] | Album[],
) => {
if (!e) return;
const clickEvent = e as MouseEvent;
clickEvent.preventDefault();
openContextMenu({
context,
data,
dataNodes: undefined,
menuItems: contextMenuItems,
type: itemType,
xPos: clickEvent.clientX + 15,
yPos: clickEvent.clientY + 5,
});
};
return handleContextMenu;
};