├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── LICENSE ├── README.md ├── buildspec.yml ├── devServer.js ├── dist ├── favicon.ico └── index.html ├── infrastructure ├── README.md ├── build-artifacts.bash ├── codebuild-role-policy.tpl ├── deploy-infrastructure.bash ├── install.bash ├── main.tf ├── outputs.tf ├── upload-artifacts.bash ├── variables.tf └── versions.tf ├── package.json ├── src ├── app │ ├── App.js │ ├── actions.js │ ├── index.js │ ├── reducer.js │ └── selectors.js ├── artists │ ├── ArtistsPage.js │ ├── actions.js │ ├── index.js │ ├── reducer.js │ ├── sagas.js │ └── selectors.js ├── auth │ ├── RestrictedPage.js │ ├── SpotifyLoginCallbackHandler.js │ ├── actions.js │ ├── index.js │ ├── reducer.js │ ├── sagas.js │ └── selectors.js ├── configureStore.js ├── index.js ├── mosaic │ ├── AlbumArtMosaic.js │ ├── MosaicPage.js │ ├── actions.js │ ├── index.js │ ├── reducer.js │ ├── sagas.js │ └── selectors.js ├── recommended │ ├── RecommendedPage.js │ ├── RecommendedTracksList.js │ ├── actions.js │ ├── index.js │ ├── reducer.js │ ├── sagas.js │ └── selectors.js ├── rootSaga.js ├── routes.js ├── shared-components │ ├── AboutPage.js │ ├── AppFooter.js │ ├── FadeImage.js │ ├── FadeInTransition.js │ ├── FullscreenLoader.js │ ├── GlossaryPage.js │ ├── HomePage.js │ ├── Navbar.js │ ├── NotFoundPage.js │ ├── PlayPause.js │ ├── TrackInfoModal.css │ ├── TrackInfoModal.js │ ├── WindowDimensionsWrapper.js │ └── glossary.js ├── spotifyApiService.js └── utils.js ├── webpack.config.babel.js ├── webpack.prod.config.babel.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | presets: ["latest", "react"], 3 | plugins: [ 4 | "transform-inline-environment-variables", 5 | "transform-class-properties", 6 | "transform-object-rest-spread" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/dist/* 2 | **/node_modules/* 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | extends: "airbnb", 3 | env: { 4 | browser: true, 5 | node: true, 6 | es6: true 7 | }, 8 | parser: "babel-eslint", 9 | rules: { 10 | max-len: ["error", 120, 2], 11 | react/jsx-filename-extension: ["error", { extensions: [".js", ".jsx"] }], 12 | react/forbid-prop-types: 0, 13 | jsx-a11y/no-static-element-interactions: 0, 14 | import/no-extraneous-dependencies: ["error", { devDependencies: true }] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist/assets/ 4 | *.d.ts 5 | .vscode/ 6 | typings.json 7 | npm-debug.log 8 | yarn-error.log 9 | .terraform/ 10 | *.tfplan* 11 | *.tfstate* 12 | .history/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Jordan Hornblow 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 | # [Audio Insights](https://audio-insights.603.nz) 2 | 3 | I built this web app teach myself about front-end development with 4 | [React](https://facebook.github.io/react/) and [Redux](http://redux.js.org). 5 | For a long while I've yearned for an enjoyable, fast and understandable way to create 6 | UIs for APIs that I build. I've experimented with various other frameworks/tools 7 | but none resonated with me like the React/Redux combo. The community around these 8 | technologies is outstanding. 9 | 10 | This app connects to the [Spotify API](https://developer.spotify.com/web-api/) using the Implicit 11 | Grant Flow to authenticate. I'm a hobby musician with a deep interest in music and music production. 12 | I thought it'd be an interesting project to present the data available from the Spotify API in 13 | various ways. 14 | 15 | I aimed to keep things simple, avoid reinventing the wheel and embrace essentialism (use as little 16 | as possible). Using a component library ([Rebass](http://jxnblk.com/rebass/)) proved invaluable. I 17 | was able to concentrate on 'business logic' yet still create something presentable. I also focused 18 | on using React and Redux best practices and making the app as responsive as possible. 19 | This project was written with a functional mindset with help from Immutable.js and Reselect for efficient client-side data manipulation. 20 | 21 | ![Mosaic](https://img.jch254.com/Mosaic.png) 22 | 23 | ![Modal](https://img.jch254.com/Modal.png) 24 | 25 | ![Recommended](https://img.jch254.com/Recommended.png) 26 | 27 | ![Artists](https://img.jch254.com/Artists.png) 28 | 29 | ## Main Technologies Used 30 | 31 | * [React](https://facebook.github.io/react/) (ft. various packages) 32 | * [Redux](https://github.com/reactjs/redux/) (ft. various middleware) 33 | * [Redux Saga](https://github.com/yelouafi/redux-saga/) 34 | * [Immutable](https://github.com/facebook/immutable-js/) 35 | * [Rebass](https://github.com/jxnblk/rebass) 36 | * [Webpack](https://github.com/webpack/webpack) 37 | * [Node.js](https://github.com/nodejs/node) 38 | 39 | **SPOTIFY_CLIENT_ID, SPOTIFY_SCOPES and SPOTIFY_CALLBACK_URI environment variable must be set before `yarn run` commands below.** 40 | 41 | E.g. `SPOTIFY_CLIENT_ID=YOUR_CLIENT_ID SPOTIFY_SCOPES="user-top-read playlist-modify-private" SPOTIFY_CALLBACK_URI="http://localhost:3001/spotifylogincallback" yarn run dev` 42 | 43 | ## Running locally 44 | 45 | 1. Create a new [Spotify API app](https://developer.spotify.com/my-applications) 46 | 1. Add http://localhost:3001/spotifylogincallback as a Redirect URI for your newly created app (don't forget to press save) 47 | 1. Run the following commands in the app's root directory then open http://localhost:3001 48 | 49 | ``` 50 | yarn install 51 | yarn run dev 52 | ``` 53 | 54 | ## Building the production version 55 | 56 | 1. Run the following commands in the app's root directory then check the /dist folder 57 | 58 | ``` 59 | yarn install 60 | yarn run build 61 | ``` 62 | 63 | ## Deployment/Infrastructure 64 | 65 | Refer to the [/infrastructure](../master/infrastructure) directory. 66 | -------------------------------------------------------------------------------- /buildspec.yml: -------------------------------------------------------------------------------- 1 | # All commands below are run from root directory of repository by CodeBuild 2 | version: 0.2 3 | 4 | env: 5 | variables: 6 | TF_VAR_region: "ap-southeast-2" 7 | TF_VAR_name: "audio-insights" 8 | TF_VAR_kms_key_arns: '["arn:aws:kms:ap-southeast-2:982898479788:key/0ec9686b-13a1-40fc-8256-86e8d3503e9c"]' 9 | TF_VAR_ssm_parameter_arns: '["arn:aws:ssm:ap-southeast-2:982898479788:parameter/shared/*","arn:aws:ssm:ap-southeast-2:982898479788:parameter/audio-insights/*"]' 10 | TF_VAR_build_docker_image: "jch254/docker-node-terraform-aws" 11 | TF_VAR_build_docker_tag: "14.x" 12 | TF_VAR_buildspec: "buildspec.yml" 13 | TF_VAR_source_location: "https://github.com/jch254/audio-insights.git" 14 | TF_VAR_cache_bucket: "603-codebuild-cache/audio-insights" 15 | TF_VAR_bucket_name: "audio-insights.603.nz" 16 | TF_VAR_dns_names: '["audio-insights.603.nz"]' 17 | TF_VAR_route53_zone_id: "Z18NTUPI1RKRGC" 18 | TF_VAR_acm_arn: "arn:aws:acm:us-east-1:982898479788:certificate/dfff91b1-8a64-41de-91b4-6e469cc15214" 19 | SPOTIFY_CALLBACK_URI: "https://audio-insights.603.nz/spotifylogincallback" 20 | REMOTE_STATE_BUCKET: "603-terraform-remote-state" 21 | parameter-store: 22 | SPOTIFY_CLIENT_ID: "/audio-insights/spotify-client-id" 23 | GA_ID: "/audio-insights/ga-id" 24 | 25 | phases: 26 | install: 27 | commands: 28 | # Workaround until CodeBuild/CodePipeline retains file permissions 29 | - find ./infrastructure -name "*.bash" -exec chmod +x {} \; 30 | - ./infrastructure/install.bash 31 | 32 | pre_build: 33 | commands: 34 | - export SPOTIFY_SCOPES="user-top-read playlist-modify-private" 35 | 36 | build: 37 | commands: 38 | - ./infrastructure/build-artifacts.bash 39 | - ./infrastructure/deploy-infrastructure.bash 40 | - ./infrastructure/upload-artifacts.bash 41 | 42 | cache: 43 | paths: 44 | - 'infrastructure/.terraform/modules/**/*' 45 | - 'infrastructure/.terraform/plugins/**/*' 46 | - '/usr/local/share/.cache/yarn/v1/**/*' -------------------------------------------------------------------------------- /devServer.js: -------------------------------------------------------------------------------- 1 | import webpack from 'webpack'; 2 | import webpackMiddleware from 'webpack-dev-middleware'; 3 | import webpackHotMiddleware from 'webpack-hot-middleware'; 4 | 5 | import express from 'express'; 6 | import path from 'path'; 7 | import http from 'http'; 8 | import webpackConfig from './webpack.config.babel'; 9 | 10 | const app = express(); 11 | const WEBPACK_PORT = 3001; 12 | const compiler = webpack(webpackConfig); 13 | 14 | app.use(webpackMiddleware(compiler, { 15 | publicPath: webpackConfig.output.publicPath, 16 | stats: { 17 | colors: true, 18 | hash: false, 19 | timings: true, 20 | chunks: false, 21 | chunkModules: false, 22 | modules: false, 23 | }, 24 | })); 25 | 26 | app.use(webpackHotMiddleware(compiler)); 27 | 28 | // This is necessary to handle URL correctly since client uses Browser History 29 | app.get('*', (request, response) => response.sendFile(path.resolve(__dirname, '', './dist/index.html'))); 30 | 31 | app.listen(WEBPACK_PORT, 'localhost', (err) => { 32 | if (err) { 33 | console.log(err); 34 | } 35 | 36 | console.log(`WebpackDevServer listening at localhost:${WEBPACK_PORT}`); 37 | }); 38 | 39 | http.createServer(app); 40 | -------------------------------------------------------------------------------- /dist/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jch254/audio-insights/c10b0ca33b0fb7017bf70272f17c1c613c34afb3/dist/favicon.ico -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Audio Insights | 603.nz 4 | 5 | 6 | 7 | 8 | 9 | 33 | 34 | 35 | 36 |
37 |
38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /infrastructure/README.md: -------------------------------------------------------------------------------- 1 | # Deployment/Infrastructure 2 | 3 | This project is built, tested and deployed to AWS by CodeBuild. Artifacts are served from S3. CloudFront is used as a CDN. Route 53 is used for DNS. 4 | 5 | --- 6 | 7 | ### Deployment Prerequisites 8 | 9 | **All commands below must be run in the /infrastructure directory.** 10 | 11 | To deploy to AWS, you must: 12 | 13 | 1. Install [Terraform](https://www.terraform.io/) and make sure it is in your PATH. 14 | 1. Set your AWS credentials using one of the following options: 15 | 1. Set your credentials as the environment variables `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`. 16 | 1. Run `aws configure` and fill in the details it asks for. 17 | 1. Run on an EC2 instance with an IAM Role. 18 | 1. Run via CodeBuild or ECS Task with an IAM Role (see [buildspec-test.yml](../buildspec-test.yml) for workaround) 19 | 20 | #### Deploying infrastructure 21 | 22 | 1. Update and export all environment variables specified in the appropriate buildspec declaration (check all phases) and bash scripts 23 | 1. Initialise Terraform: 24 | ``` 25 | terraform init \ 26 | -backend-config 'bucket=YOUR_S3_BUCKET' \ 27 | -backend-config 'key=YOUR_S3_KEY' \ 28 | -backend-config 'region=YOUR_REGION' \ 29 | -get=true \ 30 | -upgrade=true 31 | ``` 32 | 1. `terraform plan -out main.tfplan` 33 | 1. `terraform apply main.tfplan` 34 | 35 | #### Updating infrastructure 36 | 37 | 1. Update and export all environment variables specified in the appropriate buildspec declaration (check all phases) and bash scripts 38 | 1. Make necessary infrastructure code changes. 39 | 1. Initialise Terraform: 40 | ``` 41 | terraform init \ 42 | -backend-config 'bucket=YOUR_S3_BUCKET' \ 43 | -backend-config 'key=YOUR_S3_KEY' \ 44 | -backend-config 'region=YOUR_REGION' \ 45 | -get=true \ 46 | -upgrade=true 47 | ``` 48 | 1. `terraform plan -out main.tfplan` 49 | 1. `terraform apply main.tfplan` 50 | 51 | #### Destroying infrastructure (use with care) 52 | 53 | 1. Update and export all environment variables specified in the appropriate buildspec declaration (check all phases) and bash scripts 54 | 1. Initialise Terraform: 55 | ``` 56 | terraform init \ 57 | -backend-config 'bucket=YOUR_S3_BUCKET' \ 58 | -backend-config 'key=YOUR_S3_KEY' \ 59 | -backend-config 'region=YOUR_REGION' \ 60 | -get=true \ 61 | -upgrade=true 62 | ``` 63 | 1. `terraform destroy` -------------------------------------------------------------------------------- /infrastructure/build-artifacts.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | 3 | echo Building artifacts... 4 | 5 | yarn run build 6 | 7 | echo Finished building artifacts 8 | -------------------------------------------------------------------------------- /infrastructure/codebuild-role-policy.tpl: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Effect": "Allow", 6 | "Resource": [ 7 | "*" 8 | ], 9 | "Action": [ 10 | "logs:*", 11 | "s3:*", 12 | "codebuild:*", 13 | "codepipeline:*", 14 | "cloudwatch:*", 15 | "cloudfront:*", 16 | "route53:*", 17 | "iam:*", 18 | "ssm:DescribeParameters" 19 | ] 20 | }, 21 | { 22 | "Effect": "Allow", 23 | "Action": [ 24 | "kms:Decrypt" 25 | ], 26 | "Resource": ${kms_key_arns} 27 | }, 28 | { 29 | "Effect": "Allow", 30 | "Action": [ 31 | "ssm:GetParameters" 32 | ], 33 | "Resource": ${ssm_parameter_arns} 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /infrastructure/deploy-infrastructure.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | 3 | echo Deploying infrastructure via Terraform... 4 | 5 | cd infrastructure 6 | terraform init \ 7 | -backend-config "bucket=${REMOTE_STATE_BUCKET}" \ 8 | -backend-config "key=${TF_VAR_name}" \ 9 | -backend-config "region=${TF_VAR_region}" \ 10 | -get=true \ 11 | -upgrade=true 12 | terraform plan -out main.tfplan 13 | terraform apply main.tfplan 14 | cd .. 15 | 16 | echo Finished deploying infrastructure 17 | -------------------------------------------------------------------------------- /infrastructure/install.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | 3 | echo Installing dependencies... 4 | 5 | yarn install 6 | 7 | echo Finished installing dependencies 8 | -------------------------------------------------------------------------------- /infrastructure/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "s3" { 3 | encrypt = "true" 4 | } 5 | } 6 | 7 | provider "aws" { 8 | region = var.region 9 | version = "~> 2.0" 10 | } 11 | 12 | resource "aws_iam_role" "codebuild_role" { 13 | name = "${var.name}-codebuild" 14 | 15 | assume_role_policy = <", 16 | "dependencies": { 17 | "babel-polyfill": "^6.23.0", 18 | "immutable": "^3.8.1", 19 | "isomorphic-fetch": "^2.2.1", 20 | "moment": "^2.18.1", 21 | "react": "^15.4.2", 22 | "react-addons-perf": "^15.4.2", 23 | "react-dom": "^15.4.2", 24 | "react-functional": "^2.0.0", 25 | "react-ga": "^2.1.2", 26 | "react-geomicons": "^2.1.0", 27 | "react-immutable-proptypes": "^2.1.0", 28 | "react-loading": "0.0.9", 29 | "react-motion": "^0.4.7", 30 | "react-motion-ui-pack": "^0.10.2", 31 | "react-redux": "^5.0.3", 32 | "react-router": "^3.0.1", 33 | "react-router-redux": "^4.0.8", 34 | "rebass": "^0.3.4", 35 | "redux": "^3.6.0", 36 | "redux-logger": "^2.10.2", 37 | "redux-recycle": "^1.3.0", 38 | "redux-saga": "^0.14.3", 39 | "reflexbox": "^2.2.3" 40 | }, 41 | "devDependencies": { 42 | "babel-cli": "^6.24.0", 43 | "babel-core": "^6.24.0", 44 | "babel-eslint": "^7.2.0", 45 | "babel-loader": "^6.4.1", 46 | "babel-plugin-transform-class-properties": "^6.23.0", 47 | "babel-plugin-transform-inline-environment-variables": "^6.8.0", 48 | "babel-plugin-transform-object-rest-spread": "^6.23.0", 49 | "babel-preset-latest": "^6.24.0", 50 | "babel-preset-react": "^6.23.0", 51 | "cross-env": "^5.0.5", 52 | "css-loader": "^0.27.3", 53 | "eslint": "^3.18.0", 54 | "eslint-config-airbnb": "14.1.0", 55 | "eslint-loader": "^1.6.3", 56 | "eslint-plugin-import": "^2.2.0", 57 | "eslint-plugin-jsx-a11y": "^4.0.0", 58 | "eslint-plugin-react": "^6.10.3", 59 | "express": "^4.15.2", 60 | "file-loader": "^0.10.1", 61 | "raw-loader": "^0.5.1", 62 | "style-loader": "^0.16.0", 63 | "url-loader": "^0.5.8", 64 | "webpack": "^1.14.0", 65 | "webpack-dev-middleware": "^1.10.1", 66 | "webpack-hot-middleware": "^2.17.1" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/app/App.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { bindActionCreators } from 'redux'; 4 | import { Flex } from 'reflexbox'; 5 | 6 | import { termChange, toggleDropdown } from './actions'; 7 | import { getCurrentTerm, getIsDropdownOpen } from './selectors'; 8 | import AppFooter from '../shared-components/AppFooter'; 9 | import Navbar from '../shared-components/Navbar'; 10 | import TrackInfoModal from '../shared-components/TrackInfoModal'; 11 | import WindowDimensionsWrapper from '../shared-components/WindowDimensionsWrapper'; 12 | import { actions as artistsActions } from '../artists'; 13 | import { selectors as authSelectors } from '../auth'; 14 | import { actions as mosaicActions } from '../mosaic'; 15 | import { actions as recommendedActions } from '../recommended'; 16 | 17 | const App = ({ children, location, currentTerm, isDropdownOpen, idToken, actions }) => { 18 | const handleTermChange = (newTerm) => { 19 | if (newTerm !== currentTerm) { 20 | actions.termChange(newTerm); 21 | 22 | if (location.pathname.includes('mosaic')) { 23 | actions.mosaicRequest(idToken); 24 | } else if (location.pathname.includes('artists')) { 25 | actions.artistsRequest(idToken); 26 | } else if (location.pathname.includes('recommended')) { 27 | actions.recommendedTracksRequest(idToken); 28 | } 29 | } 30 | 31 | actions.toggleDropdown(); 32 | }; 33 | 34 | return ( 35 | 36 | actions.toggleDropdown()} 40 | onTermChange={handleTermChange} 41 | /> 42 | {children} 43 | 44 | 45 | 46 | 47 | 48 | ); 49 | }; 50 | 51 | App.propTypes = { 52 | children: PropTypes.node.isRequired, 53 | location: PropTypes.object.isRequired, 54 | currentTerm: PropTypes.string.isRequired, 55 | isDropdownOpen: PropTypes.bool.isRequired, 56 | idToken: PropTypes.string, 57 | actions: PropTypes.object.isRequired, 58 | }; 59 | 60 | App.defaultProps = { 61 | idToken: null, 62 | }; 63 | 64 | const mapStateToProps = state => ( 65 | { 66 | currentTerm: getCurrentTerm(state), 67 | isDropdownOpen: getIsDropdownOpen(state), 68 | idToken: authSelectors.getIdToken(state), 69 | } 70 | ); 71 | 72 | const mapDispatchToProps = dispatch => ( 73 | { 74 | actions: bindActionCreators( 75 | { termChange, toggleDropdown, ...mosaicActions, ...artistsActions, ...recommendedActions }, 76 | dispatch, 77 | ), 78 | } 79 | ); 80 | 81 | export default connect(mapStateToProps, mapDispatchToProps)(App); 82 | -------------------------------------------------------------------------------- /src/app/actions.js: -------------------------------------------------------------------------------- 1 | export const TERM_CHANGE = 'TERM_CHANGE'; 2 | export function termChange(term) { 3 | return { 4 | type: TERM_CHANGE, 5 | term, 6 | }; 7 | } 8 | 9 | export const TOGGLE_DROPDOWN = 'TOGGLE_DROPDOWN'; 10 | export function toggleDropdown() { 11 | return { 12 | type: TOGGLE_DROPDOWN, 13 | }; 14 | } 15 | 16 | export const OPEN_MODAL = 'OPEN_MODAL'; 17 | export function openModal(trackId) { 18 | return { 19 | type: OPEN_MODAL, 20 | trackId, 21 | }; 22 | } 23 | 24 | export const CLOSE_MODAL = 'CLOSE_MODAL'; 25 | export function closeModal() { 26 | return { 27 | type: CLOSE_MODAL, 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /src/app/index.js: -------------------------------------------------------------------------------- 1 | import * as actions from './actions'; 2 | import reducer from './reducer'; 3 | import * as selectors from './selectors'; 4 | 5 | export { actions, reducer, selectors }; 6 | -------------------------------------------------------------------------------- /src/app/reducer.js: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable'; 2 | 3 | import { 4 | TERM_CHANGE, 5 | TOGGLE_DROPDOWN, 6 | OPEN_MODAL, 7 | CLOSE_MODAL, 8 | } from './actions'; 9 | 10 | export const initialState = new Map({ 11 | currentTerm: 'short_term', 12 | isDropdownOpen: false, 13 | isModalOpen: false, 14 | selectedTrackId: null, 15 | }); 16 | 17 | export default function app(state = initialState, action) { 18 | switch (action.type) { 19 | case TERM_CHANGE: 20 | return state.set('currentTerm', action.term); 21 | case TOGGLE_DROPDOWN: 22 | return state.set('isDropdownOpen', !state.get('isDropdownOpen')); 23 | case OPEN_MODAL: 24 | return state.merge({ 25 | isModalOpen: true, 26 | selectedTrackId: action.trackId, 27 | }); 28 | case CLOSE_MODAL: 29 | return state.merge({ 30 | isModalOpen: false, 31 | selectedTrackId: null, 32 | }); 33 | default: 34 | return state; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/app/selectors.js: -------------------------------------------------------------------------------- 1 | export const getCurrentTerm = state => state.app.get('currentTerm'); 2 | 3 | export const getIsDropdownOpen = state => state.app.get('isDropdownOpen'); 4 | 5 | export const getIsModalOpen = state => state.app.get('isModalOpen'); 6 | 7 | export const getSelectedTrackId = state => state.app.get('selectedTrackId'); 8 | -------------------------------------------------------------------------------- /src/artists/ArtistsPage.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { bindActionCreators } from 'redux'; 4 | import { Box } from 'reflexbox'; 5 | import { 6 | PageHeader, 7 | Container, 8 | Message, 9 | } from 'rebass'; 10 | import Immutable from 'immutable'; 11 | import ImmutablePropTypes from 'react-immutable-proptypes'; 12 | 13 | import { artistsRequest } from './actions'; 14 | import { getArtists, getError, getIsFetching } from './selectors'; 15 | import FadeImage from '../shared-components/FadeImage'; 16 | import FadeInTransition from '../shared-components/FadeInTransition'; 17 | import FullscreenLoader from '../shared-components/FullscreenLoader'; 18 | import { selectors as authSelectors } from '../auth'; 19 | 20 | class ArtistsPage extends Component { 21 | componentDidMount() { 22 | const { actions, idToken } = this.props; 23 | 24 | actions.artistsRequest(idToken); 25 | } 26 | 27 | shouldComponentUpdate(nextProps) { 28 | return !Immutable.is(nextProps.artists, this.props.artists); 29 | } 30 | 31 | render() { 32 | const { isFetching, artists, error } = this.props; 33 | 34 | return ( 35 | isFetching ? 36 | : 37 | 38 | 39 | 40 | 41 | { 42 | error && 43 | 44 | { `Error: ${JSON.stringify(error)}` } 45 | 46 | } 47 | { 48 | artists 49 | .entrySeq() 50 | .map(([id, artist]) => 51 | , 63 | ) 64 | } 65 | 66 | 67 | 68 | ); 69 | } 70 | } 71 | 72 | ArtistsPage.propTypes = { 73 | idToken: PropTypes.string.isRequired, 74 | artists: ImmutablePropTypes.map.isRequired, 75 | isFetching: PropTypes.bool.isRequired, 76 | error: PropTypes.object, 77 | actions: PropTypes.object.isRequired, 78 | }; 79 | 80 | ArtistsPage.defaultProps = { 81 | error: null, 82 | }; 83 | 84 | const mapStateToProps = state => ( 85 | { 86 | idToken: authSelectors.getIdToken(state), 87 | artists: getArtists(state), 88 | isFetching: getIsFetching(state), 89 | error: getError(state), 90 | } 91 | ); 92 | 93 | const mapDispatchToProps = dispatch => ( 94 | { 95 | actions: bindActionCreators({ artistsRequest }, dispatch), 96 | } 97 | ); 98 | 99 | export default connect(mapStateToProps, mapDispatchToProps)(ArtistsPage); 100 | -------------------------------------------------------------------------------- /src/artists/actions.js: -------------------------------------------------------------------------------- 1 | export const ARTISTS_REQUEST = 'ARTISTS_REQUEST'; 2 | export function artistsRequest(idToken) { 3 | return { 4 | type: ARTISTS_REQUEST, 5 | idToken, 6 | }; 7 | } 8 | 9 | export const ARTISTS_HYDRATED = 'ARTISTS_HYDRATED'; 10 | export function artistsHydrated() { 11 | return { 12 | type: ARTISTS_HYDRATED, 13 | }; 14 | } 15 | 16 | export const ARTISTS_SUCCESS = 'ARTISTS_SUCCESS'; 17 | export function artistsSuccess(artists) { 18 | return { 19 | type: ARTISTS_SUCCESS, 20 | artists, 21 | }; 22 | } 23 | 24 | export const ARTISTS_FAILURE = 'ARTISTS_FAILURE'; 25 | export function artistsFailure(error) { 26 | return { 27 | type: ARTISTS_FAILURE, 28 | error, 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /src/artists/index.js: -------------------------------------------------------------------------------- 1 | import * as actions from './actions'; 2 | import reducer from './reducer'; 3 | import * as sagas from './sagas'; 4 | import * as selectors from './selectors'; 5 | 6 | export { actions, reducer, sagas, selectors }; 7 | -------------------------------------------------------------------------------- /src/artists/reducer.js: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable'; 2 | 3 | import { 4 | ARTISTS_REQUEST, 5 | ARTISTS_HYDRATED, 6 | ARTISTS_SUCCESS, 7 | ARTISTS_FAILURE, 8 | } from './actions'; 9 | 10 | export const initialState = new Map({ 11 | isFetching: false, 12 | isHydrated: false, 13 | artists: new Map(), 14 | error: null, 15 | }); 16 | 17 | export default function artists(state = initialState, action) { 18 | switch (action.type) { 19 | case ARTISTS_REQUEST: 20 | return state.set('isFetching', true); 21 | case ARTISTS_HYDRATED: 22 | return state.set('isFetching', false); 23 | case ARTISTS_SUCCESS: 24 | return state.merge({ 25 | artists: action.artists, 26 | isFetching: false, 27 | isHydrated: true, 28 | error: null, 29 | }); 30 | case ARTISTS_FAILURE: 31 | return state.merge({ 32 | isFetching: false, 33 | error: action.error, 34 | }); 35 | default: 36 | return state; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/artists/sagas.js: -------------------------------------------------------------------------------- 1 | import 'isomorphic-fetch'; 2 | import { call, put, select, take } from 'redux-saga/effects'; 3 | 4 | import { 5 | ARTISTS_REQUEST, 6 | artistsHydrated, 7 | artistsSuccess, 8 | artistsFailure, 9 | } from './actions'; 10 | import { getIsHydrated } from './selectors'; 11 | import { selectors as appSelectors } from '../app'; 12 | import { fetchArtists, handleSpotifyApiError } from '../spotifyApiService'; 13 | 14 | export function* fetchArtistsSaga(idToken) { 15 | try { 16 | const isHydrated = yield select(getIsHydrated); 17 | 18 | if (isHydrated) { 19 | yield put(artistsHydrated()); 20 | } else { 21 | const currentTerm = yield select(appSelectors.getCurrentTerm); 22 | 23 | const { artists } = yield call(fetchArtists, idToken, currentTerm); 24 | 25 | if (artists.isEmpty()) { 26 | throw new Error('Unfortunately you do not have enough Spotify data to display top artists'); 27 | } 28 | 29 | yield put(artistsSuccess(artists.sort(() => Math.random()))); 30 | } 31 | } catch (error) { 32 | yield call(handleSpotifyApiError, error, artistsFailure, 'artists'); 33 | } 34 | } 35 | 36 | export function* watchArtistsRequest() { 37 | while (true) { 38 | const { idToken } = yield take(ARTISTS_REQUEST); 39 | 40 | yield call(fetchArtistsSaga, idToken); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/artists/selectors.js: -------------------------------------------------------------------------------- 1 | export const getArtist = (state, artistId) => state.artists.getIn(['artists', artistId]); 2 | 3 | export const getArtists = state => state.artists.get('artists'); 4 | 5 | export const getError = state => state.artists.get('error'); 6 | 7 | export const getIsFetching = state => state.artists.get('isFetching'); 8 | 9 | export const getIsHydrated = state => state.artists.get('isHydrated'); 10 | -------------------------------------------------------------------------------- /src/auth/RestrictedPage.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { bindActionCreators } from 'redux'; 4 | import functional from 'react-functional'; 5 | 6 | import FullscreenLoader from '../shared-components/FullscreenLoader'; 7 | import { loginRequest } from './actions'; 8 | import { getIdToken } from './selectors'; 9 | 10 | const RestrictedPage = ({ children, idToken }) => (idToken ? children : ); 11 | 12 | RestrictedPage.propTypes = { 13 | children: PropTypes.node.isRequired, 14 | idToken: PropTypes.string, 15 | }; 16 | 17 | RestrictedPage.defaultProps = { 18 | idToken: null, 19 | }; 20 | 21 | RestrictedPage.componentWillMount = ({ actions, idToken, location }) => { 22 | const path = location.pathname.substring(1); 23 | 24 | if (!idToken) { 25 | actions.loginRequest(path); 26 | } 27 | }; 28 | 29 | const mapStateToProps = state => ( 30 | { 31 | idToken: getIdToken(state), 32 | } 33 | ); 34 | 35 | const mapDispatchToProps = dispatch => ( 36 | { 37 | actions: bindActionCreators({ loginRequest }, dispatch), 38 | } 39 | ); 40 | 41 | export default connect(mapStateToProps, mapDispatchToProps)(functional(RestrictedPage)); 42 | -------------------------------------------------------------------------------- /src/auth/SpotifyLoginCallbackHandler.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { loginSuccess } from './actions'; 5 | import FullscreenLoader from '../shared-components/FullscreenLoader'; 6 | 7 | class SpotifyLoginCallbackHandler extends Component { 8 | componentWillMount() { 9 | // TODO: Handle errors and utilise Immutable here 10 | 11 | const { router } = this.context; 12 | const { dispatch, location } = this.props; 13 | 14 | if (!location.hash) { 15 | router.push('/'); 16 | } else { 17 | const hashArray = location.hash.substring(1).split('&'); 18 | const hashParams = hashArray.reduce((result, item) => { 19 | const res = result; 20 | const keyValPair = item.split('='); 21 | 22 | res[keyValPair[0]] = keyValPair[1]; 23 | 24 | return res; 25 | }, {}); 26 | 27 | // expires_in is in seconds so convert to milliseconds to calculate token expiry 28 | const idTokenExpiryMilliseconds = Date.now() + (hashParams.expires_in * 1000); 29 | 30 | dispatch(loginSuccess(hashParams.access_token, idTokenExpiryMilliseconds)); 31 | router.push(hashParams.state); 32 | } 33 | } 34 | 35 | render() { 36 | return ( 37 | 38 | ); 39 | } 40 | } 41 | 42 | SpotifyLoginCallbackHandler.propTypes = { 43 | location: PropTypes.object.isRequired, 44 | dispatch: PropTypes.func.isRequired, 45 | }; 46 | 47 | SpotifyLoginCallbackHandler.contextTypes = { 48 | router: PropTypes.object.isRequired, 49 | }; 50 | 51 | export default connect()(SpotifyLoginCallbackHandler); 52 | -------------------------------------------------------------------------------- /src/auth/actions.js: -------------------------------------------------------------------------------- 1 | export const LOGIN_REQUEST = 'LOGIN_REQUEST'; 2 | export function loginRequest(returnPath = '/') { 3 | return { 4 | type: LOGIN_REQUEST, 5 | returnPath, 6 | }; 7 | } 8 | 9 | export const LOGIN_SUCCESS = 'LOGIN_SUCCESS'; 10 | export function loginSuccess(idToken, idTokenExpiry) { 11 | return { 12 | type: LOGIN_SUCCESS, 13 | idToken, 14 | idTokenExpiry, 15 | }; 16 | } 17 | 18 | export const LOGIN_FAILURE = 'LOGIN_FAILURE'; 19 | export function loginFailure(error) { 20 | return { 21 | type: LOGIN_FAILURE, 22 | error, 23 | }; 24 | } 25 | 26 | export const LOGOUT = 'LOGOUT'; 27 | export function logout() { 28 | return { 29 | type: LOGOUT, 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/auth/index.js: -------------------------------------------------------------------------------- 1 | import * as actions from './actions'; 2 | import reducer from './reducer'; 3 | import * as sagas from './sagas'; 4 | import * as selectors from './selectors'; 5 | 6 | export { actions, reducer, sagas, selectors }; 7 | -------------------------------------------------------------------------------- /src/auth/reducer.js: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable'; 2 | 3 | import { 4 | LOGIN_REQUEST, 5 | LOGIN_SUCCESS, 6 | LOGIN_FAILURE, 7 | LOGOUT, 8 | } from './actions'; 9 | import { getStoredAuthState } from '../utils'; 10 | 11 | export const initialState = new Map({ 12 | isLoggingIn: false, 13 | idToken: null, 14 | error: null, 15 | }); 16 | 17 | export default function auth(state = initialState.merge(getStoredAuthState()), action) { 18 | switch (action.type) { 19 | case LOGIN_REQUEST: 20 | return state.set('isLoggingIn', true); 21 | case LOGIN_SUCCESS: 22 | return state.merge({ 23 | isLoggingIn: false, 24 | idToken: action.idToken, 25 | }); 26 | case LOGIN_FAILURE: 27 | return state.merge({ 28 | isLoggingIn: false, 29 | idToken: null, 30 | error: action.error, 31 | }); 32 | case LOGOUT: 33 | return initialState; 34 | default: 35 | return state; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/auth/sagas.js: -------------------------------------------------------------------------------- 1 | import 'isomorphic-fetch'; 2 | import { take } from 'redux-saga/effects'; 3 | 4 | import { LOGIN_REQUEST, LOGIN_SUCCESS, LOGIN_FAILURE, LOGOUT } from './actions'; 5 | import { redirectToSpotifyLogin } from '../spotifyApiService'; 6 | import { setStoredAuthState, removeStoredAuthState } from '../utils'; 7 | 8 | export function* watchLoginRequest() { 9 | while (true) { 10 | const { returnPath } = yield take(LOGIN_REQUEST); 11 | 12 | redirectToSpotifyLogin(returnPath); 13 | } 14 | } 15 | 16 | export function* watchLoginSuccess() { 17 | while (true) { 18 | const { idToken, idTokenExpiry } = yield take(LOGIN_SUCCESS); 19 | 20 | setStoredAuthState(idToken, idTokenExpiry); 21 | } 22 | } 23 | 24 | export function* watchLoginFailure() { 25 | while (true) { 26 | yield take(LOGIN_FAILURE); 27 | 28 | removeStoredAuthState(); 29 | } 30 | } 31 | 32 | export function* watchLogout() { 33 | while (true) { 34 | yield take(LOGOUT); 35 | 36 | removeStoredAuthState(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/auth/selectors.js: -------------------------------------------------------------------------------- 1 | export const getError = state => state.auth.get('error'); 2 | 3 | export const getIdToken = state => state.auth.get('idToken'); 4 | 5 | export const getIsLoggingIn = state => state.auth.get('isLoggingIn'); 6 | 7 | export const getIsLoggedIn = state => state.auth.get('idToken') != null; 8 | -------------------------------------------------------------------------------- /src/configureStore.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, combineReducers, compose } from 'redux'; 2 | import createSagaMiddleware from 'redux-saga'; 3 | import createLogger from 'redux-logger'; 4 | import { routerMiddleware, routerReducer } from 'react-router-redux'; 5 | import recycleState from 'redux-recycle'; 6 | import Perf from 'react-addons-perf'; 7 | import { Iterable } from 'immutable'; 8 | 9 | import { reducer as appReducer, actions as appActions } from './app'; 10 | import { reducer as artists } from './artists'; 11 | import { reducer as authReducer, actions as authActions } from './auth'; 12 | import { reducer as mosaic } from './mosaic'; 13 | import { reducer as recommended } from './recommended'; 14 | import rootSaga from './rootSaga'; 15 | 16 | const reducer = combineReducers( 17 | { 18 | auth: authReducer, 19 | app: recycleState(appReducer, [authActions.LOGOUT], appReducer.initialState), 20 | artists: recycleState( 21 | artists, 22 | [authActions.LOGOUT, appActions.TERM_CHANGE], 23 | artists.initialState, 24 | ), 25 | mosaic: recycleState( 26 | mosaic, 27 | [authActions.LOGOUT, appActions.TERM_CHANGE], 28 | mosaic.initialState, 29 | ), 30 | recommended: recycleState( 31 | recommended, 32 | [authActions.LOGOUT, appActions.TERM_CHANGE], 33 | recommended.initialState, 34 | ), 35 | routing: routerReducer, 36 | }, 37 | ); 38 | 39 | export default function configureStore(browserHistory, initialState) { 40 | const sagaMiddleware = createSagaMiddleware(); 41 | const middlewares = [sagaMiddleware, routerMiddleware(browserHistory)]; 42 | 43 | if (process.env.NODE_ENV !== 'production') { 44 | // Log Immutable state beautifully 45 | const logger = createLogger({ 46 | stateTransformer: (state) => { 47 | const beautifulState = {}; 48 | 49 | Object.keys(state).forEach((key) => { 50 | if (Iterable.isIterable(state[key])) { 51 | beautifulState[key] = state[key].toJS(); 52 | } else { 53 | beautifulState[key] = state[key]; 54 | } 55 | }); 56 | 57 | return beautifulState; 58 | }, 59 | }); 60 | 61 | middlewares.push(logger); 62 | 63 | window.Perf = Perf; 64 | } 65 | 66 | const store = createStore( 67 | reducer, 68 | initialState, 69 | compose( 70 | applyMiddleware(...middlewares), 71 | window.devToolsExtension && 72 | process.env.NODE_ENV !== 'production' ? window.devToolsExtension() : f => f, 73 | )); 74 | 75 | sagaMiddleware.run(rootSaga); 76 | return store; 77 | } 78 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import { Provider } from 'react-redux'; 5 | import { Router, browserHistory } from 'react-router'; 6 | import { syncHistoryWithStore } from 'react-router-redux'; 7 | import ga from 'react-ga'; 8 | 9 | import configureStore from './configureStore'; 10 | import routes from './routes'; 11 | 12 | const store = configureStore(browserHistory); 13 | const history = syncHistoryWithStore(browserHistory, store); 14 | 15 | if (process.env.NODE_ENV === 'production') { 16 | ga.initialize(process.env.GA_ID); 17 | } 18 | 19 | const logPageView = () => { 20 | if (process.env.NODE_ENV === 'production') { 21 | ga.pageview(window.location.pathname); 22 | } 23 | }; 24 | 25 | ReactDOM.render( 26 | 27 | 32 | , 33 | document.getElementById('root'), 34 | ); 35 | -------------------------------------------------------------------------------- /src/mosaic/AlbumArtMosaic.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { Flex } from 'reflexbox'; 3 | import Immutable from 'immutable'; 4 | import ImmutablePropTypes from 'react-immutable-proptypes'; 5 | 6 | import FadeImage from '../shared-components/FadeImage'; 7 | import { getAlbumArtUrlForTrack } from '../utils'; 8 | 9 | class AlbumArtMosaic extends Component { 10 | shouldComponentUpdate(nextProps) { 11 | if (Immutable.is(nextProps.tracks, this.props.tracks) && 12 | nextProps.windowWidth === this.props.windowWidth) { 13 | return false; 14 | } 15 | 16 | return true; 17 | } 18 | 19 | getColumnCount = () => { 20 | const { windowWidth } = this.props; 21 | let columns = 5; 22 | 23 | if (windowWidth <= 480) { 24 | columns = 2; 25 | } else if (windowWidth <= 768) { 26 | columns = 3; 27 | } else if (windowWidth <= 1024) { 28 | columns = 4; 29 | } 30 | 31 | return columns; 32 | } 33 | 34 | render() { 35 | const { tracks, onTileClick } = this.props; 36 | 37 | const columnWidth = Math.floor(100 / this.getColumnCount()); 38 | 39 | const mosaicTiles = tracks 40 | .entrySeq() 41 | .map(([id, track]) => 42 | onTileClick(id)} 47 | />, 48 | ); 49 | 50 | return ( 51 | 59 | {mosaicTiles} 60 | 61 | ); 62 | } 63 | } 64 | 65 | AlbumArtMosaic.propTypes = { 66 | tracks: ImmutablePropTypes.map.isRequired, 67 | windowWidth: PropTypes.number.isRequired, 68 | onTileClick: PropTypes.func, 69 | }; 70 | 71 | AlbumArtMosaic.defaultProps = { 72 | onTileClick: null, 73 | }; 74 | 75 | AlbumArtMosaic.defaultProps = { 76 | windowWidth: 0, 77 | }; 78 | 79 | export default AlbumArtMosaic; 80 | -------------------------------------------------------------------------------- /src/mosaic/MosaicPage.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Box } from 'reflexbox'; 4 | import { 5 | Message, 6 | } from 'rebass'; 7 | import ImmutablePropTypes from 'react-immutable-proptypes'; 8 | import { bindActionCreators } from 'redux'; 9 | import functional from 'react-functional'; 10 | 11 | import AlbumArtMosaic from './AlbumArtMosaic'; 12 | import { mosaicRequest } from './actions'; 13 | import { getError, getIsFetching, getTracks } from './selectors'; 14 | import FadeInTransition from '../shared-components/FadeInTransition'; 15 | import FullscreenLoader from '../shared-components/FullscreenLoader'; 16 | import WindowDimensionsWrapper from '../shared-components/WindowDimensionsWrapper'; 17 | import { selectors as authSelectors } from '../auth'; 18 | import { actions as appActions } from '../app'; 19 | 20 | const MosaicPage = ({ isFetching, tracks, error, actions }) => { 21 | const onTileClick = trackId => actions.openModal(trackId); 22 | 23 | return ( 24 | isFetching ? 25 | : 26 | 27 | 28 | { 29 | error && 30 | 31 | { `Error: ${JSON.stringify(error)}` } 32 | 33 | } 34 | 35 | 36 | 37 | 38 | 39 | ); 40 | }; 41 | 42 | MosaicPage.propTypes = { 43 | actions: PropTypes.object.isRequired, 44 | tracks: ImmutablePropTypes.map.isRequired, 45 | isFetching: PropTypes.bool.isRequired, 46 | error: PropTypes.object, 47 | }; 48 | 49 | MosaicPage.defaultProps = { 50 | error: null, 51 | }; 52 | 53 | MosaicPage.componentDidMount = ({ actions, idToken }) => actions.mosaicRequest(idToken); 54 | 55 | const mapStateToProps = state => ( 56 | { 57 | idToken: authSelectors.getIdToken(state), 58 | tracks: getTracks(state), 59 | isFetching: getIsFetching(state), 60 | error: getError(state), 61 | } 62 | ); 63 | 64 | const mapDispatchToProps = dispatch => ( 65 | { 66 | actions: bindActionCreators({ mosaicRequest, ...appActions }, dispatch), 67 | } 68 | ); 69 | 70 | export default connect(mapStateToProps, mapDispatchToProps)(functional(MosaicPage)); 71 | -------------------------------------------------------------------------------- /src/mosaic/actions.js: -------------------------------------------------------------------------------- 1 | export const MOSAIC_REQUEST = 'MOSAIC_REQUEST'; 2 | export function mosaicRequest(idToken) { 3 | return { 4 | type: MOSAIC_REQUEST, 5 | idToken, 6 | }; 7 | } 8 | 9 | export const MOSAIC_HYDRATED = 'MOSAIC_HYDRATED'; 10 | export function mosaicHydrated() { 11 | return { 12 | type: MOSAIC_HYDRATED, 13 | }; 14 | } 15 | 16 | export const MOSAIC_SUCCESS = 'MOSAIC_SUCCESS'; 17 | export function mosaicSuccess(tracks) { 18 | return { 19 | type: MOSAIC_SUCCESS, 20 | tracks, 21 | }; 22 | } 23 | 24 | export const MOSAIC_FAILURE = 'MOSAIC_FAILURE'; 25 | export function mosaicFailure(error) { 26 | return { 27 | type: MOSAIC_FAILURE, 28 | error, 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /src/mosaic/index.js: -------------------------------------------------------------------------------- 1 | import * as actions from './actions'; 2 | import reducer from './reducer'; 3 | import * as sagas from './sagas'; 4 | import * as selectors from './selectors'; 5 | 6 | export { actions, reducer, sagas, selectors }; 7 | -------------------------------------------------------------------------------- /src/mosaic/reducer.js: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable'; 2 | 3 | import { 4 | MOSAIC_REQUEST, 5 | MOSAIC_HYDRATED, 6 | MOSAIC_SUCCESS, 7 | MOSAIC_FAILURE, 8 | } from './actions'; 9 | 10 | export const initialState = new Map({ 11 | isFetching: false, 12 | isHydrated: false, 13 | tracks: new Map(), 14 | error: null, 15 | }); 16 | 17 | export default function mosaic(state = initialState, action) { 18 | switch (action.type) { 19 | case MOSAIC_REQUEST: 20 | return state.set('isFetching', true); 21 | case MOSAIC_HYDRATED: 22 | return state.set('isFetching', false); 23 | case MOSAIC_SUCCESS: 24 | return state.merge({ 25 | tracks: action.tracks, 26 | isFetching: false, 27 | isHydrated: true, 28 | error: null, 29 | }); 30 | case MOSAIC_FAILURE: 31 | return state.merge({ 32 | isFetching: false, 33 | error: action.error, 34 | }); 35 | default: 36 | return state; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/mosaic/sagas.js: -------------------------------------------------------------------------------- 1 | import 'isomorphic-fetch'; 2 | import { call, put, select, take } from 'redux-saga/effects'; 3 | 4 | import { MOSAIC_REQUEST, mosaicHydrated, mosaicSuccess, mosaicFailure } from './actions'; 5 | import { getIsHydrated } from './selectors'; 6 | import { selectors as appSelectors } from '../app'; 7 | import { 8 | fetchTopTracks, 9 | fetchAudioFeaturesForTracks, 10 | handleSpotifyApiError, 11 | } from '../spotifyApiService'; 12 | 13 | export function* fetchMosaicSaga(idToken) { 14 | try { 15 | const isHydrated = yield select(getIsHydrated); 16 | 17 | if (isHydrated) { 18 | yield put(mosaicHydrated()); 19 | } else { 20 | const currentTerm = yield select(appSelectors.getCurrentTerm); 21 | 22 | const { tracks } = yield call(fetchTopTracks, idToken, currentTerm); 23 | 24 | if (tracks.isEmpty()) { 25 | throw new Error('Unfortunately you do not have enough Spotify data to display the mosaic'); 26 | } 27 | 28 | const trackIds = tracks.keySeq().join(); 29 | 30 | const { audioFeaturesForTracks } = yield call(fetchAudioFeaturesForTracks, idToken, trackIds); 31 | 32 | const tracksWithAudioFeatures = tracks.mergeDeep(audioFeaturesForTracks); 33 | 34 | yield put(mosaicSuccess(tracksWithAudioFeatures.sort(() => Math.random()))); 35 | } 36 | } catch (error) { 37 | yield call(handleSpotifyApiError, error, mosaicFailure, 'mosaic'); 38 | } 39 | } 40 | 41 | export function* watchMosaicRequest() { 42 | while (true) { 43 | const { idToken } = yield take(MOSAIC_REQUEST); 44 | 45 | yield call(fetchMosaicSaga, idToken); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/mosaic/selectors.js: -------------------------------------------------------------------------------- 1 | export const getError = state => state.mosaic.get('error'); 2 | 3 | export const getIsFetching = state => state.mosaic.get('isFetching'); 4 | 5 | export const getIsHydrated = state => state.mosaic.get('isHydrated'); 6 | 7 | export const getTracks = state => state.mosaic.get('tracks'); 8 | 9 | export const getTrack = (state, trackId) => state.mosaic.getIn(['tracks', trackId]); 10 | -------------------------------------------------------------------------------- /src/recommended/RecommendedPage.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Flex, Box } from 'reflexbox'; 4 | import { 5 | PageHeader, 6 | Container, 7 | Message, 8 | Stat, 9 | Button, 10 | ButtonOutline, 11 | Overlay, 12 | } from 'rebass'; 13 | import ImmutablePropTypes from 'react-immutable-proptypes'; 14 | import { bindActionCreators } from 'redux'; 15 | import functional from 'react-functional'; 16 | 17 | import RecommendedTracksList from './RecommendedTracksList'; 18 | import { recommendedTracksRequest, createRecommendedPlaylistRequest } from './actions'; 19 | import { 20 | getError, 21 | getIsCreatingPlaylist, 22 | getIsFetching, 23 | getIsPlaylistCreated, 24 | getRecommendedTracks, 25 | getTargetAttributePercentages, 26 | } from './selectors'; 27 | import FadeInTransition from '../shared-components/FadeInTransition'; 28 | import FullscreenLoader from '../shared-components/FullscreenLoader'; 29 | import { actions as appActions } from '../app'; 30 | import { selectors as authSelectors } from '../auth'; 31 | 32 | const RecommendedPage = ({ 33 | actions, 34 | idToken, 35 | isFetching, 36 | isCreatingPlaylist, 37 | isPlaylistCreated, 38 | recommendedTracks, 39 | targetAttributePercentages, 40 | error, 41 | }) => { 42 | const onTrackClick = trackId => actions.openModal(trackId); 43 | 44 | return ( 45 | isFetching ? 46 | : 47 | 48 | 49 | 50 | 51 | 52 | 53 | 59 | { 60 | isPlaylistCreated ? 61 | !error && 62 | 69 | Playlist saved to Spotify 70 | : 71 | !error && 72 | 81 | 82 | } 83 | 84 | { 85 | error && 86 | 87 | { `Error: ${JSON.stringify(error)}` } 88 | 89 | } 90 | 91 | 98 | 105 | 112 | 119 | 126 | 133 | 134 | 138 | 139 | 140 | 141 | ); 142 | }; 143 | 144 | RecommendedPage.propTypes = { 145 | actions: PropTypes.object.isRequired, 146 | idToken: PropTypes.string.isRequired, 147 | recommendedTracks: ImmutablePropTypes.map.isRequired, 148 | targetAttributePercentages: ImmutablePropTypes.map.isRequired, 149 | isFetching: PropTypes.bool.isRequired, 150 | isCreatingPlaylist: PropTypes.bool.isRequired, 151 | isPlaylistCreated: PropTypes.bool.isRequired, 152 | error: PropTypes.object, 153 | }; 154 | 155 | RecommendedPage.defaultProps = { 156 | error: null, 157 | }; 158 | 159 | RecommendedPage.componentDidMount = ({ actions, idToken }) => actions.recommendedTracksRequest(idToken); 160 | 161 | const mapStateToProps = state => ( 162 | { 163 | idToken: authSelectors.getIdToken(state), 164 | recommendedTracks: getRecommendedTracks(state), 165 | targetAttributePercentages: getTargetAttributePercentages(state), 166 | isFetching: getIsFetching(state), 167 | isCreatingPlaylist: getIsCreatingPlaylist(state), 168 | isPlaylistCreated: getIsPlaylistCreated(state), 169 | error: getError(state), 170 | } 171 | ); 172 | 173 | const mapDispatchToProps = dispatch => ( 174 | { 175 | actions: bindActionCreators( 176 | { recommendedTracksRequest, createRecommendedPlaylistRequest, ...appActions }, 177 | dispatch, 178 | ), 179 | } 180 | ); 181 | 182 | export default connect(mapStateToProps, mapDispatchToProps)(functional(RecommendedPage)); 183 | -------------------------------------------------------------------------------- /src/recommended/RecommendedTracksList.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { Box, Flex } from 'reflexbox'; 3 | import { 4 | Text, 5 | Space, 6 | Heading, 7 | } from 'rebass'; 8 | import Immutable from 'immutable'; 9 | import ImmutablePropTypes from 'react-immutable-proptypes'; 10 | 11 | import FadeImage from '../shared-components/FadeImage'; 12 | import { getAlbumArtUrlForTrack } from '../utils'; 13 | 14 | class RecommendedTracksList extends Component { 15 | shouldComponentUpdate(nextProps) { 16 | return !Immutable.is(nextProps.recommendedTracks, this.props.recommendedTracks); 17 | } 18 | 19 | render() { 20 | const { recommendedTracks, onTrackClick } = this.props; 21 | 22 | return ( 23 | 24 | { 25 | recommendedTracks 26 | .entrySeq() 27 | .map(([id, recommendedTrack]) => 28 | onTrackClick(id)} 34 | > 35 | 45 | 46 | 47 | {recommendedTrack.get('name')} 48 | 49 | 50 | {recommendedTrack.get('artists').map(a => a.get('name')).join(', ')} 51 | 52 | 53 | {recommendedTrack.getIn(['album', 'name'])} 54 | 55 | 56 | 57 | , 58 | ) 59 | } 60 | 61 | ); 62 | } 63 | } 64 | 65 | RecommendedTracksList.propTypes = { 66 | recommendedTracks: ImmutablePropTypes.map.isRequired, 67 | onTrackClick: PropTypes.func, 68 | }; 69 | 70 | RecommendedTracksList.defaultProps = { 71 | onTrackClick: null, 72 | }; 73 | 74 | export default RecommendedTracksList; 75 | -------------------------------------------------------------------------------- /src/recommended/actions.js: -------------------------------------------------------------------------------- 1 | export const RECOMMENDED_TRACKS_REQUEST = 'RECOMMENDED_TRACKS_REQUEST'; 2 | export function recommendedTracksRequest(idToken) { 3 | return { 4 | type: RECOMMENDED_TRACKS_REQUEST, 5 | idToken, 6 | }; 7 | } 8 | 9 | export const RECOMMENDED_TRACKS_HYDRATED = 'RECOMMENDED_TRACKS_HYDRATED'; 10 | export function recommendedTracksHydrated() { 11 | return { 12 | type: RECOMMENDED_TRACKS_HYDRATED, 13 | }; 14 | } 15 | 16 | export const RECOMMENDED_TRACKS_SUCCESS = 'RECOMMENDED_TRACKS_SUCCESS'; 17 | export function recommendedTracksSuccess(recommendedTracks, targetAttributes) { 18 | return { 19 | type: RECOMMENDED_TRACKS_SUCCESS, 20 | recommendedTracks, 21 | targetAttributes, 22 | }; 23 | } 24 | 25 | export const RECOMMENDED_TRACKS_FAILURE = 'RECOMMENDED_TRACKS_FAILURE'; 26 | export function recommendedTracksFailure(error) { 27 | return { 28 | type: RECOMMENDED_TRACKS_FAILURE, 29 | error, 30 | }; 31 | } 32 | 33 | export const CREATE_RECOMMENDED_PLAYLIST_REQUEST = 'CREATE_RECOMMENDED_PLAYLIST_REQUEST'; 34 | export function createRecommendedPlaylistRequest(idToken) { 35 | return { 36 | type: CREATE_RECOMMENDED_PLAYLIST_REQUEST, 37 | idToken, 38 | }; 39 | } 40 | 41 | export const CREATE_RECOMMENDED_PLAYLIST_SUCCESS = 'CREATE_RECOMMENDED_PLAYLIST_SUCCESS'; 42 | export function createRecommendedPlaylistSuccess() { 43 | return { 44 | type: CREATE_RECOMMENDED_PLAYLIST_SUCCESS, 45 | }; 46 | } 47 | 48 | export const CREATE_RECOMMENDED_PLAYLIST_FAILURE = 'CREATE_RECOMMENDED_PLAYLIST_FAILURE'; 49 | export function createRecommendedPlaylistFailure(error) { 50 | return { 51 | type: CREATE_RECOMMENDED_PLAYLIST_FAILURE, 52 | error, 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /src/recommended/index.js: -------------------------------------------------------------------------------- 1 | import * as actions from './actions'; 2 | import reducer from './reducer'; 3 | import * as sagas from './sagas'; 4 | import * as selectors from './selectors'; 5 | 6 | export { actions, reducer, sagas, selectors }; 7 | -------------------------------------------------------------------------------- /src/recommended/reducer.js: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable'; 2 | 3 | import { 4 | RECOMMENDED_TRACKS_REQUEST, 5 | RECOMMENDED_TRACKS_HYDRATED, 6 | RECOMMENDED_TRACKS_SUCCESS, 7 | RECOMMENDED_TRACKS_FAILURE, 8 | CREATE_RECOMMENDED_PLAYLIST_REQUEST, 9 | CREATE_RECOMMENDED_PLAYLIST_SUCCESS, 10 | CREATE_RECOMMENDED_PLAYLIST_FAILURE, 11 | } from './actions'; 12 | 13 | export const initialState = new Map({ 14 | isFetching: false, 15 | isCreatingPlaylist: false, 16 | isPlaylistCreated: false, 17 | isHydrated: false, 18 | recommendedTracks: new Map(), 19 | targetAttributes: new Map(), 20 | error: null, 21 | }); 22 | 23 | export default function recommended(state = initialState, action) { 24 | switch (action.type) { 25 | case RECOMMENDED_TRACKS_REQUEST: 26 | return state.set('isFetching', true); 27 | case RECOMMENDED_TRACKS_HYDRATED: 28 | return state.set('isFetching', false); 29 | case RECOMMENDED_TRACKS_SUCCESS: { 30 | return state.merge({ 31 | isFetching: false, 32 | isHydrated: true, 33 | recommendedTracks: action.recommendedTracks, 34 | targetAttributes: action.targetAttributes, 35 | error: null, 36 | }); 37 | } 38 | case RECOMMENDED_TRACKS_FAILURE: 39 | return state.merge({ 40 | isFetching: false, 41 | error: action.error, 42 | }); 43 | case CREATE_RECOMMENDED_PLAYLIST_REQUEST: 44 | return state.set('isCreatingPlaylist', true); 45 | case CREATE_RECOMMENDED_PLAYLIST_SUCCESS: 46 | return state.merge({ 47 | isCreatingPlaylist: false, 48 | isPlaylistCreated: true, 49 | error: null, 50 | }); 51 | case CREATE_RECOMMENDED_PLAYLIST_FAILURE: 52 | return state.merge({ 53 | isCreatingPlaylist: false, 54 | isPlaylistCreated: false, 55 | error: action.error, 56 | }); 57 | default: 58 | return state; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/recommended/sagas.js: -------------------------------------------------------------------------------- 1 | import 'isomorphic-fetch'; 2 | import { Map } from 'immutable'; 3 | import { call, put, select, take } from 'redux-saga/effects'; 4 | 5 | import { 6 | RECOMMENDED_TRACKS_REQUEST, 7 | recommendedTracksHydrated, 8 | recommendedTracksSuccess, 9 | recommendedTracksFailure, 10 | CREATE_RECOMMENDED_PLAYLIST_REQUEST, 11 | createRecommendedPlaylistSuccess, 12 | createRecommendedPlaylistFailure, 13 | } from './actions'; 14 | import { getIsHydrated, getRecommendedTracks } from './selectors'; 15 | import { selectors as appSelectors } from '../app'; 16 | import { 17 | sagas as mosaicSagas, 18 | selectors as mosaicSelectors, 19 | } from '../mosaic'; 20 | import { 21 | fetchAudioFeaturesForTracks, 22 | fetchRecommendedTracks, 23 | fetchUserProfile, 24 | createPrivatePlaylist, 25 | addTracksToPlaylist, 26 | handleSpotifyApiError, 27 | } from '../spotifyApiService'; 28 | 29 | const getAverageAudioFeature = (tracks, featureSelector) => 30 | tracks.map(featureSelector).reduce((a, b) => a + b, 0) / tracks.count(); 31 | 32 | const getCommaSeparatedSeedArtistIds = tracks => 33 | tracks 34 | .valueSeq() 35 | .flatMap(t => t.get('artists')) 36 | .groupBy(a => a.get('id')) 37 | .sort((a, b) => b.size - a.size) 38 | .take(5) 39 | .keySeq() 40 | .join(); 41 | 42 | export function* fetchRecommendedTracksSaga(idToken) { 43 | try { 44 | const isHydrated = yield select(getIsHydrated); 45 | 46 | if (isHydrated) { 47 | yield put(recommendedTracksHydrated()); 48 | } else { 49 | yield call(mosaicSagas.fetchMosaicSaga, idToken); 50 | 51 | const tracks = yield select(mosaicSelectors.getTracks); 52 | 53 | if (tracks.isEmpty()) { 54 | throw new Error( 55 | 'Unfortunately you do not have enough Spotify data to generate a recommended playlist'); 56 | } 57 | 58 | const seedArtistIds = getCommaSeparatedSeedArtistIds(tracks); 59 | 60 | const targetAttributes = new Map({ 61 | acousticness: getAverageAudioFeature(tracks, t => t.get('acousticness')), 62 | danceability: getAverageAudioFeature(tracks, t => t.get('danceability')), 63 | energy: getAverageAudioFeature(tracks, t => t.get('energy')), 64 | instrumentalness: getAverageAudioFeature(tracks, t => t.get('instrumentalness')), 65 | speechiness: getAverageAudioFeature(tracks, t => t.get('speechiness')), 66 | valence: getAverageAudioFeature(tracks, t => t.get('valence')), 67 | }); 68 | 69 | const { recommendedTracks } = 70 | yield call(fetchRecommendedTracks, idToken, targetAttributes, seedArtistIds); 71 | 72 | const recommendedTrackIds = recommendedTracks.keySeq().toSet().join(); 73 | 74 | const { audioFeaturesForTracks } = 75 | yield call(fetchAudioFeaturesForTracks, idToken, recommendedTrackIds); 76 | 77 | const tracksWithAudioFeatures = recommendedTracks.mergeDeep(audioFeaturesForTracks); 78 | 79 | yield put(recommendedTracksSuccess( 80 | tracksWithAudioFeatures.sort(() => Math.random()), targetAttributes)); 81 | } 82 | } catch (error) { 83 | yield call(handleSpotifyApiError, error, recommendedTracksFailure, 'recommended'); 84 | } 85 | } 86 | 87 | export function* watchRecommendedTracksRequest() { 88 | while (true) { 89 | const { idToken } = yield take(RECOMMENDED_TRACKS_REQUEST); 90 | 91 | yield call(fetchRecommendedTracksSaga, idToken); 92 | } 93 | } 94 | 95 | export function* createRecommendedPlaylistSaga(idToken) { 96 | try { 97 | const recommendedTracks = yield select(getRecommendedTracks); 98 | 99 | const recommendedTrackUris = recommendedTracks.map(track => track.get('uri')).join(); 100 | 101 | const { userProfile } = yield call(fetchUserProfile, idToken); 102 | 103 | const currentTerm = yield select(appSelectors.getCurrentTerm); 104 | 105 | const playlistName = `AI Recommended (${currentTerm})`; 106 | 107 | const { playlist } = 108 | yield call(createPrivatePlaylist, idToken, userProfile.get('id'), playlistName); 109 | 110 | yield call( 111 | addTracksToPlaylist, 112 | idToken, 113 | userProfile.get('id'), 114 | playlist.get('id'), 115 | recommendedTrackUris, 116 | ); 117 | 118 | yield put(createRecommendedPlaylistSuccess()); 119 | } catch (error) { 120 | yield call(handleSpotifyApiError, error, createRecommendedPlaylistFailure, 'recommended'); 121 | } 122 | } 123 | 124 | export function* watchCreateRecommendedPlaylistRequest() { 125 | while (true) { 126 | const { idToken } = yield take(CREATE_RECOMMENDED_PLAYLIST_REQUEST); 127 | 128 | yield call(createRecommendedPlaylistSaga, idToken); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/recommended/selectors.js: -------------------------------------------------------------------------------- 1 | export const getError = state => state.recommended.get('error'); 2 | 3 | export const getIsCreatingPlaylist = state => state.recommended.get('isCreatingPlaylist'); 4 | 5 | export const getIsFetching = state => state.recommended.get('isFetching'); 6 | 7 | export const getIsHydrated = state => state.recommended.get('isHydrated'); 8 | 9 | export const getIsPlaylistCreated = state => state.recommended.get('isPlaylistCreated'); 10 | 11 | export const getRecommendedTracks = state => state.recommended.get('recommendedTracks'); 12 | 13 | export const getRecommendedTrack = (state, recommendedTrackId) => 14 | state.recommended.getIn(['recommendedTracks', recommendedTrackId]); 15 | 16 | export const getTargetAttributePercentages = state => 17 | state.recommended.get('targetAttributes').map(t => Math.round(t * 100)); 18 | -------------------------------------------------------------------------------- /src/rootSaga.js: -------------------------------------------------------------------------------- 1 | import { fork } from 'redux-saga/effects'; 2 | 3 | import { sagas as authSagas } from './auth'; 4 | import { sagas as mosaicSagas } from './mosaic'; 5 | import { sagas as recommendedSagas } from './recommended'; 6 | import { sagas as artistsSagas } from './artists'; 7 | 8 | export default function* rootSaga() { 9 | yield [ 10 | fork(authSagas.watchLoginRequest), 11 | fork(authSagas.watchLoginSuccess), 12 | fork(authSagas.watchLoginFailure), 13 | fork(authSagas.watchLogout), 14 | fork(mosaicSagas.watchMosaicRequest), 15 | fork(recommendedSagas.watchRecommendedTracksRequest), 16 | fork(recommendedSagas.watchCreateRecommendedPlaylistRequest), 17 | fork(artistsSagas.watchArtistsRequest), 18 | ]; 19 | } 20 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route, IndexRoute } from 'react-router'; 3 | 4 | import AboutPage from './shared-components/AboutPage'; 5 | import GlossaryPage from './shared-components/GlossaryPage'; 6 | import HomePage from './shared-components/HomePage'; 7 | import NotFoundPage from './shared-components/NotFoundPage'; 8 | import App from './app/App'; 9 | import ArtistsPage from './artists/ArtistsPage'; 10 | import SpotifyLoginCallbackHandler from './auth/SpotifyLoginCallbackHandler'; 11 | import RestrictedPage from './auth/RestrictedPage'; 12 | import MosaicPage from './mosaic/MosaicPage'; 13 | import RecommendedPage from './recommended/RecommendedPage'; 14 | 15 | export default ( 16 | { 20 | if (nextState.location.action !== 'POP') { 21 | window.scrollTo(0, 0); 22 | } 23 | }} 24 | > 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | -------------------------------------------------------------------------------- /src/shared-components/AboutPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box } from 'reflexbox'; 3 | import { 4 | PageHeader, 5 | Text, 6 | Container, 7 | } from 'rebass'; 8 | 9 | import FadeInTransition from './FadeInTransition'; 10 | 11 | const AboutPage = () => ( 12 | 13 | 14 | 15 | 21 | 22 | I built this web app teach myself about front-end development with  23 | 29 | React 30 | 31 |  and  32 | 38 | Redux 39 | . 40 | For a long while I've yearned for an enjoyable, fast and understandable way to create 41 | UIs for APIs that I build. I've experimented with various other frameworks/tools 42 | but none resonated with me like the React/Redux combo. The community around these 43 | technologies is outstanding. I haven't had this much fun writing code in a while! 44 | 45 | 46 | This app connects to the  47 | 53 | Spotify API 54 | 55 |  using the Implicit Grant Flow to authenticate. I'm a hobby musician 56 | with a deep interest in music and music production. I thought it'd be an interesting 57 | project to present the data available from the Spotify API in various ways. 58 | 59 | 60 | I aimed to keep things simple, avoid reinventing the wheel and embrace essentialism 61 | (use as little as possible). Using a component library ( 62 | 68 | Rebass 69 | 70 | ) proved invaluable. I was able to concentrate on 'business logic' yet still 71 | create something presentable. I also focused on using React and Redux best practices and 72 | making the app as responsive as possible. 73 | 74 | 75 | I'd be thankful for any feedback or suggestions ( 76 | 82 | GitHub 83 | 84 |  or  85 | 86 | email 87 | 88 | ). 89 | I look forward to applying the skills I've acquired building this app on future projects. 90 | 91 | 92 | 93 | 94 | ); 95 | 96 | export default AboutPage; 97 | -------------------------------------------------------------------------------- /src/shared-components/AppFooter.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router'; 3 | import { Box } from 'reflexbox'; 4 | import moment from 'moment'; 5 | import { 6 | Toolbar, 7 | Space, 8 | NavItem, 9 | } from 'rebass'; 10 | 11 | const AppFooter = () => ( 12 | 13 | 14 | 15 | 16 | 17 | About 18 | 19 | 20 | 21 | 22 | Glossary 23 | 24 | 25 | 26 | { '//' } 27 | 28 | 29 | {`© 603.nz ${moment().year()}`} 30 | 31 | 32 | 33 | ); 34 | 35 | export default AppFooter; 36 | -------------------------------------------------------------------------------- /src/shared-components/FadeImage.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { Motion, spring } from 'react-motion'; 3 | 4 | class FadeImage extends Component { 5 | constructor(props) { 6 | super(props); 7 | 8 | this.state = { 9 | loaded: false, 10 | }; 11 | } 12 | 13 | handleImageLoad = () => { 14 | this.setState({ loaded: true }); 15 | } 16 | 17 | render() { 18 | const { src, onClickHandler, style = {} } = this.props; 19 | 20 | return ( 21 | 25 | { interpolatedStyle => 26 | Fade 36 | } 37 | 38 | ); 39 | } 40 | } 41 | 42 | FadeImage.propTypes = { 43 | src: PropTypes.string.isRequired, 44 | onClickHandler: PropTypes.func, 45 | style: PropTypes.object, 46 | }; 47 | 48 | FadeImage.defaultProps = { 49 | onClickHandler: null, 50 | style: null, 51 | }; 52 | 53 | export default FadeImage; 54 | -------------------------------------------------------------------------------- /src/shared-components/FadeInTransition.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import Transition from 'react-motion-ui-pack'; 3 | 4 | const FadeInTransition = ({ children }) => ( 5 | 6 | {children} 7 | 8 | ); 9 | 10 | FadeInTransition.propTypes = { 11 | children: PropTypes.node.isRequired, 12 | }; 13 | 14 | export default FadeInTransition; 15 | -------------------------------------------------------------------------------- /src/shared-components/FullscreenLoader.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import Loading from 'react-loading'; 3 | import { Flex } from 'reflexbox'; 4 | 5 | const FullscreenLoader = ({ delay = 1000 }) => ( 6 | 7 | 8 | 9 | ); 10 | 11 | FullscreenLoader.propTypes = { 12 | delay: PropTypes.number, 13 | }; 14 | 15 | FullscreenLoader.defaultProps = { 16 | delay: null, 17 | }; 18 | 19 | export default FullscreenLoader; 20 | -------------------------------------------------------------------------------- /src/shared-components/GlossaryPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box } from 'reflexbox'; 3 | import { 4 | PageHeader, 5 | Text, 6 | Container, 7 | Heading, 8 | } from 'rebass'; 9 | 10 | import glossary from './glossary'; 11 | import FadeInTransition from './FadeInTransition'; 12 | 13 | const GlossaryPage = () => ( 14 | 15 | 16 | 17 | 23 | { 24 | glossary.sort((a, b) => a.title.localeCompare(b.title)).map(g => 25 | 26 | 27 | {g.title} 28 | 29 | 30 | {g.definition} 31 | 32 | , 33 | ) 34 | } 35 | 36 | 37 | 38 | ); 39 | 40 | export default GlossaryPage; 41 | -------------------------------------------------------------------------------- /src/shared-components/HomePage.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { bindActionCreators } from 'redux'; 4 | import { Flex } from 'reflexbox'; 5 | import { 6 | Heading, 7 | Button, 8 | } from 'rebass'; 9 | 10 | import FadeInTransition from './FadeInTransition'; 11 | import { actions as authActions, selectors as authSelectors } from '../auth'; 12 | 13 | const HomePage = ({ isLoggedIn, actions }) => ( 14 | 15 | 16 | 17 | Audio Insights 18 | 19 | 20 | Derived from your Spotify library 21 | 22 | { 23 | !isLoggedIn ? 24 | : 27 | 30 | } 31 | 32 | 33 | ); 34 | 35 | HomePage.propTypes = { 36 | actions: PropTypes.object.isRequired, 37 | isLoggedIn: PropTypes.bool, 38 | }; 39 | 40 | HomePage.defaultProps = { 41 | isLoggedIn: false, 42 | }; 43 | 44 | const mapStateToProps = state => ( 45 | { 46 | isLoggedIn: authSelectors.getIsLoggedIn(state), 47 | } 48 | ); 49 | 50 | const mapDispatchToProps = dispatch => ( 51 | { 52 | actions: bindActionCreators({ ...authActions }, dispatch), 53 | } 54 | ); 55 | 56 | export default connect(mapStateToProps, mapDispatchToProps)(HomePage); 57 | -------------------------------------------------------------------------------- /src/shared-components/Navbar.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { Link, IndexLink } from 'react-router'; 3 | import { 4 | NavItem, 5 | Space, 6 | Toolbar, 7 | Dropdown, 8 | DropdownMenu, 9 | Arrow, 10 | } from 'rebass'; 11 | 12 | const Navbar = ({ isLoggedIn, isDropdownOpen, onToggleDropdown, onTermChange }) => ( 13 | 14 | 15 | 16 | Home 17 | 18 | 19 | { 20 | isLoggedIn && 21 | 22 | 23 | Mosaic 24 | 25 | 26 | } 27 | { 28 | isLoggedIn && 29 | 30 | 31 | Recommended 32 | 33 | 34 | } 35 | { 36 | isLoggedIn && 37 | 38 | 39 | Artists 40 | 41 | 42 | } 43 | 44 | { 45 | isLoggedIn && 46 | 47 | onToggleDropdown()}> 48 | Term 49 | 50 | 51 | onToggleDropdown()} 54 | open={isDropdownOpen} 55 | > 56 | onTermChange('short_term')} 58 | > 59 | Short 60 | 61 | onTermChange('medium_term')} 63 | > 64 | Medium 65 | 66 | onTermChange('long_term')} 68 | > 69 | Long 70 | 71 | 72 | 73 | } 74 | 75 | ); 76 | 77 | Navbar.propTypes = { 78 | isLoggedIn: PropTypes.bool.isRequired, 79 | isDropdownOpen: PropTypes.bool.isRequired, 80 | onToggleDropdown: PropTypes.func.isRequired, 81 | onTermChange: PropTypes.func.isRequired, 82 | }; 83 | 84 | export default Navbar; 85 | -------------------------------------------------------------------------------- /src/shared-components/NotFoundPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box } from 'reflexbox'; 3 | import { 4 | PageHeader, 5 | Blockquote, 6 | Container, 7 | } from 'rebass'; 8 | 9 | import FadeInTransition from './FadeInTransition'; 10 | 11 | const NotFoundPage = () => ( 12 | 13 | 14 | 15 | 21 |
22 | All that is gold does not glitter,
23 | Not all those who wander are lost;
24 | The old that is strong does not wither,
25 | Deep roots are not reached by the frost. 26 |
27 |
28 |
29 |
30 | ); 31 | 32 | export default NotFoundPage; 33 | -------------------------------------------------------------------------------- /src/shared-components/PlayPause.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import Icon from 'react-geomicons'; 3 | import { ButtonCircle } from 'rebass'; 4 | 5 | class PlayPause extends Component { 6 | constructor(props) { 7 | super(props); 8 | 9 | this.state = { 10 | playing: false, 11 | }; 12 | } 13 | 14 | handleClick = () => { 15 | const isPlaying = this.state.playing; 16 | 17 | if (isPlaying) { 18 | this.preview.pause(); 19 | } else { 20 | this.preview.play(); 21 | } 22 | 23 | this.setState({ playing: !isPlaying }); 24 | } 25 | 26 | previewEnded = () => { 27 | this.setState({ playing: false }); 28 | } 29 | 30 | render() { 31 | return ( 32 | 38 | 45 | 52 | ); 53 | } 54 | } 55 | 56 | PlayPause.propTypes = { 57 | previewUrl: PropTypes.string.isRequired, 58 | }; 59 | 60 | export default PlayPause; 61 | -------------------------------------------------------------------------------- /src/shared-components/TrackInfoModal.css: -------------------------------------------------------------------------------- 1 | .overflowHidden { 2 | overflow-x: hidden !important; 3 | overflow-y: hidden !important; 4 | } -------------------------------------------------------------------------------- /src/shared-components/TrackInfoModal.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { bindActionCreators } from 'redux'; 4 | import Icon from 'react-geomicons'; 5 | import { 6 | Overlay, 7 | Panel, 8 | PanelHeader, 9 | Text, 10 | Close, 11 | Space, 12 | Heading, 13 | Divider, 14 | ButtonCircle, 15 | } from 'rebass'; 16 | import { Flex } from 'reflexbox'; 17 | 18 | import FadeInTransition from './FadeInTransition'; 19 | import FadeImage from './FadeImage'; 20 | import PlayPause from './PlayPause'; 21 | import { actions as appActions, selectors as appSelectors } from '../app'; 22 | import { selectors as mosaicSelectors } from '../mosaic'; 23 | import { selectors as recommendedSelectors } from '../recommended'; 24 | import { getAlbumArtUrlForTrack } from '../utils'; 25 | 26 | const styles = require('./TrackInfoModal.css'); 27 | 28 | const TrackInfoModal = ({ isModalOpen, selectedTrack, windowWidth, actions }) => { 29 | const mapPitchClassToKey = (pitchClass) => { 30 | switch (pitchClass) { 31 | case 0: 32 | return 'C'; 33 | case 1: 34 | return 'C♯/D♭'; 35 | case 2: 36 | return 'D'; 37 | case 3: 38 | return 'D♯/E♭'; 39 | case 4: 40 | return 'E'; 41 | case 5: 42 | return 'F'; 43 | case 6: 44 | return 'F♯/G♭'; 45 | case 7: 46 | return 'G'; 47 | case 8: 48 | return 'G♯/A♭'; 49 | case 9: 50 | return 'A'; 51 | case 10: 52 | return 'A♯/B♭'; 53 | case 11: 54 | return 'B'; 55 | default: 56 | return 'UNKNOWN'; 57 | } 58 | }; 59 | 60 | const mapMode = mode => (mode === 1 ? 'major' : 'minor'); 61 | 62 | return ( 63 | selectedTrack ? 64 | 65 | actions.closeModal()} className={styles.overflowHidden}> 66 | 67 | 68 | 69 | {`${selectedTrack.getIn(['artists', 0, 'name'])} - ${selectedTrack.get('name')}`} 70 | 71 | 72 | actions.closeModal()} /> 73 | 74 | 79 | 88 | 89 | 90 | {selectedTrack.get('name')} 91 | 92 | 95 | {selectedTrack.get('artists').map(a => a.get('name')).join(', ')} 96 | 97 | 98 | {selectedTrack.getIn(['album', 'name'])} 99 | 100 | 101 | 480} style={{ marginTop: '-16px' }}> 102 | 103 | 104 | {`${Math.round(selectedTrack.get('tempo'))} bpm`} 105 | 106 | 107 | { 108 | `${mapPitchClassToKey(selectedTrack.get('key'))} 109 | ${mapMode(selectedTrack.get('mode'))}` 110 | } 111 | 112 | 113 | 114 | 115 | 118 | 126 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | : null 141 | ); 142 | }; 143 | 144 | TrackInfoModal.propTypes = { 145 | actions: PropTypes.object.isRequired, 146 | isModalOpen: PropTypes.bool.isRequired, 147 | windowWidth: PropTypes.number.isRequired, 148 | selectedTrack: PropTypes.object, 149 | }; 150 | 151 | TrackInfoModal.defaultProps = { 152 | windowWidth: 0, 153 | selectedTrack: null, 154 | }; 155 | 156 | const mapStateToProps = state => ( 157 | { 158 | isModalOpen: appSelectors.getIsModalOpen(state), 159 | selectedTrack: mosaicSelectors.getTrack(state, appSelectors.getSelectedTrackId(state)) || 160 | recommendedSelectors.getRecommendedTrack(state, appSelectors.getSelectedTrackId(state)), 161 | } 162 | ); 163 | 164 | const mapDispatchToProps = dispatch => ( 165 | { 166 | actions: bindActionCreators({ ...appActions }, dispatch), 167 | } 168 | ); 169 | 170 | export default connect(mapStateToProps, mapDispatchToProps)(TrackInfoModal); 171 | -------------------------------------------------------------------------------- /src/shared-components/WindowDimensionsWrapper.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | 3 | class WindowDimensionsWrapper extends Component { 4 | constructor(props) { 5 | super(props); 6 | 7 | this.state = { 8 | windowWidth: window.innerWidth, 9 | }; 10 | } 11 | 12 | componentDidMount() { 13 | window.addEventListener('resize', this.handleResize); 14 | } 15 | 16 | componentWillUnmount() { 17 | window.removeEventListener('resize', this.handleResize); 18 | } 19 | 20 | handleResize = () => { 21 | this.setState({ windowWidth: window.innerWidth }); 22 | } 23 | 24 | render() { 25 | return (React.cloneElement(this.props.children, { windowWidth: this.state.windowWidth })); 26 | } 27 | } 28 | 29 | WindowDimensionsWrapper.propTypes = { 30 | children: PropTypes.node.isRequired, 31 | }; 32 | 33 | export default WindowDimensionsWrapper; 34 | -------------------------------------------------------------------------------- /src/shared-components/glossary.js: -------------------------------------------------------------------------------- 1 | const glossary = [ 2 | { 3 | title: 'Mosaic', 4 | definition: `An album art mosaic of your top fifty tracks based on calculated affinity. 5 | Affinity is a measure of your expected preference for each particular track. 6 | It is based on your Spotify behavior, including play history, but does not include actions 7 | made while in incognito mode. If you're a light or infrequent user of Spotify, you might not 8 | have sufficient play history to generate a full affinity data set :(`, 9 | }, 10 | { 11 | title: 'Recommended', 12 | definition: `A generated playlist of recommendations based on features of your top tracks. The 13 | average acousticness, danceability, energy, instrumentalness, speechiness and valence of your 14 | top fifty tracks are used to generate the playlist. The playlist can be saved to Spotify and 15 | will be named 'AI Recommended (*_term)' where * is the current term.`, 16 | }, 17 | { 18 | title: 'Term', 19 | definition: `As your Spotify behavior is likely to shift over time, the data displayed in the 20 | app is available over three terms/time frames. Long term (default) is calculated from several 21 | years of usage. Medium term is calculated from approximately the last six months of usage. 22 | Short term is calculated from approximately the last four weeks of usage.`, 23 | }, 24 | { 25 | title: 'Acousticness', 26 | definition: `A confidence measure of whether a track is acoustic. A value of 0% represents low 27 | confidence the track is acoustic while 100% represents high confidence the track is 28 | acoustic.`, 29 | }, 30 | { 31 | title: 'Danceability', 32 | definition: `Represents a track's suitability for dancing. Based on a combination of 33 | musical elements including tempo, rhythm stability, beat strength, and overall regularity. 34 | A value of 0% is least danceable while 100% is most danceable.`, 35 | }, 36 | { 37 | title: 'Energy', 38 | definition: `A perceptual measure of a track's intensity and activity. Typically, 39 | energetic tracks feel fast, loud, and noisy. For example, death metal has high energy, while a 40 | Bach prelude scores low on the scale. Perceptual features contributing to energy 41 | include dynamic range, perceived loudness, timbre, onset rate, and general entropy. A value of 42 | 0% is least energetic while 100% is most energetic.`, 43 | }, 44 | { 45 | title: 'Instrumentalness', 46 | definition: `Predicts whether a track contains no vocals. "Ooh" and "aah" sounds are treated as 47 | instrumental in this context. Rap or spoken word tracks are clearly "vocal". The closer the 48 | instrumentalness value is to 100%, the greater likelihood the track contains no vocal content. 49 | Values above 50% are intended to represent instrumental tracks, but confidence is higher as 50 | the value approaches 100%.`, 51 | }, 52 | { 53 | title: 'Speechiness', 54 | definition: `Represents the presence of spoken words in a track. The more exclusively 55 | speech-like the recording (e.g. talk show, audio book, poetry), the closer to 100% the value 56 | will be. Values above 66% describe tracks that are probably made entirely of spoken words. 57 | Values between 33% and 66% describe tracks that may contain both music and speech, either in 58 | sections or layered, including such cases as rap music. Values below 33% most likely represent 59 | music and other non-speech-like tracks.`, 60 | }, 61 | { 62 | title: 'Valence', 63 | definition: `A measure describing the musical positiveness conveyed by a track. Tracks with high 64 | valence sound more positive (e.g. happy, cheerful, euphoric), while tracks with low valence 65 | sound more negative (e.g. sad, depressed, angry).`, 66 | }, 67 | { 68 | title: 'Popularity', 69 | definition: `The popularity of a track is calculated by algorithm and is based, in the most 70 | part, on the total number of plays the track has had and how recent those plays are. Generally 71 | speaking, songs that are being played a lot now will have a higher popularity than songs that 72 | were played a lot in the past. Duplicate tracks (e.g. the same track from a single and an 73 | album) are rated independently. Popularity may lag actual popularity by a few days: the value 74 | is not updated in real time.`, 75 | }, 76 | ]; 77 | 78 | export default glossary; 79 | -------------------------------------------------------------------------------- /src/spotifyApiService.js: -------------------------------------------------------------------------------- 1 | import 'isomorphic-fetch'; 2 | import Immutable, { Map } from 'immutable'; 3 | import { put } from 'redux-saga/effects'; 4 | 5 | import { actions as authActions } from './auth'; 6 | 7 | const baseUrl = 'https://api.spotify.com'; 8 | 9 | const getFetchInit = (idToken, requestMethod, body) => { 10 | const requestHeaders = new Headers(); 11 | requestHeaders.append('Authorization', `Bearer ${idToken}`); 12 | requestHeaders.append('Content-Type', 'application/json'); 13 | 14 | const fetchInit = { method: requestMethod, headers: requestHeaders }; 15 | 16 | if (body) { 17 | fetchInit.body = JSON.stringify(body); 18 | } 19 | 20 | return fetchInit; 21 | }; 22 | 23 | const checkStatus = (response) => { 24 | if (response.ok) { 25 | return response.json(); 26 | } 27 | 28 | return response.json() 29 | .then((err) => { 30 | throw new Error(`${response.statusText} (${response.status}) error occurred downstream: ${err.message}`); 31 | }); 32 | }; 33 | 34 | export const redirectToSpotifyLogin = (returnPath) => { 35 | window.location = `https://accounts.spotify.com/authorize? 36 | client_id=${encodeURIComponent(process.env.SPOTIFY_CLIENT_ID)}& 37 | redirect_uri=${encodeURIComponent(process.env.SPOTIFY_CALLBACK_URI)}& 38 | scope=${encodeURIComponent(process.env.SPOTIFY_SCOPES)}& 39 | response_type=token&state=${encodeURIComponent(returnPath)}`; 40 | }; 41 | 42 | export const fetchUserProfile = idToken => 43 | fetch(`${baseUrl}/v1/me`, getFetchInit(idToken, 'GET')) 44 | .then(checkStatus) 45 | .then(json => ({ userProfile: Immutable.fromJS(json) })) 46 | .catch(error => Promise.reject(error)); 47 | 48 | export const fetchTopTracks = (idToken, term) => 49 | fetch(`${baseUrl}/v1/me/top/tracks?limit=50&time_range=${term}`, getFetchInit(idToken, 'GET')) 50 | .then(checkStatus) 51 | .then(json => 52 | ({ tracks: new Map(json.items.map(track => [track.id, Immutable.fromJS(track)])) })) 53 | .catch(error => Promise.reject(error)); 54 | 55 | export const fetchAudioFeaturesForTracks = (idToken, trackIds) => 56 | fetch(`${baseUrl}/v1/audio-features/?ids=${trackIds}`, getFetchInit(idToken, 'GET')) 57 | .then(checkStatus) 58 | .then(json => ({ 59 | audioFeaturesForTracks: new Map( 60 | json.audio_features.map(audioFeature => [audioFeature.id, Immutable.fromJS(audioFeature)]), 61 | ), 62 | })) 63 | .catch(error => Promise.reject(error)); 64 | 65 | export const fetchRecommendedTracks = (idToken, targetAttributes, seedArtistIds) => 66 | fetch(`${baseUrl}/v1/recommendations? 67 | limit=100& 68 | seed_artists=${seedArtistIds}& 69 | target_acousticness=${targetAttributes.get('acousticness')}& 70 | target_danceability=${targetAttributes.get('danceability')}& 71 | target_energy=${targetAttributes.get('energy')}& 72 | target_instrumentalness=${targetAttributes.get('instrumentalness')}& 73 | target_speechiness=${targetAttributes.get('speechiness')}& 74 | target_valence=${targetAttributes.get('valence')}`, getFetchInit(idToken, 'GET')) 75 | .then(checkStatus) 76 | .then(json => ({ 77 | recommendedTracks: new Map( 78 | json.tracks.map(track => [track.id, Immutable.fromJS(track)]), 79 | ), 80 | })) 81 | .catch(error => Promise.reject(error)); 82 | 83 | export const fetchArtists = (idToken, term) => 84 | fetch(`${baseUrl}/v1/me/top/artists?limit=50&time_range=${term}`, getFetchInit(idToken, 'GET')) 85 | .then(checkStatus) 86 | .then(json => 87 | ({ artists: new Map(json.items.map(artist => [artist.id, Immutable.fromJS(artist)])) })) 88 | .catch(error => Promise.reject(error)); 89 | 90 | export const createPrivatePlaylist = (idToken, userId, playlistName) => { 91 | const body = { name: playlistName, public: false }; 92 | 93 | return fetch(`${baseUrl}/v1/users/${userId}/playlists`, getFetchInit(idToken, 'POST', body)) 94 | .then(checkStatus) 95 | .then(json => ({ playlist: Immutable.fromJS(json) })) 96 | .catch(error => Promise.reject(error)); 97 | }; 98 | 99 | export const addTracksToPlaylist = (idToken, userId, playlistId, trackUris) => 100 | fetch( 101 | `${baseUrl}/v1/users/${userId}/playlists/${playlistId}/tracks?uris=${trackUris}`, 102 | getFetchInit(idToken, 'POST'), 103 | ) 104 | .then(checkStatus) 105 | .then(json => ({ playlist: Immutable.fromJS(json) })) 106 | .catch(error => Promise.reject(error)); 107 | 108 | export function* handleSpotifyApiError(error, failureAction, redirectPath) { 109 | const response = error.response; 110 | 111 | if (response === undefined) { 112 | yield put(failureAction(error.message)); 113 | } else if (response.status === 401) { 114 | // Unauthorised - redirect to Spotify login 115 | yield put(authActions.loginRequest(redirectPath)); 116 | } else { 117 | const responseError = { 118 | status: response.status, 119 | statusText: response.statusText, 120 | message: error.message, 121 | }; 122 | 123 | yield put(failureAction(responseError)); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable'; 2 | 3 | export const getAlbumArtUrlForTrack = (track) => { 4 | const lowerResImageUrl = track.getIn(['album', 'images', '1', 'url']); 5 | const higherResImageUrl = track.getIn(['album', 'images', '0', 'url']); 6 | 7 | return lowerResImageUrl || higherResImageUrl || 'https://img.jch254.com/Blank.jpg'; 8 | }; 9 | 10 | const ID_TOKEN = 'id_token'; 11 | 12 | const ID_TOKEN_EXPIRY = 'id_token_expiry'; 13 | 14 | export const setStoredAuthState = (idToken, idTokenExpiry) => { 15 | localStorage.setItem(ID_TOKEN, idToken); 16 | localStorage.setItem(ID_TOKEN_EXPIRY, idTokenExpiry); 17 | }; 18 | 19 | export const removeStoredAuthState = () => { 20 | localStorage.removeItem(ID_TOKEN); 21 | localStorage.removeItem(ID_TOKEN_EXPIRY); 22 | }; 23 | 24 | export const getStoredAuthState = () => { 25 | try { 26 | const idToken = localStorage.getItem(ID_TOKEN); 27 | const idTokenExpiry = localStorage.getItem(ID_TOKEN_EXPIRY); 28 | 29 | if (Date.now() > idTokenExpiry) { 30 | // Token has expired 31 | removeStoredAuthState(); 32 | 33 | return new Map(); 34 | } 35 | 36 | return new Map({ idToken }); 37 | } catch (err) { 38 | removeStoredAuthState(); 39 | 40 | return new Map(); 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /webpack.config.babel.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import webpack from 'webpack'; 3 | 4 | export default { 5 | devtool: 'eval-source-map', 6 | entry: [ 7 | 'babel-polyfill', 8 | 'webpack-hot-middleware/client?reload=true', 9 | path.join(__dirname, 'src', 'index.js'), 10 | ], 11 | output: { 12 | path: path.join(__dirname, 'dist', 'assets'), 13 | filename: 'bundle.js', 14 | publicPath: '/assets', 15 | }, 16 | plugins: [ 17 | new webpack.HotModuleReplacementPlugin(), 18 | new webpack.NoErrorsPlugin(), 19 | new webpack.DefinePlugin({ 20 | 'process.env': 21 | { NODE_ENV: JSON.stringify(process.env.NODE_ENV || 'development') }, 22 | }), 23 | ], 24 | resolve: { 25 | modulesDirectories: [ 26 | 'node_modules', 27 | 'src', 28 | ], 29 | extensions: ['', '.js', '.jsx', '.css'], 30 | }, 31 | module: { 32 | loaders: [ 33 | { 34 | test: /\.js$/, 35 | loader: 'babel-loader', 36 | include: path.join(__dirname, 'src'), 37 | }, 38 | { 39 | test: /\.json?$/, 40 | loader: 'json-loader', 41 | include: path.join(__dirname, 'src'), 42 | }, 43 | { 44 | test: /\.css?$/, 45 | loader: 'style-loader!css-loader?modules', 46 | include: path.join(__dirname, 'src'), 47 | }, 48 | { 49 | test: /\.(jpe?g|png|gif|svg|ico)$/, 50 | loader: 'url-loader', 51 | include: path.join(__dirname, 'src'), 52 | query: { limit: 10240 }, 53 | }, 54 | ], 55 | }, 56 | }; 57 | -------------------------------------------------------------------------------- /webpack.prod.config.babel.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import webpack from 'webpack'; 3 | 4 | export default { 5 | entry: [ 6 | 'babel-polyfill', 7 | path.join(__dirname, 'src', 'index.js'), 8 | ], 9 | output: { 10 | path: path.join(__dirname, 'dist', 'assets'), 11 | filename: 'bundle.js', 12 | publicPath: '/assets', 13 | }, 14 | plugins: [ 15 | new webpack.optimize.OccurenceOrderPlugin(), 16 | new webpack.optimize.DedupePlugin(), 17 | new webpack.optimize.UglifyJsPlugin({ 18 | compressor: { 19 | warnings: false, 20 | screw_ie8: true, 21 | }, 22 | }), 23 | new webpack.DefinePlugin({ 24 | 'process.env': { 25 | NODE_ENV: JSON.stringify(process.env.NODE_ENV || 'production'), 26 | }, 27 | }), 28 | ], 29 | resolve: { 30 | modulesDirectories: [ 31 | 'node_modules', 32 | 'src', 33 | ], 34 | extensions: ['', '.js', '.jsx', '.css'], 35 | }, 36 | module: { 37 | preLoaders: [ 38 | { 39 | test: /\.js$/, 40 | loader: 'eslint-loader', 41 | include: path.join(__dirname, 'src'), 42 | query: { quiet: true, failOnError: false }, 43 | }, 44 | ], 45 | loaders: [ 46 | { 47 | test: /\.js$/, 48 | loader: 'babel-loader', 49 | include: path.join(__dirname, 'src'), 50 | }, 51 | { 52 | test: /\.json?$/, 53 | loader: 'json-loader', 54 | include: path.join(__dirname, 'src'), 55 | }, 56 | { 57 | test: /\.css?$/, 58 | loader: 'style-loader!css-loader?modules', 59 | include: path.join(__dirname, 'src'), 60 | }, 61 | { 62 | test: /\.(jpe?g|png|gif|svg|ico)$/, 63 | loader: 'url-loader', 64 | include: path.join(__dirname, 'src'), 65 | query: { limit: 10240 }, 66 | }, 67 | ], 68 | }, 69 | }; 70 | --------------------------------------------------------------------------------