diff --git a/ui/package-lock.json b/ui/package-lock.json index 79315b91a..ba4f8be5a 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1693,6 +1693,18 @@ "@babel/runtime": "^7.4.4" } }, + "@material-ui/lab": { + "version": "4.0.0-alpha.54", + "resolved": "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.54.tgz", + "integrity": "sha512-BK/z+8xGPQoMtG6gWKyagCdYO1/2DzkBchvvXs2bbTVh3sbi/QQLIqWV6UA1KtMVydYVt22NwV3xltgPkaPKLg==", + "requires": { + "@babel/runtime": "^7.4.4", + "@material-ui/utils": "^4.9.6", + "clsx": "^1.0.4", + "prop-types": "^15.7.2", + "react-is": "^16.8.0" + } + }, "@material-ui/styles": { "version": "4.10.0", "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.10.0.tgz", diff --git a/ui/package.json b/ui/package.json index 48cfdf180..1caef17c6 100644 --- a/ui/package.json +++ b/ui/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { + "@material-ui/lab": "^4.0.0-alpha.54", "deepmerge": "^4.2.2", "jwt-decode": "^2.2.0", "lodash.throttle": "^4.1.1", diff --git a/ui/src/App.js b/ui/src/App.js index 4b4de694d..68f725d8f 100644 --- a/ui/src/App.js +++ b/ui/src/App.js @@ -17,9 +17,10 @@ import { albumViewReducer } from './album/albumState' import config from './config' import customRoutes from './routes' import themeReducer from './personal/themeReducer' -import { newPlaylistDialogReducer } from './dialogs/dialogState' +import { addToPlaylistDialogReducer } from './dialogs/dialogState' import createAdminStore from './store/createAdminStore' import { i18nProvider } from './i18n' +import AddToPlaylistDialog from './dialogs/AddToPlaylistDialog' const history = createHashHistory() @@ -33,7 +34,7 @@ const App = () => ( queue: playQueueReducer, albumView: albumViewReducer, theme: themeReducer, - newPlaylistDialog: newPlaylistDialogReducer, + addToPlaylistDialog: addToPlaylistDialogReducer, }, })} > @@ -77,7 +78,10 @@ const App = () => ( , , , + + // Detached components , + , ]} diff --git a/ui/src/album/AlbumContextMenu.js b/ui/src/album/AlbumContextMenu.js index 0047d5e3f..117f7f19b 100644 --- a/ui/src/album/AlbumContextMenu.js +++ b/ui/src/album/AlbumContextMenu.js @@ -1,14 +1,13 @@ import React, { useState } from 'react' +import { useDispatch } from 'react-redux' import IconButton from '@material-ui/core/IconButton' import Menu from '@material-ui/core/Menu' import MenuItem from '@material-ui/core/MenuItem' import MoreVertIcon from '@material-ui/icons/MoreVert' import { makeStyles } from '@material-ui/core/styles' import { useDataProvider, useNotify, useTranslate } from 'react-admin' -import { useDispatch } from 'react-redux' import { addTracks, playTracks, shuffleTracks } from '../audioplayer' -import NestedMenuItem from 'material-ui-nested-menu-item' -import { AddToPlaylistMenu } from '../common' +import { openAddToPlaylist } from '../dialogs/dialogState' const useStyles = makeStyles({ icon: { @@ -23,19 +22,23 @@ const AlbumContextMenu = ({ record, color }) => { const translate = useTranslate() const notify = useNotify() const [anchorEl, setAnchorEl] = useState(null) - const open = Boolean(anchorEl) + const options = { play: { label: translate('resources.album.actions.playAll'), - action: (data) => playTracks(data), + action: playTracks, }, addToQueue: { label: translate('resources.album.actions.addToQueue'), - action: (data) => addTracks(data), + action: addTracks, }, shuffle: { label: translate('resources.album.actions.shuffle'), - action: (data) => shuffleTracks(data), + action: shuffleTracks, + }, + addToPlaylist: { + label: translate('resources.song.actions.addToPlaylist'), + action: () => openAddToPlaylist({ albumId: record.id }), }, } @@ -74,6 +77,8 @@ const AlbumContextMenu = ({ record, color }) => { e.stopPropagation() } + const open = Boolean(anchorEl) + return (
{ {options[key].label} ))} - - setAnchorEl(null)} - /> -
) diff --git a/ui/src/common/AddToPlaylistMenu.js b/ui/src/common/AddToPlaylistMenu.js deleted file mode 100644 index 4d993448b..000000000 --- a/ui/src/common/AddToPlaylistMenu.js +++ /dev/null @@ -1,117 +0,0 @@ -import React from 'react' -import { useDispatch } from 'react-redux' -import PropTypes from 'prop-types' -import { - useDataProvider, - useGetList, - useNotify, - useTranslate, -} from 'react-admin' -import { MenuItem, Divider } from '@material-ui/core' -import NewPlaylistIcon from '@material-ui/icons/Add' -import { openNewPlaylist } from '../dialogs/dialogState' -import NewPlaylistDialog from '../dialogs/NewPlaylist' - -export const addTracksToPlaylist = (dataProvider, selectedIds, playlistId) => - dataProvider - .create('playlistTrack', { - data: { ids: selectedIds }, - filter: { playlist_id: playlistId }, - }) - .then(() => selectedIds.length) - -export const addAlbumToPlaylist = (dataProvider, albumId, playlistId) => - dataProvider - .getList('albumSong', { - pagination: { page: 1, perPage: -1 }, - sort: { field: 'discNumber asc, trackNumber asc', order: 'ASC' }, - filter: { album_id: albumId }, - }) - .then((response) => response.data.map((song) => song.id)) - .then((ids) => addTracksToPlaylist(dataProvider, ids, playlistId)) - -const AddToPlaylistMenu = React.forwardRef( - ({ selectedIds, albumId, onClose, onItemAdded }, ref) => { - const notify = useNotify() - const dispatch = useDispatch() - const translate = useTranslate() - const dataProvider = useDataProvider() - const { ids, data, loaded } = useGetList( - 'playlist', - { page: 1, perPage: -1 }, - { field: 'name', order: 'ASC' }, - {} - ) - - if (!loaded) { - return Loading... - } - - const addToPlaylist = (playlistId) => { - const add = albumId - ? addAlbumToPlaylist(dataProvider, albumId, playlistId) - : addTracksToPlaylist(dataProvider, selectedIds, playlistId) - - add - .then((len) => { - notify('message.songsAddedToPlaylist', 'info', { smart_count: len }) - onItemAdded(playlistId) - }) - .catch(() => { - notify('ra.page.error', 'warning') - }) - } - - const handleItemClick = (e) => { - e.preventDefault() - const playlistId = e.target.getAttribute('value') - if (playlistId !== '') { - addToPlaylist(playlistId) - } - e.stopPropagation() - onClose(e) - } - - const handleOpenDialog = (e) => { - e.preventDefault() - dispatch(openNewPlaylist(albumId, selectedIds)) - e.stopPropagation() - onClose(e) - } - - return ( - <> - - {ids.map((id) => ( - - {data[id].name} - - ))} - - {}  - {translate('resources.playlist.actions.newPlaylist')} - - - - ) - } -) - -AddToPlaylistMenu.propTypes = { - selectedIds: PropTypes.arrayOf(PropTypes.any).isRequired, - albumId: PropTypes.string, - onClose: PropTypes.func, - onItemAdded: PropTypes.func, -} - -AddToPlaylistMenu.defaultProps = { - selectedIds: [], - onClose: () => {}, - onItemAdded: () => {}, -} - -export default AddToPlaylistMenu diff --git a/ui/src/common/SongContextMenu.js b/ui/src/common/SongContextMenu.js index ad2788b53..35cf198ce 100644 --- a/ui/src/common/SongContextMenu.js +++ b/ui/src/common/SongContextMenu.js @@ -7,10 +7,9 @@ import { makeStyles } from '@material-ui/core/styles' import MoreVertIcon from '@material-ui/icons/MoreVert' import StarIcon from '@material-ui/icons/Star' import StarBorderIcon from '@material-ui/icons/StarBorder' -import NestedMenuItem from 'material-ui-nested-menu-item' import { addTracks, setTrack } from '../audioplayer' -import AddToPlaylistMenu from './AddToPlaylistMenu' import config from '../config' +import { openAddToPlaylist } from '../dialogs/dialogState' const useStyles = makeStyles({ noWrap: { @@ -41,6 +40,14 @@ const SongContextMenu = ({ record, showStar, onAddToPlaylist, visible }) => { label: translate('resources.song.actions.addToQueue'), action: (record) => addTracks({ [record.id]: record }), }, + addToPlaylist: { + label: translate('resources.song.actions.addToPlaylist'), + action: (record) => + openAddToPlaylist({ + selectedIds: [record.mediaFileId || record.id], + onSuccess: (id) => onAddToPlaylist(id), + }), + }, } const handleClick = (e) => { @@ -122,16 +129,6 @@ const SongContextMenu = ({ record, showStar, onAddToPlaylist, visible }) => { {options[key].label} ))} - - - ) @@ -145,6 +142,7 @@ SongContextMenu.propTypes = { } SongContextMenu.defaultProps = { + onAddToPlaylist: () => {}, visible: true, showStar: true, addLabel: true, diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 4c1eeca5d..458cf31b1 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -11,7 +11,6 @@ import SizeField from './SizeField' import DocLink from './DocLink' import List from './List' import { SongDatagrid, SongDatagridRow } from './SongDatagrid' -import AddToPlaylistMenu from './AddToPlaylistMenu' import SongContextMenu from './SongContextMenu' import QuickFilter from './QuickFilter' @@ -32,7 +31,6 @@ export { formatRange, ArtistLinkField, artistLink, - AddToPlaylistMenu, SongContextMenu, QuickFilter, } diff --git a/ui/src/dialogs/AddToPlaylistDialog.js b/ui/src/dialogs/AddToPlaylistDialog.js new file mode 100644 index 000000000..d4067e415 --- /dev/null +++ b/ui/src/dialogs/AddToPlaylistDialog.js @@ -0,0 +1,117 @@ +import React, { useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { + useCreate, + useDataProvider, + useTranslate, + useNotify, +} from 'react-admin' +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from '@material-ui/core' +import { closeAddToPlaylist } from './dialogState' +import SelectPlaylistInput from './SelectPlaylistInput' + +const AddToPlaylistDialog = () => { + const { open, albumId, selectedIds, onSuccess } = useSelector( + (state) => state.addToPlaylistDialog + ) + const dispatch = useDispatch() + const translate = useTranslate() + const notify = useNotify() + const [value, setValue] = useState({}) + const dataProvider = useDataProvider() + const [create] = useCreate( + 'playlist', + { name: value.name }, + { + onSuccess: ({ data }) => { + setValue(data) + addToPlaylist(data.id) + }, + onFailure: (error) => notify(`Error: ${error.message}`, 'warning'), + } + ) + + const addTracksToPlaylist = (selectedIds, playlistId) => + dataProvider + .create('playlistTrack', { + data: { ids: selectedIds }, + filter: { playlist_id: playlistId }, + }) + .then(() => selectedIds.length) + + const addAlbumToPlaylist = (albumId, playlistId) => + dataProvider + .getList('albumSong', { + pagination: { page: 1, perPage: -1 }, + sort: { field: 'discNumber asc, trackNumber asc', order: 'ASC' }, + filter: { album_id: albumId }, + }) + .then((response) => response.data.map((song) => song.id)) + .then((ids) => addTracksToPlaylist(ids, playlistId)) + + const addToPlaylist = (playlistId) => { + const add = albumId + ? addAlbumToPlaylist(albumId, playlistId) + : addTracksToPlaylist(selectedIds, playlistId) + + add + .then((len) => { + notify('message.songsAddedToPlaylist', 'info', { smart_count: len }) + onSuccess && onSuccess(value, len) + }) + .catch(() => { + notify('ra.page.error', 'warning') + }) + } + + const handleSubmit = (e) => { + if (value.id) { + addToPlaylist(value.id) + } else { + create() + } + dispatch(closeAddToPlaylist()) + e.stopPropagation() + } + + const handleClickClose = (e) => { + dispatch(closeAddToPlaylist()) + e.stopPropagation() + } + + const handleChange = (pls) => { + setValue(pls) + } + + return ( + + + {translate('resources.playlist.actions.selectPlaylist')} + + + + + + + + + + ) +} + +export default AddToPlaylistDialog diff --git a/ui/src/dialogs/NewPlaylist.js b/ui/src/dialogs/NewPlaylist.js deleted file mode 100644 index 891c4bd08..000000000 --- a/ui/src/dialogs/NewPlaylist.js +++ /dev/null @@ -1,120 +0,0 @@ -import React from 'react' -import { useDispatch, useSelector } from 'react-redux' -import { - useCreate, - useDataProvider, - useTranslate, - useNotify, -} from 'react-admin' -import { - Button, - TextField, - Dialog, - DialogActions, - DialogContent, - DialogTitle, -} from '@material-ui/core' -import PropTypes from 'prop-types' -import { closeNewPlaylist } from './dialogState' -import { - addAlbumToPlaylist, - addTracksToPlaylist, -} from '../common/AddToPlaylistMenu' - -const NewPlaylistDialog = ({ onCancel, onSubmit }) => { - const { open, albumId, selectedIds } = useSelector( - (state) => state.newPlaylistDialog - ) - const dispatch = useDispatch() - const translate = useTranslate() - const notify = useNotify() - const [value, setValue] = React.useState('') - const dataProvider = useDataProvider() - const [create] = useCreate( - 'playlist', - { name: value }, - { - onSuccess: ({ data }) => { - addToPlaylist(data.id) - }, - onFailure: (error) => notify(`Error: ${error.message}`, 'warning'), - } - ) - - const addToPlaylist = (playlistId) => { - const add = albumId - ? addAlbumToPlaylist(dataProvider, albumId, playlistId) - : addTracksToPlaylist(dataProvider, selectedIds, playlistId) - - add - .then((len) => { - notify('message.songsAddedToPlaylist', 'info', { smart_count: len }) - onSubmit(value) - }) - .catch(() => { - notify('ra.page.error', 'warning') - }) - } - - const handleSubmit = (e) => { - create() - dispatch(closeNewPlaylist()) - e.stopPropagation() - } - - const handleChange = (e) => { - setValue(e.target.value) - } - - const handleClickClose = (e) => { - onCancel(e) - dispatch(closeNewPlaylist()) - e.stopPropagation() - } - - return ( -
- - - {translate('resources.playlist.actions.newPlaylist')} - - - - - - - - - -
- ) -} - -NewPlaylistDialog.propTypes = { - onCancel: PropTypes.func, - onSubmit: PropTypes.func, -} - -NewPlaylistDialog.defaultProps = { - onCancel: () => {}, - onSubmit: () => {}, -} - -export default NewPlaylistDialog diff --git a/ui/src/dialogs/SelectPlaylistInput.js b/ui/src/dialogs/SelectPlaylistInput.js new file mode 100644 index 000000000..f7f4cb372 --- /dev/null +++ b/ui/src/dialogs/SelectPlaylistInput.js @@ -0,0 +1,96 @@ +/* eslint-disable no-use-before-define */ +import React from 'react' +import TextField from '@material-ui/core/TextField' +import Autocomplete, { + createFilterOptions, +} from '@material-ui/lab/Autocomplete' +import { useGetList, useTranslate } from 'react-admin' +import PropTypes from 'prop-types' + +const filter = createFilterOptions() + +const SelectPlaylistInput = ({ onChange }) => { + const translate = useTranslate() + const { ids, data, loaded } = useGetList( + 'playlist', + { page: 1, perPage: -1 }, + { field: 'name', order: 'ASC' }, + {} + ) + + if (!loaded) { + return null + } + + const options = ids.map((id) => data[id]) + + const handleOnChange = (event, newValue) => { + if (newValue == null) { + onChange({}) + } else if (typeof newValue === 'string') { + onChange({ + name: newValue, + }) + } else if (newValue && newValue.inputValue) { + // Create a new value from the user input + onChange({ + name: newValue.inputValue, + }) + } else { + onChange(newValue) + } + } + + return ( + { + const filtered = filter(options, params) + + // Suggest the creation of a new value + if (params.inputValue !== '') { + filtered.push({ + inputValue: params.inputValue, + name: `Add "${params.inputValue}"`, + }) + } + + return filtered + }} + clearOnBlur + handleHomeEndKeys + openOnFocus + selectOnFocus + id="select-playlist-input" + options={options} + getOptionLabel={(option) => { + // Value selected with enter, right from the input + if (typeof option === 'string') { + return option + } + // Add "xxx" option created dynamically + if (option.inputValue) { + return option.inputValue + } + // Regular option + return option.name + }} + renderOption={(option) => option.name} + style={{ width: 300 }} + freeSolo + renderInput={(params) => ( + + )} + /> + ) +} + +SelectPlaylistInput.propTypes = { + onChange: PropTypes.func.isRequired, +} + +export default SelectPlaylistInput diff --git a/ui/src/dialogs/dialogState.js b/ui/src/dialogs/dialogState.js index 4d77a930b..da0e4f9f0 100644 --- a/ui/src/dialogs/dialogState.js +++ b/ui/src/dialogs/dialogState.js @@ -1,17 +1,18 @@ -const NEW_PLAYLIST_OPEN = 'NEW_PLAYLIST_OPEN' -const NEW_PLAYLIST_CLOSE = 'NEW_PLAYLIST_CLOSE' +const ADD_TO_PLAYLIST_OPEN = 'ADD_TO_PLAYLIST_OPEN' +const ADD_TO_PLAYLIST_CLOSE = 'ADD_TO_PLAYLIST_CLOSE' -const openNewPlaylist = (albumId, selectedIds) => ({ - type: NEW_PLAYLIST_OPEN, +const openAddToPlaylist = ({ albumId, selectedIds, onSuccess }) => ({ + type: ADD_TO_PLAYLIST_OPEN, albumId, selectedIds, + onSuccess, }) -const closeNewPlaylist = () => ({ - type: NEW_PLAYLIST_CLOSE, +const closeAddToPlaylist = () => ({ + type: ADD_TO_PLAYLIST_CLOSE, }) -const newPlaylistDialogReducer = ( +const addToPlaylistDialogReducer = ( previousState = { open: false, }, @@ -19,18 +20,19 @@ const newPlaylistDialogReducer = ( ) => { const { type } = payload switch (type) { - case NEW_PLAYLIST_OPEN: + case ADD_TO_PLAYLIST_OPEN: return { ...previousState, open: true, albumId: payload.albumId, selectedIds: payload.selectedIds, + onSuccess: payload.onSuccess, } - case NEW_PLAYLIST_CLOSE: - return { ...previousState, open: false } + case ADD_TO_PLAYLIST_CLOSE: + return { ...previousState, open: false, onSuccess: undefined } default: return previousState } } -export { openNewPlaylist, closeNewPlaylist, newPlaylistDialogReducer } +export { openAddToPlaylist, closeAddToPlaylist, addToPlaylistDialogReducer } diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 274390ca5..6c7c5ed68 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -69,8 +69,8 @@ "createdAt": "Created at" }, "actions": { - "selectPlaylist": "Add songs to playlist:", - "newPlaylist": "New playlist" + "selectPlaylist": "Select a playlist:", + "addToPlaylist": "New playlist" } }, "user": { diff --git a/ui/src/playlist/PlaylistSongs.js b/ui/src/playlist/PlaylistSongs.js index f85c1d763..0c4d42d7d 100644 --- a/ui/src/playlist/PlaylistSongs.js +++ b/ui/src/playlist/PlaylistSongs.js @@ -81,8 +81,8 @@ const PlaylistSongs = (props) => { return
} - const onAddToPlaylist = (playlistId) => { - if (playlistId === props.id) { + const onAddToPlaylist = (pls) => { + if (pls.id === props.id) { refresh() } } diff --git a/ui/src/song/AddToPlaylistButton.js b/ui/src/song/AddToPlaylistButton.js index a31117060..1bfe1c78d 100644 --- a/ui/src/song/AddToPlaylistButton.js +++ b/ui/src/song/AddToPlaylistButton.js @@ -1,49 +1,30 @@ import React from 'react' +import { useDispatch } from 'react-redux' import { Button, useTranslate, useUnselectAll } from 'react-admin' -import { Menu } from '@material-ui/core' import PlaylistAddIcon from '@material-ui/icons/PlaylistAdd' -import { AddToPlaylistMenu } from '../common' +import { openAddToPlaylist } from '../dialogs/dialogState' const AddToPlaylistButton = ({ resource, selectedIds, onAddToPlaylist }) => { - const [anchorEl, setAnchorEl] = React.useState(null) const translate = useTranslate() + const dispatch = useDispatch() const unselectAll = useUnselectAll() - const handleClick = (event) => { - setAnchorEl(event.currentTarget) - } - - const handleClose = () => { - setAnchorEl(null) - unselectAll(resource) + const handleClick = () => { + dispatch( + openAddToPlaylist({ selectedIds, onSuccess: () => unselectAll(resource) }) + ) } return ( - <> - - - - - + ) }