├── .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 |
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 |
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 | {
57 | this.props.authService.authenticate(
58 | () => {
59 | this.authenticated()
60 | }
61 | )
62 | }
63 | }
64 | className="button button--primary button--play login__sign-in"
65 | >
66 | Sign in
67 |
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 |
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 |
103 | Create
104 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------