diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index d6024d20..ef326b76 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -33,6 +33,8 @@ import type { PlaylistSongListArgs, ArtistListArgs, RawArtistListResponse, + UpdatePlaylistArgs, + RawUpdatePlaylistResponse, } from '/@/renderer/api/types'; import { subsonicApi } from '/@/renderer/api/subsonic.api'; import { jellyfinApi } from '/@/renderer/api/jellyfin.api'; @@ -60,7 +62,7 @@ export type ControllerEndpoint = Partial<{ getPlaylistSongList: (args: PlaylistSongListArgs) => Promise; getSongDetail: (args: SongDetailArgs) => Promise; getSongList: (args: SongListArgs) => Promise; - updatePlaylist: () => void; + updatePlaylist: (args: UpdatePlaylistArgs) => Promise; updateRating: (args: RatingArgs) => Promise; }>; @@ -120,7 +122,7 @@ const endpoints: ApiController = { getPlaylistSongList: navidromeApi.getPlaylistSongList, getSongDetail: navidromeApi.getSongDetail, getSongList: navidromeApi.getSongList, - updatePlaylist: undefined, + updatePlaylist: navidromeApi.updatePlaylist, updateRating: subsonicApi.updateRating, }, subsonic: { @@ -203,7 +205,16 @@ const getPlaylistList = async (args: PlaylistListArgs) => { return (apiController('getPlaylistList') as ControllerEndpoint['getPlaylistList'])?.(args); }; +const createPlaylist = async (args: CreatePlaylistArgs) => { + return (apiController('createPlaylist') as ControllerEndpoint['createPlaylist'])?.(args); +}; + +const updatePlaylist = async (args: UpdatePlaylistArgs) => { + return (apiController('updatePlaylist') as ControllerEndpoint['updatePlaylist'])?.(args); +}; + export const controller = { + createPlaylist, getAlbumArtistList, getAlbumDetail, getAlbumList, @@ -212,4 +223,5 @@ export const controller = { getMusicFolderList, getPlaylistList, getSongList, + updatePlaylist, }; diff --git a/src/renderer/api/navidrome.api.ts b/src/renderer/api/navidrome.api.ts index e60c6c58..87934bec 100644 --- a/src/renderer/api/navidrome.api.ts +++ b/src/renderer/api/navidrome.api.ts @@ -32,6 +32,8 @@ import type { NDSongListResponse, NDAlbumArtist, NDPlaylist, + NDUpdatePlaylistParams, + NDUpdatePlaylistResponse, } from '/@/renderer/api/navidrome.types'; import { NDPlaylistListSort, NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types'; import type { @@ -53,6 +55,8 @@ import type { PlaylistSongListArgs, AlbumArtist, Playlist, + UpdatePlaylistResponse, + UpdatePlaylistArgs, } from '/@/renderer/api/types'; import { playlistListSortMap, @@ -286,7 +290,7 @@ const getSongDetail = async (args: SongDetailArgs): Promise => { }; const createPlaylist = async (args: CreatePlaylistArgs): Promise => { - const { query, server, signal } = args; + const { query, server } = args; const json: NDCreatePlaylistParams = { comment: query.comment, @@ -299,7 +303,6 @@ const createPlaylist = async (args: CreatePlaylistArgs): Promise(); @@ -309,6 +312,33 @@ const createPlaylist = async (args: CreatePlaylistArgs): Promise => { + const { query, server, signal } = args; + + const previous = query.previous as NDPlaylist; + + const json: NDUpdatePlaylistParams = { + ...previous, + comment: query.comment || '', + name: query.name, + public: query.public || false, + }; + + const data = await api + .post(`api/playlist/${previous.id}`, { + headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` }, + json, + prefixUrl: server?.url, + signal, + }) + .json(); + + return { + id: data.id, + name: query.name, + }; +}; + const deletePlaylist = async (args: DeletePlaylistArgs): Promise => { const { query, server, signal } = args; @@ -552,6 +582,7 @@ export const navidromeApi = { getPlaylistSongList, getSongDetail, getSongList, + updatePlaylist, }; export const ndNormalize = { diff --git a/src/renderer/api/navidrome.types.ts b/src/renderer/api/navidrome.types.ts index 204b914f..fb712cdf 100644 --- a/src/renderer/api/navidrome.types.ts +++ b/src/renderer/api/navidrome.types.ts @@ -259,7 +259,8 @@ export type NDAlbumArtistListParams = { export type NDCreatePlaylistParams = { comment?: string; name: string; - public: boolean; + public?: boolean; + rules?: Record | null; }; export type NDCreatePlaylistResponse = { @@ -268,6 +269,10 @@ export type NDCreatePlaylistResponse = { export type NDCreatePlaylist = NDCreatePlaylistResponse; +export type NDUpdatePlaylistParams = NDPlaylist; + +export type NDUpdatePlaylistResponse = NDPlaylist; + export type NDDeletePlaylistParams = { id: string; }; diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index babe7f8e..e5480521 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -120,16 +120,6 @@ export interface BasePaginatedResponse { totalRecordCount: number; } -export type ApiError = { - error: { - message: string; - path: string; - trace: string[]; - }; - response: string; - statusCode: number; -}; - export type AuthenticationResponse = { credential: string; ndCredential?: string; @@ -740,10 +730,30 @@ export type RawCreatePlaylistResponse = CreatePlaylistResponse | undefined; export type CreatePlaylistResponse = { id: string; name: string }; -export type CreatePlaylistQuery = { comment?: string; name: string; public?: boolean }; +export type CreatePlaylistQuery = { + comment?: string; + name: string; + public?: boolean; + rules?: Record; +}; export type CreatePlaylistArgs = { query: CreatePlaylistQuery } & BaseEndpointArgs; +// Update Playlist +export type RawUpdatePlaylistResponse = UpdatePlaylistResponse | undefined; + +export type UpdatePlaylistResponse = { id: string; name: string }; + +export type UpdatePlaylistQuery = { + comment?: string; + name: string; + previous: RawPlaylistDetailResponse; + public?: boolean; + rules?: Record; +}; + +export type UpdatePlaylistArgs = { query: UpdatePlaylistQuery } & BaseEndpointArgs; + // Delete Playlist export type RawDeletePlaylistResponse = NDDeletePlaylist | undefined; diff --git a/src/renderer/features/playlists/components/create-playlist-form.tsx b/src/renderer/features/playlists/components/create-playlist-form.tsx new file mode 100644 index 00000000..6795d8b6 --- /dev/null +++ b/src/renderer/features/playlists/components/create-playlist-form.tsx @@ -0,0 +1,76 @@ +import { Group, Stack } from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { CreatePlaylistQuery } from '/@/renderer/api/types'; +import { Button, Switch, TextInput, toast } from '/@/renderer/components'; +import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation'; + +interface CreatePlaylistFormProps { + onCancel: () => void; +} + +export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => { + const mutation = useCreatePlaylist(); + + const form = useForm({ + initialValues: { + comment: '', + name: '', + public: false, + rules: undefined, + }, + }); + + const handleSubmit = form.onSubmit((values) => { + mutation.mutate( + { query: values }, + { + onError: (err) => { + toast.error({ message: err.message, title: 'Error creating playlist' }); + }, + onSuccess: () => { + toast.success({ message: 'Playlist created successfully' }); + onCancel(); + }, + }, + ); + }); + + const isSubmitDisabled = !form.values.name || mutation.isLoading; + + return ( +
+ + + + + + + + + +
+ ); +}; diff --git a/src/renderer/features/playlists/index.ts b/src/renderer/features/playlists/index.ts index b7a85f71..aaac84c1 100644 --- a/src/renderer/features/playlists/index.ts +++ b/src/renderer/features/playlists/index.ts @@ -1 +1,2 @@ export * from './queries/playlist-list-query'; +export * from './components/create-playlist-form'; diff --git a/src/renderer/features/playlists/mutations/create-playlist-mutation.ts b/src/renderer/features/playlists/mutations/create-playlist-mutation.ts new file mode 100644 index 00000000..6d02a11b --- /dev/null +++ b/src/renderer/features/playlists/mutations/create-playlist-mutation.ts @@ -0,0 +1,25 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { HTTPError } from 'ky'; +import { api } from '/@/renderer/api'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { CreatePlaylistArgs, RawCreatePlaylistResponse } from '/@/renderer/api/types'; +import { MutationOptions } from '/@/renderer/lib/react-query'; +import { useCurrentServer } from '/@/renderer/store'; + +export const useCreatePlaylist = (options?: MutationOptions) => { + const queryClient = useQueryClient(); + const server = useCurrentServer(); + + return useMutation< + RawCreatePlaylistResponse, + HTTPError, + Omit, + null + >({ + mutationFn: (args) => api.controller.createPlaylist({ ...args, server }), + onSuccess: () => { + queryClient.invalidateQueries(queryKeys.playlists.list(server?.id || '')); + }, + ...options, + }); +}; diff --git a/src/renderer/features/playlists/mutations/update-playlist-mutation.ts b/src/renderer/features/playlists/mutations/update-playlist-mutation.ts new file mode 100644 index 00000000..18abf810 --- /dev/null +++ b/src/renderer/features/playlists/mutations/update-playlist-mutation.ts @@ -0,0 +1,29 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { HTTPError } from 'ky'; +import { api } from '/@/renderer/api'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { RawUpdatePlaylistResponse, UpdatePlaylistArgs } from '/@/renderer/api/types'; +import { MutationOptions } from '/@/renderer/lib/react-query'; +import { useCurrentServer } from '/@/renderer/store'; + +export const useUpdatePlaylist = (options?: MutationOptions) => { + const queryClient = useQueryClient(); + const server = useCurrentServer(); + + return useMutation< + RawUpdatePlaylistResponse, + HTTPError, + Omit, + null + >({ + mutationFn: (args) => api.controller.updatePlaylist({ ...args, server }), + onSuccess: (data) => { + queryClient.invalidateQueries(queryKeys.playlists.list(server?.id || '')); + + if (data?.id) { + queryClient.invalidateQueries(queryKeys.playlists.detail(server?.id || '', data.id)); + } + }, + ...options, + }); +}; diff --git a/src/renderer/features/sidebar/components/sidebar-item.tsx b/src/renderer/features/sidebar/components/sidebar-item.tsx index da4f336d..470ee6ef 100644 --- a/src/renderer/features/sidebar/components/sidebar-item.tsx +++ b/src/renderer/features/sidebar/components/sidebar-item.tsx @@ -1,16 +1,16 @@ import type { ReactNode } from 'react'; +import { createPolymorphicComponent, Flex, FlexProps } from '@mantine/core'; import type { LinkProps } from 'react-router-dom'; import { Link } from 'react-router-dom'; import styled, { css } from 'styled-components'; -interface ListItemProps { +interface ListItemProps extends FlexProps { children: ReactNode; disabled?: boolean; to?: string; } -const StyledItem = styled.div` - display: flex; +const StyledItem = styled(Flex)` width: 100%; font-family: var(--content-font-family); @@ -32,11 +32,7 @@ const ItemStyle = css` } `; -const Box = styled.div` - ${ItemStyle} -`; - -const ItemLink = styled(Link)` +const _ItemLink = styled(StyledItem)` opacity: ${(props) => props.disabled && 0.6}; pointer-events: ${(props) => props.disabled && 'none'}; @@ -47,12 +43,15 @@ const ItemLink = styled(Link)` ${ItemStyle} `; -export const SidebarItem = ({ to, children, ...rest }: ListItemProps) => { +const ItemLink = createPolymorphicComponent<'a', ListItemProps>(_ItemLink); + +export const SidebarItem = ({ to, children, ...props }: ListItemProps) => { if (to) { return ( {children} @@ -61,15 +60,13 @@ export const SidebarItem = ({ to, children, ...rest }: ListItemProps) => { return ( {children} ); }; -SidebarItem.Box = Box; - SidebarItem.Link = ItemLink; SidebarItem.defaultProps = { diff --git a/src/renderer/features/sidebar/components/sidebar.tsx b/src/renderer/features/sidebar/components/sidebar.tsx index fdbd6042..43d3f79d 100644 --- a/src/renderer/features/sidebar/components/sidebar.tsx +++ b/src/renderer/features/sidebar/components/sidebar.tsx @@ -1,9 +1,12 @@ +import { MouseEvent } from 'react'; import { Stack, Grid, Accordion, Center, Group } from '@mantine/core'; +import { closeAllModals, openModal } from '@mantine/modals'; import { SpotlightProvider } from '@mantine/spotlight'; import { AnimatePresence, motion } from 'framer-motion'; import { BsCollection } from 'react-icons/bs'; -import { Button, TextInput } from '/@/renderer/components'; +import { Button, ScrollArea, TextInput } from '/@/renderer/components'; import { + RiAddFill, RiAlbumFill, RiAlbumLine, RiArrowDownSLine, @@ -16,22 +19,26 @@ import { RiFolder3Line, RiHome5Fill, RiHome5Line, + RiMenuUnfoldLine, RiMusicFill, RiMusicLine, RiPlayListLine, RiSearchLine, + RiUserVoiceFill, RiUserVoiceLine, } from 'react-icons/ri'; -import { useNavigate, Link, useLocation } from 'react-router-dom'; +import { useNavigate, Link, useLocation, generatePath } from 'react-router-dom'; import styled from 'styled-components'; import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item'; import { AppRoute } from '/@/renderer/router/routes'; import { useSidebarStore, useAppStoreActions, useCurrentSong } from '/@/renderer/store'; import { fadeIn } from '/@/renderer/styles'; +import { CreatePlaylistForm, usePlaylistList } from '/@/renderer/features/playlists'; +import { PlaylistListSort, SortOrder } from '/@/renderer/api/types'; const SidebarContainer = styled.div` height: 100%; - max-height: calc(100vh - 85px); // Account for and playerbar + max-height: calc(100vh - 85px); // Account for playerbar user-select: none; `; @@ -68,20 +75,35 @@ export const Sidebar = () => { const sidebar = useSidebarStore(); const { setSidebar } = useAppStoreActions(); const imageUrl = useCurrentSong()?.imageUrl; - const showImage = sidebar.image; + const playlistsQuery = usePlaylistList({ + limit: 0, + sortBy: PlaylistListSort.NAME, + sortOrder: SortOrder.ASC, + startIndex: 0, + }); + + const handleCreatePlaylistModal = (e: MouseEvent) => { + e.stopPropagation(); + + openModal({ + children: closeAllModals()} />, + size: 'sm', + title: 'Create Playlist', + }); + }; + return ( @@ -120,121 +142,167 @@ export const Sidebar = () => { - - - - {location.pathname === AppRoute.HOME ? ( - - ) : ( - - )} - Home - - - setSidebar({ expanded: e })} - > - - - - {location.pathname.includes('/library/') ? ( - - ) : ( - - )} - Library - - - - + + + + {location.pathname === AppRoute.HOME ? ( + + ) : ( + + )} + Home + + + setSidebar({ expanded: e })} + > + + - {location.pathname === AppRoute.LIBRARY_ALBUMS ? ( - + {location.pathname.includes('/library/') ? ( + ) : ( - + )} - Albums + Library - - + + + + + {location.pathname === AppRoute.LIBRARY_ALBUMS ? ( + + ) : ( + + )} + Albums + + + + + {location.pathname === AppRoute.LIBRARY_SONGS ? ( + + ) : ( + + )} + Tracks + + + + + {location.pathname === AppRoute.LIBRARY_ALBUMARTISTS ? ( + + ) : ( + + )} + Album Artists + + + + + + Genres + + + + + + Folders + + + + + + - {location.pathname === AppRoute.LIBRARY_SONGS ? ( - - ) : ( - - )} - Tracks + + Collections - - - - - Album Artists + + + + + + + + + Playlists + + + + + - - - - - Genres - - - - - - Folders - - - - - - - - - Collections - - - - - - - - - Playlists - - - - - - + + + {playlistsQuery?.data?.items?.map((playlist) => ( + + {playlist.name} + + ))} + + + + + void; + onSettled?: any; + onSuccess?: any; + retry?: UseQueryOptions['retry']; + retryDelay?: UseQueryOptions['retryDelay']; + useErrorBoundary?: boolean; +};