UI actions; Context menu

This commit is contained in:
James Barnsley
2016-10-27 10:02:12 +13:00
parent 05e131ce75
commit 560fb4df19
19 changed files with 378 additions and 46 deletions

7
src/js/bootstrap.js vendored
View File

@ -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
}
}
};

142
src/js/components/ContextMenu.js Executable file
View File

@ -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 (
<div>
{
items.map((item, index) => {
return <a key={item.handleClick} onClick={ (e) => this[item.handleClick](e) }>{ item.label }</a>
})
}
</div>
);
}
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 (
<div className="context-menu" style={style}>
{ this.renderItems() }
</div>
);
}
}
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)

View File

@ -50,6 +50,10 @@ class Sidebar extends React.Component{
<Icon name="star" className="white" />
Featured playlists
</Link>
<Link activeClassName="active" to="/discover/new-releases">
<Icon name="leaf" className="white" />
New releases
</Link>
</section>
<section>

View File

@ -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{
<div
className={className}
onDoubleClick={ (e) => this.handleDoubleClick(e) }
onClick={ (e) => this.handleClick(e) }>
onClick={ (e) => this.handleClick(e) }
onContextMenu={ (e) => this.handleContextMenu(e) }>
{ this.props.track.selected ? <FontAwesome name="check" className="select-state" fixedWidth /> : null }
<span className="col name">
{this.props.track.name}

View File

@ -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 (
<div>
<ul>
<li className="list-item header track">
<span className="col name">Name</span>
<span className="col artists">Artists</span>
<span className="col album">Album</span>
<span className="col duration">Duration</span>
</li>
{
this.state.tracks.map(
(track, index) => {
<ul>
<li className="list-item header track">
<span className="col name">Name</span>
<span className="col artists">Artists</span>
<span className="col album">Album</span>
<span className="col duration">Duration</span>
</li>
{
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 <Track
key={index+'_'+track.uri}
track={track}
handleDoubleClick={(e) => self.handleDoubleClick(e, index)}
handleClick={(e) => self.handleClick(e, index)} />
}
)
}
</ul>
<button onClick={() => this.removeTracks()}>Delete selected</button>
<button onClick={() => this.playTracks()}>Play selected</button>
</div>
return <Track
key={index+'_'+track.uri}
track={track}
handleDoubleClick={(e) => self.handleDoubleClick(e, index)}
handleClick={(e) => self.handleClick(e, index)}
handleContextMenu={(e) => self.handleContextMenu(e, index)} />
}
)
}
</ul>
);
}
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)
}
}

View File

@ -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(
<Route path="/discover/featured" component={DiscoverFeatured} />
<Route path="/discover/categories" component={DiscoverCategories} />
<Route path="/discover/categories/:id" component={DiscoverCategory} />
<Route path="/discover/new-releases" component={DiscoverNewReleases} />
<Route path="/library/artists" component={LibraryArtists} />
<Route path="/library/albums" component={LibraryAlbums} />

View File

@ -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
});
});
}
}

View File

@ -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
}

19
src/js/services/ui/actions.js Executable file
View File

@ -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'
}
}

25
src/js/services/ui/reducer.js Executable file
View File

@ -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
}
}

View File

@ -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{
<main>
{this.props.children}
</main>
{ this.props.ui.context_menu.test }
<ContextMenu state={this.props.ui.context_menu} />
</div>
);
}
@ -48,6 +52,7 @@ const mapStateToProps = (state, ownProps) => {
const mapDispatchToProps = (dispatch) => {
return {
uiActions: bindActionCreators(uiActions, dispatch),
mopidyActions: bindActionCreators(mopidyActions, dispatch),
spotifyActions: bindActionCreators(spotifyActions, dispatch)
}

View File

@ -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 (
<div className="view playlist-view">
<Header icon="playlist" title={ this.props.spotify.playlist.name } />
<TrackList tracks={this.props.spotify.playlist.tracks.items} />
<TrackList context={context} tracks={this.props.spotify.playlist.tracks.items} />
</div>
);
}

View File

@ -36,7 +36,7 @@ class Queue extends React.Component{
if( this.props.mopidy && this.props.mopidy.tracks ){
return (
<TrackList
type="tltrack"
context="queue"
tracks={this.props.mopidy.tracks}
removeTracks={ tracks => this.removeTracks( tracks ) }
playTracks={ null }

View File

@ -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 (
<div className="view discover-new-releases-view">
<Header icon="leaf" title="New Releases" />
{ this.props.spotify.new_releases ? <AlbumGrid albums={this.props.spotify.new_releases.items} /> : null }
</div>
);
}
}
/**
* 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)

View File

@ -7,6 +7,7 @@
@import 'global/reset';
@import 'global/forms';
@import 'components/context-menu';
@import 'components/images';
@import 'components/icon';
@import 'components/sidebar';

View File

@ -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%);
}
}
}

View File

@ -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;
}
}
}

View File

@ -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%;
}

View File

@ -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 );
}