From 560fb4df19632d11c77a411ae2f816901f527f74 Mon Sep 17 00:00:00 2001 From: James Barnsley Date: Thu, 27 Oct 2016 10:02:12 +1300 Subject: [PATCH] UI actions; Context menu --- src/js/bootstrap.js | 7 + src/js/components/ContextMenu.js | 142 +++++++++++++++++++ src/js/components/Sidebar.js | 4 + src/js/components/Track.js | 17 ++- src/js/components/TrackList.js | 81 ++++++----- src/js/index.js | 2 + src/js/services/spotify/actions.js | 17 ++- src/js/services/spotify/reducer.js | 3 + src/js/services/ui/actions.js | 19 +++ src/js/services/ui/reducer.js | 25 ++++ src/js/views/App.js | 5 + src/js/views/Playlist.js | 6 +- src/js/views/Queue.js | 2 +- src/js/views/discover/DiscoverNewReleases.js | 49 +++++++ src/scss/app.scss | 1 + src/scss/components/_context-menu.scss | 24 ++++ src/scss/components/_grid.scss | 6 +- src/scss/components/_sidebar.scss | 9 +- src/scss/global/_variables.scss | 5 + 19 files changed, 378 insertions(+), 46 deletions(-) create mode 100755 src/js/components/ContextMenu.js create mode 100755 src/js/services/ui/actions.js create mode 100755 src/js/services/ui/reducer.js create mode 100755 src/js/views/discover/DiscoverNewReleases.js create mode 100755 src/scss/components/_context-menu.scss diff --git a/src/js/bootstrap.js b/src/js/bootstrap.js index e4a1bb01..924e6aa4 100755 --- a/src/js/bootstrap.js +++ b/src/js/bootstrap.js @@ -3,6 +3,7 @@ console.info('Bootstrapping...'); import { createStore, applyMiddleware, combineReducers } from 'redux' +import ui from './services/ui/reducer' import mopidy from './services/mopidy/reducer' import spotify from './services/spotify/reducer' @@ -12,6 +13,7 @@ import spotifyMiddleware from './services/spotify/middleware' import localstorageMiddleware from './services/localstorage/middleware' let reducers = combineReducers({ + ui, mopidy, spotify }); @@ -28,6 +30,11 @@ var initialState = { country: 'NZ', locale: 'en_NZ', me: false + }, + ui: { + context_menu: { + show: false + } } }; diff --git a/src/js/components/ContextMenu.js b/src/js/components/ContextMenu.js new file mode 100755 index 00000000..b5c9b5cd --- /dev/null +++ b/src/js/components/ContextMenu.js @@ -0,0 +1,142 @@ + +import React, { PropTypes } from 'react' +import { connect } from 'react-redux' +import { bindActionCreators } from 'redux' + +import TrackList from './TrackList' +import * as uiActions from '../services/ui/actions' +import * as mopidyActions from '../services/mopidy/actions' +import * as spotifyActions from '../services/spotify/actions' + +class ContextMenu extends React.Component{ + + constructor(props) { + super(props); + } + + handleClick(e){ + } + + renderItems(){ + var items = []; + + switch( this.props.state.context ){ + + case 'queue': + items = [ + { handleClick: 'playQueueItem', label: 'Play' }, + { handleClick: 'removeFromQueue', label: 'Remove' } + ]; + break; + + case 'editable-playlist': + items = [ + { handleClick: 'playItems', label: 'Play' }, + { handleClick: 'removeFromPlaylist', label: 'Remove' } + ]; + break; + + default: + items = [ + { handleClick: 'playItems', label: 'Play' }, + { handleClick: 'playItemsNext', label: 'Play next' }, + { handleClick: 'addToQueue', label: 'Add to queue' } + ]; + break; + } + + return ( +
+ { + items.map((item, index) => { + return this[item.handleClick](e) }>{ item.label } + }) + } +
+ ); + } + + playQueueItem(){ + var selectedTracks = this.props.state.data.selected_tracks; + this.props.mopidyActions.changeTrack( selectedTracks[0].tlid ); + this.props.uiActions.hideContextMenu(); + } + + removeFromQueue(){ + var selectedTracks = this.props.state.data.selected_tracks; + var selectedTracksTlids = []; + for( var i = 0; i < selectedTracks.length; i++ ){ + selectedTracksTlids.push( selectedTracks[i].tlid ); + } + this.props.mopidyActions.removeTracks( selectedTracksTlids ); + this.props.uiActions.hideContextMenu(); + } + + playItems(){ + var selectedTracks = this.props.state.data.selected_tracks; + var selectedTracksUris = []; + for( var i = 0; i < selectedTracks.length; i++ ){ + selectedTracksUris.push( selectedTracks[i].uri ); + } + this.props.mopidyActions.playTracks(selectedTracksUris); + this.props.uiActions.hideContextMenu(); + } + + playItemsNext(){ + var selectedTracks = this.props.state.data.selected_tracks; + var selectedTracksUris = []; + for( var i = 0; i < selectedTracks.length; i++ ){ + selectedTracksUris.push( selectedTracks[i].uri ); + } + + // TODO: figure out what index the currentTlTrack is (.indexOf()?) + var currentTrackIndex = 1; + + this.props.mopidyActions.enqueueTracks(selectedTracksUris, currentTrackIndex); + this.props.uiActions.hideContextMenu(); + } + + addToQueue(){ + var selectedTracks = this.props.state.data.selected_tracks; + var selectedTracksUris = []; + for( var i = 0; i < selectedTracks.length; i++ ){ + selectedTracksUris.push( selectedTracks[i].uri ); + } + this.props.mopidyActions.enqueueTracks(selectedTracksUris); + this.props.uiActions.hideContextMenu(); + } + + removeFromPlaylist(){ + console.log('removeFromPlaylist') + this.props.uiActions.hideContextMenu(); + } + + render(){ + if( !this.props.state.show ) return null; + + var style = { + left: this.props.state.position_x, + top: this.props.state.position_y, + } + + return ( +
+ { this.renderItems() } +
+ ); + } +} + +const mapStateToProps = (state, ownProps) => { + return state; +} + +const mapDispatchToProps = (dispatch) => { + return { + uiActions: bindActionCreators(uiActions, dispatch), + spotifyActions: bindActionCreators(spotifyActions, dispatch), + mopidyActions: bindActionCreators(mopidyActions, dispatch) + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(ContextMenu) \ No newline at end of file diff --git a/src/js/components/Sidebar.js b/src/js/components/Sidebar.js index 45f3038c..41d26877 100755 --- a/src/js/components/Sidebar.js +++ b/src/js/components/Sidebar.js @@ -50,6 +50,10 @@ class Sidebar extends React.Component{ Featured playlists + + + New releases +
diff --git a/src/js/components/Track.js b/src/js/components/Track.js index 5166058a..c2dc76a1 100755 --- a/src/js/components/Track.js +++ b/src/js/components/Track.js @@ -10,17 +10,27 @@ export default class Track extends React.Component{ super(props); } - handleClick( e ){ + handleClick(e){ var target = $(e.target); if( !target.is('a') && target.closest('a').length <= 0 ){ this.props.handleClick(e); } } - handleDoubleClick( e ){ + handleDoubleClick(e){ return this.props.handleDoubleClick(e); } + handleContextMenu(e){ + e.preventDefault(); + + // trigger a regular click event + if( !this.props.track.selected ) this.handleClick(e); + + // notify our tracklist + this.props.handleContextMenu(e); + } + formatDuration(){ if( typeof(this.props.track.duration_ms) !== 'undefined' ){ var ms = this.props.track.duration_ms; @@ -52,7 +62,8 @@ export default class Track extends React.Component{
this.handleDoubleClick(e) } - onClick={ (e) => this.handleClick(e) }> + onClick={ (e) => this.handleClick(e) } + onContextMenu={ (e) => this.handleContextMenu(e) }> { this.props.track.selected ? : null } {this.props.track.name} diff --git a/src/js/components/TrackList.js b/src/js/components/TrackList.js index 675a4e5f..4c90ae45 100755 --- a/src/js/components/TrackList.js +++ b/src/js/components/TrackList.js @@ -3,6 +3,7 @@ import React, { PropTypes } from 'react' import { connect } from 'react-redux' import { bindActionCreators } from 'redux' import * as mopidyActions from '../services/mopidy/actions' +import * as uiActions from '../services/ui/actions' import Track from './Track' @@ -22,6 +23,8 @@ class TrackList extends React.Component{ } handleClick( e, index ){ + if( this.props.ui.context_menu.show ) this.props.uiActions.hideContextMenu(); + var tracks = this.state.tracks; if( e.ctrlKey ){ @@ -54,10 +57,19 @@ class TrackList extends React.Component{ } handleDoubleClick( e, index ){ + if( this.props.ui.context_menu.show ) this.props.uiActions.hideContextMenu(); + var tracks = this.state.tracks; this.playTrack( tracks[index] ) } + handleContextMenu( e, index ){ + var data = { + selected_tracks: this.selectedTracks() + } + this.props.uiActions.showContextMenu( e, this.props.context, data ) + } + selectedTracks(){ function isSelected( track ){ return ( typeof(track.selected) !== 'undefined' && track.selected ); @@ -103,46 +115,44 @@ class TrackList extends React.Component{ let self = this; if( this.state.tracks ){ return ( -
-
    -
  • - Name - Artists - Album - Duration -
  • - { - this.state.tracks.map( - (track, index) => { +
      +
    • + Name + Artists + Album + Duration +
    • + { + this.state.tracks.map( + (track, index) => { - // flatten nested track objects (as in the case of TlTracks) - if( typeof(track.track) !== 'undefined' ){ + // flatten nested track objects (as in the case of TlTracks) + if( typeof(track.track) !== 'undefined' ){ - // see if we're the current tlTrack - if( self.props.mopidy.trackInFocus && self.props.mopidy.trackInFocus.tlid == track.tlid ){ - track.playing = true; - }else{ - track.playing = false; - } + // see if we're the current tlTrack + // TODO: figure out why this isn't fired when the tracklist changes + if( self.props.mopidy.trackInFocus && self.props.mopidy.trackInFocus.tlid == track.tlid ){ + track.playing = true; + }else{ + track.playing = false; + } - for( var property in track.track ){ - if( track.track.hasOwnProperty(property) ){ - track[property] = track.track[property] - } + for( var property in track.track ){ + if( track.track.hasOwnProperty(property) ){ + track[property] = track.track[property] } } - return self.handleDoubleClick(e, index)} - handleClick={(e) => self.handleClick(e, index)} /> } - ) - } -
    - - -
+ return self.handleDoubleClick(e, index)} + handleClick={(e) => self.handleClick(e, index)} + handleContextMenu={(e) => self.handleContextMenu(e, index)} /> + } + ) + } + ); } return null; @@ -162,7 +172,8 @@ const mapStateToProps = (state, ownProps) => { const mapDispatchToProps = (dispatch) => { return { - mopidyActions: bindActionCreators(mopidyActions, dispatch) + mopidyActions: bindActionCreators(mopidyActions, dispatch), + uiActions: bindActionCreators(uiActions, dispatch) } } diff --git a/src/js/index.js b/src/js/index.js index 1dd82549..dd420287 100755 --- a/src/js/index.js +++ b/src/js/index.js @@ -23,6 +23,7 @@ import Discover from './views/discover/Discover' import DiscoverFeatured from './views/discover/DiscoverFeatured' import DiscoverCategories from './views/discover/DiscoverCategories' import DiscoverCategory from './views/discover/DiscoverCategory' +import DiscoverNewReleases from './views/discover/DiscoverNewReleases' import LibraryArtists from './views/library/LibraryArtists' import LibraryAlbums from './views/library/LibraryAlbums' @@ -41,6 +42,7 @@ ReactDOM.render( + diff --git a/src/js/services/spotify/actions.js b/src/js/services/spotify/actions.js index 1ccfba67..0da94961 100755 --- a/src/js/services/spotify/actions.js +++ b/src/js/services/spotify/actions.js @@ -405,7 +405,7 @@ export function getCategory( id ){ } export function getCategoryPlaylists( id ){ - return (dispatch, getState) => { + return (dispatch, getState) => { dispatch({ type: 'SPOTIFY_CATEGORY_PLAYLISTS_LOADED', data: false }); @@ -416,6 +416,21 @@ export function getCategoryPlaylists( id ){ data: response.playlists }); }); + } +} + +export function getNewReleases(){ + return (dispatch, getState) => { + + dispatch({ type: 'SPOTIFY_NEW_RELEASES_LOADED', data: false }); + + sendRequest( dispatch, getState, 'browse/new-releases?country='+getState().spotify.country+'&limit=50' ) + .then( response => { + dispatch({ + type: 'SPOTIFY_NEW_RELEASES_LOADED', + data: response.albums + }); + }); } } diff --git a/src/js/services/spotify/reducer.js b/src/js/services/spotify/reducer.js index 34736502..ec9786ed 100755 --- a/src/js/services/spotify/reducer.js +++ b/src/js/services/spotify/reducer.js @@ -82,6 +82,9 @@ export default function reducer(spotify = {}, action){ case 'SPOTIFY_CATEGORY_PLAYLISTS_LOADED': return Object.assign({}, spotify, { category_playlists: action.data }); + case 'SPOTIFY_NEW_RELEASES_LOADED': + return Object.assign({}, spotify, { new_releases: action.data }); + default: return spotify } diff --git a/src/js/services/ui/actions.js b/src/js/services/ui/actions.js new file mode 100755 index 00000000..47cccd8b --- /dev/null +++ b/src/js/services/ui/actions.js @@ -0,0 +1,19 @@ + + +export function showContextMenu( e, context = false, data ){ + return { + type: 'UI_SHOW_CONTEXT_MENU', + position_x: e.clientX, + position_y: e.clientY, + context: context, + data: data + } +} + +export function hideContextMenu(){ + return { + type: 'UI_HIDE_CONTEXT_MENU' + } +} + + diff --git a/src/js/services/ui/reducer.js b/src/js/services/ui/reducer.js new file mode 100755 index 00000000..52154250 --- /dev/null +++ b/src/js/services/ui/reducer.js @@ -0,0 +1,25 @@ + +export default function reducer(ui = {}, action){ + switch (action.type) { + + case 'UI_SHOW_CONTEXT_MENU': + return Object.assign({}, ui, { + context_menu: { + show: true, + position_x: action.position_x, + position_y: action.position_y, + context: action.context, + data: action.data + } + }); + + case 'UI_HIDE_CONTEXT_MENU': + return Object.assign({}, ui, { context_menu: { show: false } }); + + default: + return ui + } +} + + + diff --git a/src/js/views/App.js b/src/js/views/App.js index 59718113..658788a1 100755 --- a/src/js/views/App.js +++ b/src/js/views/App.js @@ -6,7 +6,9 @@ import { Link } from 'react-router' import { connect } from 'react-redux' import Sidebar from '../components/Sidebar' +import ContextMenu from '../components/ContextMenu' +import * as uiActions from '../services/ui/actions' import * as mopidyActions from '../services/mopidy/actions' import * as spotifyActions from '../services/spotify/actions' @@ -31,6 +33,8 @@ class App extends React.Component{
{this.props.children}
+ { this.props.ui.context_menu.test } +
); } @@ -48,6 +52,7 @@ const mapStateToProps = (state, ownProps) => { const mapDispatchToProps = (dispatch) => { return { + uiActions: bindActionCreators(uiActions, dispatch), mopidyActions: bindActionCreators(mopidyActions, dispatch), spotifyActions: bindActionCreators(spotifyActions, dispatch) } diff --git a/src/js/views/Playlist.js b/src/js/views/Playlist.js index f9b22349..5c14fab8 100755 --- a/src/js/views/Playlist.js +++ b/src/js/views/Playlist.js @@ -29,10 +29,14 @@ class Playlist extends React.Component{ render(){ if( this.props.spotify.playlist ){ + + var context = null; + if( this.props.spotify.playlist.owner.id == this.props.spotify.me.id ) context = 'editable-playlist' + return (
- +
); } diff --git a/src/js/views/Queue.js b/src/js/views/Queue.js index 3040cfaf..ceea9cce 100755 --- a/src/js/views/Queue.js +++ b/src/js/views/Queue.js @@ -36,7 +36,7 @@ class Queue extends React.Component{ if( this.props.mopidy && this.props.mopidy.tracks ){ return ( this.removeTracks( tracks ) } playTracks={ null } diff --git a/src/js/views/discover/DiscoverNewReleases.js b/src/js/views/discover/DiscoverNewReleases.js new file mode 100755 index 00000000..8812bc94 --- /dev/null +++ b/src/js/views/discover/DiscoverNewReleases.js @@ -0,0 +1,49 @@ + +import React, { PropTypes } from 'react' +import { connect } from 'react-redux' +import { bindActionCreators } from 'redux' + +import Header from '../../components/Header' +import AlbumGrid from '../../components/AlbumGrid' + +import * as spotifyActions from '../../services/spotify/actions' + +class DiscoverNewReleases extends React.Component{ + + constructor(props) { + super(props); + } + + // on render + componentDidMount(){ + this.props.spotifyActions.getNewReleases(); + } + + render(){ + return ( +
+
+ { this.props.spotify.new_releases ? : null } +
+ ); + } +} + + +/** + * Export our component + * + * We also integrate our global store, using connect() + **/ + +const mapStateToProps = (state, ownProps) => { + return state; +} + +const mapDispatchToProps = (dispatch) => { + return { + spotifyActions: bindActionCreators(spotifyActions, dispatch) + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(DiscoverNewReleases) \ No newline at end of file diff --git a/src/scss/app.scss b/src/scss/app.scss index 60e96ee9..096c731c 100755 --- a/src/scss/app.scss +++ b/src/scss/app.scss @@ -7,6 +7,7 @@ @import 'global/reset'; @import 'global/forms'; +@import 'components/context-menu'; @import 'components/images'; @import 'components/icon'; @import 'components/sidebar'; diff --git a/src/scss/components/_context-menu.scss b/src/scss/components/_context-menu.scss new file mode 100755 index 00000000..8a378c4f --- /dev/null +++ b/src/scss/components/_context-menu.scss @@ -0,0 +1,24 @@ + +.context-menu { + position: fixed; + left: 0; + top: 0; + z-index: 9999; + background: $dark_grey; + color: #FFFFFF; + + a { + display: block; + padding: 8px 12px; + width: 120px; + + &:not(:first-child){ + border-top: 1px solid lighten($dark_grey, 8%); + } + + &:hover { + cursor: pointer; + background: lighten($dark_grey, 5%); + } + } +} \ No newline at end of file diff --git a/src/scss/components/_grid.scss b/src/scss/components/_grid.scss index 100cc054..9ba1c335 100755 --- a/src/scss/components/_grid.scss +++ b/src/scss/components/_grid.scss @@ -11,6 +11,7 @@ border-bottom: 0; .thumbnail { + @include animate(); max-width: 100%; } @@ -31,7 +32,10 @@ &:hover{ cursor: pointer; - opacity: 0.75; + + .thumbnail { + opacity: 0.75; + } } } diff --git a/src/scss/components/_sidebar.scss b/src/scss/components/_sidebar.scss index 0d27fbf2..2105c2b1 100755 --- a/src/scss/components/_sidebar.scss +++ b/src/scss/components/_sidebar.scss @@ -12,10 +12,10 @@ aside{ .thumbnail { position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; + top: -30px; + right: -30px; + bottom: -30px; + left: -30px; height: auto; width: auto; max-width: none; @@ -23,6 +23,7 @@ aside{ opacity: 0.2; .image { + @include blur(); padding-bottom: 0; height: 100%; } diff --git a/src/scss/global/_variables.scss b/src/scss/global/_variables.scss index f9a75519..796332e9 100755 --- a/src/scss/global/_variables.scss +++ b/src/scss/global/_variables.scss @@ -25,4 +25,9 @@ $red: #cf2d2d; -moz-transition: all $speed ease-in-out; -o-transition: all $speed ease-in-out; transition: all $speed ease-in-out; +} + +@mixin blur( $size: 10px ) { + -webkit-filter: blur( $size ); + filter: blur( $size ); } \ No newline at end of file