Move Playlists to the sidebar menu (#1339)

* Show playlists in sidebar menu

* Fix menu

* Refresh playlist submenu when adding new playlist

* Group shared playlists below user's playlists

* Fix text overflow in menu options

* Add button in playlist menu to go to Playlists list

* Add config option `DevSidebarPlaylists` to enable this feature (default false)
This commit is contained in:
Deluan Quintão 2021-09-11 13:11:15 -04:00 committed by GitHub
parent a7017e4bb0
commit 79363d6c07
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 211 additions and 17 deletions

View file

@ -70,6 +70,7 @@ type configOptions struct {
DevFastAccessCoverArt bool DevFastAccessCoverArt bool
DevActivityPanel bool DevActivityPanel bool
DevEnableShare bool DevEnableShare bool
DevSidebarPlaylists bool
DevEnableBufferedScrobble bool DevEnableBufferedScrobble bool
} }
@ -234,6 +235,7 @@ func init() {
viper.SetDefault("devactivitypanel", true) viper.SetDefault("devactivitypanel", true)
viper.SetDefault("devenableshare", false) viper.SetDefault("devenableshare", false)
viper.SetDefault("devenablebufferedscrobble", true) viper.SetDefault("devenablebufferedscrobble", true)
viper.SetDefault("devsidebarplaylists", false)
} }
func InitConfig(cfgFile string) { func InitConfig(cfgFile string) {

View file

@ -45,6 +45,7 @@ func serveIndex(ds model.DataStore, fs fs.FS) http.HandlerFunc {
"devFastAccessCoverArt": conf.Server.DevFastAccessCoverArt, "devFastAccessCoverArt": conf.Server.DevFastAccessCoverArt,
"enableUserEditing": conf.Server.EnableUserEditing, "enableUserEditing": conf.Server.EnableUserEditing,
"devEnableShare": conf.Server.DevEnableShare, "devEnableShare": conf.Server.DevEnableShare,
"devSidebarPlaylists": conf.Server.DevSidebarPlaylists,
"lastFMEnabled": conf.Server.LastFM.Enabled, "lastFMEnabled": conf.Server.LastFM.Enabled,
"lastFMApiKey": conf.Server.LastFM.ApiKey, "lastFMApiKey": conf.Server.LastFM.ApiKey,
} }

View file

@ -211,6 +211,18 @@ var _ = Describe("serveIndex", func() {
Expect(config).To(HaveKeyWithValue("devEnableShare", false)) Expect(config).To(HaveKeyWithValue("devEnableShare", false))
}) })
It("sets the devSidebarPlaylists", func() {
conf.Server.DevSidebarPlaylists = true
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("devSidebarPlaylists", true))
})
It("sets the lastFMEnabled", func() { It("sets the lastFMEnabled", func() {
r := httptest.NewRequest("GET", "/index.html", nil) r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()

View file

@ -91,7 +91,11 @@ const Admin = (props) => {
<Resource name="album" {...album} options={{ subMenu: 'albumList' }} />, <Resource name="album" {...album} options={{ subMenu: 'albumList' }} />,
<Resource name="artist" {...artist} />, <Resource name="artist" {...artist} />,
<Resource name="song" {...song} />, <Resource name="song" {...song} />,
<Resource name="playlist" {...playlist} />, <Resource
name="playlist"
{...playlist}
options={{ subMenu: 'playlist' }}
/>,
<Resource name="user" {...user} options={{ subMenu: 'settings' }} />, <Resource name="user" {...user} options={{ subMenu: 'settings' }} />,
<Resource <Resource
name="player" name="player"

View file

@ -19,6 +19,7 @@ const defaultConfig = {
defaultTheme: 'Dark', defaultTheme: 'Dark',
enableUserEditing: true, enableUserEditing: true,
devEnableShare: true, devEnableShare: true,
devSidebarPlaylists: true,
lastFMEnabled: true, lastFMEnabled: true,
lastFMApiKey: '9b94a5515ea66b2da3ec03c12300327e', lastFMApiKey: '9b94a5515ea66b2da3ec03c12300327e',
enableCoverAnimation: true, enableCoverAnimation: true,

View file

@ -1,6 +1,11 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import { useDataProvider, useNotify, useTranslate } from 'react-admin' import {
useDataProvider,
useNotify,
useRefresh,
useTranslate,
} from 'react-admin'
import { import {
Button, Button,
Dialog, Dialog,
@ -22,6 +27,7 @@ export const AddToPlaylistDialog = () => {
const dispatch = useDispatch() const dispatch = useDispatch()
const translate = useTranslate() const translate = useTranslate()
const notify = useNotify() const notify = useNotify()
const refresh = useRefresh()
const [value, setValue] = useState({}) const [value, setValue] = useState({})
const [check, setCheck] = useState(false) const [check, setCheck] = useState(false)
const dataProvider = useDataProvider() const dataProvider = useDataProvider()
@ -47,6 +53,7 @@ export const AddToPlaylistDialog = () => {
const len = trackIds.length const len = trackIds.length
notify('message.songsAddedToPlaylist', 'info', { smart_count: len }) notify('message.songsAddedToPlaylist', 'info', { smart_count: len })
onSuccess && onSuccess(value, len) onSuccess && onSuccess(value, len)
refresh()
}) })
.catch(() => { .catch(() => {
notify('ra.page.error', 'warning') notify('ra.page.error', 'warning')

View file

@ -325,6 +325,8 @@
} }
}, },
"albumList": "Albums", "albumList": "Albums",
"playlists": "Playlists",
"sharedPlaylists": "Shared Playlists",
"about": "About" "about": "About"
}, },
"player": { "player": {
@ -380,4 +382,4 @@
"toggle_love": "Add this track to favourites" "toggle_love": "Add this track to favourites"
} }
} }
} }

View file

@ -1,6 +1,6 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { makeStyles } from '@material-ui/core' import { Divider, makeStyles } from '@material-ui/core'
import clsx from 'clsx' import clsx from 'clsx'
import { useTranslate, MenuItemLink, getResources } from 'react-admin' import { useTranslate, MenuItemLink, getResources } from 'react-admin'
import { withRouter } from 'react-router-dom' import { withRouter } from 'react-router-dom'
@ -10,6 +10,8 @@ import SubMenu from './SubMenu'
import inflection from 'inflection' import inflection from 'inflection'
import albumLists from '../album/albumLists' import albumLists from '../album/albumLists'
import { HelpDialog } from '../dialogs' import { HelpDialog } from '../dialogs'
import PlaylistsSubMenu from './PlaylistsSubMenu'
import config from '../config'
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
root: { root: {
@ -53,8 +55,8 @@ const Menu = ({ dense = false }) => {
// TODO State is not persisted in mobile when you close the sidebar menu. Move to redux? // TODO State is not persisted in mobile when you close the sidebar menu. Move to redux?
const [state, setState] = useState({ const [state, setState] = useState({
menuAlbumList: true, menuAlbumList: true,
menuLibrary: true, menuPlaylists: true,
menuSettings: false, menuSharedPlaylists: true,
}) })
const handleToggle = (menu) => { const handleToggle = (menu) => {
@ -122,6 +124,19 @@ const Menu = ({ dense = false }) => {
)} )}
</SubMenu> </SubMenu>
{resources.filter(subItems(undefined)).map(renderResourceMenuItemLink)} {resources.filter(subItems(undefined)).map(renderResourceMenuItemLink)}
{config.devSidebarPlaylists && open ? (
<>
<Divider />
<PlaylistsSubMenu
state={state}
setState={setState}
sidebarIsOpen={open}
dense={dense}
/>
</>
) : (
resources.filter(subItems('playlist')).map(renderResourceMenuItemLink)
)}
<HelpDialog /> <HelpDialog />
</div> </div>
) )

View file

@ -0,0 +1,95 @@
import React, { useCallback } from 'react'
import { MenuItemLink, useQueryWithStore } from 'react-admin'
import { useHistory } from 'react-router-dom'
import QueueMusicIcon from '@material-ui/icons/QueueMusic'
import { Typography } from '@material-ui/core'
import QueueMusicOutlinedIcon from '@material-ui/icons/QueueMusicOutlined'
import { BiCog } from 'react-icons/all'
import SubMenu from './SubMenu'
const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => {
const history = useHistory()
const { data, loaded } = useQueryWithStore({
type: 'getList',
resource: 'playlist',
payload: {
pagination: {
page: 0,
perPage: 0,
},
sort: { field: 'name' },
},
})
const handleToggle = (menu) => {
setState((state) => ({ ...state, [menu]: !state[menu] }))
}
const renderPlaylistMenuItemLink = (pls) => {
return (
<MenuItemLink
key={pls.id}
to={`/playlist/${pls.id}/show`}
primaryText={
<Typography variant="inherit" noWrap>
{pls.name}
</Typography>
}
sidebarIsOpen={sidebarIsOpen}
dense={false}
/>
)
}
const user = localStorage.getItem('username')
const myPlaylists = []
const sharedPlaylists = []
if (loaded) {
const allPlaylists = Object.keys(data).map((id) => data[id])
allPlaylists.forEach((pls) => {
if (user === pls.owner) {
myPlaylists.push(pls)
} else {
sharedPlaylists.push(pls)
}
})
}
const onPlaylistConfig = useCallback(
() => history.push('/playlist'),
[history]
)
return (
<>
<SubMenu
handleToggle={() => handleToggle('menuPlaylists')}
isOpen={state.menuPlaylists}
sidebarIsOpen={sidebarIsOpen}
name={'menu.playlists'}
icon={<QueueMusicIcon />}
dense={dense}
actionIcon={<BiCog />}
onAction={onPlaylistConfig}
>
{myPlaylists.map(renderPlaylistMenuItemLink)}
</SubMenu>
{sharedPlaylists?.length > 0 && (
<SubMenu
handleToggle={() => handleToggle('menuSharedPlaylists')}
isOpen={state.menuSharedPlaylists}
sidebarIsOpen={sidebarIsOpen}
name={'menu.sharedPlaylists'}
icon={<QueueMusicOutlinedIcon />}
dense={dense}
>
{sharedPlaylists.map(renderPlaylistMenuItemLink)}
</SubMenu>
)}
</>
)
}
export default PlaylistsSubMenu

View file

@ -1,14 +1,15 @@
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
import ExpandMore from '@material-ui/icons/ExpandMore' import ExpandMore from '@material-ui/icons/ExpandMore'
import ArrowRightOutlined from '@material-ui/icons/ArrowRightOutlined'
import List from '@material-ui/core/List' import List from '@material-ui/core/List'
import MenuItem from '@material-ui/core/MenuItem' import MenuItem from '@material-ui/core/MenuItem'
import ListItemIcon from '@material-ui/core/ListItemIcon' import ListItemIcon from '@material-ui/core/ListItemIcon'
import Typography from '@material-ui/core/Typography' import Typography from '@material-ui/core/Typography'
import Divider from '@material-ui/core/Divider'
import Collapse from '@material-ui/core/Collapse' import Collapse from '@material-ui/core/Collapse'
import Tooltip from '@material-ui/core/Tooltip' import Tooltip from '@material-ui/core/Tooltip'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import { useTranslate } from 'react-admin' import { useTranslate } from 'react-admin'
import { IconButton, useMediaQuery } from '@material-ui/core'
const useStyles = makeStyles( const useStyles = makeStyles(
(theme) => ({ (theme) => ({
@ -25,6 +26,18 @@ const useStyles = makeStyles(
paddingLeft: theme.spacing(2), paddingLeft: theme.spacing(2),
}, },
}, },
actionIcon: {
opacity: 0,
},
menuHeader: {
width: '100%',
},
headerWrapper: {
display: 'flex',
'&:hover $actionIcon': {
opacity: 1,
},
},
}), }),
{ {
name: 'NDSubMenu', name: 'NDSubMenu',
@ -39,19 +52,43 @@ const SubMenu = ({
icon, icon,
children, children,
dense, dense,
onAction,
actionIcon,
}) => { }) => {
const translate = useTranslate() const translate = useTranslate()
const classes = useStyles() const classes = useStyles()
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('sm'))
const handleOnClick = (e) => {
e.stopPropagation()
onAction(e)
}
const header = ( const header = (
<MenuItem dense={dense} button onClick={handleToggle}> <div className={classes.headerWrapper}>
<ListItemIcon className={classes.icon}> <MenuItem
{isOpen ? <ExpandMore /> : icon} dense={dense}
</ListItemIcon> button
<Typography variant="inherit" color="textSecondary"> className={classes.menuHeader}
{translate(name)} onClick={handleToggle}
</Typography> >
</MenuItem> <ListItemIcon className={classes.icon}>
{isOpen ? <ExpandMore /> : icon}
</ListItemIcon>
<Typography variant="inherit" color="textSecondary">
{translate(name)}
</Typography>
{onAction && sidebarIsOpen && (
<IconButton
size={'small'}
className={isDesktop ? classes.actionIcon : null}
onClick={handleOnClick}
>
{actionIcon}
</IconButton>
)}
</MenuItem>
</div>
) )
return ( return (
@ -74,10 +111,14 @@ const SubMenu = ({
> >
{children} {children}
</List> </List>
<Divider />
</Collapse> </Collapse>
</Fragment> </Fragment>
) )
} }
SubMenu.defaultProps = {
action: null,
actionIcon: <ArrowRightOutlined fontSize={'small'} />,
}
export default SubMenu export default SubMenu

View file

@ -6,17 +6,31 @@ import {
BooleanInput, BooleanInput,
required, required,
useTranslate, useTranslate,
useRefresh,
useNotify,
useRedirect,
} from 'react-admin' } from 'react-admin'
import { Title } from '../common' import { Title } from '../common'
const PlaylistCreate = (props) => { const PlaylistCreate = (props) => {
const { basePath } = props
const refresh = useRefresh()
const notify = useNotify()
const redirect = useRedirect()
const translate = useTranslate() const translate = useTranslate()
const resourceName = translate('resources.playlist.name', { smart_count: 1 }) const resourceName = translate('resources.playlist.name', { smart_count: 1 })
const title = translate('ra.page.create', { const title = translate('ra.page.create', {
name: `${resourceName}`, name: `${resourceName}`,
}) })
const onSuccess = () => {
notify('ra.notification.created', 'info', { smart_count: 1 })
redirect('list', basePath)
refresh()
}
return ( return (
<Create title={<Title subTitle={title} />} {...props}> <Create title={<Title subTitle={title} />} {...props} onSuccess={onSuccess}>
<SimpleForm redirect="list" variant={'outlined'}> <SimpleForm redirect="list" variant={'outlined'}>
<TextInput source="name" validate={required()} /> <TextInput source="name" validate={required()} />
<TextInput multiline source="comment" /> <TextInput multiline source="comment" />