├── .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 | 
22 |
23 | 
24 |
25 | 
26 |
27 | 
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 | actions.createRecommendedPlaylistRequest(idToken)}
77 | backgroundColor="green"
78 | >
79 | Save playlist to Spotify
80 |
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 |
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 | actions.loginRequest('mosaic')} backgroundColor="green">
25 | Login with Spotify
26 | :
27 | actions.logout()} backgroundColor="red">
28 | Logout
29 |
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 | { this.preview = component; }}
47 | src={this.props.previewUrl}
48 | type="audio/mpeg"
49 | onEnded={this.previewEnded}
50 | />
51 |
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 |
--------------------------------------------------------------------------------