mirror of
https://github.com/antebudimir/feishin.git
synced 2026-01-01 02:13:33 +00:00
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:
parent
f50ec5cf31
commit
22fec8f9d3
27 changed files with 1189 additions and 503 deletions
|
|
@ -1,7 +1,6 @@
|
|||
import { MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
getColumnDefs,
|
||||
GridCarousel,
|
||||
TextTitle,
|
||||
|
|
@ -12,7 +11,6 @@ import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
|
|||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Box, Group, Stack } from '@mantine/core';
|
||||
import { useSetState } from '@mantine/hooks';
|
||||
import { openContextModal } from '@mantine/modals';
|
||||
import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
||||
import { generatePath, useParams } from 'react-router';
|
||||
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query';
|
||||
|
|
@ -22,15 +20,16 @@ import styled from 'styled-components';
|
|||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { Play } from '/@/renderer/types';
|
||||
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import {
|
||||
PlayButton,
|
||||
PLAY_TYPES,
|
||||
useCreateFavorite,
|
||||
useDeleteFavorite,
|
||||
} from '/@/renderer/features/shared';
|
||||
useHandleGeneralContextMenu,
|
||||
useHandleTableContextMenu,
|
||||
} from '/@/renderer/features/context-menu';
|
||||
import { Play } from '/@/renderer/types';
|
||||
import {
|
||||
ALBUM_CONTEXT_MENU_ITEMS,
|
||||
SONG_CONTEXT_MENU_ITEMS,
|
||||
} from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { PlayButton, useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
||||
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
|
||||
import { AlbumListSort, LibraryItem, QueueSong, SortOrder } from '/@/renderer/api/types';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
|
|
@ -183,16 +182,10 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
|
|||
|
||||
const { intersectRef, tableContainerRef } = useFixedTableHeader();
|
||||
|
||||
const handleAddToPlaylist = () => {
|
||||
openContextModal({
|
||||
innerProps: {
|
||||
albumId: [albumId],
|
||||
},
|
||||
modal: 'addToPlaylist',
|
||||
size: 'md',
|
||||
title: 'Add to playlist',
|
||||
});
|
||||
};
|
||||
const handleGeneralContextMenu = useHandleGeneralContextMenu(
|
||||
LibraryItem.ALBUM,
|
||||
ALBUM_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
|
||||
return (
|
||||
<ContentContainer>
|
||||
|
|
@ -220,28 +213,16 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
|
|||
<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 onClick={handleAddToPlaylist}>Add to playlist</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
if (!detailQuery?.data) return;
|
||||
handleGeneralContextMenu(e, [detailQuery.data!]);
|
||||
}}
|
||||
>
|
||||
<RiMoreFill size={20} />
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import { Group, Stack } from '@mantine/core';
|
|||
import { forwardRef, Fragment, Ref } from 'react';
|
||||
import { generatePath, useParams } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { Text } from '/@/renderer/components';
|
||||
import { LibraryItem, ServerType } from '/@/renderer/api/types';
|
||||
import { Rating, Text } from '/@/renderer/components';
|
||||
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query';
|
||||
import { LibraryHeader } from '/@/renderer/features/shared';
|
||||
import { LibraryHeader, useUpdateRating } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { formatDurationString } from '/@/renderer/utils';
|
||||
|
|
@ -38,6 +38,34 @@ export const AlbumDetailHeader = forwardRef(
|
|||
},
|
||||
];
|
||||
|
||||
const updateRatingMutation = useUpdateRating();
|
||||
|
||||
const handleUpdateRating = (rating: number) => {
|
||||
if (!detailQuery?.data) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
_serverId: detailQuery?.data.serverId,
|
||||
query: {
|
||||
item: [detailQuery.data],
|
||||
rating,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearRating = () => {
|
||||
if (!detailQuery?.data || !detailQuery?.data.userRating) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
_serverId: detailQuery.data.serverId,
|
||||
query: {
|
||||
item: [detailQuery.data],
|
||||
rating: 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME;
|
||||
|
||||
return (
|
||||
<Stack ref={cq.ref}>
|
||||
<LibraryHeader
|
||||
|
|
@ -55,6 +83,17 @@ export const AlbumDetailHeader = forwardRef(
|
|||
<Text $secondary={item.secondary}>{item.value}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
{showRating && (
|
||||
<>
|
||||
<Text $noSelect>•</Text>
|
||||
<Rating
|
||||
readOnly={detailQuery?.isFetching || updateRatingMutation.isLoading}
|
||||
value={detailQuery?.data?.userRating || 0}
|
||||
onChange={handleUpdateRating}
|
||||
onClick={handleClearRating}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Group
|
||||
spacing="sm"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
getColumnDefs,
|
||||
GridCarousel,
|
||||
Text,
|
||||
|
|
@ -10,8 +9,7 @@ import {
|
|||
} from '/@/renderer/components';
|
||||
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
|
||||
import { Box, Group, Stack } from '@mantine/core';
|
||||
import { openContextModal } from '@mantine/modals';
|
||||
import { RiArrowDownSLine, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
||||
import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
||||
import { generatePath, useParams } from 'react-router';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { createSearchParams, Link } from 'react-router-dom';
|
||||
|
|
@ -19,15 +17,16 @@ import styled from 'styled-components';
|
|||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { Play, TableColumn } from '/@/renderer/types';
|
||||
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import {
|
||||
PlayButton,
|
||||
PLAY_TYPES,
|
||||
useCreateFavorite,
|
||||
useDeleteFavorite,
|
||||
} from '/@/renderer/features/shared';
|
||||
useHandleGeneralContextMenu,
|
||||
useHandleTableContextMenu,
|
||||
} from '/@/renderer/features/context-menu';
|
||||
import { Play, TableColumn } from '/@/renderer/types';
|
||||
import {
|
||||
ARTIST_CONTEXT_MENU_ITEMS,
|
||||
SONG_CONTEXT_MENU_ITEMS,
|
||||
} from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { PlayButton, useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
||||
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
|
||||
import {
|
||||
AlbumListSort,
|
||||
|
|
@ -267,16 +266,10 @@ export const AlbumArtistDetailContent = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleAddToPlaylist = () => {
|
||||
openContextModal({
|
||||
innerProps: {
|
||||
artistId: [albumArtistId],
|
||||
},
|
||||
modal: 'addToPlaylist',
|
||||
size: 'md',
|
||||
title: 'Add to playlist',
|
||||
});
|
||||
};
|
||||
const handleGeneralContextMenu = useHandleGeneralContextMenu(
|
||||
LibraryItem.ALBUM_ARTIST,
|
||||
ARTIST_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
|
||||
const topSongs = topSongsQuery?.data?.items?.slice(0, 10);
|
||||
|
||||
|
|
@ -311,28 +304,16 @@ export const AlbumArtistDetailContent = () => {
|
|||
<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 onClick={handleAddToPlaylist}>Add to playlist</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
if (!detailQuery?.data) return;
|
||||
handleGeneralContextMenu(e, [detailQuery.data!]);
|
||||
}}
|
||||
>
|
||||
<RiMoreFill size={20} />
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
uppercase
|
||||
|
|
@ -422,22 +403,6 @@ export const AlbumArtistDetailContent = () => {
|
|||
View all
|
||||
</Button>
|
||||
</Group>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
uppercase
|
||||
rightIcon={<RiArrowDownSLine size={20} />}
|
||||
variant="subtle"
|
||||
>
|
||||
Community
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item>Community</DropdownMenu.Item>
|
||||
<DropdownMenu.Item>User</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
<VirtualTable
|
||||
autoFitColumns
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { Group, Stack } from '@mantine/core';
|
||||
import { forwardRef, Fragment, Ref } from 'react';
|
||||
import { Group, Rating, Stack } from '@mantine/core';
|
||||
import { forwardRef, Fragment, Ref, MouseEvent } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { LibraryItem, ServerType } from '/@/renderer/api/types';
|
||||
import { Text } from '/@/renderer/components';
|
||||
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
|
||||
import { LibraryHeader } from '/@/renderer/features/shared';
|
||||
import { LibraryHeader, useUpdateRating } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { formatDurationString } from '/@/renderer/utils';
|
||||
|
|
@ -37,6 +37,39 @@ export const AlbumArtistDetailHeader = forwardRef(
|
|||
},
|
||||
];
|
||||
|
||||
const updateRatingMutation = useUpdateRating();
|
||||
|
||||
const handleUpdateRating = (rating: number) => {
|
||||
if (!detailQuery?.data) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
_serverId: detailQuery?.data.serverId,
|
||||
query: {
|
||||
item: [detailQuery.data],
|
||||
rating,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearRating = (_e: MouseEvent<HTMLDivElement>, rating?: number) => {
|
||||
if (!detailQuery?.data || !detailQuery?.data.userRating) return;
|
||||
|
||||
console.log(rating, detailQuery.data.userRating);
|
||||
|
||||
const isSameRatingAsPrevious = rating === detailQuery.data.userRating;
|
||||
if (!isSameRatingAsPrevious) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
_serverId: detailQuery.data.serverId,
|
||||
query: {
|
||||
item: [detailQuery.data],
|
||||
rating: 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const showRating = detailQuery?.data?.serverType === ServerType.NAVIDROME;
|
||||
|
||||
return (
|
||||
<Stack ref={cq.ref}>
|
||||
<LibraryHeader
|
||||
|
|
@ -56,6 +89,17 @@ export const AlbumArtistDetailHeader = forwardRef(
|
|||
<Text $secondary={item.secondary}>{item.value}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
{showRating && (
|
||||
<>
|
||||
<Text $noSelect>•</Text>
|
||||
<Rating
|
||||
readOnly={detailQuery?.isFetching || updateRatingMutation.isLoading}
|
||||
value={detailQuery?.data?.userRating || 0}
|
||||
onChange={handleUpdateRating}
|
||||
onClick={handleClearRating}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Group
|
||||
sx={{
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}[];
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import React from 'react';
|
||||
import { Center, Group } from '@mantine/core';
|
||||
import { openContextModal } from '@mantine/modals';
|
||||
import { motion, AnimatePresence, LayoutGroup } from 'framer-motion';
|
||||
import { RiArrowUpSLine, RiDiscLine, RiMore2Fill } from 'react-icons/ri';
|
||||
import { generatePath, Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { Button, DropdownMenu, Text } from '/@/renderer/components';
|
||||
import { Button, Text } from '/@/renderer/components';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useAppStoreActions, useAppStore, useCurrentSong } from '/@/renderer/store';
|
||||
import { fadeIn } from '/@/renderer/styles';
|
||||
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
|
||||
|
||||
const LeftControlsContainer = styled.div`
|
||||
display: flex;
|
||||
|
|
@ -82,41 +82,10 @@ export const LeftControls = () => {
|
|||
|
||||
const isSongDefined = Boolean(currentSong?.id);
|
||||
|
||||
const openAddToPlaylistModal = () => {
|
||||
openContextModal({
|
||||
innerProps: {
|
||||
songId: [currentSong?.id],
|
||||
},
|
||||
modal: 'addToPlaylist',
|
||||
size: 'md',
|
||||
title: 'Add to playlist',
|
||||
});
|
||||
};
|
||||
|
||||
const addToFavoritesMutation = useCreateFavorite();
|
||||
const removeFromFavoritesMutation = useDeleteFavorite();
|
||||
|
||||
const handleAddToFavorites = () => {
|
||||
if (!isSongDefined || !currentSong) return;
|
||||
|
||||
addToFavoritesMutation.mutate({
|
||||
query: {
|
||||
id: [currentSong.id],
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = () => {
|
||||
if (!isSongDefined || !currentSong) return;
|
||||
|
||||
removeFromFavoritesMutation.mutate({
|
||||
query: {
|
||||
id: [currentSong.id],
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
});
|
||||
};
|
||||
const handleGeneralContextMenu = useHandleGeneralContextMenu(
|
||||
LibraryItem.SONG,
|
||||
SONG_CONTEXT_MENU_ITEMS,
|
||||
);
|
||||
|
||||
return (
|
||||
<LeftControlsContainer>
|
||||
|
|
@ -196,28 +165,13 @@ export const LeftControls = () => {
|
|||
{title || '—'}
|
||||
</Text>
|
||||
{isSongDefined && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMore2Fill size="1.2rem" />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<DropdownMenu.Item onClick={openAddToPlaylistModal}>
|
||||
Add to playlist
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Divider />
|
||||
<DropdownMenu.Item onClick={handleAddToFavorites}>
|
||||
Add to favorites
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onClick={handleRemoveFromFavorites}>
|
||||
Remove from favorites
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
compact
|
||||
variant="subtle"
|
||||
onClick={(e) => handleGeneralContextMenu(e, [currentSong!])}
|
||||
>
|
||||
<RiMore2Fill size="1.2rem" />
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</LineItem>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { MouseEvent } from 'react';
|
||||
import { Flex, Group } from '@mantine/core';
|
||||
import { HiOutlineQueueList } from 'react-icons/hi2';
|
||||
import {
|
||||
|
|
@ -19,7 +20,7 @@ import {
|
|||
import { useRightControls } from '../hooks/use-right-controls';
|
||||
import { PlayerButton } from './player-button';
|
||||
import { LibraryItem, ServerType } from '/@/renderer/api/types';
|
||||
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
||||
import { useCreateFavorite, useDeleteFavorite, useUpdateRating } from '/@/renderer/features/shared';
|
||||
import { Rating } from '/@/renderer/components';
|
||||
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
|
||||
|
||||
|
|
@ -32,6 +33,7 @@ export const RightControls = () => {
|
|||
const { rightExpanded: isQueueExpanded } = useSidebarStore();
|
||||
const { handleVolumeSlider, handleVolumeWheel, handleMute } = useRightControls();
|
||||
|
||||
const updateRatingMutation = useUpdateRating();
|
||||
const addToFavoritesMutation = useCreateFavorite();
|
||||
const removeFromFavoritesMutation = useDeleteFavorite();
|
||||
const setFavorite = useSetQueueFavorite();
|
||||
|
|
@ -54,6 +56,30 @@ export const RightControls = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const handleUpdateRating = (rating: number) => {
|
||||
if (!currentSong) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
_serverId: currentSong?.serverId,
|
||||
query: {
|
||||
item: [currentSong],
|
||||
rating,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearRating = (_e: MouseEvent<HTMLDivElement>, rating?: number) => {
|
||||
if (!currentSong || !rating) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
_serverId: currentSong?.serverId,
|
||||
query: {
|
||||
item: [currentSong],
|
||||
rating: 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = () => {
|
||||
if (!currentSong) return;
|
||||
|
||||
|
|
@ -96,9 +122,10 @@ export const RightControls = () => {
|
|||
<Group h="calc(100% / 3)">
|
||||
{showRating && (
|
||||
<Rating
|
||||
readOnly
|
||||
size="sm"
|
||||
value={currentSong?.userRating ?? 0}
|
||||
value={currentSong?.userRating || 0}
|
||||
onChange={handleUpdateRating}
|
||||
onClick={handleClearRating}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ export * from './components/library-header';
|
|||
export * from './components/library-header-bar';
|
||||
export * from './mutations/create-favorite-mutation';
|
||||
export * from './mutations/delete-favorite-mutation';
|
||||
export * from './mutations/update-rating-mutation';
|
||||
|
|
|
|||
133
src/renderer/features/shared/mutations/update-rating-mutation.ts
Normal file
133
src/renderer/features/shared/mutations/update-rating-mutation.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { HTTPError } from 'ky';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { NDAlbumArtistDetail, NDAlbumDetail } from '/@/renderer/api/navidrome.types';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { SSAlbumArtistDetail, SSAlbumDetail } from '/@/renderer/api/subsonic.types';
|
||||
import {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
AnyLibraryItems,
|
||||
LibraryItem,
|
||||
RatingArgs,
|
||||
RawRatingResponse,
|
||||
ServerType,
|
||||
} from '/@/renderer/api/types';
|
||||
import { MutationOptions } from '/@/renderer/lib/react-query';
|
||||
import {
|
||||
useAuthStore,
|
||||
useCurrentServer,
|
||||
useSetAlbumListItemDataById,
|
||||
useSetQueueRating,
|
||||
} from '/@/renderer/store';
|
||||
|
||||
export const useUpdateRating = (options?: MutationOptions) => {
|
||||
const queryClient = useQueryClient();
|
||||
const currentServer = useCurrentServer();
|
||||
const setAlbumListData = useSetAlbumListItemDataById();
|
||||
const setQueueRating = useSetQueueRating();
|
||||
|
||||
return useMutation<
|
||||
RawRatingResponse,
|
||||
HTTPError,
|
||||
Omit<RatingArgs, 'server'>,
|
||||
{ previous: { items: AnyLibraryItems } | undefined }
|
||||
>({
|
||||
mutationFn: (args) => {
|
||||
const server = useAuthStore.getState().actions.getServer(args._serverId) || currentServer;
|
||||
return api.controller.updateRating({ ...args, server });
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
for (const item of context?.previous?.items || []) {
|
||||
switch (item.itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
setAlbumListData(item.id, { userRating: item.userRating });
|
||||
break;
|
||||
case LibraryItem.SONG:
|
||||
setQueueRating([item.id], item.userRating);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
onMutate: (variables) => {
|
||||
for (const item of variables.query.item) {
|
||||
switch (item.itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
setAlbumListData(item.id, { userRating: variables.query.rating });
|
||||
break;
|
||||
case LibraryItem.SONG:
|
||||
setQueueRating([item.id], variables.query.rating);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { previous: { items: variables.query.item } };
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// We only need to set if we're already on the album detail page
|
||||
const isAlbumDetailPage =
|
||||
variables.query.item.length === 1 && variables.query.item[0].itemType === LibraryItem.ALBUM;
|
||||
|
||||
if (isAlbumDetailPage) {
|
||||
const { serverType, id: albumId, serverId } = variables.query.item[0] as Album;
|
||||
|
||||
const queryKey = queryKeys.albums.detail(serverId || '', { id: albumId });
|
||||
const previous = queryClient.getQueryData<any>(queryKey);
|
||||
if (previous) {
|
||||
switch (serverType) {
|
||||
case ServerType.NAVIDROME:
|
||||
queryClient.setQueryData<NDAlbumDetail>(queryKey, {
|
||||
...previous,
|
||||
rating: variables.query.rating,
|
||||
});
|
||||
break;
|
||||
case ServerType.SUBSONIC:
|
||||
queryClient.setQueryData<SSAlbumDetail>(queryKey, {
|
||||
...previous,
|
||||
userRating: variables.query.rating,
|
||||
});
|
||||
break;
|
||||
case ServerType.JELLYFIN:
|
||||
// Jellyfin does not support ratings
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We only need to set if we're already on the album detail page
|
||||
const isAlbumArtistDetailPage =
|
||||
variables.query.item.length === 1 &&
|
||||
variables.query.item[0].itemType === LibraryItem.ALBUM_ARTIST;
|
||||
|
||||
if (isAlbumArtistDetailPage) {
|
||||
const { serverType, id: albumArtistId, serverId } = variables.query.item[0] as AlbumArtist;
|
||||
|
||||
const queryKey = queryKeys.albumArtists.detail(serverId || '', {
|
||||
id: albumArtistId,
|
||||
});
|
||||
const previous = queryClient.getQueryData<any>(queryKey);
|
||||
if (previous) {
|
||||
switch (serverType) {
|
||||
case ServerType.NAVIDROME:
|
||||
queryClient.setQueryData<NDAlbumArtistDetail>(queryKey, {
|
||||
...previous,
|
||||
rating: variables.query.rating,
|
||||
});
|
||||
break;
|
||||
case ServerType.SUBSONIC:
|
||||
queryClient.setQueryData<SSAlbumArtistDetail>(queryKey, {
|
||||
...previous,
|
||||
userRating: variables.query.rating,
|
||||
});
|
||||
break;
|
||||
case ServerType.JELLYFIN:
|
||||
// Jellyfin does not support ratings
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
...options,
|
||||
});
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue