├── .babelrc ├── .eslintrc ├── .gitignore ├── LICENSE ├── README.md ├── app ├── actions │ ├── album │ │ ├── index.js │ │ └── spec.js │ ├── artist │ │ └── index.js │ ├── auth │ │ └── index.js │ ├── homepage │ │ └── index.js │ ├── lastfm │ │ └── index.js │ ├── modal │ │ └── index.js │ ├── play-queue │ │ └── index.js │ ├── player │ │ └── index.js │ ├── playlists │ │ └── index.js │ ├── search-results │ │ └── index.js │ ├── search │ │ └── index.js │ └── top-tracks │ │ └── index.js ├── components │ ├── album │ │ ├── index.js │ │ ├── spec.js │ │ └── styles.scss │ ├── app │ │ ├── index.js │ │ ├── spec.js │ │ └── styles.scss │ ├── artist │ │ ├── index.js │ │ ├── spec.js │ │ └── styles.scss │ ├── autocomplete-section │ │ ├── index.js │ │ ├── spec.js │ │ └── styles.scss │ ├── autocomplete-thumbnail │ │ ├── index.js │ │ └── spec.js │ ├── autocomplete │ │ ├── index.js │ │ ├── spec.js │ │ └── styles.scss │ ├── current-track-summary │ │ ├── index.js │ │ ├── spec.js │ │ └── styles.scss │ ├── error-message │ │ ├── index.js │ │ └── styles.scss │ ├── home │ │ ├── index.js │ │ ├── spec.js │ │ └── styles.scss │ ├── login │ │ ├── index.js │ │ └── styles.scss │ ├── modal │ │ ├── index.js │ │ ├── modals │ │ │ ├── artist-bio │ │ │ │ ├── index.js │ │ │ │ ├── spec.js │ │ │ │ └── styles.scss │ │ │ ├── create-playlist-modal │ │ │ │ ├── index.js │ │ │ │ └── spec.js │ │ │ └── save-playlist-modal │ │ │ │ ├── index.js │ │ │ │ └── spec.js │ │ ├── spec.js │ │ └── styles.scss │ ├── page-not-found │ │ ├── index.js │ │ ├── spec.js │ │ └── styles.scss │ ├── play-queue-tools │ │ ├── index.js │ │ ├── spec.js │ │ └── styles.scss │ ├── play-queue │ │ ├── index.js │ │ ├── spec.js │ │ └── styles.scss │ ├── playlist-image │ │ ├── index.js │ │ └── styles.scss │ ├── playlist-page │ │ ├── index.js │ │ ├── spec.js │ │ └── styles.scss │ ├── playlist │ │ ├── index.js │ │ └── spec.js │ ├── playlists-page │ │ ├── index.js │ │ ├── spec.js │ │ └── styles.scss │ ├── playlists │ │ ├── index.js │ │ ├── spec.js │ │ └── styles.scss │ ├── routes │ │ └── index.js │ ├── search-results │ │ ├── index.js │ │ ├── spec.js │ │ └── styles.scss │ ├── search │ │ ├── index.js │ │ ├── spec.js │ │ └── styles.scss │ ├── top-tracks │ │ ├── index.js │ │ └── spec.js │ ├── track-table │ │ ├── index.js │ │ ├── spec.js │ │ └── track-table-header │ │ │ └── index.js │ ├── track-tools │ │ ├── index.js │ │ ├── spec.js │ │ └── styles.scss │ ├── track │ │ ├── index.js │ │ └── styles.scss │ ├── user-sidebar │ │ ├── index.js │ │ ├── spec.js │ │ └── styles.scss │ └── youtube-player │ │ ├── index.js │ │ ├── spec.js │ │ └── styles.scss ├── constants │ └── ActionTypes.js ├── fonts │ ├── FontAwesome.otf │ ├── fontawesome-webfont.eot │ ├── fontawesome-webfont.svg │ ├── fontawesome-webfont.ttf │ ├── fontawesome-webfont.woff │ └── fontawesome-webfont.woff2 ├── images │ ├── FB-f-Logo__blue_29.png │ ├── Twitter_Logo_White_On_Blue.png │ ├── bin.svg │ ├── next.svg │ ├── pause.svg │ ├── play.svg │ ├── previous.svg │ ├── repeat.svg │ ├── shuffle.svg │ ├── spinner.svg │ ├── summary-record.png │ ├── volume-high.svg │ ├── volume-low.svg │ ├── volume-medium.svg │ ├── volume-mute.svg │ └── volume-mute2.svg ├── index.js ├── reducers │ ├── album-page │ │ └── index.js │ ├── artist-page │ │ └── index.js │ ├── auth │ │ └── index.js │ ├── autocomplete │ │ └── index.js │ ├── modal │ │ └── index.js │ ├── play-queue │ │ └── index.js │ ├── playlists │ │ └── index.js │ ├── search-results │ │ └── index.js │ ├── search │ │ └── index.js │ ├── top-artists │ │ └── index.js │ ├── top-tracks │ │ └── index.js │ ├── track-summary │ │ └── index.js │ ├── video-data │ │ └── index.js │ └── video-player │ │ └── index.js ├── redux │ ├── create.js │ ├── middleware │ │ ├── auth.js │ │ ├── fetch.js │ │ └── lastfm.js │ └── modules │ │ ├── reducers.js │ │ └── store.js ├── styles │ ├── base.scss │ ├── breakpoints.scss │ ├── content-result.scss │ ├── font-awesome.css │ ├── global.scss │ ├── hero.scss │ ├── normalize.css │ ├── tracks.scss │ └── variables.scss └── utils │ ├── auth0-service.js │ ├── jwt-helper.js │ ├── post-to-fb-feed.js │ ├── prepare-track-data.js │ └── youtube-data-api.js ├── config ├── default.json └── production.json ├── layout.ejs ├── package.json ├── server └── lambda │ ├── authentication-service │ ├── handler.js │ ├── package.json │ └── serverless.yml │ └── playlist-service │ ├── handler.js │ ├── package.json │ └── serverless.yml ├── test └── helper.js ├── webpack.config.js └── webpack.production.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015" 4 | ], 5 | "plugins": [ 6 | "transform-react-jsx", 7 | "babel-plugin-transform-object-rest-spread", 8 | "transform-class-properties", 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "env": { 4 | "browser": true, 5 | "node": true 6 | }, 7 | "extends": "airbnb", 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .nyc_output 4 | .DS_STORE 5 | npm-debug.log 6 | .serverless 7 | .env 8 | dist/ 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 James Filtness 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tuneify 2 | 3 | Tuneify is a music app built using the YouTube video player API and data from Musicbrainz and LastFm. 4 | 5 | The focus for this project has been on learning new tech and not so much on code quality 🙈 6 | 7 | ## Update on this project: 8 | I ran this project locally recently and there are a few things that no longer work: 9 | 10 | 1. Playing a track no longer queue's the video in the embedded YouTube player. There has probably been a change to YouTube's Player API. Likely to be a quick fix. 11 | 2. LastFm has stopped providing thumbnails for artists. See https://www.reddit.com/r/lastfm/comments/bjwcqh/api_announcement_lastfm_support_community/ for details. 12 | 13 | ## Moving away from LastFm for search and images 14 | I had started to investigate removing LastFm as a dependency for a few reasons: 15 | 1. The data was not kept up to date. New releases and artists took months to appear in the data, if at all 16 | 2. LastFm prohibits the use of Artist/release images in third party apps 17 | 18 | Tuneify is essentially powered by LastFm. It is used by the autocomplete search, track data and images. So I started to look for other solutions and decided to see if I could roll my own. There are some repos related to that effort: 19 | 20 | * https://github.com/jamesfiltness/tuneify-python - scripts to collect artist/release artwork from various sources 21 | * https://github.com/jamesfiltness/musicbrainz-elasticsearch - notes and code related to creating an autocomplete by taking the Musicbrainz database and using it to create a search engine running on elasticsearch, with weighting for the most popular artists and releases. 22 | 23 | ## Tech 24 | * React Redux, Redux Router frontend 25 | * Mocha, Chai, Enzyme, Sinon 26 | * Es6 + Babel 27 | * Serverless (AWS Lambda) + DynamoDb to store user playlist data 28 | * Auth0 29 | * ElasticSearch for autocomplete 30 | * Musicbrainz slave https://bitbucket.org/lalinsky/mbslave 31 | * S3 + Cloudfront to serve static frontend 32 | * S3 + Cloudfront to store artist / album images 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /app/actions/album/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../../constants/ActionTypes.js'; 2 | import { fetchLastFmData } from '../lastfm'; 3 | 4 | export function getAlbumPageData(params) { 5 | const actions = [ 6 | types.LAST_FM_API_REQUEST, 7 | types.RECEIVE_ALBUM_PAGE_DATA, 8 | types.ALBUM_PAGE_DATA_ERROR 9 | ]; 10 | 11 | const query = { 12 | method: 'album.getinfo', 13 | ...params, 14 | }; 15 | 16 | return fetchLastFmData(actions, query); 17 | } 18 | 19 | export function clearAlbumPageData() { 20 | return { 21 | type: types.CLEAR_ALBUM_PAGE_DATA, 22 | } 23 | } 24 | 25 | export function clearAlbumPageError() { 26 | return { 27 | type: types.CLEAR_ALBUM_PAGE_ERROR, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/actions/album/spec.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesfiltness/tuneify/04250a1e8e292e1faa5eb6be65f4fefd94992225/app/actions/album/spec.js -------------------------------------------------------------------------------- /app/actions/artist/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../../constants/ActionTypes.js' 2 | import { fetchLastFmData } from '../lastfm' 3 | import { showModal } from '../modal'; 4 | 5 | export function getArtistPageData(params) { 6 | const actions = 7 | [ 8 | types.LAST_FM_API_REQUEST, 9 | types.RECEIVE_ARTIST_PAGE_DATA, 10 | types.ARTIST_PAGE_DATA_ERROR 11 | ]; 12 | 13 | const query = { 14 | method: 'artist.getinfo', 15 | ...params, 16 | }; 17 | 18 | return fetchLastFmData(actions, query); 19 | }; 20 | 21 | export function getArtistAlbums(params) { 22 | const actions = 23 | [ 24 | types.LAST_FM_API_REQUEST, 25 | types.RECEIVE_ARTIST_ALBUM_DATA, 26 | types.ARTIST_ALBUM_DATA_ERROR 27 | ]; 28 | 29 | const query = { 30 | method: 'artist.getTopAlbums', 31 | ...params, 32 | }; 33 | 34 | return fetchLastFmData(actions, query); 35 | }; 36 | 37 | export function showFullBio(props) { 38 | return (dispatch, getState) => { 39 | dispatch(showModal('full-bio', props)) 40 | } 41 | } 42 | 43 | export function getSimilarArtists(params) { 44 | const actions = 45 | [ 46 | types.LAST_FM_API_REQUEST, 47 | types.RECEIVE_SIMILAR_ARTIST_DATA, 48 | types.SIMILAR_ARTIST_ERROR 49 | ]; 50 | 51 | const query = { 52 | method: 'artist.getsimilar', 53 | ...params, 54 | }; 55 | 56 | return fetchLastFmData(actions, query); 57 | }; 58 | 59 | export function clearArtistPageData() { 60 | return { 61 | type: types.CLEAR_ARTIST_PAGE_DATA, 62 | } 63 | }; 64 | 65 | export function clearArtistPageError() { 66 | return { 67 | type: types.CLEAR_ARTIST_PAGE_ERROR, 68 | } 69 | }; 70 | -------------------------------------------------------------------------------- /app/actions/auth/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../../constants/ActionTypes.js' 2 | 3 | export function loggedIn() { 4 | return { 5 | type: types.LOGGED_IN, 6 | } 7 | }; 8 | 9 | export function loggedOut() { 10 | return { 11 | type: types.LOGGED_OUT, 12 | } 13 | }; 14 | 15 | export function authenticate() { 16 | return { 17 | type: types.AUTHENTICATE, 18 | authenticate: true, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/actions/homepage/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../../constants/ActionTypes.js' 2 | import { fetchLastFmData } from '../lastfm'; 3 | 4 | export function getTopArtists() { 5 | const actions = 6 | [ 7 | types.LAST_FM_API_REQUEST, 8 | types.RECEIVE_TOP_ARTIST_DATA, 9 | types.TOP_ARTIST_DATA_ERROR 10 | ]; 11 | 12 | const params = { 13 | method: 'chart.gettopartists', 14 | }; 15 | 16 | return fetchLastFmData(actions, params); 17 | } 18 | 19 | -------------------------------------------------------------------------------- /app/actions/lastfm/index.js: -------------------------------------------------------------------------------- 1 | export function fetchLastFmData(actions, params) { 2 | return { 3 | actions, 4 | promise: { 5 | url: window.clientConfig.endpoints.lastfm.url, 6 | headers: {}, 7 | params: { 8 | api_key: window.clientConfig.endpoints.lastfm.api_key, 9 | format : 'json', 10 | ...params 11 | }, 12 | mode: 'cors', 13 | }, 14 | } 15 | }; 16 | 17 | -------------------------------------------------------------------------------- /app/actions/modal/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../../constants/ActionTypes.js' 2 | 3 | export function showModal(modalType, props) { 4 | return { 5 | type: types.SHOW_MODAL, 6 | modalType, 7 | } 8 | } 9 | 10 | export function hideModal() { 11 | return { 12 | type: types.HIDE_MODAL, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/actions/player/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../../constants/ActionTypes.js'; 2 | import { 3 | incrementCurrentIndex, 4 | decrementCurrentIndex, 5 | playCurrentIndex, 6 | resetPlayQueueEnded, 7 | } from '../play-queue'; 8 | 9 | export function playVideo(videoData) { 10 | return { 11 | type: types.PLAY_VIDEO, 12 | videoData 13 | } 14 | } 15 | 16 | export function trackEnded() { 17 | return (dispatch, getState) => { 18 | dispatch(incrementCurrentIndex()); 19 | if (!getState().playQueue.playQueueEnded) { 20 | dispatch(playCurrentIndex()); 21 | } else { 22 | dispatch(resetPlayQueueEnded()); 23 | } 24 | } 25 | } 26 | 27 | export function playNextTrack() { 28 | return (dispatch, getState) => { 29 | dispatch(incrementCurrentIndex()); 30 | dispatch(playCurrentIndex()); 31 | } 32 | } 33 | 34 | export function playPreviousTrack() { 35 | return (dispatch, getState) => { 36 | dispatch(decrementCurrentIndex()); 37 | dispatch(playCurrentIndex()); 38 | } 39 | } 40 | 41 | export function receiveVideoData(json) { 42 | return { 43 | type: types.RECEIVE_VIDEO_DATA, 44 | videoData : json.items 45 | } 46 | } 47 | 48 | export function restartedTrack() { 49 | return { 50 | type: types.RESTARTED_TRACK, 51 | } 52 | } 53 | 54 | 55 | export function pauseBySpacebar() { 56 | return { 57 | type: types.PAUSE_BY_SPACEBAR, 58 | } 59 | } 60 | 61 | export function fetchVideoData(selectedTrackString) { 62 | return dispatch => { 63 | // TODO: move this url out in to config 64 | const query = encodeURIComponent(selectedTrackString); 65 | const youtubeUrl = window.clientConfig.endpoints.youtube.url; 66 | const youtubeApiKey = window.clientConfig.endpoints.youtube.api_key; 67 | 68 | return fetch( 69 | `${youtubeUrl}${query}&type=video&key=${youtubeApiKey}`, 70 | { mode: 'cors' } 71 | ) 72 | .then(response => response.json()) 73 | .then(json => { dispatch(receiveVideoData(json)) }) 74 | } 75 | } 76 | 77 | export function playTrack(trackName, artist) { 78 | return (dispatch, getState) => { 79 | dispatch(fetchVideoData(`${trackName} - ${artist}`)).then(() => { 80 | dispatch(playVideo(getState().videoData)); 81 | }); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /app/actions/playlists/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../../constants/ActionTypes.js'; 2 | import prepareTrackData from '../../utils/prepare-track-data'; 3 | 4 | export function fetchLambda(actions, endpoint, httpMethod, params, body) { 5 | const jwt = localStorage.getItem('idToken'); 6 | const bodyPayload = JSON.stringify(body); 7 | 8 | return { 9 | actions, 10 | promise: { 11 | method: httpMethod, 12 | url: `${window.clientConfig.endpoints.awslambda.url}${endpoint}`, 13 | headers: { 14 | 'Authorization': jwt, 15 | 'Accept': 'application/json', 16 | 'Content-Type': 'application/json' 17 | }, 18 | params: { 19 | ...params 20 | }, 21 | body: bodyPayload, 22 | mode: 'cors' 23 | }, 24 | } 25 | }; 26 | 27 | export function getUserPlaylists() { 28 | const actions = [ 29 | types.REQUEST_USER_PLAYLISTS, 30 | types.RECEIVE_USER_PLAYLIST_DATA, 31 | types.USER_PLAYLIST_REQUEST_ERROR 32 | ]; 33 | 34 | return fetchLambda(actions, 'playlists', 'GET'); 35 | } 36 | 37 | export function createPlaylist(playlistName, playlist) { 38 | const actions = [ 39 | types.CREATE_PLAYLIST, 40 | types.PLAYLIST_CREATED, 41 | types.PLAYLIST_CREATE_ERROR 42 | ]; 43 | 44 | const body = { 45 | playlistName, 46 | playlist, 47 | }; 48 | 49 | return fetchLambda(actions, 'playlists', 'POST', null, body); 50 | } 51 | 52 | export function updatePlaylist(playlist, trackToAdd, trackToAddImg) { 53 | const actions = [ 54 | types.UPDATE_PLAYLIST, 55 | types.PLAYLIST_UPDATED, 56 | types.PLAYLIST_UPDATE_ERROR 57 | ]; 58 | 59 | 60 | const preparedTrack = prepareTrackData([trackToAdd], trackToAddImg); 61 | 62 | const updatedTracklist = playlist.tracks.concat(preparedTrack); 63 | 64 | const body = { 65 | playlistId: playlist.id, 66 | updatedTracklist, 67 | }; 68 | 69 | return fetchLambda(actions, 'playlists', 'PUT', null, body); 70 | } 71 | -------------------------------------------------------------------------------- /app/actions/search-results/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../../constants/ActionTypes.js'; 2 | import { addTrackToQueueAndPlay } from '../play-queue'; 3 | 4 | // TODO these three action creators could be rolled in to one that accepts 5 | // a param 6 | import { 7 | fetchArtistData, 8 | fetchTrackData, 9 | fetchAlbumData, 10 | } from '../search'; 11 | 12 | export function clearFullSearchResults() { 13 | return { 14 | type: types.CLEAR_FULL_SEARCH_RESULTS, 15 | } 16 | } 17 | 18 | export function getFullSearchResults(searchTerm) { 19 | return dispatch => { 20 | const limit = 30; 21 | dispatch(clearFullSearchResults()); 22 | dispatch(fetchArtistData(searchTerm, 'FULL', limit)); 23 | dispatch(fetchTrackData(searchTerm, 'FULL', limit)); 24 | dispatch(fetchAlbumData(searchTerm, 'FULL', limit)); 25 | } 26 | } 27 | 28 | export function searchResultTrackSelected(track) { 29 | return (dispatch, getState) => { 30 | dispatch( 31 | addTrackToQueueAndPlay( 32 | { 33 | name: track.name, 34 | artist: track.artist, 35 | }, 36 | track.image[2]['#text'], 37 | ) 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/actions/search/index.js: -------------------------------------------------------------------------------- 1 | require('es6-promise').polyfill(); 2 | import fetch from 'isomorphic-fetch'; 3 | import { push } from 'react-router-redux'; 4 | import * as types from '../../constants/ActionTypes.js'; 5 | import { addTrackToQueueAndPlay } from '../play-queue'; 6 | import { fetchLastFmData } from '../lastfm'; 7 | 8 | export function clearSearch() { 9 | return { 10 | type: types.CLEAR_SEARCH 11 | } 12 | }; 13 | 14 | export function autocompleteTrackSelected(selectedTrackData) { 15 | return (dispatch, getState) => { 16 | dispatch( 17 | addTrackToQueueAndPlay( 18 | { 19 | name: selectedTrackData.name, 20 | artist: selectedTrackData.artist, 21 | }, 22 | selectedTrackData.image[2]['#text'], 23 | ) 24 | ); 25 | } 26 | }; 27 | 28 | export function fetchArtistData(searchTerm, type, limit = 3) { 29 | const actions = 30 | [ 31 | types.LAST_FM_API_REQUEST, 32 | types[`RECEIVE_${type}_ARTIST_DATA`], 33 | types[`RECEIVE_${type}_ARTIST_DATA_ERROR`] 34 | ]; 35 | 36 | const params = { 37 | method: 'artist.search', 38 | artist: searchTerm, 39 | limit: limit, 40 | }; 41 | 42 | return fetchLastFmData(actions, params); 43 | }; 44 | 45 | // TODO: Add error actions here! 46 | export function fetchAlbumData(searchTerm, type, limit = 3) { 47 | const actions = 48 | [ 49 | types.LAST_FM_API_REQUEST, 50 | types[`RECEIVE_${type}_ALBUM_DATA`], 51 | types[`RECEIVE_${type}_ALBUM_DATA_ERROR`] 52 | ]; 53 | 54 | const params = { 55 | method: 'album.search', 56 | album: searchTerm, 57 | limit: limit, 58 | }; 59 | 60 | return fetchLastFmData(actions, params); 61 | }; 62 | 63 | export function fetchTrackData(searchTerm, type, limit = 3) { 64 | const actions = 65 | [ 66 | types.LAST_FM_API_REQUEST, 67 | types[`RECEIVE_${type}_TRACK_DATA`], 68 | types[`RECEIVE_${type}_TRACK_DATA_ERROR`] 69 | ]; 70 | 71 | const params = { 72 | method: 'track.search', 73 | track: searchTerm, 74 | limit: limit, 75 | }; 76 | 77 | return fetchLastFmData(actions, params); 78 | }; 79 | 80 | export function initialisingSearch(searchTerm) { 81 | return { 82 | type: types.INITIALISING_SEARCH, 83 | searchTerm, 84 | } 85 | }; 86 | 87 | export function searchPerformed(searchTerm) { 88 | return dispatch => { 89 | if (searchTerm.length > 1) { 90 | dispatch(initialisingSearch(searchTerm)); 91 | dispatch(fetchArtistData(searchTerm, 'AUTOCOMPLETE')); 92 | dispatch(fetchTrackData(searchTerm, 'AUTOCOMPLETE')); 93 | dispatch(fetchAlbumData(searchTerm, 'AUTOCOMPLETE')); 94 | } else { 95 | dispatch(clearSearch()) 96 | } 97 | } 98 | }; 99 | -------------------------------------------------------------------------------- /app/actions/top-tracks/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../../constants/ActionTypes.js' 2 | import { fetchLastFmData } from '../lastfm'; 3 | import { addTrackToQueueAndPlay } from '../play-queue'; 4 | 5 | export function getTopTracks() { 6 | const actions = 7 | [ 8 | types.LAST_FM_API_REQUEST, 9 | types.RECEIVE_TOP_TRACK_DATA, 10 | types.TOP_TRACK_DATA_ERROR 11 | ]; 12 | 13 | const params = { 14 | method: 'chart.gettoptracks', 15 | }; 16 | 17 | return fetchLastFmData(actions, params); 18 | } 19 | 20 | export function playTopTrack(name, artist, image) { 21 | return (dispatch, getState) => { 22 | dispatch( 23 | addTrackToQueueAndPlay( 24 | { 25 | name, 26 | artist, 27 | }, 28 | image, 29 | ) 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/components/album/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import Playlist from '../playlist'; 4 | import ErrorMessage from '../error-message'; 5 | import { 6 | clearAlbumPageError, 7 | getAlbumPageData, 8 | clearAlbumPageData, 9 | } from '../../actions/album'; 10 | 11 | export class Album extends React.Component { 12 | static propTypes = { 13 | clearAlbumPageError: PropTypes.func.isRequired, 14 | getAlbumPageData: PropTypes.func.isRequired, 15 | clearAlbumPageData: PropTypes.func.isRequired, 16 | albumPageData: PropTypes.object, 17 | currentAlbumPageError: PropTypes.bool, 18 | }; 19 | 20 | componentDidMount() { 21 | if (this.props.params.mbid) { 22 | this.getAlbumDataByMbid(this.props.params.mbid); 23 | } else { 24 | this.getAlbumDataByName( 25 | this.props.params.artist, 26 | this.props.params.album 27 | ); 28 | } 29 | } 30 | 31 | componentWillReceiveProps(nextProps) { 32 | if (nextProps.params.mbid) { 33 | if (nextProps.params.mbid !== this.props.params.mbid) { 34 | this.getAlbumDataByMbid(nextProps.params.mbid); 35 | } 36 | } else if (nextProps.params.album !== this.props.params.album) { 37 | this.getAlbumDataByName(nextProps.params.artist, nextProps.params.album); 38 | } 39 | } 40 | 41 | getAlbumDataByMbid(mbid) { 42 | // TODO: investigate whether getAlbumPageData action creator 43 | // can use a thunk as well as using promise middleware 44 | // if so we can just dispatch one action instead of three here 45 | this.props.clearAlbumPageData(); 46 | this.props.clearAlbumPageError(); 47 | this.props.getAlbumPageData({ mbid: mbid }); 48 | } 49 | 50 | getAlbumDataByName(artist, album) { 51 | this.props.clearAlbumPageData(); 52 | this.props.clearAlbumPageError(); 53 | this.props.getAlbumPageData({ 54 | artist: artist, 55 | album: album 56 | }); 57 | } 58 | 59 | render() { 60 | const { 61 | albumPageData, 62 | currentAlbumPageError, 63 | } = this.props; 64 | 65 | if (albumPageData) { 66 | // sometimes lastfm returns successfully but with an empty 67 | // json object. To counter this the reducer has a case for 68 | // this an returns and error property when it does happen 69 | return ( 70 | 84 | ); 85 | } else if (currentAlbumPageError) { 86 | return ( 87 | 88 | ); 89 | } else { 90 | return ( 91 |
92 | ); 93 | } 94 | } 95 | } 96 | 97 | function mapStateToProps(state) { 98 | return { 99 | albumPageData: state.albumPage.albumPageData, 100 | currentAlbumPageError: state.albumPage.currentAlbumPageError, 101 | } 102 | } 103 | 104 | const mapDispatchToProps = { 105 | clearAlbumPageError, 106 | getAlbumPageData, 107 | clearAlbumPageData, 108 | } 109 | 110 | export default connect( 111 | mapStateToProps, 112 | mapDispatchToProps 113 | )(Album); 114 | 115 | -------------------------------------------------------------------------------- /app/components/album/styles.scss: -------------------------------------------------------------------------------- 1 | .album { 2 | padding: $route-content-padding; 3 | } 4 | -------------------------------------------------------------------------------- /app/components/app/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Link } from 'react-router'; 4 | 5 | import { searchPerformed } from '../../actions/search'; 6 | import { playVideo, pauseBySpacebar } from '../../actions/player'; 7 | 8 | import Search from '../search'; 9 | import SearchAutoComplete from '../autocomplete'; 10 | import YouTubePlayer from '../youtube-player'; 11 | import PlayQueue from '../play-queue'; 12 | import PlayQueueTools from '../play-queue-tools'; 13 | import CurrentTrackSummary from '../current-track-summary'; 14 | import UserSidebar from '../user-sidebar'; 15 | import Login from '../login'; 16 | import Modal from '../modal'; 17 | 18 | export class App extends React.Component { 19 | 20 | static propTypes = { 21 | currentSearch: PropTypes.string, 22 | artists: PropTypes.array, 23 | tracks: PropTypes.array, 24 | albums: PropTypes.array, 25 | children: React.PropTypes.object, 26 | playQueueTracks: React.PropTypes.array, 27 | trackSummary: React.PropTypes.object, 28 | videoData: React.PropTypes.array, 29 | }; 30 | 31 | constructor() { 32 | super(); 33 | 34 | this.state = { 35 | searchFocused: false, 36 | } 37 | 38 | document.onkeydown = (e) => { 39 | // If the user has clicked the spacebar 40 | // and the element is not an input 41 | // and there is video data 42 | if ( 43 | e.keyCode == 32 && 44 | e.target.type !== 'text' && 45 | this.props.videoData.length 46 | ) { 47 | e.preventDefault(); 48 | this.props.pauseBySpacebar(); 49 | } 50 | } 51 | } 52 | 53 | searchFocused() { 54 | this.setState({ 55 | searchFocused: true, 56 | }); 57 | } 58 | 59 | searchBlurred() { 60 | this.setState({ 61 | searchFocused: false, 62 | }); 63 | } 64 | 65 | isMobile() { 66 | const userAgent = navigator.userAgent || navigator.vendor || window.opera; 67 | 68 | // Windows Phone must come first because its UA also contains "Android" 69 | if ( 70 | /windows phone/i.test(userAgent) || 71 | /android/i.test(userAgent) || 72 | (/iPhone|iPod/.test(userAgent) && !window.MSStream)) { 73 | return true; 74 | } 75 | 76 | return false; 77 | } 78 | 79 | renderMobileMessage() { 80 | return ( 81 |
82 |

Sorry!

83 |

Tuneify doesn't currently support mobile devices and is best viewed on a desktop or tablet device.

84 |

We are considering building a mobile app. Follow us on Facebook to keep updated!

85 |
91 |
92 | ) 93 | } 94 | 95 | renderApp() { 96 | const { 97 | artists, 98 | albums, 99 | tracks, 100 | videoData, 101 | playQueueTracks, 102 | trackSummary, 103 | authService, 104 | searchPerformed, 105 | } = this.props; 106 | 107 | return ( 108 |
109 | 110 |
111 |
117 |
118 |
119 |

120 | 124 | Tuneify 125 | 126 |

127 | searchPerformed(text) 132 | } 133 | /> 134 |
135 | 136 |
137 | 143 |
144 | {this.props.children} 145 |
146 | 147 |
148 | 153 | 154 | 155 | 156 |
157 |
158 | ) 159 | } 160 | 161 | render() { 162 | return this.isMobile() ? this.renderMobileMessage() : this.renderApp(); 163 | } 164 | } 165 | 166 | const mapDispatchToProps = { 167 | pauseBySpacebar, 168 | searchPerformed, 169 | } 170 | 171 | function mapStateToProps(state) { 172 | return { 173 | currentSearch: state.search.currentSearch, 174 | artists: state.autocomplete.autocompleteArtistData, 175 | tracks: state.autocomplete.autocompleteTrackData, 176 | albums: state.autocomplete.autocompleteAlbumData, 177 | videoData: state.videoData, 178 | playQueueTracks: state.playQueue.playQueueTracks, 179 | trackSummary: state.currentTrackSummaryData, 180 | } 181 | } 182 | 183 | export default connect(mapStateToProps, mapDispatchToProps)(App) 184 | -------------------------------------------------------------------------------- /app/components/app/spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { App } from './'; 3 | 4 | describe('App component', () => { 5 | let component; 6 | 7 | beforeEach(() => { 8 | component = shallow( 9 | {}} 12 | /> 13 | ); 14 | }); 15 | 16 | it('renders the component wrapper', () => { 17 | expect(component.find('.app')).to.be.present(); 18 | }); 19 | 20 | it('renders the header markup correctly', () => { 21 | const header = component.find('.header'); 22 | expect(header).to.have.length(1); 23 | expect(header.find('.header__container')).to.be.present(); 24 | expect(header.find('.header__title')).to.be.present(); 25 | expect(header.find('.header__title-link')).to.be.present(); 26 | }); 27 | 28 | it('renders the route content div correctly', () => { 29 | expect(component.find('.route-content')).to.be.present(); 30 | }); 31 | 32 | it('it renders the right sidebar', () => { 33 | expect(component.find('.sidebar--right')).to.be.present(); 34 | }); 35 | 36 | }); 37 | -------------------------------------------------------------------------------- /app/components/app/styles.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/breakpoints"; 2 | 3 | body { 4 | background: #111; 5 | color: #eee; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | .header { 11 | position: fixed; 12 | top: 0; 13 | width: 100%; 14 | background: #282727; 15 | padding: 10px 0; 16 | z-index: 3; 17 | 18 | &__container { 19 | margin: 0 auto; 20 | width: 320px; 21 | } 22 | 23 | &__title { 24 | display: inline-block; 25 | margin: 0 10px 0 0; 26 | font-size: 1.5em; 27 | } 28 | 29 | &__title-link { 30 | color: #fff; 31 | text-decoration: none; 32 | } 33 | } 34 | 35 | .route-content { 36 | margin: 61px 330px 0 0px; 37 | min-width: 290px; 38 | @include breakpoint(999px) { 39 | margin-left: 174px; 40 | height: 100%; 41 | min-height: 600px; 42 | } 43 | 44 | @include breakpoint(1024px) { 45 | margin-left: 240px; 46 | } 47 | } 48 | 49 | .sidebar { 50 | background: #191919; 51 | top: 61px; 52 | z-index: 2; 53 | 54 | &--left { 55 | 56 | @include breakpoint(999px) { 57 | border-right: 4px solid #282727; 58 | padding: 0 20px; 59 | position: fixed; 60 | height: 100%; 61 | width: 175px; 62 | } 63 | 64 | @include breakpoint(1024px) { 65 | width: 240px; 66 | } 67 | } 68 | 69 | &--right { 70 | position: fixed; 71 | height: 100%; 72 | right: 0; 73 | top: 64px; 74 | width: 328px; 75 | padding-top: 3px; 76 | border-left: 4px solid #282727; 77 | background: #282727; 78 | border-right: 4px solid #282727; 79 | } 80 | 81 | &--left { 82 | .fa { 83 | margin-right: 10px; 84 | } 85 | } 86 | } 87 | 88 | .fb-follow { 89 | position: absolute !important; 90 | top: 16px; 91 | left: 16px; 92 | 93 | &--mobile { 94 | position: relative !important; 95 | top: 0; 96 | left: 0; 97 | } 98 | } 99 | 100 | .mobile-message { 101 | padding: 0 20px; 102 | } 103 | -------------------------------------------------------------------------------- /app/components/artist/spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Artist } from './'; 3 | import sinon from 'sinon'; 4 | 5 | describe('Artist component', () => { 6 | let component; 7 | let artistStub; 8 | 9 | beforeEach(() => { 10 | artistStub = sinon.stub(Artist.prototype, 'getArtistData').returns(false); 11 | 12 | component = shallow( 13 | {}} 15 | params={{mbid: 'some-mbid'}} 16 | artistPageData={{ 17 | name: "Radiohead", 18 | bio: { 19 | summary: "some summary about the artist" 20 | }, 21 | similar: [{ 22 | name: "Thom Yorke", 23 | image: [ 24 | {"#text": 'http://example.com/thom.jpg'}, 25 | {"#text": 'http://example.com/thom.jpg'} 26 | ], 27 | }], 28 | image: 'http://example.com/image.jpg', 29 | }} 30 | /> 31 | ); 32 | }); 33 | 34 | afterEach(() => { 35 | artistStub.restore(); 36 | }) 37 | 38 | it('renders the artist wrapper', () => { 39 | expect(component.find('.artist')).to.be.present(); 40 | }); 41 | 42 | it('renders the artist heading', () => { 43 | expect(component.find('.artist__header-name')).to.have.text('Radiohead'); 44 | }); 45 | 46 | it('renders the artist image', () => { 47 | expect(component.find('.artist__header-image')).to.have.attr('src', 'http://example.com/image.jpg'); 48 | }); 49 | 50 | it('renders the artist summary bio', () => { 51 | expect( 52 | component 53 | .find('.artist__bio') 54 | .html() 55 | ).to.be.equal('
some summary about the artist
'); 56 | }); 57 | 58 | it('renders the similar artists wrapper', () => { 59 | expect( 60 | component 61 | .find('.artist__similar') 62 | ).to.be.present(); 63 | }); 64 | 65 | it('renders the similar artists list', () => { 66 | const listItem = component 67 | .find('.artist__similar-list') 68 | .find('li') 69 | 70 | expect(listItem.find('a')).to.have.text('Thom Yorke'); 71 | expect(listItem.find('img')).to.have.attr('src', 'http://example.com/thom.jpg'); 72 | }); 73 | 74 | it('renders the error message when one is provided', () => { 75 | component = shallow( 76 | {}} 78 | artistPageData={{ 79 | error: "Some error", 80 | }} 81 | /> 82 | ); 83 | expect(component.find('h3')).to.have.text('No artist found for this search result.'); 84 | }); 85 | 86 | it('renders the spinner if no artist data is provided', () => { 87 | component = shallow( 88 | {}} 90 | /> 91 | ); 92 | expect(component.find('.route-content-spinner')).to.be.present(); 93 | }); 94 | 95 | it('if a new mbid param is provided the page should be updated', () => { 96 | component.setProps( 97 | { params: { mbid: 'some-other-mbid' } } 98 | ); 99 | expect(artistStub).to.have.been.calledWith({ mbid: 'some-other-mbid' }); 100 | }); 101 | 102 | it('if a new artist param is provided the page should be updated', () => { 103 | component.setProps( 104 | { params: { artist: '22-20s' } } 105 | ); 106 | expect(artistStub).to.have.been.calledWith({ artist: '22-20s' }); 107 | }); 108 | 109 | it('if the same mbid param is provided the page should not be updated', () => { 110 | component.setProps( 111 | { params: { mbid: 'some-mbid' } } 112 | ); 113 | expect(artistStub).to.not.have.been.called; 114 | }); 115 | 116 | it('if new artist data has been proided via props the component should be updated', () => { 117 | component.setProps( 118 | { 119 | artistPageData: { 120 | name: "The Cure", 121 | bio: { 122 | summary: "The Cure summary" 123 | }, 124 | similar: [{ 125 | name: "Thom Yorke", 126 | image: [ 127 | {"#text": 'http://example.com/thom.jpg'}, 128 | {"#text": 'http://example.com/thom.jpg'} 129 | ], 130 | }], 131 | image: 'http://example.com/image.jpg', 132 | } 133 | } 134 | ); 135 | 136 | expect(component.find('.artist__header-name')).to.have.text('The Cure'); 137 | expect( 138 | component 139 | .find('.artist__bio') 140 | .html() 141 | ).to.be.equal('
The Cure summary
'); 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /app/components/artist/styles.scss: -------------------------------------------------------------------------------- 1 | .artist { 2 | min-width: 440px; 3 | 4 | &__header { 5 | overflow: hidden; 6 | } 7 | 8 | &__header-image { 9 | float: left; 10 | margin-right: 20px; 11 | } 12 | 13 | &__header-identifier { 14 | text-transform: uppercase; 15 | color: #ccc; 16 | margin: 0px 0 25px 0; 17 | } 18 | 19 | &__bio { 20 | font-size: 14px; 21 | height: 49px; 22 | overflow: hidden; 23 | } 24 | 25 | &__read-more { 26 | margin: 0; 27 | text-decoration: none; 28 | } 29 | 30 | &__read-more-link { 31 | text-decoration: none; 32 | font-size: 12px; 33 | color: #19b736; 34 | &:hover { 35 | text-decoration: underline; 36 | } 37 | } 38 | } 39 | 40 | .modal { 41 | &--full-bio { 42 | .modal__dialog { 43 | top: 10px; 44 | width: 650px; 45 | left: calc(50% - 325px); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/components/autocomplete-section/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { Link } from 'react-router'; 3 | import SearchAutoCompleteThumbnail from '../autocomplete-thumbnail'; 4 | 5 | export class SearchAutoCompleteSection extends React.Component { 6 | static PropTypes = { 7 | onSelectResult: PropTypes.func.isRequired, 8 | title: PropTypes.string, 9 | data: PropTypes.array, 10 | }; 11 | 12 | constructor() { 13 | super(); 14 | this.resultSelected = this.resultSelected.bind(this); 15 | } 16 | 17 | resultSelected(result) { 18 | this.props.onSelectResult(result); 19 | } 20 | 21 | resultContent(result) { 22 | return ( 23 |
24 | 28 |
29 | 30 | {result.name} 31 | 32 | 33 | {result.artist} 34 | 35 |
36 |
37 | ); 38 | } 39 | 40 | renderTrackResults(result) { 41 | return ( 42 |
this.resultSelected(result)}> 43 | {this.resultContent(result)} 44 |
45 | ) 46 | } 47 | 48 | renderAlbumResults(result) { 49 | let path = `/album/${result.mbid}`; 50 | 51 | if (!result.mbid) { 52 | path = `/album/${encodeURIComponent(result.artist)}/${encodeURIComponent(result.name)}`; 53 | } 54 | 55 | return ( 56 | 57 | {this.resultContent(result)} 58 | 59 | ) 60 | } 61 | 62 | renderArtistResults(result) { 63 | let path = `/artist/${result.mbid}`; 64 | 65 | if(!result.mbid) { 66 | path = `/artist/${encodeURIComponent(result.artist)}`; 67 | } 68 | 69 | return ( 70 | 71 | {this.resultContent(result)} 72 | 73 | ) 74 | } 75 | 76 | render() { 77 | const { 78 | title, 79 | data, 80 | onSelectResult, 81 | } = this.props; 82 | 83 | return ( 84 |
85 |

{title}

86 |
    87 | { 88 | data.map( 89 | (result, i) => { 90 | return ( 91 |
  • 92 | { 93 | title === 'Tracks' ? 94 | this.renderTrackResults(result) : 95 | 96 | title === 'Artists' ? 97 | this.renderArtistResults(result) : 98 | 99 | this.renderAlbumResults(result) 100 | } 101 |
  • 102 | ) 103 | } 104 | ) 105 | } 106 |
107 |
108 | ) 109 | } 110 | } 111 | 112 | export default SearchAutoCompleteSection 113 | -------------------------------------------------------------------------------- /app/components/autocomplete-section/spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { SearchAutoCompleteSection } from './'; 3 | import sinon from 'sinon'; 4 | 5 | let component; 6 | let selectResultSpy; 7 | 8 | beforeEach(() => { 9 | selectResultSpy = sinon.spy(); 10 | component = mount( 11 | 28 | ); 29 | }); 30 | 31 | describe('SearchAutoCompleteSection component', () => { 32 | it('renders the autocomplete-section wrapper div', () => { 33 | expect(component.find('.autocomplete-section')).to.be.present(); 34 | }); 35 | 36 | it('renders the title with the correct text', () => { 37 | expect(component.find('.autocomplete-section__title')).to.have.text('Tracks'); 38 | }); 39 | 40 | it('renders the section list', () => { 41 | expect(component.find('.autocomplete-section__list')).to.be.present(); 42 | }); 43 | 44 | it('renders the correct amount of results', () => { 45 | expect(component.find('.autocomplete-section__list-item')).to.have.length(1) 46 | }); 47 | 48 | it('renders a result thumbnail', () => { 49 | expect(component.find('.autocomplete-section__list-item img')).to.have.length(1) 50 | }); 51 | 52 | it('renders the result name', () => { 53 | expect(component.find('.autocomplete-section__target')).to.have.text('Airbag') 54 | }); 55 | 56 | it('renders the result artist', () => { 57 | expect(component.find('.autocomplete-section__artist')).to.have.text('Radiohead') 58 | }); 59 | 60 | it('if a track result is clicked on then the callback prop should be called', () => { 61 | const trackWrapper = component.find('.autocomplete-section__list-item div').at(0) 62 | trackWrapper.simulate('click'); 63 | expect(selectResultSpy).to.have.been.calledWith( 64 | { 65 | artist: "Radiohead", 66 | image: [{ '#text': "http://example.com/image.jpg" }], 67 | name: "Airbag" 68 | } 69 | ); 70 | }); 71 | // TODO: specs around the links being rendered correctly are needed 72 | // this is not so straightforward. 73 | // One possible solution is to render the react router... 74 | }); 75 | -------------------------------------------------------------------------------- /app/components/autocomplete-section/styles.scss: -------------------------------------------------------------------------------- 1 | .autocomplete-section { 2 | &__title { 3 | background: #333; 4 | color: #dcdcdc; 5 | margin: 0; 6 | padding: 5px 10px; 7 | font-size: 14px; 8 | font-weight: normal; 9 | } 10 | 11 | &__list { 12 | margin: 5px 10px; 13 | } 14 | 15 | &__list-item { 16 | margin-bottom: 5px; 17 | font-size: 13px; 18 | clear: left; 19 | cursor: pointer; 20 | overflow:hidden; 21 | } 22 | 23 | &__item-details { 24 | display: inline-block; 25 | vertical-align: top; 26 | width: 235px; 27 | color: #f5f5f5; 28 | margin-left: 5px; 29 | } 30 | 31 | &__thumbnail { 32 | float:left; 33 | margin-right: 5px; 34 | } 35 | 36 | &__artist { 37 | margin-top: 3px; 38 | font-size: 11px; 39 | color: #aaa; 40 | display:block; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/components/autocomplete-thumbnail/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | // TODO: should be stateless 4 | export class SearchAutoCompleteThumbnail extends React.Component { 5 | render() { 6 | let { thumb, altText } = this.props; 7 | let img; 8 | 9 | // TODO: This needs some work 10 | if (thumb[0]['#text'] === '') { 11 | img = 'http://placehold.it/34x34' 12 | } else { 13 | img = thumb[0]['#text']; 14 | } 15 | 16 | return ( 17 | {altText} 18 | ) 19 | } 20 | } 21 | 22 | SearchAutoCompleteThumbnail.propTypes = { 23 | thumb: PropTypes.array, 24 | altText: PropTypes.string, 25 | }; 26 | 27 | export default SearchAutoCompleteThumbnail 28 | -------------------------------------------------------------------------------- /app/components/autocomplete-thumbnail/spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { SearchAutoCompleteThumbnail } from './'; 3 | 4 | let component; 5 | 6 | beforeEach(() => { 7 | component = shallow( 8 | 18 | ); 19 | }); 20 | 21 | describe('SearchAutoCompleteThumbnail component', () => { 22 | it('renders the image with the correct src', () => { 23 | expect(component.find('img')).to.have.attr('src', 'http://example.com/some-image.jpg'); 24 | }); 25 | 26 | it('renders the image with the correct alt text', () => { 27 | expect(component.find('img')).to.have.attr('alt', 'Some alt text'); 28 | }); 29 | 30 | it('if no image is present then the component should render a placeholder', () => { 31 | component = shallow( 32 | 42 | ); 43 | 44 | expect(component.find('img')).to.have.attr('src', 'http://placehold.it/34x34'); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /app/components/autocomplete/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { browserHistory, Link } from 'react-router'; 4 | import SearchAutoCompleteSection from '../autocomplete-section'; 5 | import { autocompleteTrackSelected } from '../../actions/search'; 6 | 7 | export class SearchAutoComplete extends React.Component { 8 | static PropTypes = { 9 | artists: PropTypes.array, 10 | albums: PropTypes.array, 11 | tracks: PropTypes.array, 12 | autoCompleteTrackSelected: PropTypes.func.isRequired, 13 | }; 14 | 15 | constructor(props) { 16 | super(props); 17 | 18 | this.state = { 19 | autoCompleteVisible: false, 20 | }; 21 | } 22 | 23 | componentWillReceiveProps(nextProps) { 24 | let autoCompleteVisible = false; 25 | 26 | if ( 27 | nextProps.artists.length || 28 | nextProps.tracks.length || 29 | nextProps.albums.length && 30 | nextProps.searchFocused 31 | ) { 32 | autoCompleteVisible = true; 33 | } 34 | 35 | this.setState({ 36 | autoCompleteVisible, 37 | }); 38 | } 39 | 40 | componentDidMount() { 41 | document.addEventListener('click', this.handleDocumentClick.bind(this), false); 42 | window.addEventListener('resize', this.handleDocumentResize.bind(this), false); 43 | 44 | this.handleRouteChange(); 45 | } 46 | 47 | componentWillUnmount() { 48 | document.removeEventListener('click', this.handleDocumentClick.bind(this), false); 49 | window.removeEventListener('resize', this.handleDocumentResize.bind(this), false); 50 | } 51 | 52 | handleRouteChange() { 53 | browserHistory.listen( location => { 54 | this.setState({ 55 | autoCompleteVisible: false, 56 | }); 57 | }); 58 | } 59 | 60 | handleDocumentResize() { 61 | this.setState({ 62 | autoCompleteVisible: false, 63 | }); 64 | } 65 | 66 | handleDocumentClick(e) { 67 | if ( 68 | this.state.autoCompleteVisible && 69 | e.target.className !== 'search__input' 70 | ) { 71 | this.setState({ 72 | autoCompleteVisible: false, 73 | }); 74 | } 75 | } 76 | 77 | render() { 78 | const { 79 | artists, 80 | tracks, 81 | albums 82 | } = this.props; 83 | 84 | if (this.state.autoCompleteVisible) { 85 | return ( 86 |
this.autoComplete = autoComplete } 89 | > 90 | 94 | { 99 | this.props.autocompleteTrackSelected(searchParams) 100 | } 101 | } 102 | /> 103 | 107 | 111 | 112 | 113 | {`More results for "${this.props.currentSearch}"`} 114 | 115 | 116 |
117 | ) 118 | } else { 119 | return null; 120 | } 121 | } 122 | } 123 | 124 | const mapDispatchToProps = { 125 | autocompleteTrackSelected, 126 | }; 127 | 128 | function mapStateToProps(state) { 129 | return { 130 | currentSearch: state.search.currentSearch, 131 | }; 132 | } 133 | 134 | export default connect( 135 | mapStateToProps, 136 | mapDispatchToProps, 137 | )(SearchAutoComplete) 138 | -------------------------------------------------------------------------------- /app/components/autocomplete/spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { SearchAutoComplete } from './'; 3 | import sinon from 'sinon'; 4 | 5 | let component; 6 | let onAutocompleteTrackSelectedSpy; 7 | 8 | beforeEach(() => { 9 | onAutocompleteTrackSelectedSpy = sinon.spy(); 10 | 11 | component = mount( 12 | 15 | ); 16 | }); 17 | 18 | // TODO: work out a way to test a resize event 19 | describe('SearchAutoComplete component', () => { 20 | it('renders the component if there is something to render', () => { 21 | expect(component.find('.autocomplete')).to.not.be.present(); 22 | }); 23 | 24 | it('renders the component if there is something to render', () => { 25 | component.setProps({ 26 | tracks: [{ image: [{'#text': 'http://example.com/som-img.jpg'}]}], 27 | albums: [{ image: [{'#text': 'http://example.com/som-img.jpg'}]}], 28 | artists: [{ image: [{'#text': 'http://example.com/som-img.jpg'}]}] 29 | }); 30 | 31 | expect(component.find('.autocomplete')).to.be.present(); 32 | }); 33 | 34 | it('renders the artist title correctly', () => { 35 | component.setProps({ 36 | tracks: [{ image: [{'#text': 'http://example.com/som-img.jpg'}]}], 37 | albums: [{ image: [{'#text': 'http://example.com/som-img.jpg'}]}], 38 | artists: [{ image: [{'#text': 'http://example.com/som-img.jpg'}]}] 39 | }); 40 | 41 | expect(component.find('.autocomplete-section__title').at(0)).to.have.text('Artists') 42 | }); 43 | 44 | it('renders the track title correctly', () => { 45 | component.setProps({ 46 | tracks: [{ image: [{'#text': 'http://example.com/som-img.jpg'}]}], 47 | albums: [{ image: [{'#text': 'http://example.com/som-img.jpg'}]}], 48 | artists: [{ image: [{'#text': 'http://example.com/som-img.jpg'}]}] 49 | }); 50 | 51 | expect(component.find('.autocomplete-section__title').at(1)).to.have.text('Tracks') 52 | }); 53 | 54 | it('renders the album title correctly', () => { 55 | component.setProps({ 56 | tracks: [{ image: [{'#text': 'http://example.com/som-img.jpg'}]}], 57 | albums: [{ image: [{'#text': 'http://example.com/som-img.jpg'}]}], 58 | artists: [{ image: [{'#text': 'http://example.com/som-img.jpg'}]}] 59 | }); 60 | 61 | expect(component.find('.autocomplete-section__title').at(2)).to.have.text('Albums') 62 | }); 63 | 64 | it('should hide the autocomplete if the body is clicked on', () => { 65 | component.setProps({ 66 | tracks: [{ image: [{'#text': 'http://example.com/som-img.jpg'}]}], 67 | albums: [{ image: [{'#text': 'http://example.com/som-img.jpg'}]}], 68 | artists: [{ image: [{'#text': 'http://example.com/som-img.jpg'}]}] 69 | }); 70 | expect(component.find('.autocomplete')).to.be.present(); 71 | document.body.click(); 72 | expect(component.find('.autocomplete')).to.not.be.present(); 73 | }); 74 | 75 | it('should call the callback when a track result is clicked on', () => { 76 | component.setProps({ 77 | tracks: [{ artist: 'Radiohead', name: 'Airbag', image: [{'#text': 'http://example.com/som-img.jpg'}]}], 78 | albums: [{ image: [{'#text': 'http://example.com/som-img.jpg'}]}], 79 | artists: [{ image: [{'#text': 'http://example.com/som-img.jpg'}]}] 80 | }); 81 | const track = component.find('.autocomplete-section').at(1).find('.autocomplete-section__list-item div').at(0); 82 | track.simulate('click'); 83 | expect(onAutocompleteTrackSelectedSpy).to.have.been.called; 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /app/components/autocomplete/styles.scss: -------------------------------------------------------------------------------- 1 | .autocomplete { 2 | position: fixed; 3 | top: 50px; 4 | left: calc(50% - 73px); 5 | width: 300px; 6 | border: 1px solid #000; 7 | background: #282727; 8 | z-index: 4; 9 | } 10 | 11 | .view-more { 12 | color: #fff; 13 | background: #444; 14 | text-decoration: none; 15 | display: block; 16 | padding: 7px; 17 | font-size: 15px; 18 | 19 | &__link { 20 | display: inline-block; 21 | padding-left: 7px; 22 | width: 270px; 23 | overflow: hidden; 24 | text-overflow: ellipsis; 25 | white-space: nowrap; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/components/current-track-summary/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | export class CurrentTrackSummary extends React.Component { 4 | static defaultProps = { 5 | name: 'Welcome to Tuneify', 6 | artist: 'Free streaming music', 7 | }; 8 | 9 | static propTypes = { 10 | name: PropTypes.string, 11 | artist: PropTypes.string, 12 | image: PropTypes.string, 13 | }; 14 | 15 | render() { 16 | let imageSrc; 17 | 18 | const { 19 | name, 20 | artist, 21 | image, 22 | } = this.props; 23 | 24 | return ( 25 |
26 | 32 |

{name}

33 |

{artist}

34 |
35 | ) 36 | } 37 | } 38 | 39 | export default CurrentTrackSummary 40 | 41 | -------------------------------------------------------------------------------- /app/components/current-track-summary/spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import CurrentTrackSummary from './'; 3 | 4 | describe('CurrentTrackSummary component', () => { 5 | let component; 6 | 7 | beforeEach(() => { 8 | 9 | component = shallow( 10 | 15 | ); 16 | 17 | }); 18 | 19 | 20 | it('The track name should have the correct text', () => { 21 | expect(component.find('.current-track-summary__track-name')).to.have.text('Sweet Virginia'); 22 | }); 23 | 24 | it('The track artist should have the correct text', () => { 25 | expect(component.find('.current-track-summary__artist')).to.have.text('The Rolling Stones'); 26 | }); 27 | 28 | it('The image should have the correct src', () => { 29 | expect(component.find('.current-track-summary__thumb')) 30 | .to.have.attr('src', 'http://example.com/rolling-stones.jpg'); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /app/components/current-track-summary/styles.scss: -------------------------------------------------------------------------------- 1 | .current-track-summary { 2 | padding: 10px 0 10px 5px;; 3 | float: left; 4 | overflow: hidden; 5 | width: 100%; 6 | position: relative; 7 | z-index: 2; 8 | &__thumb { 9 | float: left; 10 | margin-right: 10px; 11 | background: url('../images/summary-record.png') no-repeat; 12 | } 13 | 14 | &__track-name { 15 | margin: 8px 0 7px 0; 16 | text-overflow: ellipsis; 17 | overflow: hidden; 18 | white-space: nowrap; 19 | } 20 | 21 | &__artist { 22 | margin: 0; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/components/error-message/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function ErrorMessage(props) { 4 | return ( 5 |
6 |

7 | { props.title } 8 |

9 |

10 | Because we're a completely free service we rely other free services to provide us 11 | with the information that you should be seeing on this page. Sometimes these services aren't 12 | quite as reliable as we'd like. We're sorry about this and we are working on a solution. 13 |

14 |
15 | ); 16 | } 17 | 18 | ErrorMessage.defaultProps = { 19 | title: "Sorry, We can't find what you're looking for" 20 | }; 21 | 22 | export default ErrorMessage; 23 | -------------------------------------------------------------------------------- /app/components/error-message/styles.scss: -------------------------------------------------------------------------------- 1 | .error-message { 2 | &__heading { 3 | margin-top: 0; 4 | color: red; 5 | font-weight: bold; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/components/home/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { Link } from 'react-router'; 3 | import { connect } from 'react-redux'; 4 | import classNames from 'classNames'; 5 | import { 6 | getTopArtists, 7 | } from '../../actions/homepage'; 8 | 9 | export class Home extends React.Component { 10 | static PropTypes = { 11 | dispatch: PropTypes.func.isRequired, 12 | topArtistData: PropTypes.array, 13 | topArtistDataError: PropTypes.string, 14 | }; 15 | 16 | constructor() { 17 | super(); 18 | this.imageLoadCount = 0; 19 | 20 | this.state = { 21 | imagesLoaded: false, 22 | } 23 | } 24 | 25 | // only call for data once the page 26 | // has rendered on the client as lastfm's 27 | // rate limiting allows 5 requests per second 28 | // per originating IP adress averaged over a 5 minute period 29 | componentDidMount() { 30 | this.props.dispatch( 31 | getTopArtists() 32 | ); 33 | } 34 | 35 | imageLoaded() { 36 | this.imageLoadCount++; 37 | if (this.imageLoadCount === 50) { 38 | this.setState({ 39 | imagesLoaded: true 40 | }); 41 | } 42 | } 43 | 44 | render() { 45 | const { topArtistData, topArtistDataError } = this.props; 46 | if (topArtistData) { 47 | // sometimes lastfm returns successfully but with an empty 48 | // json object. To counter this the reducer has a case for 49 | // this an returns and error property when it does happen 50 | if (topArtistData.error) { 51 | return ( 52 |

No data found.

53 | ) 54 | } else { 55 | 56 | const classes = classNames( 57 | 'top-artist__name', 58 | this.state.imagesLoaded ? 'top-artist__name--visible' : '', 59 | ); 60 | return ( 61 |
62 |
    63 | { 64 | topArtistData.artistData.map( 65 | (artist, i) => { 66 | return ( 67 |
  • 68 | 72 | {this.imageLoaded()}} 74 | className="top-artist__image" 75 | src={artist.image[3]['#text']} 76 | height="230" 77 | width="230" 78 | /> 79 | {artist.name} 80 | 81 |
  • 82 | ); 83 | } 84 | ) 85 | } 86 |
87 |
88 | ); 89 | } 90 | } else if(topArtistDataError) { 91 | return( 92 |

No data found.

93 | ); 94 | } else { 95 | return ( 96 |
97 | ); 98 | } 99 | } 100 | } 101 | 102 | function mapStateToProps(state) { 103 | return { 104 | topArtistData: state.topArtists.topArtistData, 105 | topArtistDataError: state.topArtists.topArtistDataError, 106 | } 107 | } 108 | 109 | export default connect(mapStateToProps)(Home); 110 | -------------------------------------------------------------------------------- /app/components/home/spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Home } from './'; 3 | 4 | describe('Home component', () => { 5 | let component; 6 | 7 | beforeEach(() => { 8 | component = shallow( 9 | {}} 11 | topArtistData={{ 12 | artistData: [{ 13 | name: "Radiohead", 14 | image: [ 15 | {"#text": 'http://example.com/thom.jpg'}, 16 | {"#text": 'http://example.com/thom.jpg'}, 17 | {"#text": 'http://example.com/thom.jpg'}, 18 | {"#text": 'http://example.com/thom.jpg'}, 19 | ], 20 | }] 21 | } 22 | } 23 | /> 24 | ); 25 | }); 26 | 27 | it('renders the topArtist list wrapper', () => { 28 | expect(component.find('.top-artist')).to.be.present(); 29 | }); 30 | 31 | it('renders the topArtist list', () => { 32 | expect(component.find('.top-artist__list').find('.top-artist__list-item')).to.be.present(); 33 | }); 34 | 35 | it('renders the image for the topArtist list item', () => { 36 | expect(component.find('.top-artist__image')).to.have.attr('src', 'http://example.com/thom.jpg') 37 | }); 38 | 39 | it('renders the image for the topArtist name', () => { 40 | expect(component.find('.top-artist__name')).to.have.text('Radiohead') 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /app/components/home/styles.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/breakpoints"; 2 | 3 | .top-artist { 4 | &__list-item { 5 | position: relative; 6 | border-bottom: none; 7 | display:inline-block; 8 | border: 3px solid #111; 9 | border-right: none; 10 | border-bottom: none; 11 | cursor: pointer; 12 | width: 33.3%; 13 | 14 | @include breakpoint(1024px) { 15 | width: 33.3%; 16 | } 17 | 18 | @include breakpoint(1476px) { 19 | width: 25%; 20 | } 21 | 22 | @include breakpoint(1724px) { 23 | width: 16.6%; 24 | } 25 | 26 | 27 | img { 28 | width: 100%; 29 | height: auto; 30 | vertical-align: bottom; 31 | border-radius: 3px; 32 | } 33 | } 34 | 35 | &__link { 36 | display: block; 37 | z-index: 2; 38 | position: relative; 39 | 40 | &:hover { 41 | &:before { 42 | content: ""; 43 | position: absolute; 44 | top: 0; 45 | left: 0; 46 | background: rgba(0,0,0,0.5); 47 | width: 100%; 48 | height: 100%; 49 | z-index: 1; 50 | } 51 | } 52 | } 53 | 54 | &__name { 55 | position: absolute; 56 | bottom: 0; 57 | left: 0; 58 | background: rgba(0,0,0,0.5); 59 | color: #fff; 60 | padding: 10px 5px; 61 | z-index: 2; 62 | width: 100%; 63 | display: none; 64 | 65 | &--visible { 66 | display:block; 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /app/components/login/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { browserHistory } from 'react-router'; 4 | import { loggedIn, loggedOut } from '../../actions/auth'; 5 | 6 | export class Login extends React.Component { 7 | static PropTypes = { 8 | loggedIn: PropTypes.func.isRequired, 9 | authenticated: PropTypes.bool.isRequired 10 | }; 11 | 12 | constructor(props) { 13 | super(props); 14 | // if on page load there is a valid token then dispatch 15 | // an action so that the Login component knows to show 16 | // the correct state 17 | if (props.authService.isLoggedIn()) { 18 | props.loggedIn(); 19 | } 20 | } 21 | 22 | authenticated() { 23 | this.props.loggedIn(); 24 | this.showProfile(); 25 | } 26 | 27 | logOut() { 28 | this.props.loggedOut(); 29 | this.props.authService.logOut(); 30 | browserHistory.push('/'); 31 | } 32 | 33 | showProfile() { 34 | const profile = this.props.authService.getProfileDetails(); 35 | 36 | return ( 37 |
38 | 39 | {profile.name} 40 | 41 |
    42 |
  • {this.logOut()}}>Log out
  • 43 |
44 |
45 | ) 46 | } 47 | 48 | render() { 49 | return ( 50 |
51 | { 52 | this.props.authenticated || this.props.authService.isLoggedIn() ? 53 | this.showProfile() : 54 | 68 | } 69 |
70 | ) 71 | } 72 | } 73 | 74 | 75 | function mapStateToProps(state) { 76 | return { 77 | authenticated: state.authenticated, 78 | } 79 | } 80 | 81 | const mapDispatchToProps = { 82 | loggedIn, 83 | loggedOut, 84 | }; 85 | 86 | export default connect( 87 | mapStateToProps, 88 | mapDispatchToProps, 89 | )(Login); 90 | -------------------------------------------------------------------------------- /app/components/login/styles.scss: -------------------------------------------------------------------------------- 1 | .login { 2 | position: absolute; 3 | top: 10px; 4 | right: 10px; 5 | 6 | &__sign-in { 7 | margin-top: 5px; 8 | } 9 | 10 | } 11 | 12 | .profile { 13 | font-weight: bold; 14 | font-size: 13px; 15 | background: #444; 16 | border: 1px solid #000; 17 | border-radius: 3px; 18 | padding: 5px 15px 5px 10px; 19 | overflow: hidden; 20 | cursor: pointer; 21 | 22 | &__options { 23 | display: none; 24 | clear: left; 25 | padding-top: 10px; 26 | li { 27 | padding: 5px 2px 5px 6px; 28 | &:hover { 29 | background: #282727; 30 | } 31 | } 32 | } 33 | 34 | &:hover { 35 | .profile__options { 36 | display: block; 37 | } 38 | } 39 | 40 | 41 | &__image { 42 | float: left; 43 | width: 30px; 44 | margin-right: 10px; 45 | } 46 | 47 | &__name { 48 | float: left; 49 | margin-top: 7px; 50 | } 51 | 52 | &__show { 53 | margin: 6px 0 0 5px; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/components/modal/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import classNames from 'classNames'; 3 | import { connect } from 'react-redux'; 4 | import SavePlaylistModal from './modals/save-playlist-modal'; 5 | import CreatePlaylistModal from './modals/create-playlist-modal'; 6 | import ArtistBioModal from './modals/artist-bio'; 7 | import { hideModal } from '../../actions/modal'; 8 | 9 | export class Modal extends React.Component { 10 | static PropTypes = { 11 | modalVisible: PropTypes.bool.isRequired, 12 | modalType: PropTypes.string.isRequired, 13 | }; 14 | 15 | constructor(props) { 16 | super(props); 17 | 18 | this.hideModal = this.hideModal.bind(this); 19 | } 20 | 21 | getModalContent() { 22 | switch(this.props.modalType) { 23 | case 'savePlaylist' : 24 | return 25 | case 'createPlaylist' : 26 | return 27 | case 'full-bio' : 28 | return 29 | default: 30 | return null 31 | } 32 | } 33 | 34 | // Hides the modal and the overlay 35 | hideModal() { 36 | this.props.hideModal(); 37 | } 38 | 39 | render() { 40 | const modalContent = this.getModalContent(); 41 | 42 | const classes = classNames( 43 | 'modal', 44 | this.props.modalType ? `modal--${this.props.modalType}` : '', 45 | this.props.modalVisible ? 'modal--visible' : '', 46 | ); 47 | 48 | return ( 49 |
50 |
54 |
55 |
56 |
57 | {modalContent} 58 |
62 | 63 |
64 |
65 |
66 |
67 | ) 68 | } 69 | } 70 | 71 | function mapStateToProps(state) { 72 | return { 73 | modalVisible: state.modal.modalVisible, 74 | modalType: state.modal.modalType, 75 | } 76 | } 77 | 78 | const mapDispatchToProps = { 79 | hideModal: hideModal, 80 | } 81 | 82 | export default connect( 83 | mapStateToProps, 84 | mapDispatchToProps, 85 | )(Modal); 86 | -------------------------------------------------------------------------------- /app/components/modal/modals/artist-bio/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | export class ArtistBioModal extends React.Component { 5 | createMarkup(bio) { 6 | const formatted = '

' + bio.replace(/\n([ \t]*\n)+/g, '

').replace('\n', '
') + '

'; 7 | const linkRemoved = formatted.replace('Read more on Last.fm', ''); 8 | const licenceRemoved = linkRemoved.replace('. User-contributed text is available under the Creative Commons By-SA License; additional terms may apply.', ''); 9 | return {__html: licenceRemoved}; 10 | } 11 | 12 | render() { 13 | const { 14 | name, 15 | bio, 16 | image 17 | } = this.props.artistPageData; 18 | 19 | return ( 20 |
21 |

22 | {name} 23 |

24 |
25 | {name} 32 |
36 |
37 |
38 | ) 39 | } 40 | } 41 | 42 | 43 | function mapStateToProps(state) { 44 | return { 45 | artistPageData: state.artistPage.artistPageData, 46 | } 47 | } 48 | 49 | 50 | export default connect( 51 | mapStateToProps 52 | )(ArtistBioModal); 53 | 54 | -------------------------------------------------------------------------------- /app/components/modal/modals/artist-bio/spec.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesfiltness/tuneify/04250a1e8e292e1faa5eb6be65f4fefd94992225/app/components/modal/modals/artist-bio/spec.js -------------------------------------------------------------------------------- /app/components/modal/modals/artist-bio/styles.scss: -------------------------------------------------------------------------------- 1 | .artist-bio { 2 | &__img { 3 | float: left; 4 | margin: 0 20px 5px 0; 5 | } 6 | 7 | &__content { 8 | font-size: 14px; 9 | line-height: 1.3; 10 | height: 500px; 11 | overflow: auto; 12 | padding-right: 10px; 13 | 14 | p:first-of-type { 15 | margin-top: 0; 16 | } 17 | 18 | &::-webkit-scrollbar { 19 | background: #282727; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/components/modal/modals/create-playlist-modal/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { SavePlaylistModal } from '../save-playlist-modal'; 4 | import { createPlaylist } from '../../../../actions/playlists'; 5 | 6 | // HOC seemed like the wrong decision here... 7 | // TODO: look in to using HOC if it can be used appropriately here 8 | export class CreatePlaylistModal extends SavePlaylistModal { 9 | savePlaylist() { 10 | if (!this.input.value.length) { 11 | this.showErrorState(); 12 | } else { 13 | this.props.createPlaylist( 14 | this.input.value, 15 | [] 16 | ); 17 | } 18 | } 19 | } 20 | 21 | const mapStateToProps = (state) => { 22 | return { 23 | playQueue: state.playQueue.playQueueTracks, 24 | creatingUserPlaylist: state.playlists.creatingUserPlaylist, 25 | } 26 | } 27 | 28 | const mapDispatchToProps = { 29 | createPlaylist 30 | } 31 | 32 | export default connect( 33 | mapStateToProps, 34 | mapDispatchToProps 35 | )(CreatePlaylistModal); 36 | -------------------------------------------------------------------------------- /app/components/modal/modals/create-playlist-modal/spec.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesfiltness/tuneify/04250a1e8e292e1faa5eb6be65f4fefd94992225/app/components/modal/modals/create-playlist-modal/spec.js -------------------------------------------------------------------------------- /app/components/modal/modals/save-playlist-modal/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import classNames from 'classNames'; 4 | import { createPlaylist } from '../../../../actions/playlists'; 5 | 6 | export class SavePlaylistModal extends React.Component { 7 | static PropTypes = { 8 | playQueue: PropTypes.array.isRequired, 9 | text: PropTypes.string.isRequired, 10 | createPlaylist: PropTypes.func.isRequired, 11 | creatingUserPlaylist: PropTypes.bool.isRequired, 12 | }; 13 | 14 | constructor(props) { 15 | super(props); 16 | 17 | this.state = { 18 | saveError: false, 19 | inputVal: '', 20 | }; 21 | 22 | this.savePlaylist = this.savePlaylist.bind(this); 23 | this.updateFieldState = this.updateFieldState.bind(this); 24 | 25 | } 26 | 27 | componentWillReceiveProps(nextProps) { 28 | if (nextProps.creatingUserPlaylist) { 29 | this.setState({ 30 | inputVal: '', 31 | }); 32 | } 33 | } 34 | 35 | savePlaylist() { 36 | if (!this.input.value.length) { 37 | this.showErrorState(); 38 | } else { 39 | this.props.createPlaylist( 40 | this.input.value, 41 | this.props.playQueue 42 | ); 43 | } 44 | } 45 | 46 | showErrorState() { 47 | this.setState({ 48 | saveError: true, 49 | }) 50 | } 51 | 52 | showErrorMessage() { 53 | return this.state.saveError ? 54 |

55 | Please give your playlist a name 56 |

: 57 | null 58 | } 59 | 60 | updateFieldState() { 61 | let saveError = true; 62 | 63 | if (this.input.value.length) { 64 | saveError = false; 65 | } 66 | 67 | this.setState({ 68 | saveError, 69 | inputVal: this.input.value, 70 | }); 71 | } 72 | 73 | render() { 74 | const inputClasses = classNames( 75 | 'dialog__input', 76 | this.state.saveError ? 'dialog__input--error' : '', 77 | ); 78 | 79 | const spinnerClasses = classNames( 80 | 'dialog__spinner', 81 | this.props.creatingUserPlaylist ? 'dialog__spinner--visible' : '', 82 | ); 83 | 84 | return ( 85 |
86 |

87 | {this.props.text} 88 |

89 |
90 | {this.showErrorMessage()} 91 | { this.input = input }} 97 | className={inputClasses} 98 | /> 99 | 105 |
106 | 107 | Loading... 108 |
109 |
110 |
111 | ) 112 | } 113 | } 114 | 115 | const mapStateToProps = (state) => { 116 | return { 117 | playQueue: state.playQueue.playQueueTracks, 118 | creatingUserPlaylist: state.playlists.creatingUserPlaylist, 119 | } 120 | } 121 | 122 | const mapDispatchToProps = { 123 | createPlaylist 124 | } 125 | 126 | export default connect( 127 | mapStateToProps, 128 | mapDispatchToProps 129 | )(SavePlaylistModal); 130 | -------------------------------------------------------------------------------- /app/components/modal/modals/save-playlist-modal/spec.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesfiltness/tuneify/04250a1e8e292e1faa5eb6be65f4fefd94992225/app/components/modal/modals/save-playlist-modal/spec.js -------------------------------------------------------------------------------- /app/components/modal/spec.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesfiltness/tuneify/04250a1e8e292e1faa5eb6be65f4fefd94992225/app/components/modal/spec.js -------------------------------------------------------------------------------- /app/components/modal/styles.scss: -------------------------------------------------------------------------------- 1 | .modal { 2 | display: none; 3 | 4 | &__overlay { 5 | z-index: 4; 6 | position: fixed; 7 | top: 0; 8 | left: 0; 9 | width: 100%; 10 | height: 100%; 11 | background: rgba(0, 0, 0, 0.7); 12 | } 13 | 14 | &__dialog { 15 | z-index: 5; 16 | position: fixed; 17 | width: 400px; 18 | background: #282727; 19 | top: calc(50% - 50px); 20 | left: calc(50% - 200px); 21 | -webkit-box-shadow: 0px 0px 45px 3px rgba(0,0,0,0.95); 22 | -moz-box-shadow: 0px 0px 45px 3px rgba(0,0,0,0.95); 23 | box-shadow: 0px 0px 45px 3px rgba(0,0,0,0.95); 24 | } 25 | 26 | &--visible { 27 | display: block; 28 | } 29 | } 30 | 31 | .dialog { 32 | &__heading { 33 | background: #444; 34 | margin: 0; 35 | padding: 10px; 36 | text-transform: uppercase; 37 | font-size: 15px; 38 | } 39 | 40 | &__content { 41 | padding: 15px; 42 | } 43 | 44 | &__close { 45 | position: absolute; 46 | top: 9px; 47 | right: 11px; 48 | } 49 | 50 | &__input { 51 | /* TODO: these styles should be a mixin for input styles */ 52 | background: #444; 53 | border: 1px solid #000; 54 | padding: 8px; 55 | color: #ddd; 56 | width: 200px; 57 | margin-right: 10px; 58 | width: 260px; 59 | 60 | &--error { 61 | border: 1px solid red; 62 | } 63 | } 64 | 65 | &__button { 66 | background: #444; 67 | padding: 8px; 68 | border: none; 69 | color: #fff; 70 | border: 1px solid #000; 71 | } 72 | 73 | &__error-text { 74 | color: red; 75 | margin: 0 0 15px 0; 76 | } 77 | 78 | &__spinner { 79 | position: absolute; 80 | top: 61px; 81 | right: 15px; 82 | display: none; 83 | color: #ccc; 84 | 85 | &--visible { 86 | display: block; 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /app/components/page-not-found/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router'; 3 | 4 | function PageNotFound() { 5 | return ( 6 |
7 |

8 | Page not found 9 |

10 |

Oops, we're not sure how you ended up here. Go back to the homepage.

11 |
12 | ); 13 | } 14 | 15 | export default PageNotFound; 16 | -------------------------------------------------------------------------------- /app/components/page-not-found/spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PageNotFound from './'; 3 | 4 | describe('PageNotFound component', () => { 5 | it('renders the correct page not found text', () => { 6 | const pageNotFound = shallow(); 7 | expect(pageNotFound.find('h3')).to.have.text('Page not found'); 8 | }); 9 | }); 10 | 11 | -------------------------------------------------------------------------------- /app/components/page-not-found/styles.scss: -------------------------------------------------------------------------------- 1 | .page-not-found { 2 | &__heading { 3 | margin-top: 0; 4 | font-weight: bold; 5 | } 6 | 7 | &__link { 8 | color: #19b736; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /app/components/play-queue-tools/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import classNames from 'classNames'; 4 | import auth0Service from '../../utils/auth0-service'; 5 | import { 6 | trashPlayQueue, 7 | shuffle, 8 | savePlayList, 9 | repeat, 10 | } from '../../actions/play-queue'; 11 | import { 12 | loggedIn, 13 | loggedOut 14 | } from '../../actions/auth'; 15 | 16 | const authService = new auth0Service(); 17 | 18 | export class PlayQueueTools extends React.Component { 19 | static PropTypes = { 20 | shuffle: PropTypes.bool.isRequired, 21 | repeat: PropTypes.bool.isRequired, 22 | onSavePlayList: PropTypes.func.isRequired, 23 | onShuffle: PropTypes.func.isrequired, 24 | onRepeat: PropTypes.func.isRequired, 25 | onTrashPlayQueue: PropTypes.func.isRequired, 26 | playQueueTracks: PropTypes.array.isRequired, 27 | }; 28 | 29 | constructor(props) { 30 | super(props); 31 | 32 | this.savePlaylist = this.savePlaylist.bind(this); 33 | } 34 | 35 | savePlaylist() { 36 | if (this.props.playQueueTracks.length) { 37 | if (!authService.isLoggedIn()) { 38 | this.props.loggedOut(); 39 | authService.authenticate(() => { 40 | this.props.loggedIn(); 41 | this.props.onSavePlayList(); 42 | }) 43 | } else { 44 | this.props.onSavePlayList(); 45 | } 46 | } 47 | } 48 | 49 | render() { 50 | const shuffleState = this.props.shuffle ? 'on' : 'off'; 51 | const repeatState = this.props.repeat ? 'on' : 'off'; 52 | const shuffleClasses = classNames( 53 | 'play-queue-tools__tool fa fa-random', 54 | 'play-queue-tools__shuffle', 55 | `play-queue-tools__shuffle--${shuffleState}`, 56 | ); 57 | 58 | const repeatClasses = classNames( 59 | 'play-queue-tools__tool fa fa-repeat', 60 | 'play-queue-tools__repeat', 61 | `play-queue-tools__repeat--${repeatState}`, 62 | ); 63 | 64 | const saveClasses = classNames( 65 | 'play-queue-tools__tool', 66 | 'play-queue-tools__save', 67 | !this.props.playQueueTracks.length ? 'play-queue-tools__save--disabled' : '', 68 | 'fa fa-save', 69 | ) 70 | 71 | return ( 72 |
    73 |
  • 78 |
  • 83 |
  • 88 |
  • 93 |
94 | ); 95 | } 96 | } 97 | 98 | const mapStateToProps = (state) => { 99 | return { 100 | shuffle: state.playQueue.shuffle, 101 | repeat: state.playQueue.repeat, 102 | playQueueTracks: state.playQueue.playQueueTracks, 103 | authenticated: state.authenticated, 104 | } 105 | } 106 | 107 | const mapDispatchToProps = { 108 | onShuffle: shuffle, 109 | onRepeat: repeat, 110 | onTrashPlayQueue: trashPlayQueue, 111 | onSavePlayList: savePlayList, 112 | loggedIn, 113 | loggedOut, 114 | } 115 | 116 | export default connect( 117 | mapStateToProps, 118 | mapDispatchToProps 119 | )(PlayQueueTools); 120 | -------------------------------------------------------------------------------- /app/components/play-queue-tools/spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { PlayQueueTools } from './'; 3 | import sinon from 'sinon'; 4 | 5 | let component; 6 | 7 | beforeEach(() => { 8 | component = shallow( 9 | 10 | ); 11 | }); 12 | 13 | describe.only('PlayQueueTools component', () => { 14 | it('renders the wrapping ul', () => { 15 | expect(component.find('.play-queue-tools')).to.be.present(); 16 | }); 17 | 18 | it('renders the save button', () => { 19 | expect(component.find('.play-queue-tools__tool.fa-save')).to.be.present(); 20 | }); 21 | 22 | it('renders the repeat button with the correct classes', () => { 23 | expect(component.find('.play-queue-tools__tool.fa.fa-repeat')).to.be.present(); 24 | }); 25 | 26 | it('render the repeat button with an off class if repeat is not passed as a prop', () => { 27 | expect( 28 | component.find('.play-queue-tools__repeat')) 29 | .to.have.className('play-queue-tools__repeat--off'); 30 | }); 31 | 32 | it('render the repeat button with an off class if repeat is not passed as a prop', () => { 33 | component = shallow( 34 | 35 | ); 36 | 37 | expect( 38 | component.find('.play-queue-tools__repeat')) 39 | .to.have.className('play-queue-tools__repeat--on'); 40 | }); 41 | 42 | it('render the shuffle button with an off class if shuffle is not passed as a prop', () => { 43 | expect( 44 | component.find('.play-queue-tools__shuffle')) 45 | .to.have.className('play-queue-tools__shuffle--off'); 46 | }); 47 | 48 | it('render the shuffle button with an on class if shuffle is passed as a prop', () => { 49 | component = shallow( 50 | 51 | ); 52 | 53 | expect( 54 | component.find('.play-queue-tools__shuffle')) 55 | .to.have.className('play-queue-tools__shuffle--on'); 56 | }); 57 | 58 | it('calls the onTrashPlayQueue callback when trash button is clicked on', () => { 59 | const callback = sinon.spy(); 60 | 61 | component = shallow( 62 | 65 | ); 66 | 67 | const trash = component.find('.fa-trash'); 68 | trash.simulate('click'); 69 | expect(callback).to.have.been.called; 70 | }); 71 | 72 | it('calls the onShuffle prop when the shuffle button is clicked on', () => { 73 | const callback = sinon.spy(); 74 | 75 | component = shallow( 76 | 79 | ); 80 | 81 | const shuffle = component.find('.play-queue-tools__shuffle'); 82 | shuffle.simulate('click'); 83 | // shuffle isn't called with any args 84 | expect(callback).to.have.been.called; 85 | }); 86 | 87 | it('calls the onRepeat prop when the repeat button is clicked on', () => { 88 | const callback = sinon.spy(); 89 | 90 | component = shallow( 91 | 94 | ); 95 | 96 | const repeat = component.find('.play-queue-tools__repeat'); 97 | repeat.simulate('click'); 98 | // shuffle isn't called with any args 99 | expect(callback).to.have.been.called; 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /app/components/play-queue-tools/styles.scss: -------------------------------------------------------------------------------- 1 | .play-queue-tools { 2 | margin: 6px 0 0 0; 3 | position: fixed; 4 | bottom: 10px; 5 | 6 | &__tool { 7 | width: 43px; 8 | height: 15px; 9 | display:inline-block; 10 | background-position: center; 11 | background-repeat: no-repeat; 12 | vertical-align:middle; 13 | cursor:pointer; 14 | 15 | &:first-of-type { 16 | margin-left: 10px; 17 | } 18 | } 19 | 20 | &__shuffle, &__repeat { 21 | &--on { 22 | color: #19b736; 23 | } 24 | } 25 | 26 | &__save { 27 | &--disabled { 28 | color: #bbb; 29 | cursor: default; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/components/play-queue/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import classNames from 'classNames'; 3 | import { connect } from 'react-redux'; 4 | import { 5 | playQueueTrackSelected, 6 | removeTrackFromQueue 7 | } from '../../actions/play-queue'; 8 | 9 | export class PlayQueue extends React.Component { 10 | static PropTypes = { 11 | playQueueTrackSelected: PropTypes.func.isRequired, 12 | removeTrackFromQueue: PropTypes.func.isRequired, 13 | playQueueCurrentIndex: PropTypes.number, 14 | playQueueTracks: PropTypes.bool.array, 15 | } 16 | 17 | constructor() { 18 | super(); 19 | 20 | this.currentScrollTop = 0; 21 | 22 | this.state = { 23 | renderPlayQueue: false, 24 | }; 25 | } 26 | 27 | componentWillMount(nextProps) { 28 | this.shouldRenderPlayQueue(this.props); 29 | } 30 | 31 | componentWillReceiveProps(nextProps) { 32 | this.shouldRenderPlayQueue(nextProps); 33 | this.savePlayQueueToLocalStorage(nextProps.tracks); 34 | 35 | if ( 36 | this.props.playQueueCurrentIndex !== 37 | nextProps.playQueueCurrentIndex 38 | ) { 39 | this.scrollToCurrentIndex(nextProps); 40 | } 41 | } 42 | 43 | savePlayQueueToLocalStorage(tracks) { 44 | console.log('new tracks', tracks); 45 | } 46 | 47 | scrollToCurrentIndex(props) { 48 | let move; 49 | 50 | const track = props.playQueueCurrentIndex; 51 | const trackEl = this.playQueueWrap.querySelectorAll('.play-queue__list-item')[0]; 52 | const trackElHeight = trackEl.getBoundingClientRect().height; 53 | const trackPos = track * trackElHeight; 54 | const trackPosBottomEdge = trackPos + trackElHeight; 55 | const playQueueHeight = this.playQueueWrap.getBoundingClientRect().height; 56 | const scrollTopPos = this.playQueueWrap.scrollTop; 57 | 58 | if (trackPosBottomEdge > (playQueueHeight + scrollTopPos)) { 59 | move = trackPosBottomEdge - (playQueueHeight + scrollTopPos); 60 | this.playQueueWrap.scrollTop = scrollTopPos + move; 61 | } else if (trackPos < scrollTopPos) { 62 | move = scrollTopPos - trackPos; 63 | this.playQueueWrap.scrollTop = scrollTopPos - move; 64 | } 65 | } 66 | 67 | shouldRenderPlayQueue(props) { 68 | let renderPlayQueue; 69 | 70 | if (props.tracks && props.tracks.length) { 71 | renderPlayQueue = true; 72 | } else { 73 | renderPlayQueue = false; 74 | } 75 | 76 | this.setState({ 77 | renderPlayQueue, 78 | }); 79 | } 80 | 81 | onRemoveTrackFromQueue(event, index) { 82 | event.stopPropagation(); 83 | this.props.removeTrackFromQueue(index); 84 | } 85 | 86 | render() { 87 | if (this.state.renderPlayQueue) { 88 | const currentIndex = 89 | this.props.playQueueCurrentIndex ? 90 | this.props.playQueueCurrentIndex : 0; 91 | return ( 92 |
this.playQueueWrap = playQueueWrap } 95 | > 96 |
    97 | { 98 | this.props.tracks.map((track, i) => { 99 | const selected = currentIndex === i ? 100 | 'play-queue__list-item--selected' : 101 | null; 102 | 103 | const classes = classNames( 104 | 'play-queue__list-item', 105 | selected, 106 | ); 107 | return ( 108 |
  • {this.props.playQueueTrackSelected(track, i)}} 111 | className={classes} 112 | > 113 | 114 | {track.artist} 115 | 116 | 117 | {track.name} 118 | 119 | { 122 | this.onRemoveTrackFromQueue(event, i) 123 | } 124 | } 125 | className="play-queue__remove-track"> 126 | 127 | 128 |
  • 129 | ) 130 | }) 131 | } 132 |
133 |
134 | ); 135 | } else { 136 | return
137 | } 138 | } 139 | } 140 | 141 | 142 | function mapStateToProps(state) { 143 | return { 144 | playQueueCurrentIndex: state.playQueue.playQueueCurrentIndex, 145 | playQueueTracks: state.playQueue.playQueueTracks, 146 | } 147 | } 148 | 149 | const mapDispatchToProps = { 150 | playQueueTrackSelected, 151 | removeTrackFromQueue, 152 | }; 153 | 154 | export default connect( 155 | mapStateToProps, 156 | mapDispatchToProps 157 | )(PlayQueue); 158 | -------------------------------------------------------------------------------- /app/components/play-queue/spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { PlayQueue } from './'; 3 | import sinon from 'sinon'; 4 | 5 | let component; 6 | let onPlayQueueTrackSelectedSpy; 7 | let onRemoveTrackFromQueueSpy; 8 | 9 | const tracks = [ 10 | { 11 | "name": "Airbag", 12 | "image": "http://google.com", 13 | "artist": "Radiohead", 14 | }, 15 | { 16 | "name": "Paranoid Android", 17 | "image": "http://google.com", 18 | "artist": "Radiohead", 19 | } 20 | ]; 21 | 22 | beforeEach(() => { 23 | onPlayQueueTrackSelectedSpy = sinon.spy(); 24 | onRemoveTrackFromQueueSpy = sinon.spy(); 25 | 26 | component = mount( 27 | 32 | ); 33 | }); 34 | 35 | describe('PlayQueue component', () => { 36 | it('renders the playQueue placeholder if no tracks are provided', () => { 37 | component = shallow( 38 | 39 | ); 40 | 41 | expect(component.find('.play-queue__placeholder')).to.be.present(); 42 | }); 43 | 44 | it('renders the PlayQueue wrapper', () => { 45 | expect(component.find('.play-queue')).to.be.present(); 46 | }); 47 | 48 | it('renders the PlayQueue list', () => { 49 | expect(component.find('.play-queue__list')).to.be.present(); 50 | }); 51 | 52 | it('renders the correct amount of tracks', () => { 53 | expect(component.find('.play-queue__list-item')).to.have.length(2); 54 | }); 55 | 56 | it('renders the correct track with a selected class', () => { 57 | expect(component.find('.play-queue__list-item').at(0)).to.have.className('play-queue__list-item--selected'); 58 | }); 59 | 60 | it('renders the artist with the correct text', () => { 61 | const trackOne = component.find('.play-queue__list-item').at(0); 62 | expect(trackOne.find('.play-queue__artist')).to.have.text('Radiohead'); 63 | }); 64 | 65 | it('renders the trackName with the correct text', () => { 66 | const trackOne = component.find('.play-queue__list-item').at(0); 67 | expect(trackOne.find('.play-queue__track')).to.have.text('Airbag'); 68 | }); 69 | 70 | it('renders the remove track icon with the correct text', () => { 71 | const trackOne = component.find('.play-queue__list-item').at(0); 72 | expect(trackOne.find('.play-queue__remove-track')).to.be.present(); 73 | }); 74 | 75 | it('when the remove track button is clicked the callback should be called with the correct args', () => { 76 | const trackOne = component.find('.play-queue__list-item').at(0); 77 | trackOne.find('.play-queue__remove-track').simulate('click'); 78 | expect(onRemoveTrackFromQueueSpy).to.have.been.calledWith(0); 79 | }); 80 | 81 | it('when a track is clicked on the onPlayQueueTrackSelected callback should be called', () => { 82 | const trackTwo = component.find('.play-queue__list-item').at(1); 83 | trackTwo.simulate('click'); 84 | expect(onPlayQueueTrackSelectedSpy).to.have.been.calledWith( 85 | { 86 | "@attr": { rank: "2" }, 87 | artist: { name: "Radiohead" }, 88 | name: "Paranoid Android" 89 | }, 1); 90 | }); 91 | 92 | it('when playQueueCurrentIndex prop is updated the current track should be updated', () => { 93 | component.setProps({ playQueueCurrentIndex: 1 }) 94 | const trackTwo = component.find('.play-queue__list-item').at(1); 95 | expect(trackTwo).to.have.className('play-queue__list-item--selected'); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /app/components/play-queue/styles.scss: -------------------------------------------------------------------------------- 1 | .play-queue { 2 | max-height: calc(100% - 450px); 3 | overflow: auto; 4 | top: 0; 5 | position: relative; 6 | padding: 0px 0px 0px 5px; 7 | border-top: 0px solid #333; 8 | border-bottom: 0px solid #333; 9 | transition: top 2s ease 0s; 10 | 11 | &::-webkit-scrollbar { 12 | width:11px; 13 | background: #282727; 14 | } 15 | 16 | &::-webkit-scrollbar:horizontal { 17 | height:11px; 18 | } 19 | 20 | &::-webkit-scrollbar-thumb { 21 | border:2px solid transparent; 22 | background-color:#505050; 23 | background-clip:content-box; 24 | -webkit-border-radius:7px; 25 | -moz-border-radius:7px; 26 | border-radius:7px; 27 | } 28 | 29 | &::-webkit-scrollbar-corner { 30 | background-color:#000000; 31 | } 32 | 33 | &__placeholder { 34 | height: calc(100% - 450px); 35 | } 36 | 37 | &__list { 38 | margin: 0; 39 | margin-right: 2px; 40 | } 41 | 42 | &__list-item { 43 | padding: 8px 10px 7px 4px; 44 | font-size: 12px; 45 | line-height: 1; 46 | cursor: pointer; 47 | border-bottom: 1px solid #333; 48 | position: relative; 49 | 50 | &:hover { 51 | background: #111; 52 | } 53 | 54 | &--selected { 55 | background: #111; 56 | color: #11dbd4; 57 | } 58 | } 59 | 60 | &__artist { 61 | margin-right: 10px; 62 | width: 100px; 63 | display: inline-block; 64 | white-space: nowrap; 65 | overflow: hidden; 66 | text-overflow: ellipsis; 67 | } 68 | 69 | &__track { 70 | width: 158px; 71 | display: inline-block; 72 | white-space: nowrap; 73 | overflow: hidden; 74 | text-overflow: ellipsis; 75 | } 76 | 77 | &__remove-track { 78 | display:none; 79 | position: absolute; 80 | right: 10px; 81 | top: 0; 82 | line-height: 29px; 83 | 84 | .play-queue__list-item:hover & { 85 | display:block; 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /app/components/playlist-image/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import classNames from 'classNames'; 4 | 5 | export default class PlaylistImage extends React.Component { 6 | static propTypes = { 7 | tracks: PropTypes.array.isRequired, 8 | image: PropTypes.string, 9 | }; 10 | 11 | renderImages(imageArr) { 12 | return imageArr.map((image, i) => { 13 | return ( 14 | Playlist Image 21 | ) 22 | }); 23 | } 24 | 25 | render() { 26 | const images = []; 27 | 28 | if (this.props.image) { 29 | images.push(this.props.image); 30 | } else { 31 | const tracks = this.props.tracks; 32 | 33 | 34 | tracks.map((track) => { 35 | if(!images.includes(track.image) && images.length < 4) { 36 | images.push(track.image); 37 | } 38 | }); 39 | } 40 | 41 | const imageContainerClasses = classNames( 42 | 'hero__image-combo', 43 | `hero__image-combo--count-${!(images.length > 4) ? images.length : 4}` 44 | ) 45 | 46 | const imageHtml = this.renderImages(images); 47 | 48 | return ( 49 |
50 | {imageHtml} 51 |
52 | ) 53 | } 54 | } 55 | 56 | -------------------------------------------------------------------------------- /app/components/playlist-image/styles.scss: -------------------------------------------------------------------------------- 1 | .hero__image-combo { 2 | background: #282727; 3 | 4 | img { 5 | float: left; 6 | } 7 | &--count-2, 8 | &--count-3, 9 | &--count-4, 10 | { 11 | img { 12 | width: 50%; 13 | height: 50%; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/components/playlist-page/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import Playlist from '../playlist'; 4 | 5 | export class PlaylistPage extends React.Component { 6 | static PropTypes = { 7 | userPlaylists: PropTypes.array.isRequired, 8 | }; 9 | 10 | constructor(props) { 11 | super(props); 12 | 13 | this.state = { 14 | playlistData: null, 15 | }; 16 | } 17 | 18 | componentDidMount() { 19 | this.extractPlaylist(this.props); 20 | } 21 | 22 | componentWillReceiveProps(nextProps) { 23 | this.extractPlaylist(nextProps); 24 | } 25 | 26 | extractPlaylist(props) { 27 | const playlistId = props.params.playlistid; 28 | 29 | if (props.userPlaylists.length) { 30 | const playlistData = props.userPlaylists.find( 31 | playlist => playlist.id === playlistId 32 | ) 33 | this.setState({ 34 | playlistData, 35 | }); 36 | } 37 | } 38 | 39 | render() { 40 | if (this.props.userPlaylists.length) { 41 | if (this.state.playlistData) { 42 | return ( 43 | 49 | ) 50 | } else { 51 | return ( 52 |
53 |

54 | Sorry, We can't find what you're looking for 55 |

56 |
57 | ); 58 | } 59 | } else { 60 | return ( 61 |
62 | ) 63 | } 64 | } 65 | } 66 | 67 | function mapStateToProps(state) { 68 | return { 69 | userPlaylists: state.playlists.userPlaylists, 70 | } 71 | } 72 | 73 | export default connect( 74 | mapStateToProps, 75 | )(PlaylistPage); 76 | -------------------------------------------------------------------------------- /app/components/playlist-page/spec.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesfiltness/tuneify/04250a1e8e292e1faa5eb6be65f4fefd94992225/app/components/playlist-page/spec.js -------------------------------------------------------------------------------- /app/components/playlist-page/styles.scss: -------------------------------------------------------------------------------- 1 | .playlist { 2 | .error-message.page-with-padding { 3 | padding: 40px 0 0 0; 4 | } 5 | } 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/components/playlist/spec.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesfiltness/tuneify/04250a1e8e292e1faa5eb6be65f4fefd94992225/app/components/playlist/spec.js -------------------------------------------------------------------------------- /app/components/playlists-page/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Link } from 'react-router'; 4 | import { getUserPlaylists } from '../../actions/playlists'; 5 | 6 | export class PlaylistsPage extends React.Component { 7 | static PropTypes = { 8 | userPlaylists: PropTypes.array.isRequired, 9 | }; 10 | 11 | constructor(props) { 12 | super(props); 13 | 14 | this.state = { 15 | shouldRenderPlaylists: false, 16 | shouldRenderSpinner: false, 17 | } 18 | } 19 | 20 | componentDidMount() { 21 | if (this.props.authenticated) { 22 | this.props.getUserPlaylists(); 23 | } 24 | } 25 | 26 | componentWillReceiveProps(nextProps) { 27 | // if the user has just authenticated then we need to call to get their playlists 28 | if (this.props.authenticated === false && nextProps.authenticated) { 29 | this.props.getUserPlaylists(); 30 | } 31 | 32 | const shouldRenderPlaylists = nextProps.userPlaylists.length ? true : false; 33 | 34 | this.setState({ 35 | shouldRenderPlaylists, 36 | }); 37 | 38 | this.renderSpinner(nextProps.requestingPlaylists); 39 | } 40 | 41 | renderSpinner(shouldRenderSpinner) { 42 | const render = shouldRenderSpinner ? true : false; 43 | 44 | this.setState({ 45 | shouldRenderSpinner: render, 46 | }); 47 | } 48 | 49 | renderUserPlaylists() { 50 | return ( 51 |
    52 | { 53 | this.props.userPlaylists.map((playlist, i) => { 54 | return ( 55 |
  • 56 | 60 | {playlist.name} 61 | 62 |
  • 63 | ) 64 | }) 65 | } 66 |
67 | ) 68 | } 69 | 70 | render() { 71 | if (this.props.userPlaylists.length) { 72 | return ( 73 |
74 |

Playlists

75 | {this.renderUserPlaylists()} 76 |
77 | ) 78 | } else { 79 | return ( 80 |
81 | ) 82 | } 83 | } 84 | } 85 | 86 | function mapStateToProps(state) { 87 | return { 88 | authenticated: state.authenticated, 89 | userPlaylists: state.playlists.userPlaylists, 90 | } 91 | } 92 | 93 | 94 | const mapDispatchToProps = { 95 | getUserPlaylists, 96 | } 97 | 98 | export default connect( 99 | mapStateToProps, 100 | mapDispatchToProps, 101 | )(PlaylistsPage); 102 | -------------------------------------------------------------------------------- /app/components/playlists-page/spec.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesfiltness/tuneify/04250a1e8e292e1faa5eb6be65f4fefd94992225/app/components/playlists-page/spec.js -------------------------------------------------------------------------------- /app/components/playlists-page/styles.scss: -------------------------------------------------------------------------------- 1 | .playlists-page { 2 | &__list { 3 | border-top: 1px solid #333; 4 | } 5 | &__item { 6 | border-bottom: 1px solid #333; 7 | } 8 | 9 | &__link { 10 | color: #fff; 11 | padding: 10px 0 10px 10px; 12 | text-decoration: none; 13 | display: block; 14 | 15 | &:hover { 16 | background: #222; 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/components/playlists/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { Link } from 'react-router'; 3 | import { connect } from 'react-redux'; 4 | import { getUserPlaylists } from '../../actions/playlists'; 5 | import { createPlaylist } from '../../actions/play-queue'; 6 | import auth0Service from '../../utils/auth0-service'; 7 | import { 8 | loggedIn, 9 | loggedOut, 10 | } from '../../actions/auth'; 11 | 12 | const authService = new auth0Service(); 13 | 14 | export class Playlists extends React.Component { 15 | static propTypes = { 16 | authenticated: PropTypes.bool.isRequired, 17 | userPlaylists: PropTypes.array, 18 | requestingPlaylists: PropTypes.bool.isRequired, 19 | } 20 | 21 | constructor(props) { 22 | super(props); 23 | 24 | this.state = { 25 | shouldRenderPlaylists: false, 26 | shouldRenderSpinner: false, 27 | } 28 | 29 | this.createPlaylist = this.createPlaylist.bind(this); 30 | } 31 | 32 | componentDidMount() { 33 | if (this.props.authenticated) { 34 | this.props.getUserPlaylists(); 35 | } 36 | } 37 | 38 | componentWillReceiveProps(nextProps) { 39 | // if the user has just authenticated then we need to call to get their playlists 40 | if (this.props.authenticated === false && nextProps.authenticated) { 41 | this.props.getUserPlaylists(); 42 | } 43 | 44 | const shouldRenderPlaylists = nextProps.userPlaylists.length ? true : false; 45 | 46 | this.setState({ 47 | shouldRenderPlaylists, 48 | }); 49 | 50 | this.renderSpinner(nextProps.requestingPlaylists); 51 | } 52 | 53 | renderPlaylists() { 54 | return this.props.userPlaylists.map((playlist, i) => { 55 | let path = `/playlist/${playlist.id}`; 56 | return ( 57 |
  • 58 | 62 | {playlist.name} 63 | 64 |
  • 65 | ) 66 | }) 67 | } 68 | 69 | renderSpinner(shouldRenderSpinner) { 70 | const render = shouldRenderSpinner ? true : false; 71 | 72 | this.setState({ 73 | shouldRenderSpinner: render, 74 | }); 75 | } 76 | 77 | createPlaylist() { 78 | if (!authService.isLoggedIn()) { 79 | this.props.loggedOut(); 80 | authService.authenticate(() => { 81 | this.props.loggedIn(); 82 | this.props.createPlaylist(); 83 | }) 84 | } else { 85 | this.props.createPlaylist(); 86 | } 87 | } 88 | 89 | renderCreatePlaylistButton() { 90 | return ( 91 |
    95 | 99 | New Playlist 100 |
    101 | ) 102 | } 103 | 104 | renderPlaylistsPageLink() { 105 | return this.props.authenticated ? 106 |
    107 | 111 | 115 | My Playlists 116 | 117 |
    : 118 | null; 119 | } 120 | 121 | render() { 122 | if (this.state.shouldRenderPlaylists) { 123 | return( 124 |
    125 |
      126 | {this.renderPlaylists()} 127 |
    128 | {this.renderCreatePlaylistButton()} 129 | {this.renderPlaylistsPageLink()} 130 |
    131 | ) 132 | } else if(this.state.shouldRenderSpinner) { 133 | return ( 134 |
    135 | 136 | Loading... 137 |
    138 | ) 139 | } else { 140 | const playlistButton = this.renderCreatePlaylistButton() 141 | return playlistButton; 142 | } 143 | } 144 | } 145 | 146 | function mapStateToProps(state) { 147 | return { 148 | authenticated: state.authenticated, 149 | userPlaylists: state.playlists.userPlaylists, 150 | requestingPlaylists: state.playlists.requestingUserPlaylists, 151 | } 152 | } 153 | 154 | const mapDispatchToProps = { 155 | createPlaylist, 156 | getUserPlaylists, 157 | loggedIn, 158 | loggedOut, 159 | } 160 | 161 | export default connect( 162 | mapStateToProps, 163 | mapDispatchToProps 164 | )(Playlists); 165 | -------------------------------------------------------------------------------- /app/components/playlists/spec.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesfiltness/tuneify/04250a1e8e292e1faa5eb6be65f4fefd94992225/app/components/playlists/spec.js -------------------------------------------------------------------------------- /app/components/playlists/styles.scss: -------------------------------------------------------------------------------- 1 | .playlist { 2 | font-size: 14px; 3 | padding-bottom: 125px; 4 | 5 | &__list { 6 | display: none; 7 | 8 | @include breakpoint(999px) { 9 | display: block; 10 | } 11 | } 12 | 13 | &__item { 14 | margin: 0; 15 | padding: 5px 0; 16 | 17 | @include breakpoint(999px) { 18 | display: block; 19 | } 20 | } 21 | 22 | &__link { 23 | color: #aaa; 24 | display: block; 25 | text-decoration: none; 26 | 27 | &:hover { 28 | color: #fff; 29 | } 30 | } 31 | 32 | &__spinner { 33 | color: #aaa; 34 | position: absolute; 35 | left: calc(50% - 15px); 36 | display: none; 37 | 38 | @include breakpoint(999px) { 39 | display: block; 40 | } 41 | } 42 | } 43 | 44 | .playlist-page-link { 45 | left: 145px; 46 | border-left: 1px solid #333; 47 | position: fixed; 48 | bottom: 0; 49 | z-index: 2; 50 | padding: 15px; 51 | color: #aaa; 52 | width: 175px; 53 | 54 | &__icon { 55 | float: left; 56 | margin-right: 10px; 57 | } 58 | 59 | &__link { 60 | line-height: 28px; 61 | display: block; 62 | color: #aaa; 63 | text-decoration: none; 64 | 65 | &:hover { 66 | color: #fff; 67 | } 68 | } 69 | 70 | @include breakpoint(999px) { 71 | display: none; 72 | } 73 | 74 | @include breakpoint(700px) { 75 | width: calc(100% - 475px); 76 | } 77 | 78 | &:hover { 79 | color: #fff; 80 | } 81 | } 82 | 83 | .new-playlist { 84 | border-top: 1px solid #282727; 85 | padding: 15px; 86 | font-size: 14px; 87 | cursor: pointer; 88 | z-index: 2; 89 | background: #161616; 90 | width: calc(100% - 328px); 91 | position: fixed; 92 | bottom: 0; 93 | left: 0; 94 | color: #aaa; 95 | 96 | @include breakpoint(999px) { 97 | width: 175px; 98 | border-right: 4px solid #282727; 99 | } 100 | 101 | &:hover { 102 | color: #fff; 103 | } 104 | 105 | @include breakpoint(1024px) { 106 | width: 240px; 107 | } 108 | 109 | &__icon { 110 | float: left; 111 | } 112 | 113 | &__text { 114 | float: left; 115 | background: #161616; 116 | margin: 0 0 0 5px; 117 | line-height: 28px; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /app/components/routes/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IndexRoute, Route } from 'react-router'; 3 | import App from '../app'; 4 | import Home from '../home'; 5 | import Artist from '../artist'; 6 | import Album from '../album'; 7 | import SearchResults from '../search-results'; 8 | import PlaylistPage from '../playlist-page'; 9 | import PageNotFound from '../page-not-found'; 10 | import auth0Service from '../../utils/auth0-service'; 11 | 12 | const authService = new auth0Service(); 13 | 14 | export default class routes extends React.Component { 15 | authenticateRoute(nextState, replace, callback) { 16 | if (authService.isLoggedIn()) { 17 | callback(); 18 | } else { 19 | authService.authenticate(() => { 20 | callback(); 21 | }); 22 | } 23 | } 24 | 25 | render() { 26 | return ( 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | } 39 | } 40 | 41 | -------------------------------------------------------------------------------- /app/components/search-results/spec.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesfiltness/tuneify/04250a1e8e292e1faa5eb6be65f4fefd94992225/app/components/search-results/spec.js -------------------------------------------------------------------------------- /app/components/search-results/styles.scss: -------------------------------------------------------------------------------- 1 | .search-results { 2 | .content-result:first-of-type { 3 | padding-top: 0; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /app/components/search/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import throttle from 'throttleit'; 4 | 5 | export class Search extends React.Component { 6 | static PropTypes = { 7 | onSearch: PropTypes.func.isRequired, 8 | onFocus: PropTypes.func.isRequired, 9 | onBlur: PropTypes.func.isRequired, 10 | searching: PropTypes.bool.isRequired, 11 | }; 12 | 13 | constructor() { 14 | super(); 15 | this.handleSearch = throttle(this.handleSearch, 1500); 16 | 17 | this.state = { 18 | searching: false, 19 | }; 20 | } 21 | 22 | componentWillReceiveProps(nextProps) { 23 | const searching = nextProps.searching; 24 | 25 | this.setState({ 26 | searching, 27 | }); 28 | } 29 | 30 | searching() { 31 | return this.state.searching ? 32 | : 33 | null; 34 | } 35 | 36 | handleSearch() { 37 | const text = this.input.value; 38 | this.props.onSearch(text); 39 | } 40 | 41 | render() { 42 | return ( 43 |
    44 | this.input = input} 49 | placeholder="Artist, Album or Track" 50 | onChange={() => this.handleSearch()} 51 | onFocus={this.props.onFocus} 52 | onBlur={this.props.onBlur} 53 | /> 54 | {this.searching()} 55 |
    56 | ); 57 | } 58 | } 59 | 60 | function mapStateToProps(state) { 61 | return { 62 | searching: state.search.searching, 63 | }; 64 | } 65 | 66 | export default connect( 67 | mapStateToProps, 68 | )(Search); 69 | 70 | -------------------------------------------------------------------------------- /app/components/search/spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Search } from './'; 3 | import sinon from 'sinon'; 4 | 5 | let component; 6 | let onSearchSpy; 7 | 8 | beforeEach(() => { 9 | onSearchSpy = sinon.spy(); 10 | 11 | component = mount( 12 | 15 | ); 16 | }); 17 | 18 | afterEach(() => { 19 | onSearchSpy.reset(); 20 | }); 21 | 22 | describe('Search component', () => { 23 | it('renders a wrapping search div', () => { 24 | expect(component.find('.search')).to.be.present(); 25 | }); 26 | 27 | it('renders the search input', () => { 28 | expect(component.find('.search__input')).to.be.present(); 29 | }); 30 | 31 | it('renders the correct placeholder', () => { 32 | expect( 33 | component.find('.search__input')) 34 | .to.have.attr('placeholder', 'Artist, Album or Track'); 35 | }); 36 | 37 | it('calls the onSearch callback when text is entered in to the input', () => { 38 | component = mount( 39 | 42 | ); 43 | 44 | const input = component.find('input'); 45 | input.node.value = 'Radiohead'; 46 | input.simulate('change'); 47 | expect(onSearchSpy).to.have.been.calledWith('Radiohead'); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /app/components/search/styles.scss: -------------------------------------------------------------------------------- 1 | .search { 2 | display: inline-block; 3 | position: relative; 4 | 5 | &__input { 6 | padding: 8px 30px 8px 8px; 7 | font-size: 20px; 8 | width: 232px; 9 | background: #444; 10 | border: 1px solid #000; 11 | color: #ddd; 12 | 13 | &:focus { 14 | outline: none; 15 | } 16 | } 17 | 18 | &__wait { 19 | color: #888; 20 | position: absolute; 21 | right: 7px; 22 | top: 13px; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/components/top-tracks/index.js: -------------------------------------------------------------------------------- 1 | // TODO: This component is prety much identical to home 2 | // Use higher order component to allow both home and top tracks to share code 3 | import React, { PropTypes } from 'react'; 4 | import { Link } from 'react-router'; 5 | import { connect } from 'react-redux'; 6 | import classNames from 'classNames'; 7 | import { 8 | getTopTracks, 9 | playTopTrack, 10 | } from '../../actions/top-tracks'; 11 | 12 | export class TopTracks extends React.Component { 13 | static PropTypes = { 14 | topTrackData: PropTypes.array, 15 | topTrackDataError: PropTypes.string, 16 | }; 17 | 18 | constructor() { 19 | super(); 20 | this.imageLoadCount = 0; 21 | 22 | this.state = { 23 | imagesLoaded: false, 24 | } 25 | 26 | this.playTrack = this.playTrack.bind(this); 27 | } 28 | 29 | // only call for data once the page 30 | // has rendered on the client as lastfm's 31 | // rate limiting allows 5 requests per second 32 | // per originating IP adress averaged over a 5 minute period 33 | componentDidMount() { 34 | this.props.getTopTracks(); 35 | } 36 | 37 | imageLoaded() { 38 | this.imageLoadCount++; 39 | if (this.imageLoadCount === 50) { 40 | this.setState({ 41 | imagesLoaded: true 42 | }); 43 | } 44 | } 45 | 46 | playTrack(track) { 47 | const name = track.name; 48 | const artist = track.artist.name; 49 | const image = track.image[3]['#text']; 50 | this.props.playTopTrack(name, artist, image); 51 | } 52 | 53 | render() { 54 | const { 55 | topTrackData, 56 | topTrackDataError 57 | } = this.props; 58 | 59 | if (topTrackData) { 60 | // sometimes lastfm returns successfully but with an empty 61 | // json object. To counter this the reducer has a case for 62 | // this an returns and error property when it does happen 63 | if (topTrackData.error) { 64 | return ( 65 |

    No data found.

    66 | ) 67 | } else { 68 | 69 | const classes = classNames( 70 | 'top-artist__name', 71 | this.state.imagesLoaded ? 'top-artist__name--visible' : '', 72 | ); 73 | return ( 74 |
    75 |
      76 | { 77 | topTrackData.trackData.map( 78 | (track, i) => { 79 | return ( 80 |
    • { 83 | this.playTrack(track); 84 | } 85 | } 86 | className="top-artist__list-item" key={i}> 87 | {this.imageLoaded()}} 89 | className="top-artist__image" 90 | src={track.image[3]['#text']} 91 | height="230" 92 | width="230" 93 | /> 94 | {track.name} 95 |
    • 96 | ); 97 | } 98 | ) 99 | } 100 |
    101 |
    102 | ); 103 | } 104 | } else if(topTrackDataError) { 105 | return( 106 |

    No data found.

    107 | ); 108 | } else { 109 | return ( 110 |
    111 | ); 112 | } 113 | } 114 | } 115 | 116 | const mapDispatchToProps = { 117 | getTopTracks, 118 | playTopTrack, 119 | }; 120 | 121 | function mapStateToProps(state) { 122 | return { 123 | topTrackData: state.topTracks.topTrackData, 124 | topTrackDataError: state.topTracks.topTrackDataError, 125 | } 126 | } 127 | 128 | export default connect(mapStateToProps, mapDispatchToProps)(TopTracks); 129 | -------------------------------------------------------------------------------- /app/components/top-tracks/spec.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesfiltness/tuneify/04250a1e8e292e1faa5eb6be65f4fefd94992225/app/components/top-tracks/spec.js -------------------------------------------------------------------------------- /app/components/track-table/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import Track from '../track'; 3 | import TrackTableHeader from './track-table-header'; 4 | import ErrorMessage from '../error-message'; 5 | 6 | export default class TrackTable extends React.Component { 7 | static PropTypes = { 8 | playlist: PropTypes.array.isRequired, 9 | onClickTrack: PropTypes.func.isRequired, 10 | onClickTrackTools: PropTypes.func.isRequired, 11 | renderArtistCol: PropTypes.string.isRequired, 12 | playlistImg: PropTypes.string, 13 | }; 14 | 15 | constructor(props) { 16 | super(props); 17 | 18 | this.onClickTrack = this.onClickTrack.bind(this); 19 | } 20 | 21 | onClickTrack(track) { 22 | const globalPlaylistImg = this.props.playlistImg; 23 | const image = globalPlaylistImg ? globalPlaylistImg : track.img; 24 | this.props.onClickTrack( 25 | track, 26 | image 27 | ) 28 | } 29 | 30 | render() { 31 | if (this.props.playlist.length) { 32 | return ( 33 |
    34 | 35 | 38 | 39 | { 40 | this.props.playlist.map((track, i) => { 41 | const artist = typeof track.artist === 'string' ? 42 | track.artist : 43 | null; 44 | 45 | return ( 46 | { 54 | this.props.onClickTrackTools(track, event) 55 | } 56 | } 57 | onClick={ 58 | () => { 59 | this.onClickTrack(track) 60 | } 61 | } 62 | /> 63 | ) 64 | }) 65 | } 66 | 67 |
    68 |
    69 | ) 70 | } else if (this.props.showEmptyWarning) { 71 | return ( 72 | 73 | ) 74 | } else { 75 | return ( 76 |

    This playlist is empty.

    77 | ) 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /app/components/track-table/spec.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesfiltness/tuneify/04250a1e8e292e1faa5eb6be65f4fefd94992225/app/components/track-table/spec.js -------------------------------------------------------------------------------- /app/components/track-table/track-table-header/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | export default class TrackTableHeader extends React.Component { 4 | static PropTypes = { 5 | renderArtistCol: PropTypes.bool.isRequired, 6 | }; 7 | 8 | renderArtistTableHeading() { 9 | return this.props.renderArtistCol ? 10 | 13 | Artist 14 | : 15 | null; 16 | } 17 | 18 | render() { 19 | return ( 20 | 21 | 22 | 25 | No 26 | 27 | 30 | Track 31 | 32 | {this.renderArtistTableHeading()} 33 | 36 | Actions 37 | 38 | 39 | 40 | ) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/components/track-tools/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import classNames from 'classNames'; 4 | import auth0Service from '../../utils/auth0-service'; 5 | import { 6 | loggedIn, 7 | loggedOut, 8 | } from '../../actions/auth'; 9 | 10 | // TODO: this should be instantiated only one (in App) and then passed around 11 | // on context perhaps 12 | const authService = new auth0Service(); 13 | 14 | export class TrackTools extends React.Component { 15 | static PropTypes = { 16 | visible: PropTypes.bool.isRequired, 17 | addTrackToPlaylist: PropTypes.func.isRequired, 18 | addToQueue: PropTypes.func.isRequired, 19 | elementPos: PropTypes.object, 20 | userPlaylists: PropTypes.array, 21 | }; 22 | 23 | constructor(props) { 24 | super(props); 25 | 26 | this.showPlaylists = this.showPlaylists.bind(this); 27 | this.hidePlaylists = this.hidePlaylists.bind(this); 28 | 29 | this.state = { 30 | addToPlaylistActive: false, 31 | }; 32 | } 33 | 34 | // TODO: this pattern is used in a few places 35 | // extract out and allow a utility method to perform this 36 | // it should accept a callback to call when authenticated 37 | addTrackToPlaylist(playlist) { 38 | if (!authService.isLoggedIn()) { 39 | this.props.loggedOut(); 40 | authService.authenticate(() => { 41 | this.props.loggedIn(); 42 | this.props.addTrackToPlaylist(playlist); 43 | }) 44 | } else { 45 | this.props.addTrackToPlaylist(playlist); 46 | } 47 | } 48 | 49 | renderPlaylistPopup() { 50 | const scrollTop = document.body.scrollTop; 51 | const elementPos = this.props.elementPos; 52 | const playlists = this.props.userPlaylists.map((playlist, i) => { 53 | return ( 54 |
  • this.addTrackToPlaylist(playlist) 59 | } 60 | > 61 | {playlist.name} 62 |
  • 63 | ); 64 | }); 65 | 66 | return ( 67 |
      78 | {playlists} 79 |
    80 | ); 81 | } 82 | 83 | showPlaylists() { 84 | this.setState({ 85 | addToPlaylistActive: true, 86 | }); 87 | } 88 | 89 | hidePlaylists() { 90 | this.setState({ 91 | addToPlaylistActive: false, 92 | }); 93 | } 94 | 95 | renderAddToPlaylist() { 96 | return authService.isLoggedIn() ? 97 |
  • 102 | Add to Playlist 103 | 104 | {this.renderPlaylistPopup()} 105 |
  • : 106 | null; 107 | } 108 | 109 | render() { 110 | if (this.props.visible) { 111 | const elementPos = this.props.elementPos; 112 | 113 | return ( 114 |
    115 |
    119 |
      120 |
    • 124 | Add to Queue 125 |
    • 126 | {this.renderAddToPlaylist()} 127 |
    128 |
    129 |
    130 | ) 131 | } 132 | 133 | return null; 134 | } 135 | } 136 | 137 | const mapDispatchToProps = { 138 | loggedIn, 139 | loggedOut, 140 | } 141 | 142 | function mapStateToProps(state) { 143 | return { 144 | userPlaylists: state.playlists.userPlaylists, 145 | } 146 | } 147 | 148 | export default connect( 149 | mapStateToProps, 150 | mapDispatchToProps, 151 | )(TrackTools); 152 | -------------------------------------------------------------------------------- /app/components/track-tools/spec.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesfiltness/tuneify/04250a1e8e292e1faa5eb6be65f4fefd94992225/app/components/track-tools/spec.js -------------------------------------------------------------------------------- /app/components/track-tools/styles.scss: -------------------------------------------------------------------------------- 1 | .track-tools-list, 2 | .playlist-popup { 3 | position: absolute; 4 | width: 180px; 5 | background: #444; 6 | z-index: 3; 7 | -webkit-box-shadow: 0px 0px 10px 1px rgba(0,0,0,0.35); 8 | -moz-box-shadow: 0px 0px 10px 1px rgba(0,0,0,0.35); 9 | box-shadow: 0px 0px 10px 1px rgba(0,0,0,0.35); 10 | 11 | &__item { 12 | padding: 10px; 13 | color: #ccc; 14 | font-size: 14px; 15 | cursor: pointer; 16 | cursor: pointer; 17 | 18 | .fa { 19 | float: right; 20 | } 21 | 22 | &:hover { 23 | background: #333; 24 | } 25 | } 26 | } 27 | 28 | .track-tools { 29 | position: absolute; 30 | &__list { 31 | margin: 0; 32 | } 33 | 34 | &__playlists { 35 | position: absolute; 36 | } 37 | } 38 | 39 | .playlist-popup { 40 | overflow: auto; 41 | display: none; 42 | position: fixed; 43 | &::-webkit-scrollbar { 44 | background: transparent; 45 | color: red; 46 | } 47 | 48 | 49 | &::-webkit-scrollbar-thumb { 50 | border:2px solid transparent; 51 | background-color:#333; 52 | background-clip:content-box; 53 | -webkit-border-radius:7px; 54 | -moz-border-radius:7px; 55 | border-radius:7px; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/components/track/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | export default class Track extends React.Component { 4 | static PropTypes = { 5 | name: PropTypes.string.isRequired, 6 | onClick: PropTypes.func.isRequired, 7 | onClickTrackTools: PropTypes.func.isRequired, 8 | rank: PropTypes.number, 9 | artist: PropTypes.string, 10 | }; 11 | 12 | constructor(props) { 13 | super(props); 14 | 15 | this.optionSelected = this.optionSelected.bind(this); 16 | } 17 | 18 | optionSelected(e) { 19 | e.stopPropagation(); 20 | this.props.onClickTrackTools(e); 21 | } 22 | 23 | render() { 24 | return ( 25 | 29 | 32 | 33 | {this.props.rank} 34 | 35 | 36 | 37 | 38 | 39 | {this.props.name} 40 | { 41 | this.props.artist ? 42 | 43 | {this.props.artist} 44 | : 45 | null 46 | } 47 | 51 | 52 | 53 | ) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/components/track/styles.scss: -------------------------------------------------------------------------------- 1 | .track { 2 | position: relative; 3 | border-top: 1px solid #333; 4 | border-bottom: 1px solid #333; 5 | cursor: pointer; 6 | 7 | &:hover { 8 | background: #222; 9 | } 10 | 11 | &__cell { 12 | position: relative; 13 | white-space: nowrap; 14 | overflow: hidden; 15 | text-overflow: ellipsis; 16 | padding: 13px 10px; 17 | &:first-of-type { 18 | width: 20px; 19 | } 20 | } 21 | 22 | &__rank { 23 | .track:hover & { 24 | visibility: hidden; 25 | } 26 | } 27 | 28 | &__play { 29 | display: none; 30 | border: 1px solid #fff; 31 | border-radius: 52px; 32 | width: 26px; 33 | height: 26px; 34 | position: absolute; 35 | left: 5px; 36 | top: 9px; 37 | z-index: 2; 38 | background: #222; 39 | 40 | .fa { 41 | margin: 5px 0 0 8px; 42 | } 43 | 44 | .track:hover & { 45 | display: block; 46 | } 47 | } 48 | 49 | &__options { 50 | &:before { 51 | content: "\f141"; 52 | font-family: FontAwesome; 53 | font-style: normal; 54 | font-weight: normal; 55 | text-decoration: inherit; 56 | position: absolute; 57 | right: 15px; 58 | top: 14px; 59 | } 60 | } 61 | } 62 | 63 | -------------------------------------------------------------------------------- /app/components/user-sidebar/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router'; 3 | import Playlists from '../playlists'; 4 | 5 | export function UserSidebar() { 6 | return ( 7 |
    8 |

    9 | 10 | Discover 11 |

    12 |
      13 |
    • 14 | Top Artists 15 |
    • 16 |
    • 17 | Top Tracks 18 |
    • 19 |
    20 |

    21 | 22 | Playlists 23 |

    24 | 25 |
    26 | ); 27 | } 28 | 29 | export default UserSidebar 30 | 31 | -------------------------------------------------------------------------------- /app/components/user-sidebar/spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { UserSidebar } from './'; 3 | 4 | let component; 5 | 6 | beforeEach(() => { 7 | component = mount( 8 | 9 | ); 10 | }); 11 | 12 | describe('UserSidebar component', () => { 13 | it('renders a wrapping use-sidebar', () => { 14 | expect(component.find('.sidebar--left.user-sidebar')).to.be.present(); 15 | }); 16 | 17 | it('renders the "Discover" heading', () => { 18 | expect(component.find('.user-sidebar__heading').at(0)).to.have.text('Discover'); 19 | }); 20 | 21 | it('renders the "your Music" heading', () => { 22 | expect(component.find('.user-sidebar__heading').at(1)).to.have.text('Your Music'); 23 | }); 24 | 25 | it('renders the "Playlists" heading', () => { 26 | expect(component.find('.user-sidebar__heading').at(2)).to.have.text('Playlists'); 27 | }); 28 | 29 | it('renders the expected items under the "Discover" section', () => { 30 | const discover = component.find('.user-sidebar__list').at(0); 31 | expect(discover.find('.user-sidebar__list-item').at(0)).to.have.text('Top Artists'); 32 | expect(discover.find('.user-sidebar__list-item').at(1)).to.have.text('Top Tracks'); 33 | expect(discover.find('.user-sidebar__list-item').at(2)).to.have.text('Trending'); 34 | expect(discover.find('.user-sidebar__list-item').at(3)).to.have.text('Decade'); 35 | }); 36 | 37 | it('renders the expected items under the "Your Music" section', () => { 38 | const yourMusic = component.find('.user-sidebar__list').at(1); 39 | expect(yourMusic.find('.user-sidebar__list-item').at(0)).to.have.text('Recent plays'); 40 | expect(yourMusic.find('.user-sidebar__list-item').at(1)).to.have.text('Library'); 41 | }); 42 | 43 | it('renders the "playlists" heading', () => { 44 | expect(component.find('.user-sidebar__heading').at(2)).to.have.text('Playlists'); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /app/components/user-sidebar/styles.scss: -------------------------------------------------------------------------------- 1 | .user-sidebar { 2 | padding: 10px 0 0 30px; 3 | overflow: scroll; 4 | background: transparent; 5 | 6 | @include breakpoint(999px) { 7 | background: #191919; 8 | } 9 | 10 | &::-webkit-scrollbar { 11 | background: transparent; 12 | } 13 | 14 | &__heading { 15 | text-transform: uppercase; 16 | color: #aaa; 17 | font-size: 14px; 18 | font-weight: 600; 19 | display: none; 20 | 21 | @include breakpoint(999px) { 22 | display: block; 23 | } 24 | } 25 | 26 | &__list { 27 | margin: 10px 0 30px 0px; 28 | font-size: 14px; 29 | display: none; 30 | 31 | @include breakpoint(999px) { 32 | display: block; 33 | } 34 | 35 | &-item { 36 | margin-bottom: 10px; 37 | 38 | a { 39 | color: #aaa; 40 | text-decoration: none; 41 | display: block; 42 | 43 | &:hover { 44 | color: #fff; 45 | } 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/components/youtube-player/spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { YouTubePlayer } from './'; 3 | import sinon from 'sinon'; 4 | 5 | /* TODO: Add missing unit tests: There's a lot not covered here */ 6 | let component; 7 | let loadPlayerIframeStub; 8 | 9 | beforeEach(() => { 10 | loadPlayerIframeStub = sinon.stub(YouTubePlayer.prototype, 'loadPlayerIframe').returns(false); 11 | component = mount( 12 | 13 | ); 14 | }); 15 | 16 | afterEach(() => { 17 | loadPlayerIframeStub.restore(); 18 | }); 19 | 20 | describe('YouTubePlayer component', () => { 21 | it('renders a wrapping youtube-player div', () => { 22 | expect(component.find('.youtube-player')).to.be.present(); 23 | }); 24 | 25 | it('renders a youtube-player-wrap div', () => { 26 | expect(component.find('.youtube-player__player-wrap')).to.be.present(); 27 | }); 28 | 29 | it('renders a container for the actual youtube player', () => { 30 | expect(component.find('#player')).to.be.present(); 31 | }); 32 | 33 | it('renders a progress bar', () => { 34 | expect(component.find('.youtube-player__progress-bar')).to.be.present(); 35 | }); 36 | 37 | it('renders a div to display the buffered percentage', () => { 38 | expect(component.find('.youtube-player__buffered')).to.be.present(); 39 | }); 40 | 41 | it('renders a div to display the elapsed percentage', () => { 42 | expect(component.find('.youtube-player__elapsed')).to.be.present(); 43 | }); 44 | 45 | it('renders the player controls', () => { 46 | expect(component.find('.youtube-player__controls')).to.be.present(); 47 | }); 48 | 49 | it('renders the prev track button', () => { 50 | expect(component.find('.youtube-player__prev-track')).to.be.present(); 51 | }); 52 | 53 | it('renders the next track button', () => { 54 | expect(component.find('.youtube-player__next-track')).to.be.present(); 55 | }); 56 | 57 | it('renders the time display', () => { 58 | const timeDisplay = component.find('.youtube-player__time'); 59 | expect(timeDisplay.find('.youtube-player__total-time')).to.be.present(); 60 | expect(timeDisplay.find('.youtube-player__elapsed-time')).to.be.present(); 61 | expect(timeDisplay.find('.youtube-player__divider')).to.be.present(); 62 | }); 63 | 64 | it('renders the volume', () => { 65 | expect(component.find('.youtube-player__volume')).to.be.present(); 66 | expect(component.find('.youtube-player__volume-control')).to.be.present(); 67 | }); 68 | 69 | it('renders the mute button as initally unmuted', () => { 70 | expect( 71 | component 72 | .find('.youtube-player__mute-unmute')) 73 | .to.have.className('youtube-player__mute-unmute--unmuted'); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /app/components/youtube-player/styles.scss: -------------------------------------------------------------------------------- 1 | .youtube-player { 2 | background: #282727; 3 | position: relative; 4 | z-index: 1; 5 | 6 | &__player { 7 | display: block; 8 | width: 320px; 9 | height: 200px; 10 | clear: left; 11 | background: #000; 12 | } 13 | &__volume { 14 | position: relative; 15 | display:inline-block; 16 | } 17 | &__volume-control { 18 | position: relative; 19 | left: 100px; 20 | width: 10px; 21 | height: 10px; 22 | border-radius: 20px; 23 | background: #ccc; 24 | cursor: pointer; 25 | display:none; 26 | } 27 | 28 | &__controls { 29 | padding: 10px 0; 30 | } 31 | 32 | &__control { 33 | width: 43px; 34 | height: 43px; 35 | display:inline-block; 36 | background-position: center; 37 | background-repeat: no-repeat; 38 | vertical-align:middle; 39 | cursor:pointer; 40 | } 41 | 42 | &__progress-bar { 43 | overflow: hidden; 44 | position: absolute; 45 | height: 4px; 46 | background: #444; 47 | transition: height .2s; 48 | bottom: 0; 49 | width: 100%; 50 | cursor: pointer; 51 | } 52 | 53 | &__elapsed { 54 | position: absolute; 55 | z-index: 2; 56 | height: 4px; 57 | background: #19b736; 58 | display: block; 59 | width: 0px; 60 | z-index: 2; 61 | transition: height .2s; 62 | cursor: pointer; 63 | } 64 | 65 | &__buffered { 66 | position: absolute; 67 | z-index: 2; 68 | height: 4px; 69 | background: #666; 70 | display: block; 71 | width: 0px; 72 | z-index: 2; 73 | transition: height .2s; 74 | cursor: pointer; 75 | } 76 | 77 | &__time { 78 | font-size: 13px; 79 | font-weight: 600; 80 | display: inline-block; 81 | margin: 12px; 82 | } 83 | 84 | &__divider { 85 | padding: 0 5px; 86 | } 87 | 88 | &__player-wrap { 89 | padding-bottom: 4px; 90 | position: relative; 91 | clear: both; 92 | &:hover { 93 | .youtube-player__progress-bar, 94 | .youtube-player__elapsed, 95 | .youtube-player__buffered { 96 | height: 7px; 97 | } 98 | } 99 | } 100 | 101 | &__play-pause { 102 | border: 1px solid #fff; 103 | border-radius: 90px; 104 | background-position: 11px 9px; 105 | &--playing { 106 | background-image: url('../images/pause.svg'); 107 | background-position: 9px; 108 | } 109 | &--paused { 110 | background-image: url('../images/play.svg'); 111 | 112 | } 113 | } 114 | 115 | &__mute-unmute { 116 | &--unmuted { 117 | background-image: url('../images/volume-high.svg'); 118 | } 119 | 120 | &--muted { 121 | background-image: url('../images/volume-mute.svg'); 122 | } 123 | } 124 | 125 | &__prev-track { 126 | background-image: url('../images/previous.svg'); 127 | } 128 | 129 | &__next-track { 130 | background-image: url('../images/next.svg'); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /app/constants/ActionTypes.js: -------------------------------------------------------------------------------- 1 | export const REQUEST_DATA = 'REQUEST_DATA'; 2 | export const RECEIVE_FULL_ALBUM_DATA_ERROR = 'RECEIVE_FULL_ALBUM_DATA_ERROR'; 3 | export const RECEIVE_FULL_ARTIST_DATA_ERROR = 'RECEIVE_FULL_ARTIST_DATA_ERROR'; 4 | export const RECEIVE_FULL_TRACK_DATA_ERROR = 'RECEIVE_FULL_TRACK_DATA_ERROR'; 5 | export const RECEIVE_FULL_ALBUM_DATA = 'RECEIVE_FULL_ALBUM_DATA'; 6 | export const RECEIVE_FULL_ARTIST_DATA = 'RECEIVE_FULL_ARTIST_DATA'; 7 | export const RECEIVE_FULL_TRACK_DATA = 'RECEIVE_FULL_TRACK_DATA'; 8 | export const RECEIVE_AUTOCOMPLETE_ALBUM_DATA_ERROR = 'RECEIVE_AUTOCOMPLETE_ALBUM_DATA_ERROR'; 9 | export const RECEIVE_AUTOCOMPLETE_TRACK_DATA_ERROR = 'RECEIVE_AUTOCOMPLETE_TRACK_DATA_ERROR'; 10 | export const RECEIVE_AUTOCOMPLETE_ARTIST_DATA_ERROR = 'RECEIVE_AUTOCOMPLETE_ARTIST_DATA_ERROR'; 11 | export const RECEIVE_AUTOCOMPLETE_ALBUM_DATA = 'RECEIVE_AUTOCOMPLETE_ALBUM_DATA'; 12 | export const RECEIVE_AUTOCOMPLETE_TRACK_DATA = 'RECEIVE_AUTOCOMPLETE_TRACK_DATA'; 13 | export const RECEIVE_AUTOCOMPLETE_ARTIST_DATA = 'RECEIVE_AUTOCOMPLETE_ARTIST_DATA'; 14 | export const CLEAR_SEARCH = 'CLEAR_SEARCH'; 15 | export const TRACK_SELECTED = 'TRACK_SELECTED'; 16 | export const ARTIST_SELECTED = 'ARTIST_SELECTED'; 17 | export const ALBUM_SELECTED = 'ALBUM_SELECTED'; 18 | export const PLAY_VIDEO = 'PLAY_VIDEO'; 19 | export const RECEIVE_VIDEO_DATA = 'RECEIVE_VIDEO_DATA'; 20 | export const RECEIVE_ALBUM_PAGE_DATA = 'RECEIVE_ALBUM_PAGE_DATA'; 21 | export const LAST_FM_API_REQUEST = 'LAST_FM_API_REQUEST'; 22 | export const CLEAR_ALBUM_PAGE_DATA = 'CLEAR_ALBUM_PAGE_DATA'; 23 | export const ALBUM_PAGE_DATA_ERROR = 'ALBUM_PAGE_DATA_ERROR'; 24 | export const CLEAR_ALBUM_PAGE_ERROR = 'CLEAR_ALBUM_PAGE_ERROR'; 25 | export const RECEIVE_ARTIST_PAGE_DATA = 'RECEIVE_ARTIST_PAGE_DATA'; 26 | export const ARTIST_PAGE_DATA_ERROR = 'ARTIST_PAGE_DATA_ERROR'; 27 | export const CLEAR_ARTIST_PAGE_DATA = 'CLEAR_ARTIST_PAGE_DATA'; 28 | export const CLEAR_ARTIST_PAGE_ERROR = 'CLEAR_ARTIST_PAGE_ERROR'; 29 | export const RECEIVE_TOP_ARTIST_DATA = 'RECEIVE_TOP_ARTIST_DATA'; 30 | export const TOP_ARTIST_DATA_ERROR = 'TOP_ARTIST_DATA_ERROR'; 31 | export const ADD_TRACKS_TO_PLAY_QUEUE = 'ADD_TRACKS_TO_PLAY_QUEUE'; 32 | export const REPLACE_QUEUE_WITH_TRACKS = 'REPLACE_QUEUE_WITH_TRACKS'; 33 | export const REMOVE_TRACK_FROM_PLAY_QUEUE = 'REMOVE_TRACK_FROM_PLAY_QUEUE'; 34 | export const RECEIVE_CURRENT_TRACK_DATA = 'RECEIVE_CURRENT_TRACK_DATA'; 35 | export const RESET_PLAY_QUEUE_INDEX = 'RESET_PLAY_QUEUE_INDEX'; 36 | export const INCREMENT_CURRENT_INDEX = 'INCREMENT_CURRENT_INDEX'; 37 | export const DECREMENT_CURRENT_INDEX = 'DECREMENT_CURRENT_INDEX'; 38 | export const TRACK_ENDED = 'TRACK_ENDED'; 39 | export const SET_CURRENT_INDEX = 'SET_CURRENT_INDEX'; 40 | export const TRASH_PLAY_QUEUE = 'TRASH_PLAY_QUEUE'; 41 | export const SHUFFLE = 'SHUFFLE'; 42 | export const REPEAT = 'REPEAT'; 43 | export const APPEND_TRACK_TO_PLAY_QUEUE = 'APPEND_TRACK_TO_PLAY_QUEUE'; 44 | export const LOGGED_IN = 'LOGGED_IN'; 45 | export const LOGGED_OUT = 'LOGGED_OUT'; 46 | export const SAVE_PLAYLIST = 'SAVE_PLAYLIST'; 47 | export const REQUEST_USER_PLAYLISTS = 'REQUEST_USER_PLAYLISTS'; 48 | export const RECEIVE_USER_PLAYLIST_DATA = 'RECEIVE_USER_PLAYLIST_DATA'; 49 | export const USER_PLAYLIST_REQUEST_ERROR = 'USER_PLAYLIST_REQUEST_ERROR'; 50 | export const AUTHENTICATE = 'AUTHENTICATE'; 51 | export const SHOW_MODAL = 'SHOW_MODAL'; 52 | export const HIDE_MODAL = 'HIDE_MODAL'; 53 | export const CREATE_PLAYLIST = 'CREATE_PLAYLIST'; 54 | export const PLAYLIST_CREATED = 'PLAYLIST_CREATED'; 55 | export const PLAYLIST_CREATE_ERROR = 'PLAYLIST_CREATED_ERROR'; 56 | export const INITIALISING_SEARCH = 'INITIALISING_SEARCH'; 57 | export const RESTART_TRACK = 'RESTART_TRACK'; 58 | export const RESTARTED_TRACK = 'RESTARTED_TRACK'; 59 | export const RECEIVE_ARTIST_ALBUM_DATA = 'RECEIVE_ARTIST_ALBUM_DATA'; 60 | export const ARTIST_ALBUM_DATA_ERROR = 'ARTIST_ALBUM_DATA_ERROR'; 61 | export const RECEIVE_SIMILAR_ARTIST_DATA = 'RECEIVE_SIMILAR_ARTIST_DATA'; 62 | export const SIMILAR_ARTIST_ERROR = 'SIMILAR_ARTIST_ERROR'; 63 | export const CLEAR_FULL_SEARCH_RESULTS = 'CLEAR_FULL_SEARCH_RESULTS'; 64 | export const UPDATE_PLAYLIST = 'UPDATE_PLAYLIST'; 65 | export const PLAYLIST_UPDATED = 'PLAYLIST_UPDATED'; 66 | export const PLAYLIST_UPDATE_ERROR = 'PLAYLIST_UPDATE_ERROR'; 67 | export const RECEIVE_TOP_TRACK_DATA = 'RECEIVE_TOP_TRACK_DATA'; 68 | export const TOP_TRACK_DATA_ERROR = 'TOP_TRACK_DATA_ERROR'; 69 | export const PAUSE_BY_SPACEBAR = 'PAUSE_BY_SPACEBAR'; 70 | export const PLAY_QUEUE_ENDED = 'PLAY_QUEUE_ENDED'; 71 | export const RESET_PLAY_QUEUE_ENDED = 'RESET_PLAY_QUEUE_ENDED'; 72 | -------------------------------------------------------------------------------- /app/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesfiltness/tuneify/04250a1e8e292e1faa5eb6be65f4fefd94992225/app/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /app/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesfiltness/tuneify/04250a1e8e292e1faa5eb6be65f4fefd94992225/app/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /app/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesfiltness/tuneify/04250a1e8e292e1faa5eb6be65f4fefd94992225/app/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /app/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesfiltness/tuneify/04250a1e8e292e1faa5eb6be65f4fefd94992225/app/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /app/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesfiltness/tuneify/04250a1e8e292e1faa5eb6be65f4fefd94992225/app/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /app/images/FB-f-Logo__blue_29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesfiltness/tuneify/04250a1e8e292e1faa5eb6be65f4fefd94992225/app/images/FB-f-Logo__blue_29.png -------------------------------------------------------------------------------- /app/images/Twitter_Logo_White_On_Blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesfiltness/tuneify/04250a1e8e292e1faa5eb6be65f4fefd94992225/app/images/Twitter_Logo_White_On_Blue.png -------------------------------------------------------------------------------- /app/images/bin.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/images/next.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/images/pause.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/images/play.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/images/previous.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/images/repeat.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/images/shuffle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/images/spinner.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/images/summary-record.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesfiltness/tuneify/04250a1e8e292e1faa5eb6be65f4fefd94992225/app/images/summary-record.png -------------------------------------------------------------------------------- /app/images/volume-high.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/images/volume-low.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/images/volume-medium.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/images/volume-mute.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/images/volume-mute2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import { Router, IndexRoute, Route, browserHistory } from 'react-router'; 5 | import { syncHistoryWithStore } from 'react-router-redux'; 6 | import store from './redux/modules/store'; 7 | import styles from './styles/global.scss'; 8 | import auth0Service from './utils/auth0-service'; 9 | 10 | import { loggedIn } from './actions/auth'; 11 | 12 | import App from './components/app'; 13 | import Home from './components/home'; 14 | import Artist from './components/artist'; 15 | import Album from './components/album'; 16 | import PlaylistPage from './components/playlist-page'; 17 | import PageNotFound from './components/page-not-found'; 18 | import SearchResults from './components/search-results'; 19 | import TopTracks from './components/top-tracks'; 20 | import PlaylistsPage from './components/playlists-page'; 21 | 22 | const history = syncHistoryWithStore(browserHistory, store); 23 | 24 | function hiddenCallback() { 25 | browserHistory.push('/'); 26 | } 27 | 28 | const authService = new auth0Service(hiddenCallback); 29 | 30 | 31 | 32 | const authenticateRoute = (nextState, replace, callback) => { 33 | if (authService.isLoggedIn()) { 34 | callback(); 35 | } else { 36 | authService.authenticate(() => { 37 | callback(); 38 | store.dispatch(loggedIn()); 39 | }); 40 | } 41 | }; 42 | 43 | const createElement = (Component, props) => 44 | 45 | 46 | render( 47 | 48 | window.scrollTo(0, 0)} 52 | > 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | , 68 | document.getElementById('react-view') 69 | ); 70 | -------------------------------------------------------------------------------- /app/reducers/album-page/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../../constants/ActionTypes.js'; 2 | import { combineReducers } from 'redux'; 3 | 4 | export function albumPageData(state = null, action) { 5 | switch (action.type) { 6 | case types.RECEIVE_ALBUM_PAGE_DATA: 7 | // TODO: need a better solution for dealing with images here 8 | // what if this image is not defined 9 | // need to also confirm that all the required properties are here 10 | return { 11 | artist: action.json.album.artist, 12 | tracks: action.json.album.tracks.track, 13 | name: action.json.album.name, 14 | image: action.json.album.image[2]['#text'], 15 | } 16 | case types.CLEAR_ALBUM_PAGE_DATA: 17 | return null; 18 | default: 19 | return state 20 | } 21 | } 22 | 23 | export function currentAlbumPageError(state = false, action) { 24 | switch(action.type) { 25 | case types.ALBUM_PAGE_DATA_ERROR: 26 | return true; 27 | case types.CLEAR_ALBUM_PAGE_ERROR: 28 | return false; 29 | default: 30 | return state; 31 | } 32 | } 33 | 34 | 35 | export const albumPage = combineReducers({ 36 | albumPageData, 37 | currentAlbumPageError, 38 | }); 39 | -------------------------------------------------------------------------------- /app/reducers/artist-page/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../../constants/ActionTypes.js'; 2 | import { combineReducers } from 'redux'; 3 | 4 | export function artistPageData(state = null, action) { 5 | switch (action.type) { 6 | case types.RECEIVE_ARTIST_PAGE_DATA: 7 | // need a better solution for dealing with images here 8 | // what if this image is not defined 9 | // need to also confirm that all the required properties are here 10 | return { 11 | name: action.json.artist.name, 12 | bio: action.json.artist.bio, 13 | image: action.json.artist.image[2]['#text'], 14 | similar: action.json.artist.similar.artist, 15 | } 16 | case types.CLEAR_ARTIST_PAGE_DATA: 17 | return null; 18 | default: 19 | return state 20 | } 21 | } 22 | 23 | export function artistPageAlbum(state = null, action) { 24 | switch (action.type) { 25 | case types.RECEIVE_ARTIST_ALBUM_DATA: 26 | return action.json.topalbums.album; 27 | case types.ARTIST_ALBUM_DATA_ERROR: 28 | case types.CLEAR_ARTIST_PAGE_DATA: 29 | return null; 30 | default: 31 | return state; 32 | } 33 | } 34 | 35 | export function similarArtists(state = null, action) { 36 | switch (action.type) { 37 | case types.RECEIVE_SIMILAR_ARTIST_DATA: 38 | return action.json.similarartists.artist.slice(0, 20); 39 | case types.SIMILAR_ARTIST_ERROR: 40 | case types.CLEAR_ARTIST_PAGE_DATA: 41 | return null; 42 | default: 43 | return state; 44 | } 45 | } 46 | 47 | export function currentArtistPageError(state = false, action) { 48 | switch(action.type) { 49 | case types.ARTIST_PAGE_DATA_ERROR: 50 | return true; 51 | case types.CLEAR_ARTIST_PAGE_ERROR: 52 | return false; 53 | default: 54 | return state 55 | } 56 | } 57 | 58 | 59 | export const artistPage = combineReducers({ 60 | artistPageData, 61 | artistPageAlbum, 62 | currentArtistPageError, 63 | similarArtists, 64 | }); 65 | -------------------------------------------------------------------------------- /app/reducers/auth/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../../constants/ActionTypes.js'; 2 | 3 | export function authenticated(state = false, action) { 4 | switch (action.type) { 5 | case types.LOGGED_IN: 6 | return true; 7 | case types.LOGGED_OUT: 8 | return false; 9 | default: 10 | return state 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/reducers/autocomplete/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../../constants/ActionTypes.js'; 2 | import { combineReducers } from 'redux'; 3 | 4 | export function autocompleteArtistData(state = [] , action) { 5 | switch (action.type) { 6 | case types.RECEIVE_AUTOCOMPLETE_ARTIST_DATA: 7 | //we always want a fresh set of results returned to the state 8 | const results = []; 9 | const artists = 10 | action 11 | .json 12 | .results 13 | .artistmatches.artist.filter( 14 | artist => artist.mbid 15 | ) 16 | return results.concat(artists); 17 | case types.RECEIVE_AUTOCOMPLETE_ARTIST_DATA_ERROR: 18 | case types.CLEAR_SEARCH: 19 | return [] 20 | default: 21 | return state 22 | } 23 | } 24 | 25 | export function autocompleteTrackData(state = [] , action) { 26 | switch (action.type) { 27 | case types.RECEIVE_AUTOCOMPLETE_TRACK_DATA: 28 | //we always want a fresh set of results returned to the state 29 | const results = []; 30 | const tracks = 31 | action 32 | .json 33 | .results 34 | .trackmatches.track.filter( 35 | track => track.mbid 36 | ); 37 | return results.concat(tracks); 38 | case types.RECEIVE_AUTOCOMPLETE_TRACK_DATA_ERROR: 39 | case types.CLEAR_SEARCH: 40 | return [] 41 | default: 42 | return state 43 | } 44 | } 45 | 46 | export function autocompleteAlbumData(state = [] , action) { 47 | switch (action.type) { 48 | case types.RECEIVE_AUTOCOMPLETE_ALBUM_DATA: 49 | //we always want a fresh set of results returned to the state 50 | const results = []; 51 | const albums = 52 | action 53 | .json 54 | .results 55 | .albummatches.album.filter( 56 | album => album.mbid 57 | ); 58 | return results.concat(albums); 59 | case types.RECEIVE_AUTOCOMPLETE_ALBUM_DATA_ERROR: 60 | case types.CLEAR_SEARCH: 61 | return [] 62 | default: 63 | return state 64 | } 65 | } 66 | 67 | export const autocomplete = combineReducers({ 68 | autocompleteAlbumData, 69 | autocompleteTrackData, 70 | autocompleteArtistData, 71 | }); 72 | -------------------------------------------------------------------------------- /app/reducers/modal/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../../constants/ActionTypes.js' 2 | import { combineReducers } from 'redux'; 3 | 4 | export function modalVisible(state = false, action) { 5 | switch (action.type) { 6 | case types.SHOW_MODAL: 7 | return true; 8 | case types.HIDE_MODAL: 9 | case types.PLAYLIST_CREATED: 10 | return false; 11 | default: 12 | return state 13 | } 14 | } 15 | 16 | export function modalType(state = null, action) { 17 | switch (action.type) { 18 | case types.SHOW_MODAL: 19 | return action.modalType; 20 | case types.HIDE_MODAL: 21 | return null; 22 | default: 23 | return state 24 | } 25 | } 26 | 27 | export const modal = combineReducers({ 28 | modalVisible, 29 | modalType, 30 | }); 31 | -------------------------------------------------------------------------------- /app/reducers/play-queue/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../../constants/ActionTypes.js' 2 | import { combineReducers } from 'redux'; 3 | 4 | export function playQueueTracks(state = [], action) { 5 | switch (action.type) { 6 | case types.APPEND_TRACK_TO_PLAY_QUEUE: 7 | return state.concat(action.trackObj); 8 | case types.ADD_TRACKS_TO_PLAY_QUEUE: 9 | return state.concat(action.trackData); 10 | case types.REPLACE_QUEUE_WITH_TRACKS: 11 | return [].concat(action.trackData); 12 | case types.REMOVE_TRACK_FROM_PLAY_QUEUE: 13 | return [ 14 | ...state.slice(0, action.index), 15 | ...state.slice(action.index + 1) 16 | ] 17 | return state; 18 | case types.TRASH_PLAY_QUEUE: 19 | return [] 20 | default: 21 | return state 22 | } 23 | } 24 | 25 | export function playQueueCurrentIndex(state = 0, action) { 26 | switch(action.type) { 27 | case types.RESET_PLAY_QUEUE_INDEX: 28 | return 0; 29 | case types.INCREMENT_CURRENT_INDEX: 30 | return state + 1; 31 | case types.DECREMENT_CURRENT_INDEX: 32 | return state - 1; 33 | case types.SET_CURRENT_INDEX: 34 | return action.index; 35 | default: 36 | return state; 37 | } 38 | } 39 | 40 | export function shuffle(state = false, action) { 41 | switch(action.type) { 42 | case types.SHUFFLE: 43 | return action.enabled; 44 | default: 45 | return state; 46 | } 47 | } 48 | 49 | export function repeat(state = false, action) { 50 | switch(action.type) { 51 | case types.REPEAT: 52 | return action.enabled; 53 | default: 54 | return state; 55 | } 56 | } 57 | 58 | export function savePlaylistPopupVisible(state = false, action) { 59 | switch(action.type) { 60 | case types.SHOW_SAVE_PLAYLIST_POPUP: 61 | return true; 62 | case types.HIDE_SAVE_PLAYLIST_POPUP: 63 | return false; 64 | default: 65 | return state; 66 | } 67 | } 68 | 69 | export function playQueueEnded(state = false, action) { 70 | switch(action.type) { 71 | case types.PLAY_QUEUE_ENDED: 72 | return true; 73 | case types.RESET_PLAY_QUEUE_ENDED: 74 | return false; 75 | default: 76 | return state; 77 | } 78 | } 79 | 80 | export const playQueue = combineReducers({ 81 | playQueueTracks, 82 | playQueueCurrentIndex, 83 | shuffle, 84 | repeat, 85 | savePlaylistPopupVisible, 86 | playQueueEnded 87 | }); 88 | -------------------------------------------------------------------------------- /app/reducers/playlists/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../../constants/ActionTypes.js'; 2 | import { combineReducers } from 'redux'; 3 | 4 | export function requestingUserPlaylists(state = false, action) { 5 | switch (action.type) { 6 | case types.REQUEST_USER_PLAYLISTS: 7 | return true; 8 | case types.RECEIVE_USER_PLAYLISTS: 9 | return false; 10 | case types.USER_PLAYLIST_REQUEST_ERROR: 11 | return false; 12 | default: 13 | return false 14 | } 15 | } 16 | 17 | export function creatingUserPlaylist(state = false, action) { 18 | switch (action.type) { 19 | case types.CREATE_PLAYLIST: 20 | return true; 21 | case types.PLAYLIST_CREATED: 22 | case types.PLAYLIST_CREATE_ERROR: 23 | return false; 24 | default: 25 | return state; 26 | } 27 | } 28 | 29 | export function userPlaylists(state = [], action) { 30 | switch (action.type) { 31 | case types.RECEIVE_USER_PLAYLIST_DATA: 32 | return action.json.data.Items.map((item) => { 33 | return { 34 | ...item, 35 | tracks: JSON.parse(item.tracks), 36 | } 37 | }) 38 | case types.PLAYLIST_CREATED: 39 | const newPlaylist = action.json; 40 | 41 | return [ 42 | ...state, 43 | { 44 | ...newPlaylist, 45 | tracks: JSON.parse(action.json.tracks), 46 | } 47 | ]; 48 | case types.PLAYLIST_UPDATED: 49 | const index = state.findIndex(playlist => playlist.id === action.json.id); 50 | return [ 51 | ...state.slice(0, index), 52 | { 53 | ...state[index], 54 | tracks: JSON.parse(action.json.tracks), 55 | }, 56 | ...state.slice(index + 1) 57 | ] 58 | case types.USER_PLAYLIST_REQUEST_ERROR: 59 | case types.LOGGED_OUT: 60 | return [] 61 | default: 62 | return state 63 | } 64 | } 65 | 66 | export const playlists = combineReducers({ 67 | requestingUserPlaylists, 68 | userPlaylists, 69 | creatingUserPlaylist, 70 | }); 71 | -------------------------------------------------------------------------------- /app/reducers/search-results/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../../constants/ActionTypes.js'; 2 | import { combineReducers } from 'redux'; 3 | 4 | export function artistData(state = [] , action) { 5 | switch (action.type) { 6 | case types.RECEIVE_FULL_ARTIST_DATA: 7 | //we always want a fresh set of results returned to the state 8 | const results = []; 9 | const artists = 10 | action 11 | .json 12 | .results 13 | .artistmatches.artist.filter(artist => artist.mbid); 14 | return results.concat(artists); 15 | case types.CLEAR_SEARCH_PAGE: 16 | case types.CLEAR_FULL_SEARCH_RESULTS: 17 | case types.RECEIVE_FULL_ARTIST_DATA_ERROR: 18 | return [] 19 | default: 20 | return state 21 | } 22 | } 23 | 24 | export function trackData(state = [] , action) { 25 | switch (action.type) { 26 | case types.RECEIVE_FULL_TRACK_DATA: 27 | //we always want a fresh set of results returned to the state 28 | const results = []; 29 | const tracks = 30 | action 31 | .json 32 | .results 33 | .trackmatches.track.filter(track => track.mbid); 34 | return results.concat(tracks); 35 | case types.CLEAR_SEARCH_PAGE: 36 | case types.CLEAR_FULL_SEARCH_RESULTS: 37 | case types.RECEIVE_FULL_TRACK_DATA_ERROR: 38 | return [] 39 | default: 40 | return state 41 | } 42 | } 43 | 44 | export function albumData(state = [] , action) { 45 | switch (action.type) { 46 | case types.RECEIVE_FULL_ALBUM_DATA: 47 | //we always want a fresh set of results returned to the state 48 | const results = []; 49 | const albums = 50 | action 51 | .json 52 | .results 53 | .albummatches.album.filter(album => album.mbid); 54 | return results.concat(albums); 55 | case types.CLEAR_SEARCH_PAGE: 56 | case types.CLEAR_FULL_SEARCH_RESULTS: 57 | case types.RECEIVE_FULL_ALBUM_DATA_ERROR: 58 | return [] 59 | default: 60 | return state 61 | } 62 | } 63 | 64 | export const searchResults = combineReducers({ 65 | albumData, 66 | trackData, 67 | artistData, 68 | }); 69 | -------------------------------------------------------------------------------- /app/reducers/search/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../../constants/ActionTypes.js'; 2 | import { combineReducers } from 'redux'; 3 | 4 | export function currentSearch(state = '' , action) { 5 | switch (action.type) { 6 | case types.INITIALISING_SEARCH: 7 | return action.searchTerm 8 | case types.CLEAR_SEARCH: 9 | return null; 10 | default: 11 | return state 12 | } 13 | } 14 | 15 | export function searching(state = false, action) { 16 | switch(action.type) { 17 | case types.INITIALISING_SEARCH: 18 | return true; 19 | case types.RECEIVE_AUTOCOMPLETE_TRACK_DATA: 20 | case types.RECEIVE_AUTOCOMPLETE_ARTIST_DATA: 21 | case types.RECEIVE_AUTOCOMPLETE_ALBUM_DATA: 22 | return false; 23 | default: 24 | return state; 25 | } 26 | } 27 | 28 | export const search = combineReducers({ 29 | searching, 30 | currentSearch, 31 | }); 32 | -------------------------------------------------------------------------------- /app/reducers/top-artists/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../../constants/ActionTypes.js'; 2 | import { combineReducers } from 'redux'; 3 | 4 | export function topArtistData(state = null, action) { 5 | switch (action.type) { 6 | case types.RECEIVE_TOP_ARTIST_DATA: 7 | return { 8 | artistData: action.json.artists.artist, 9 | } 10 | default: 11 | return state 12 | } 13 | } 14 | 15 | // TODO: these actions are not properly hooked up yet... 16 | export function topArtistDataError(state = null, action) { 17 | switch(action.type) { 18 | case types.TOP_ARTIST__DATA_ERROR: 19 | return { 20 | error: action, 21 | } 22 | case types.CLEAR_TOP_ARTIST_ERROR: 23 | return null; 24 | default: 25 | return state 26 | } 27 | } 28 | 29 | export const topArtists = combineReducers({ 30 | topArtistData, 31 | topArtistDataError, 32 | }); 33 | -------------------------------------------------------------------------------- /app/reducers/top-tracks/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../../constants/ActionTypes.js'; 2 | import { combineReducers } from 'redux'; 3 | 4 | export function topTrackData(state = null, action) { 5 | switch (action.type) { 6 | case types.RECEIVE_TOP_TRACK_DATA: 7 | return { 8 | trackData: action.json.tracks.track, 9 | } 10 | default: 11 | return state 12 | } 13 | } 14 | 15 | // TODO: these actions are not properly hooked up yet... 16 | export function topTrackDataError(state = null, action) { 17 | switch(action.type) { 18 | case types.TOP_TRACK_DATA_ERROR: 19 | return { 20 | error: action, 21 | } 22 | case types.CLEAR_TOP_TRACK_ERROR: 23 | return null; 24 | default: 25 | return state 26 | } 27 | } 28 | 29 | export const topTracks = combineReducers({ 30 | topTrackData, 31 | topTrackDataError, 32 | }); 33 | -------------------------------------------------------------------------------- /app/reducers/track-summary/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../../constants/ActionTypes.js'; 2 | 3 | export function currentTrackSummaryData(state = {}, action) { 4 | switch (action.type) { 5 | case types.TRACK_SELECTED: 6 | return action.selectedTrackSummaryData 7 | default: 8 | return state 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /app/reducers/video-data/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../../constants/ActionTypes.js'; 2 | 3 | export function videoData(state = [], action) { 4 | switch(action.type) { 5 | case types.RECEIVE_VIDEO_DATA: 6 | const results = []; 7 | return results.concat(action.videoData); 8 | default: return state 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /app/reducers/video-player/index.js: -------------------------------------------------------------------------------- 1 | import * as types from '../../constants/ActionTypes.js'; 2 | import { combineReducers } from 'redux'; 3 | 4 | export function currentVideo(state = '' , action) { 5 | switch (action.type) { 6 | case types.PLAY_VIDEO: 7 | return action.videoData[0]; 8 | default: 9 | return state; 10 | } 11 | } 12 | 13 | export function restartCurrentTrack(state = false, action) { 14 | switch (action.type) { 15 | case types.RESTART_TRACK: 16 | return true; 17 | case types.RESTARTED_TRACK: 18 | return false; 19 | default: 20 | return state; 21 | } 22 | } 23 | 24 | export function pauseBySpacebar(state = false, action) { 25 | switch (action.type) { 26 | case types.PAUSE_BY_SPACEBAR: 27 | console.log(!state); 28 | return !state; 29 | default: 30 | return state; 31 | } 32 | } 33 | 34 | export const videoPlayer = combineReducers({ 35 | currentVideo, 36 | restartCurrentTrack, 37 | pauseBySpacebar 38 | }); 39 | 40 | -------------------------------------------------------------------------------- /app/redux/create.js: -------------------------------------------------------------------------------- 1 | import { createStore as _createStore } from 'redux'; 2 | import reducers from './modules/reducers'; 3 | 4 | export default function(data) { 5 | return _createStore(reducers, data); 6 | } 7 | -------------------------------------------------------------------------------- /app/redux/middleware/auth.js: -------------------------------------------------------------------------------- 1 | import auth0Service from '../../utils/auth0-service'; 2 | import { loggedIn, loggedOut } from '../../actions/auth'; 3 | 4 | const authService = new auth0Service(); 5 | 6 | const authMiddleware = store => next => action => { 7 | const { authenticate, ...rest } = action; 8 | 9 | if (!authenticate || authService.isLoggedIn()) { 10 | return next(action); 11 | } else { 12 | authService.authenticate( 13 | () => { 14 | store.dispatch(loggedIn()); 15 | next(action) 16 | } 17 | ); 18 | } 19 | } 20 | 21 | export default authMiddleware 22 | -------------------------------------------------------------------------------- /app/redux/middleware/fetch.js: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-fetch' 2 | 3 | const fetchMiddleware = store => next => action => { 4 | let queryString = ''; 5 | // if action is a function this middleware is not interested in it 6 | // it's probably a thunk, so just return it so that thunk middleware 7 | // can deal with it 8 | if (typeof action === 'function') { 9 | return action(store.dispatch, store.getState); 10 | } 11 | 12 | const { promise, actions, ...rest } = action; 13 | // if the action object does not contain a promise 14 | // property then we can assume it's a standard action 15 | // so pass it on to the next middleware 16 | if (!promise) { 17 | return next(action); 18 | } 19 | 20 | // fetch does not support passing a params object 21 | // so build the queryString here and append to the url 22 | if (promise.params && Object.keys(promise.params).length > 0) { 23 | queryString = Object.keys(promise.params).map(function(key) { 24 | return [key, promise.params[key]].map(encodeURIComponent).join("="); 25 | }).join("&"); 26 | queryString = `?${queryString}`; 27 | } 28 | 29 | const promiseUrl = promise.url + queryString; 30 | 31 | const [REQUEST, SUCCESS, FAILURE] = actions; 32 | // dispatch the request action 33 | next({ ...rest, type: REQUEST }); 34 | const actionPromise = fetch(promiseUrl, promise); 35 | 36 | actionPromise 37 | .then((response) => response.json()) 38 | .then(json => { next({ ...rest, json, type: SUCCESS })} ) 39 | .catch(error => next({ ...rest, error, type: FAILURE })); 40 | 41 | return actionPromise; 42 | }; 43 | 44 | export default fetchMiddleware 45 | -------------------------------------------------------------------------------- /app/redux/middleware/lastfm.js: -------------------------------------------------------------------------------- 1 | // This is a quick makeshift middleware to see how many calls are being 2 | // made to last fm over time 3 | const appStartupTime = Date.now(); 4 | 5 | let calls = 0; 6 | 7 | const lastFmCallCountMiddleware = store => next => action => { 8 | if (action.type === 'LAST_FM_API_REQUEST') { 9 | calls++; 10 | const diff = Date.now() - appStartupTime; 11 | const secs = Math.floor(diff / 1000); 12 | const mins = secs / 60 < 1 ? 0 : secs / 60; 13 | console.log('calls: ', calls, ' in: ', mins); 14 | } 15 | 16 | next(action); 17 | } 18 | 19 | export default lastFmCallCountMiddleware; 20 | -------------------------------------------------------------------------------- /app/redux/modules/reducers.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { routerReducer } from 'react-router-redux'; 3 | 4 | import { search } from '../../reducers/search'; 5 | import { currentTrackSummaryData } from '../../reducers/track-summary'; 6 | import { autocomplete } from '../../reducers/autocomplete'; 7 | import { videoPlayer } from '../../reducers/video-player'; 8 | import { albumPage } from '../../reducers/album-page'; 9 | import { artistPage } from '../../reducers/artist-page'; 10 | import { topArtists } from '../../reducers/top-artists'; 11 | import { topTracks } from '../../reducers/top-tracks'; 12 | import { videoData } from '../../reducers/video-data'; 13 | import { playQueue } from '../../reducers/play-queue'; 14 | import { authenticated } from '../../reducers/auth'; 15 | import { playlists } from '../../reducers/playlists'; 16 | import { modal } from '../../reducers/modal'; 17 | import { searchResults } from '../../reducers/search-results'; 18 | 19 | import { loggedIn, loggedOut } from '../../actions/auth'; 20 | 21 | const reducers = combineReducers( 22 | { 23 | search, 24 | currentTrackSummaryData, 25 | videoPlayer, 26 | videoData, 27 | albumPage, 28 | autocomplete, 29 | authenticated, 30 | artistPage, 31 | topArtists, 32 | topTracks, 33 | playQueue, 34 | playlists, 35 | modal, 36 | searchResults, 37 | routing: routerReducer, 38 | } 39 | ); 40 | 41 | export default reducers; 42 | -------------------------------------------------------------------------------- /app/redux/modules/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import { browserHistory } from 'react-router'; 3 | import { routerMiddleware } from 'react-router-redux'; 4 | import thunkMiddleware from 'redux-thunk'; 5 | import createLogger from 'redux-logger'; 6 | import fetchMiddleware from '../middleware/fetch'; 7 | import authMiddleware from '../middleware/auth'; 8 | import lastFmCallCountMiddleware from '../middleware/lastfm'; 9 | import reducers from './reducers'; 10 | 11 | const initialState = window.__PRELOADED_STATE__; // eslint-disable-line no-underscore-dangle 12 | 13 | const logger = createLogger(); // eslint-disable-line no-unused-vars 14 | const reactRouterReduxMiddleware = routerMiddleware(browserHistory); 15 | const createStoreWithMiddleware = applyMiddleware( 16 | authMiddleware, 17 | fetchMiddleware, 18 | lastFmCallCountMiddleware, 19 | thunkMiddleware, 20 | reactRouterReduxMiddleware, 21 | )(createStore); 22 | 23 | // export the store so it can be imported and used to allow dispatch to work in non-react components 24 | // such as the authService 25 | const store = createStoreWithMiddleware(reducers, initialState); 26 | 27 | export default store 28 | -------------------------------------------------------------------------------- /app/styles/base.scss: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | background: #111; 7 | font-family: arial, sans-serif; 8 | } 9 | 10 | ul { 11 | padding: 0; 12 | } 13 | 14 | li { 15 | list-style: none; 16 | } 17 | 18 | h1, 19 | h2, 20 | h3, 21 | h4 { 22 | font-weight: normal 23 | } 24 | 25 | -------------------------------------------------------------------------------- /app/styles/breakpoints.scss: -------------------------------------------------------------------------------- 1 | @mixin breakpoint($class) { 2 | @if $class == xs { 3 | @media (max-width: 767px) { @content; } 4 | } 5 | 6 | @else if $class == sm { 7 | @media (min-width: 768px) { @content; } 8 | } 9 | 10 | @else if $class == md { 11 | @media (min-width: 992px) { @content; } 12 | } 13 | 14 | @else if $class == lg { 15 | @media (min-width: 1200px) { @content; } 16 | } 17 | 18 | @else { 19 | @media (min-width: $class) { @content; } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/styles/content-result.scss: -------------------------------------------------------------------------------- 1 | .content-result { 2 | clear: left; 3 | padding-top: 25px; 4 | 5 | &__list { 6 | display: flex; 7 | flex-wrap: wrap; 8 | } 9 | 10 | &__image { 11 | display:block; 12 | border-radius: 3px; 13 | } 14 | 15 | &__wrap { 16 | margin: 0 auto; 17 | 18 | &:first-of-type { 19 | margin-left: 0; 20 | } 21 | } 22 | 23 | &__item { 24 | margin-right: 10px; 25 | overflow: hidden; 26 | font-size: 13px; 27 | width: calc(20% - 10px); 28 | 29 | @include breakpoint(1700px) { 30 | width: calc(10% - 10px); 31 | } 32 | 33 | } 34 | 35 | &__image { 36 | width: 100%; 37 | } 38 | 39 | &__text { 40 | display: block; 41 | text-align: center; 42 | margin: 6px 0 20px 0; 43 | } 44 | 45 | &__link { 46 | display:block; 47 | color: #fff; 48 | text-decoration: none; 49 | 50 | &:hover { 51 | text-decoration: underline; 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/styles/global.scss: -------------------------------------------------------------------------------- 1 | @import './normalize.css'; 2 | @import './base.scss'; 3 | @import './font-awesome.css'; 4 | @import './variables.scss'; 5 | @import './hero.scss'; 6 | @import './tracks.scss'; 7 | @import './breakpoints.scss'; 8 | @import './content-result.scss'; 9 | 10 | @import '../components/app/styles'; 11 | @import '../components/search/styles'; 12 | @import '../components/autocomplete/styles'; 13 | @import '../components/autocomplete-section/styles'; 14 | @import '../components/youtube-player/styles'; 15 | @import '../components/album/styles'; 16 | @import '../components/artist/styles'; 17 | @import '../components/home/styles'; 18 | @import '../components/play-queue/styles'; 19 | @import '../components/play-queue-tools/styles'; 20 | @import '../components/current-track-summary/styles'; 21 | @import '../components/user-sidebar/styles'; 22 | @import '../components/login/styles'; 23 | @import '../components/playlists/styles'; 24 | @import '../components/playlist-page/styles'; 25 | @import '../components/track/styles'; 26 | @import '../components/modal/styles'; 27 | @import '../components/playlist-image/styles'; 28 | @import '../components/track-tools/styles'; 29 | @import '../components/error-message/styles'; 30 | @import '../components/page-not-found/styles'; 31 | @import '../components/search-results/styles'; 32 | @import '../components/modal/modals/artist-bio/styles'; 33 | @import '../components/playlists-page/styles'; 34 | 35 | .route-content-spinner { 36 | background: url('../images/spinner.svg') no-repeat; 37 | width: 165px; 38 | height: 165px; 39 | margin: 100px auto 0 auto; 40 | } 41 | 42 | 43 | *::-webkit-scrollbar 44 | { 45 | width:11px; 46 | background-color: #000; 47 | } 48 | 49 | *::-webkit-scrollbar:horizontal 50 | { 51 | height:11px; 52 | } 53 | 54 | *::-webkit-scrollbar-thumb 55 | { 56 | border:2px solid transparent; 57 | background-color:#505050; 58 | background-clip:content-box; 59 | -webkit-border-radius:7px; 60 | -moz-border-radius:7px; 61 | border-radius:7px; 62 | } 63 | 64 | *::-webkit-scrollbar-corner 65 | { 66 | background-color:#000000; 67 | } 68 | 69 | .button { 70 | border-radius: 20px; 71 | text-transform: uppercase; 72 | font-size: 12px; 73 | color: #fff; 74 | padding: 10px 40px; 75 | border: none; 76 | margin-right: 10px; 77 | cursor: pointer; 78 | 79 | &:focus { 80 | outline: none; 81 | } 82 | 83 | &--primary { 84 | background: #19b736; 85 | } 86 | } 87 | 88 | .uppercase { 89 | text-transform: uppercase; 90 | } 91 | 92 | .auth0-lock-widget-container { 93 | font-family: arial, sans-serif !important; 94 | } 95 | 96 | .auth0-lock-header-logo { 97 | display: none !important; 98 | } 99 | 100 | .auth0-lock-header { 101 | height: 50px !important; 102 | } 103 | 104 | ::selection { 105 | background: #aaa; 106 | } 107 | ::-moz-selection { 108 | background: #aaa; 109 | } 110 | 111 | .page-with-padding { 112 | padding: 40px; 113 | } 114 | 115 | .facebook-share { 116 | cursor: pointer; 117 | display: block; 118 | background: url("../images/FB-f-Logo__blue_29.png") no-repeat; 119 | background-size: 21px 21px; 120 | width: 21px; 121 | height: 21px; 122 | position: absolute; 123 | right: 0px; 124 | top: 0px; 125 | } 126 | 127 | .twitter-share { 128 | cursor: pointer; 129 | display: block; 130 | background: url("../images/Twitter_Logo_White_On_Blue.png") no-repeat; 131 | background-size: 21px 21px; 132 | width: 21px; 133 | height: 21px; 134 | position: absolute; 135 | right: 26px; 136 | border-radius: 2px; 137 | top: 0px; 138 | } 139 | -------------------------------------------------------------------------------- /app/styles/hero.scss: -------------------------------------------------------------------------------- 1 | .hero { 2 | overflow: hidden; 3 | position: relative; 4 | 5 | &__image, 6 | &__image-combo { 7 | float: left; 8 | margin-right: 20px; 9 | } 10 | 11 | &__image-combo { 12 | width: 174px; 13 | height: 174px; 14 | position: relative; 15 | } 16 | 17 | &__identifier { 18 | text-transform: uppercase; 19 | color: #ccc; 20 | margin-top: 0px; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/styles/tracks.scss: -------------------------------------------------------------------------------- 1 | .tracks { 2 | margin: 40px 0; 3 | 4 | &__heading { 5 | text-transform: uppercase; 6 | color: #999; 7 | font-weight: normal; 8 | font-size: 12px; 9 | padding: 10px; 10 | text-align: left; 11 | 12 | &--no { 13 | width: 40px; 14 | } 15 | 16 | &--actions { 17 | width: 65px; 18 | } 19 | } 20 | 21 | &__table { 22 | border-collapse: collapse; 23 | width: 100%; 24 | table-layout: fixed; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/styles/variables.scss: -------------------------------------------------------------------------------- 1 | $route-content-padding: 40px 40px 0 40px; 2 | -------------------------------------------------------------------------------- /app/utils/auth0-service.js: -------------------------------------------------------------------------------- 1 | import { isTokenExpired } from './jwt-helper'; 2 | 3 | export default class auth0Service { 4 | constructor(hiddenCallback) { 5 | // if this is the client then instantiate the lock 6 | // this is a temporary workaround as there seems to be issues with auth0Lock and import 7 | // Auth0Lock only works in the browser also 8 | // TODO: Move authLock config out 9 | this.lock = new Auth0Lock( 10 | 'quW61JSOhGAG8vBykmt6vuSf3nS0vaTK', 11 | 'jamesfiltness.eu.auth0.com', 12 | { 13 | auth: { 14 | redirect: false 15 | }, 16 | autoclose: true, 17 | languageDictionary: { 18 | title: "Tuneify" 19 | }, 20 | } 21 | ); 22 | 23 | this.lock.on('hide', () => { 24 | if (hiddenCallback) { 25 | hiddenCallback(); 26 | } 27 | }); 28 | } 29 | 30 | isLoggedIn() { 31 | return !!this.getToken() && !isTokenExpired(this.getToken()); 32 | } 33 | 34 | setToken(tokenName, value) { 35 | localStorage.setItem(tokenName, value); 36 | } 37 | 38 | unsetToken(tokenName) { 39 | localStorage.removeItem(tokenName); 40 | } 41 | 42 | getToken(tokenName) { 43 | // Retrieves the user token from local storage 44 | return localStorage.getItem('idToken'); 45 | } 46 | 47 | getProfileDetails() { 48 | if (this.isLoggedIn()) { 49 | const profile = localStorage.getItem('profile'); 50 | const JSONProfile = JSON.parse(profile); 51 | return { 52 | name: JSONProfile.given_name, 53 | avatar: JSONProfile.picture 54 | } 55 | } 56 | 57 | return null; 58 | } 59 | 60 | logOut() { 61 | // Clear user token and profile data from local storage 62 | this.unsetToken('idToken'); 63 | this.unsetToken('profile'); 64 | } 65 | 66 | authenticate(callback) { 67 | this.showLock(); 68 | 69 | this.lock.on("authenticated", (authResult) => { 70 | this.lock.getUserInfo(authResult.accessToken, (error, profile) => { 71 | if (error) { 72 | // Handle error 73 | return; 74 | } 75 | 76 | this.setToken("idToken", authResult.idToken); 77 | this.setToken("profile", JSON.stringify(profile)); 78 | callback(); 79 | }); 80 | }); 81 | } 82 | 83 | showLock() { 84 | this.lock.show(); 85 | } 86 | 87 | hideLock() { 88 | this.lock.hide(); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /app/utils/jwt-helper.js: -------------------------------------------------------------------------------- 1 | import decode from 'jwt-decode'; 2 | 3 | export function getTokenExpirationDate(token) { 4 | const decoded = decode(token) 5 | if(!decoded.exp) { 6 | return null 7 | } 8 | 9 | const date = new Date(0) // The 0 here is the key, which sets the date to the epoch 10 | date.setUTCSeconds(decoded.exp) 11 | return date 12 | } 13 | 14 | export function isTokenExpired(token) { 15 | const date = getTokenExpirationDate(token) 16 | const offsetSeconds = 0; 17 | if (date === null) { 18 | return false 19 | } 20 | return !(date.valueOf() > (new Date().valueOf() + (offsetSeconds * 1000))) 21 | } 22 | -------------------------------------------------------------------------------- /app/utils/post-to-fb-feed.js: -------------------------------------------------------------------------------- 1 | export default function postToFeed(title, desc, url, image) { 2 | const obj = { 3 | method: 'feed', 4 | link: url, 5 | picture: image, 6 | name: title, 7 | description: desc 8 | }; 9 | 10 | function callback(response){} 11 | 12 | FB.ui(obj, callback); 13 | } 14 | -------------------------------------------------------------------------------- /app/utils/prepare-track-data.js: -------------------------------------------------------------------------------- 1 | export default function(trackArr, img) { 2 | return trackArr.map((track) => { 3 | const artist = typeof track.artist === 'object' ? 4 | track.artist.name : 5 | track.artist; 6 | 7 | const trackImg = track.image ? track.image : img; 8 | 9 | return { 10 | name: track.name, 11 | artist, 12 | image: trackImg, 13 | } 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /app/utils/youtube-data-api.js: -------------------------------------------------------------------------------- 1 | class YouTubeDataApi { 2 | constructor() { 3 | window.gapi.load('client', this.ApiLoaded); 4 | this.loadApi(); 5 | } 6 | 7 | ApiLoaded() { 8 | console.log('Data API loaded'); 9 | } 10 | 11 | loadApi() { 12 | const tag = document.createElement('script'); 13 | tag.src = "http://apis.google.com/js/api.js"; 14 | const firstScriptTag = document.getElementsByTagName('script')[0]; 15 | firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); 16 | } 17 | } 18 | 19 | export default YouTubeDataApi 20 | -------------------------------------------------------------------------------- /config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "protocol": "http://", 3 | "baseurl": "localhost:8900", 4 | "port": 8900, 5 | "app-title": "Tuneify", 6 | "endpoints": { 7 | "lastfm": { 8 | "url": "https://ws.audioscrobbler.com/2.0/", 9 | "api_key": "57ee3318536b23ee81d6b27e36997cde" 10 | }, 11 | "awslambda": { 12 | "url": "https://om2mx762gd.execute-api.eu-west-1.amazonaws.com/dev/" 13 | }, 14 | "youtube" : { 15 | "url": "https://www.googleapis.com/youtube/v3/search?part=snippet&q=", 16 | "api_key": "AIzaSyBXmXzAhx7HgpOx9jdDh6X_y5ar13WAGBE" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /config/production.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 3000, 3 | "endpoints": { 4 | "lastfm": { 5 | "url": "http://ws.audioscrobbler.com/2.0/", 6 | "api_key": "57ee3318536b23ee81d6b27e36997cde" 7 | }, 8 | "awslambda": { 9 | "url": "https://om2mx762gd.execute-api.eu-west-1.amazonaws.com/dev/" 10 | }, 11 | "youtube" : { 12 | "url": "https://www.googleapis.com/youtube/v3/search?part=snippet&q=", 13 | "api_key": "AIzaSyBXmXzAhx7HgpOx9jdDh6X_y5ar13WAGBE" 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /layout.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= htmlWebpackPlugin.options.title %> 6 | 7 | 8 | 9 | <% if (htmlWebpackPlugin.files.favicon) { %> 10 | 11 | <% } %> 12 | 13 | 14 |
    15 | 26 | 43 |
    44 | <% if (htmlWebpackPlugin.options.window) { %> 45 | 50 | <% } %> 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Tunefiy", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "npm run start:client", 8 | "start:client": "webpack-dev-server", 9 | "build": "rm -rf ./dist && webpack -p --config ./webpack.production.config.js", 10 | "test": "mocha --compilers js:babel-register test/helper app/**/spec.js", 11 | "coverage": "node_modules/.bin/nyc --reporter=text --reporter=lcov --require babel-core/register --all node_modules/.bin/mocha test/helper app/**/spec.js" 12 | }, 13 | "author": "James Filtness", 14 | "license": "MIT", 15 | "dependencies": { 16 | "auth0-lock": "10.7.3", 17 | "babel-plugin-react-transform": "2.0.2", 18 | "babel-plugin-transform-class-properties": "6.19.0", 19 | "babel-plugin-transform-react-jsx": "6.8.0", 20 | "babel-polyfill": "6.13.0", 21 | "babel-preset-es2015": "6.13.2", 22 | "babel-register": "6.11.6", 23 | "classnames": "2.2.5", 24 | "config": "1.24.0", 25 | "es6-promise": "3.3.1", 26 | "extract-text-webpack-plugin": "2.0.0-rc.3", 27 | "isomorphic-fetch": "2.2.1", 28 | "jwt-decode": "2.1.0", 29 | "react": "15.4.1", 30 | "react-dom": "15.4.1", 31 | "react-redux": "4.4.5", 32 | "react-router": "2.6.1", 33 | "react-router-redux": "4.0.5", 34 | "redux": "3.5.2", 35 | "redux-logger": "2.8.1", 36 | "redux-thunk": "2.1.0", 37 | "throttleit": "1.0.0" 38 | }, 39 | "devDependencies": { 40 | "babel-cli": "6.22.2", 41 | "babel-core": "6.13.2", 42 | "babel-eslint": "6.1.2", 43 | "babel-loader": "6.2.4", 44 | "babel-plugin-transform-object-rest-spread": "6.8.0", 45 | "chai": "3.5.0", 46 | "chai-enzyme": "0.6.0", 47 | "cheerio": "0.22.0", 48 | "css-loader": "0.26.1", 49 | "enzyme": "2.6.0", 50 | "eslint": "3.3.0", 51 | "eslint-config-airbnb": "10.0.1", 52 | "eslint-plugin-import": "1.13.0", 53 | "eslint-plugin-jsx-a11y": "2.1.0", 54 | "eslint-plugin-react": "6.1.0", 55 | "extract-text-webpack-plugin": "2.0.0-rc.3", 56 | "file-loader": "0.9.0", 57 | "html-webpack-plugin": "2.28.0", 58 | "jsdom": "9.8.3", 59 | "mocha": "3.1.2", 60 | "node-sass": "4.5.0", 61 | "nodemon": "1.11.0", 62 | "npm-run-all": "4.0.1", 63 | "nyc": "10.0.0", 64 | "react-addons-test-utils": "15.4.1", 65 | "sass-loader": "5.0.1", 66 | "sinon": "1.17.6", 67 | "sinon-chai": "2.8.0", 68 | "webpack": "2.2.1", 69 | "webpack-bundle-analyzer": "2.2.3", 70 | "webpack-dev-server": "1.16.3" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /server/lambda/authentication-service/handler.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | 3 | // TODO: Replace this function with something based on the official AWS customAuthorizer blueprint 4 | function generatePolicyDocument(principalId, effect, resource) { 5 | const authResponse = {}; 6 | 7 | authResponse.principalId = principalId; 8 | if (effect && resource) { 9 | const policyDocument = {}; 10 | policyDocument.Version = '2012-10-17'; // default version 11 | policyDocument.Statement = []; 12 | const statementOne = {}; 13 | statementOne.Action = 'execute-api:Invoke'; // default action 14 | statementOne.Effect = effect; 15 | statementOne.Resource = resource; 16 | policyDocument.Statement[0] = statementOne; 17 | authResponse.policyDocument = policyDocument; 18 | } 19 | 20 | authResponse.context = {}; 21 | authResponse.context.userId = principalId; 22 | 23 | return authResponse; 24 | } 25 | 26 | module.exports.authorise = (event, context, callback) => { 27 | const token = event.authorizationToken; 28 | const secret = process.env.AUTH0_SECRET; 29 | 30 | jwt.verify(token, secret, function(err, decoded) { 31 | if (err) { 32 | callback("Unauthorized"); 33 | } else if (decoded) { 34 | const userId = decoded.sub; 35 | callback(null, generatePolicyDocument(userId, 'Allow', '*')); 36 | } 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /server/lambda/authentication-service/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "authentication-service", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "handler.js", 6 | "scripts": {}, 7 | "author": "James Filtness", 8 | "license": "MIT", 9 | "dependencies": { 10 | "jsonwebtoken": "7.2.1" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /server/lambda/authentication-service/serverless.yml: -------------------------------------------------------------------------------- 1 | service: authentication-service 2 | provider: 3 | name: aws 4 | runtime: nodejs4.3 5 | profile: personal-admin 6 | region: eu-west-1 7 | functions: 8 | authorise: 9 | handler: handler.authorise 10 | environment: 11 | AUTH0_SECRET: ${env:AUTH0_SECRET} 12 | resources: 13 | Resources: 14 | DynamoDbTable: 15 | Type: AWS::DynamoDB::Table 16 | Properties: 17 | TableName: users 18 | AttributeDefinitions: 19 | - AttributeName: id 20 | AttributeType: S 21 | KeySchema: 22 | - AttributeName: id 23 | KeyType: HASH 24 | ProvisionedThroughput: 25 | ReadCapacityUnits: 5 26 | WriteCapacityUnits: 5 27 | DynamoDBIamPolicy: 28 | Type: AWS::IAM::Policy 29 | DependsOn: DynamoDbTable 30 | Properties: 31 | PolicyName: lambda-dynamodb 32 | PolicyDocument: 33 | Version: '2012-10-17' 34 | Statement: 35 | - Effect: Allow 36 | Action: 37 | - dynamodb:GetItem 38 | - dynamodb:PutItem 39 | Resource: arn:aws:dynamodb:*:*:table/users 40 | Roles: 41 | - Ref: IamRoleLambdaExecution 42 | -------------------------------------------------------------------------------- /server/lambda/playlist-service/handler.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | const uuid = require('node-uuid'); 3 | 4 | const docClient = new AWS.DynamoDB.DocumentClient(); 5 | 6 | module.exports.savePlaylist = (event, context, callback) => { 7 | const jsonPayload = JSON.parse(event.body); 8 | const userId = event.requestContext.authorizer.userId; 9 | const playlistId = uuid.v4(); 10 | const playlist = JSON.stringify(jsonPayload.playlist); 11 | 12 | const params = { 13 | TableName: 'playlists', 14 | Item: { 15 | id: playlistId, 16 | name: jsonPayload.playlistName, 17 | userid: userId, 18 | tracks: playlist 19 | } 20 | } 21 | 22 | docClient.put(params, function(err, data) { 23 | if (err) { 24 | console.log(error); 25 | callback(err); 26 | } 27 | else { 28 | const response = { 29 | statusCode: 200, 30 | headers: { 31 | "Access-Control-Allow-Origin" : "*" 32 | }, 33 | body: JSON.stringify({ 34 | id: playlistId, 35 | name: jsonPayload.playlistName, 36 | tracks: playlist, 37 | userId, 38 | }), 39 | }; 40 | 41 | callback(null, response); 42 | } 43 | }); 44 | } 45 | 46 | module.exports.updatePlaylist = (event, context, callback) => { 47 | const jsonPayload = JSON.parse(event.body); 48 | const playlistId = jsonPayload.playlistId; 49 | const playlist = JSON.stringify(jsonPayload.updatedTracklist); 50 | const params = { 51 | TableName: 'playlists', 52 | Key: { 53 | id: playlistId, 54 | }, 55 | UpdateExpression: "set tracks = :tracks", 56 | ExpressionAttributeValues:{ 57 | ":tracks": playlist, 58 | }, 59 | ReturnValues:"ALL_NEW" 60 | }; 61 | 62 | docClient.update(params, function(err, data) { 63 | if (err) { 64 | console.log(error); 65 | callback(err); 66 | } 67 | else { 68 | const response = { 69 | statusCode: 200, 70 | headers: { 71 | "Access-Control-Allow-Origin" : "*" 72 | }, 73 | body: JSON.stringify({ 74 | tracks: data.Attributes.tracks, 75 | id: data.Attributes.id, 76 | }), 77 | }; 78 | 79 | callback(null, response); 80 | } 81 | }); 82 | } 83 | 84 | module.exports.getPlaylistsByUserId = (event, context, callback) => { 85 | const userId = event.requestContext.authorizer.userId; 86 | const params = { 87 | TableName: 'playlists', 88 | IndexName: 'PlaylistUsers', 89 | KeyConditionExpression: "userid = :a", 90 | ExpressionAttributeValues: { 91 | ":a": userId 92 | } 93 | } 94 | 95 | docClient.query(params, function(err, data) { 96 | if (err) { 97 | console.log(err); 98 | } else { 99 | const response = { 100 | statusCode: 200, 101 | headers: { 102 | "Access-Control-Allow-Origin" : "*" 103 | }, 104 | body: JSON.stringify({ 105 | data: data 106 | }), 107 | }; 108 | callback(null, response); 109 | } 110 | }); 111 | }; 112 | 113 | -------------------------------------------------------------------------------- /server/lambda/playlist-service/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playlist-service", 3 | "version": "1.0.0", 4 | "description": "Playlist service for Tuneify", 5 | "main": "handler.js", 6 | "scripts": {}, 7 | "author": "James Filtness", 8 | "license": "MIT", 9 | "dependencies": { 10 | "node-uuid": "^1.4.7" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /server/lambda/playlist-service/serverless.yml: -------------------------------------------------------------------------------- 1 | service: playlist-service 2 | provider: 3 | name: aws 4 | runtime: nodejs4.3 5 | profile: personal-admin 6 | region: eu-west-1 7 | functions: 8 | getPlaylistsByUserId: 9 | handler: handler.getPlaylistsByUserId 10 | events: 11 | - http: 12 | path: playlists 13 | method: get 14 | cors: true 15 | authorizer: 16 | arn: arn:aws:lambda:eu-west-1:${env:AWS_ACCOUNT_NO}:function:authentication-service-dev-authorise 17 | savePlaylist: 18 | handler: handler.savePlaylist 19 | events: 20 | - http: 21 | path: playlists 22 | method: post 23 | cors: true 24 | authorizer: 25 | arn: arn:aws:lambda:eu-west-1:${env:AWS_ACCOUNT_NO}:function:authentication-service-dev-authorise 26 | updatePlaylist: 27 | handler: handler.updatePlaylist 28 | events: 29 | - http: 30 | path: playlists 31 | method: put 32 | cors: true 33 | authorizer: 34 | arn: arn:aws:lambda:eu-west-1:${env:AWS_ACCOUNT_NO}:function:authentication-service-dev-authorise 35 | resources: 36 | Resources: 37 | DynamoDbTable: 38 | Type: AWS::DynamoDB::Table 39 | Properties: 40 | TableName: playlists 41 | AttributeDefinitions: 42 | - AttributeName: id 43 | AttributeType: S 44 | - AttributeName: userid 45 | AttributeType: S 46 | KeySchema: 47 | - AttributeName: id 48 | KeyType: HASH 49 | ProvisionedThroughput: 50 | ReadCapacityUnits: 5 51 | WriteCapacityUnits: 5 52 | # Allow queries to get playlists by userID 53 | GlobalSecondaryIndexes: 54 | - IndexName: PlaylistUsers 55 | KeySchema: 56 | - AttributeName: userid 57 | KeyType: HASH 58 | - AttributeName: id 59 | KeyType: RANGE 60 | Projection: 61 | ProjectionType: ALL 62 | ProvisionedThroughput: 63 | ReadCapacityUnits: 5 64 | WriteCapacityUnits: 5 65 | DynamoDBIamPolicy: 66 | Type: AWS::IAM::Policy 67 | DependsOn: DynamoDbTable 68 | Properties: 69 | PolicyName: lambda-dynamodb 70 | PolicyDocument: 71 | Version: '2012-10-17' 72 | Statement: 73 | - Effect: Allow 74 | Action: 75 | - dynamodb:* 76 | Resource: 77 | - arn:aws:dynamodb:*:*:table/playlists 78 | - arn:aws:dynamodb:*:*:table/playlists/index/* 79 | Roles: 80 | - Ref: IamRoleLambdaExecution 81 | -------------------------------------------------------------------------------- /test/helper.js: -------------------------------------------------------------------------------- 1 | // TODO: comment this file 2 | 3 | global.document = require('jsdom').jsdom(''); 4 | global.window = document.defaultView; 5 | global.navigator = { 6 | userAgent: 'node.js', 7 | }; 8 | 9 | const exposedProperties = ['window', 'navigator', 'document']; 10 | 11 | Object.keys(document.defaultView).forEach((property) => { 12 | if (typeof global[property] === 'undefined') { 13 | exposedProperties.push(property); 14 | global[property] = document.defaultView[property]; 15 | } 16 | }); 17 | 18 | const chai = require('chai'); 19 | const sinonChai = require('sinon-chai'); 20 | const chaiEnzyme = require('chai-enzyme'); 21 | const { mount, shallow, render } = require('enzyme'); 22 | 23 | chai.should(); 24 | chai.use(sinonChai); 25 | 26 | global.mount = mount; 27 | global.shallow = shallow; 28 | global.render = render; 29 | global.expect = chai.expect; 30 | 31 | chai.use(chaiEnzyme()); 32 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const config = require('config'); 2 | const webpack = require('webpack'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 5 | const path = require('path'); 6 | 7 | module.exports = { 8 | entry: { 9 | app: [ 10 | path.resolve(__dirname, './app'), 11 | ], 12 | }, 13 | output: { 14 | path: path.resolve(__dirname, '/dist'), 15 | publicPath: '/', 16 | filename: 'bundle.js', 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.js?$/, 22 | loader: 'babel-loader', 23 | exclude: 'node_modules', 24 | }, 25 | { 26 | test: /\.scss$/, 27 | use: ExtractTextPlugin.extract({ 28 | use: ['css-loader', 'sass-loader'], 29 | }), 30 | exclude: 'node_modules', 31 | }, 32 | { 33 | test: /\.(jpg|png|gif|svg|woff|woff2|eot|ttf)(\?.*$|$)/i, 34 | loader: 'file-loader', 35 | exclude: 'node_modules', 36 | }, 37 | ], 38 | }, 39 | devServer: { 40 | historyApiFallback: true, 41 | port: 8900, 42 | }, 43 | devtool: 'source-map', 44 | plugins: [ 45 | new HtmlWebpackPlugin({ 46 | title: config.get('app-title'), 47 | template: 'layout.ejs', 48 | window: { 49 | clientConfig: { 50 | endpoints: config.get('endpoints'), 51 | protocol: config.get('protocol'), 52 | baseurl: config.get('baseurl'), 53 | }, 54 | }, 55 | }), 56 | new ExtractTextPlugin('styles.css'), 57 | ], 58 | }; 59 | 60 | -------------------------------------------------------------------------------- /webpack.production.config.js: -------------------------------------------------------------------------------- 1 | const config = require('config'); 2 | const webpack = require('webpack'); 3 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 4 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | const path = require('path'); 6 | 7 | module.exports = { 8 | entry: { 9 | app: [ 10 | path.resolve(__dirname, './app'), 11 | ], 12 | }, 13 | output: { 14 | path: path.resolve(__dirname, './dist'), 15 | publicPath: '/', 16 | filename: 'bundle-[chunkhash].js', 17 | }, 18 | devtool: 'source-map', 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.js?$/, 23 | loader: 'babel-loader', 24 | exclude: 'node_modules', 25 | }, 26 | { 27 | test: /\.scss$/, 28 | use: ExtractTextPlugin.extract({ 29 | use: ['css-loader', 'sass-loader'], 30 | }), 31 | exclude: 'node_modules', 32 | }, 33 | { 34 | test: /\.(jpg|png|gif|svg|woff|woff2|eot|ttf)(\?.*$|$)/i, 35 | loader: 'file-loader', 36 | exclude: 'node_modules', 37 | }, 38 | ], 39 | }, 40 | plugins: [ 41 | new ExtractTextPlugin('styles-[chunkhash].css'), 42 | new HtmlWebpackPlugin({ 43 | title: config.get('app-title'), 44 | template: 'layout.ejs', 45 | window: { 46 | clientConfig: { 47 | endpoints: config.get('endpoints'), 48 | }, 49 | }, 50 | minify: { 51 | collapseWhitespace: true, 52 | removeComments: true, 53 | }, 54 | }), 55 | new webpack.DefinePlugin({ 56 | 'process.env.NODE_ENV': '"production"', 57 | }), 58 | new webpack.optimize.UglifyJsPlugin({ 59 | beautify: false, 60 | comments: false, 61 | sourceMap: true, 62 | }), 63 | ], 64 | }; 65 | 66 | --------------------------------------------------------------------------------