├── .nvmrc ├── client ├── .eslintrc ├── public │ ├── og.png │ ├── favicons │ │ ├── favicon.ico │ │ ├── apple-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon-96x96.png │ │ ├── ms-icon-70x70.png │ │ ├── apple-icon-57x57.png │ │ ├── apple-icon-60x60.png │ │ ├── apple-icon-72x72.png │ │ ├── apple-icon-76x76.png │ │ ├── ms-icon-144x144.png │ │ ├── ms-icon-150x150.png │ │ ├── ms-icon-310x310.png │ │ ├── android-icon-36x36.png │ │ ├── android-icon-48x48.png │ │ ├── android-icon-72x72.png │ │ ├── android-icon-96x96.png │ │ ├── apple-icon-114x114.png │ │ ├── apple-icon-120x120.png │ │ ├── apple-icon-144x144.png │ │ ├── apple-icon-152x152.png │ │ ├── apple-icon-180x180.png │ │ ├── android-icon-144x144.png │ │ ├── android-icon-192x192.png │ │ └── apple-icon-precomposed.png │ ├── fonts │ │ ├── CircularStd-Bold.woff │ │ ├── CircularStd-Book.woff │ │ ├── CircularStd-Black.woff │ │ ├── CircularStd-Black.woff2 │ │ ├── CircularStd-Bold.woff2 │ │ ├── CircularStd-Book.woff2 │ │ ├── CircularStd-Medium.woff │ │ ├── CircularStd-Medium.woff2 │ │ ├── CircularStd-BlackItalic.woff │ │ ├── CircularStd-BlackItalic.woff2 │ │ ├── CircularStd-BoldItalic.woff │ │ ├── CircularStd-BoldItalic.woff2 │ │ ├── CircularStd-BookItalic.woff │ │ ├── CircularStd-BookItalic.woff2 │ │ ├── CircularStd-MediumItalic.woff │ │ └── CircularStd-MediumItalic.woff2 │ ├── browserconfig.xml │ ├── manifest.json │ └── index.html ├── src │ ├── images │ │ ├── og.png │ │ └── favicons │ │ │ ├── favicon.ico │ │ │ ├── apple-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon-96x96.png │ │ │ ├── ms-icon-70x70.png │ │ │ ├── ms-icon-144x144.png │ │ │ ├── ms-icon-150x150.png │ │ │ ├── ms-icon-310x310.png │ │ │ ├── android-icon-36x36.png │ │ │ ├── android-icon-48x48.png │ │ │ ├── android-icon-72x72.png │ │ │ ├── android-icon-96x96.png │ │ │ ├── apple-icon-114x114.png │ │ │ ├── apple-icon-120x120.png │ │ │ ├── apple-icon-144x144.png │ │ │ ├── apple-icon-152x152.png │ │ │ ├── apple-icon-180x180.png │ │ │ ├── apple-icon-57x57.png │ │ │ ├── apple-icon-60x60.png │ │ │ ├── apple-icon-72x72.png │ │ │ ├── apple-icon-76x76.png │ │ │ ├── android-icon-144x144.png │ │ │ ├── android-icon-192x192.png │ │ │ └── apple-icon-precomposed.png │ ├── styles │ │ ├── Nav.js │ │ ├── Header.js │ │ ├── Footer.js │ │ ├── Button.js │ │ ├── index.js │ │ ├── Section.js │ │ ├── Main.js │ │ ├── media.js │ │ ├── theme.js │ │ ├── mixins.js │ │ └── GlobalStyle.js │ ├── components │ │ ├── ScrollToTop.js │ │ ├── icons │ │ │ ├── index.js │ │ │ ├── music.js │ │ │ ├── playlist.js │ │ │ ├── info.js │ │ │ ├── time.js │ │ │ ├── user.js │ │ │ ├── external.js │ │ │ ├── microphone.js │ │ │ ├── spotify.js │ │ │ ├── github.js │ │ │ └── loader.js │ │ ├── App.js │ │ ├── RecentlyPlayed.js │ │ ├── LoginScreen.js │ │ ├── Loader.js │ │ ├── Profile.js │ │ ├── TopTracks.js │ │ ├── TrackItem.js │ │ ├── Artist.js │ │ ├── Playlists.js │ │ ├── FeatureChart.js │ │ ├── Playlist.js │ │ ├── Nav.js │ │ ├── Recommendations.js │ │ ├── TopArtists.js │ │ ├── Track.js │ │ └── User.js │ ├── App.test.js │ ├── index.js │ ├── utils │ │ └── index.js │ ├── serviceWorker.js │ └── spotify │ │ └── index.js ├── .babelrc ├── .gitignore └── package.json ├── prettier.config.js ├── .env.example ├── .editorconfig ├── .eslintrc ├── .gitignore ├── README.md ├── package.json └── server └── index.js /.nvmrc: -------------------------------------------------------------------------------- 1 | 10.13.0 2 | -------------------------------------------------------------------------------- /client/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@upstatement/eslint-config/react" 3 | } 4 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@upstatement/prettier-config'); 2 | -------------------------------------------------------------------------------- /client/public/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/og.png -------------------------------------------------------------------------------- /client/src/images/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/og.png -------------------------------------------------------------------------------- /client/public/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/favicon.ico -------------------------------------------------------------------------------- /client/public/favicons/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/apple-icon.png -------------------------------------------------------------------------------- /client/src/images/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/favicon.ico -------------------------------------------------------------------------------- /client/public/favicons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/favicon-16x16.png -------------------------------------------------------------------------------- /client/public/favicons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/favicon-32x32.png -------------------------------------------------------------------------------- /client/public/favicons/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/favicon-96x96.png -------------------------------------------------------------------------------- /client/public/favicons/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/ms-icon-70x70.png -------------------------------------------------------------------------------- /client/public/fonts/CircularStd-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/fonts/CircularStd-Bold.woff -------------------------------------------------------------------------------- /client/public/fonts/CircularStd-Book.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/fonts/CircularStd-Book.woff -------------------------------------------------------------------------------- /client/src/images/favicons/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/apple-icon.png -------------------------------------------------------------------------------- /client/public/favicons/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/apple-icon-57x57.png -------------------------------------------------------------------------------- /client/public/favicons/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/apple-icon-60x60.png -------------------------------------------------------------------------------- /client/public/favicons/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/apple-icon-72x72.png -------------------------------------------------------------------------------- /client/public/favicons/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/apple-icon-76x76.png -------------------------------------------------------------------------------- /client/public/favicons/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/ms-icon-144x144.png -------------------------------------------------------------------------------- /client/public/favicons/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/ms-icon-150x150.png -------------------------------------------------------------------------------- /client/public/favicons/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/ms-icon-310x310.png -------------------------------------------------------------------------------- /client/public/fonts/CircularStd-Black.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/fonts/CircularStd-Black.woff -------------------------------------------------------------------------------- /client/public/fonts/CircularStd-Black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/fonts/CircularStd-Black.woff2 -------------------------------------------------------------------------------- /client/public/fonts/CircularStd-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/fonts/CircularStd-Bold.woff2 -------------------------------------------------------------------------------- /client/public/fonts/CircularStd-Book.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/fonts/CircularStd-Book.woff2 -------------------------------------------------------------------------------- /client/public/fonts/CircularStd-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/fonts/CircularStd-Medium.woff -------------------------------------------------------------------------------- /client/public/fonts/CircularStd-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/fonts/CircularStd-Medium.woff2 -------------------------------------------------------------------------------- /client/src/images/favicons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/favicon-16x16.png -------------------------------------------------------------------------------- /client/src/images/favicons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/favicon-32x32.png -------------------------------------------------------------------------------- /client/src/images/favicons/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/favicon-96x96.png -------------------------------------------------------------------------------- /client/src/images/favicons/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/ms-icon-70x70.png -------------------------------------------------------------------------------- /client/public/favicons/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/android-icon-36x36.png -------------------------------------------------------------------------------- /client/public/favicons/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/android-icon-48x48.png -------------------------------------------------------------------------------- /client/public/favicons/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/android-icon-72x72.png -------------------------------------------------------------------------------- /client/public/favicons/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/android-icon-96x96.png -------------------------------------------------------------------------------- /client/public/favicons/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/apple-icon-114x114.png -------------------------------------------------------------------------------- /client/public/favicons/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/apple-icon-120x120.png -------------------------------------------------------------------------------- /client/public/favicons/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/apple-icon-144x144.png -------------------------------------------------------------------------------- /client/public/favicons/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/apple-icon-152x152.png -------------------------------------------------------------------------------- /client/public/favicons/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/apple-icon-180x180.png -------------------------------------------------------------------------------- /client/src/images/favicons/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/ms-icon-144x144.png -------------------------------------------------------------------------------- /client/src/images/favicons/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/ms-icon-150x150.png -------------------------------------------------------------------------------- /client/src/images/favicons/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/ms-icon-310x310.png -------------------------------------------------------------------------------- /client/public/favicons/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/android-icon-144x144.png -------------------------------------------------------------------------------- /client/public/favicons/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/android-icon-192x192.png -------------------------------------------------------------------------------- /client/public/favicons/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/favicons/apple-icon-precomposed.png -------------------------------------------------------------------------------- /client/public/fonts/CircularStd-BlackItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/fonts/CircularStd-BlackItalic.woff -------------------------------------------------------------------------------- /client/public/fonts/CircularStd-BlackItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/fonts/CircularStd-BlackItalic.woff2 -------------------------------------------------------------------------------- /client/public/fonts/CircularStd-BoldItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/fonts/CircularStd-BoldItalic.woff -------------------------------------------------------------------------------- /client/public/fonts/CircularStd-BoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/fonts/CircularStd-BoldItalic.woff2 -------------------------------------------------------------------------------- /client/public/fonts/CircularStd-BookItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/fonts/CircularStd-BookItalic.woff -------------------------------------------------------------------------------- /client/public/fonts/CircularStd-BookItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/fonts/CircularStd-BookItalic.woff2 -------------------------------------------------------------------------------- /client/public/fonts/CircularStd-MediumItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/fonts/CircularStd-MediumItalic.woff -------------------------------------------------------------------------------- /client/src/images/favicons/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/android-icon-36x36.png -------------------------------------------------------------------------------- /client/src/images/favicons/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/android-icon-48x48.png -------------------------------------------------------------------------------- /client/src/images/favicons/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/android-icon-72x72.png -------------------------------------------------------------------------------- /client/src/images/favicons/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/android-icon-96x96.png -------------------------------------------------------------------------------- /client/src/images/favicons/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/apple-icon-114x114.png -------------------------------------------------------------------------------- /client/src/images/favicons/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/apple-icon-120x120.png -------------------------------------------------------------------------------- /client/src/images/favicons/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/apple-icon-144x144.png -------------------------------------------------------------------------------- /client/src/images/favicons/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/apple-icon-152x152.png -------------------------------------------------------------------------------- /client/src/images/favicons/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/apple-icon-180x180.png -------------------------------------------------------------------------------- /client/src/images/favicons/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/apple-icon-57x57.png -------------------------------------------------------------------------------- /client/src/images/favicons/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/apple-icon-60x60.png -------------------------------------------------------------------------------- /client/src/images/favicons/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/apple-icon-72x72.png -------------------------------------------------------------------------------- /client/src/images/favicons/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/apple-icon-76x76.png -------------------------------------------------------------------------------- /client/public/fonts/CircularStd-MediumItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/public/fonts/CircularStd-MediumItalic.woff2 -------------------------------------------------------------------------------- /client/src/images/favicons/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/android-icon-144x144.png -------------------------------------------------------------------------------- /client/src/images/favicons/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/android-icon-192x192.png -------------------------------------------------------------------------------- /client/src/styles/Nav.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components/macro'; 2 | 3 | const Nav = styled.nav` 4 | margin: 0; 5 | `; 6 | 7 | export default Nav; 8 | -------------------------------------------------------------------------------- /client/src/images/favicons/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bchiang7/spotify-profile/HEAD/client/src/images/favicons/apple-icon-precomposed.png -------------------------------------------------------------------------------- /client/src/styles/Header.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components/macro'; 2 | 3 | const Header = styled.header` 4 | margin: 0; 5 | `; 6 | 7 | export default Header; 8 | -------------------------------------------------------------------------------- /client/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | [ 4 | "babel-plugin-styled-components", 5 | { 6 | "displayName": true 7 | } 8 | ] 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | CLIENT_ID=abcdefghijklmnopqrstuvwxyz 2 | CLIENT_SECRET=abcdefghijklmnopqrstuvwxyz 3 | FRONTEND_URI=example.com 4 | REDIRECT_URI=example.com/callback 5 | LOGIN_URI=example.com/login 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@upstatement", 3 | "parserOptions": { 4 | "sourceType": "module" 5 | }, 6 | "env": { 7 | "browser": true, 8 | "node": true, 9 | "es6": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | .DS_Store 4 | 5 | .env 6 | .env.local 7 | .env.development 8 | .env.test 9 | .env.production 10 | 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | 15 | .vscode/ 16 | 17 | -------------------------------------------------------------------------------- /client/src/styles/Footer.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components/macro'; 2 | import theme from './theme'; 3 | const { spacing } = theme; 4 | 5 | const Footer = styled.footer` 6 | padding: ${spacing.base}; 7 | `; 8 | 9 | export default Footer; 10 | -------------------------------------------------------------------------------- /client/src/components/ScrollToTop.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const ScrollToTop = ({ children, location }) => { 4 | React.useEffect(() => window.scrollTo(0, 0), [location.pathname]); 5 | return children; 6 | }; 7 | 8 | export default ScrollToTop; 9 | -------------------------------------------------------------------------------- /client/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff -------------------------------------------------------------------------------- /client/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /client/src/styles/Button.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components/macro'; 2 | import theme from './theme'; 3 | const { fontSizes } = theme; 4 | 5 | const Button = styled.button` 6 | font-size: ${fontSizes.base}; 7 | cursor: pointer; 8 | border: 0; 9 | border-radius: 0; 10 | transition: ${theme.transition}; 11 | &:focus, 12 | &:active { 13 | outline: 0; 14 | } 15 | `; 16 | 17 | export default Button; 18 | -------------------------------------------------------------------------------- /client/src/styles/index.js: -------------------------------------------------------------------------------- 1 | import GlobalStyle from './GlobalStyle'; 2 | import theme from './theme'; 3 | import mixins from './mixins'; 4 | import media from './media'; 5 | import Button from './Button'; 6 | import Header from './Header'; 7 | import Nav from './Nav'; 8 | import Main from './Main'; 9 | import Section from './Section'; 10 | import Footer from './Footer'; 11 | 12 | export { GlobalStyle, theme, mixins, media, Button, Header, Nav, Main, Section, Footer }; 13 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | src/**/*.css 25 | 26 | package-lock.json 27 | 28 | .vscode/ 29 | -------------------------------------------------------------------------------- /client/src/styles/Section.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components/macro'; 2 | import media from './media'; 3 | 4 | const Section = styled.section` 5 | width: 100%; 6 | margin: 0 auto; 7 | max-width: 1400px; 8 | min-height: 100vh; 9 | padding: 90px 0; 10 | ${media.tablet` 11 | padding: 0 0 90px; 12 | h2 { 13 | text-align: center; 14 | } 15 | `}; 16 | ${media.phablet` 17 | padding: 0 0 20px; 18 | `}; 19 | `; 20 | 21 | export default Section; 22 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './components/App'; 4 | import * as serviceWorker from './serviceWorker'; 5 | 6 | ReactDOM.render(, document.getElementById('root')); 7 | 8 | // If you want your app to work offline and load faster, you can change 9 | // unregister() to register() below. Note this comes with some pitfalls. 10 | // Learn more about service workers: http://bit.ly/CRA-PWA 11 | serviceWorker.unregister(); 12 | -------------------------------------------------------------------------------- /client/src/styles/Main.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components/macro'; 2 | import media from './media'; 3 | 4 | const Main = styled.main` 5 | width: 100%; 6 | margin: 0 auto; 7 | max-width: 1400px; 8 | min-height: 100vh; 9 | padding: 80px; 10 | ${media.desktop` 11 | padding: 60px 50px; 12 | `}; 13 | ${media.tablet` 14 | padding: 50px 40px; 15 | `}; 16 | ${media.phablet` 17 | padding: 30px 25px; 18 | `}; 19 | h2 { 20 | ${media.tablet` 21 | text-align: center; 22 | `}; 23 | } 24 | `; 25 | 26 | export default Main; 27 | -------------------------------------------------------------------------------- /client/src/components/icons/index.js: -------------------------------------------------------------------------------- 1 | import IconUser from './user'; 2 | import IconGithub from './github'; 3 | import IconExternal from './external'; 4 | import IconSpotify from './spotify'; 5 | import IconLoader from './loader'; 6 | import IconTime from './time'; 7 | import IconMicrophone from './microphone'; 8 | import IconPlaylist from './playlist'; 9 | import IconMusic from './music'; 10 | import IconInfo from './info'; 11 | 12 | export { 13 | IconUser, 14 | IconGithub, 15 | IconExternal, 16 | IconSpotify, 17 | IconLoader, 18 | IconTime, 19 | IconMicrophone, 20 | IconPlaylist, 21 | IconMusic, 22 | IconInfo, 23 | }; 24 | -------------------------------------------------------------------------------- /client/src/components/icons/music.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const IconMusic = () => ( 4 | 12 | 13 | 14 | ); 15 | 16 | export default IconMusic; 17 | -------------------------------------------------------------------------------- /client/src/components/icons/playlist.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const IconPlaylist = () => ( 4 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | 20 | export default IconPlaylist; 21 | -------------------------------------------------------------------------------- /client/src/components/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { token } from '../spotify'; 3 | 4 | import LoginScreen from './LoginScreen'; 5 | import Profile from './Profile'; 6 | 7 | import styled from 'styled-components/macro'; 8 | import { GlobalStyle } from '../styles'; 9 | 10 | const AppContainer = styled.div` 11 | height: 100%; 12 | min-height: 100vh; 13 | `; 14 | 15 | const App = () => { 16 | const [accessToken, setAccessToken] = useState(''); 17 | 18 | useEffect(() => { 19 | setAccessToken(token); 20 | }, []); 21 | 22 | return ( 23 | 24 | 25 | 26 | {accessToken ? : } 27 | 28 | ); 29 | }; 30 | 31 | export default App; 32 | -------------------------------------------------------------------------------- /client/src/styles/media.js: -------------------------------------------------------------------------------- 1 | import { css } from 'styled-components'; 2 | 3 | const sizes = { 4 | giant: 1440, 5 | desktop: 1200, 6 | netbook: 1000, 7 | tablet: 768, 8 | thone: 600, 9 | phablet: 480, 10 | phone: 376, 11 | tiny: 330, 12 | }; 13 | 14 | // iterate through the sizes and create a media template 15 | export const media = Object.keys(sizes).reduce((accumulator, label) => { 16 | // use em in breakpoints to work properly cross-browser and support users 17 | // changing their browsers font-size: https://zellwk.com/blog/media-query-units/ 18 | const emSize = sizes[label] / 16; 19 | accumulator[label] = (...args) => css` 20 | @media (max-width: ${emSize}em) { 21 | ${css(...args)}; 22 | } 23 | `; 24 | return accumulator; 25 | }, {}); 26 | 27 | export default media; 28 | -------------------------------------------------------------------------------- /client/src/components/icons/info.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const IconInfo = () => ( 4 | 13 | 19 | 20 | ); 21 | 22 | export default IconInfo; 23 | -------------------------------------------------------------------------------- /client/src/components/icons/time.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const IconTime = () => ( 4 | 12 | Time 13 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | 21 | export default IconTime; 22 | -------------------------------------------------------------------------------- /client/src/components/icons/user.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const IconUser = () => ( 4 | 5 | 6 | 7 | ); 8 | 9 | export default IconUser; 10 | -------------------------------------------------------------------------------- /client/src/components/icons/external.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const IconExternal = () => ( 4 | 5 | External 6 | 7 | 12 | 16 | 17 | 18 | ); 19 | 20 | export default IconExternal; 21 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "App", 3 | "icons": [ 4 | { 5 | "src": "/android-icon-36x36.png", 6 | "sizes": "36x36", 7 | "type": "image/png", 8 | "density": "0.75" 9 | }, 10 | { 11 | "src": "/android-icon-48x48.png", 12 | "sizes": "48x48", 13 | "type": "image/png", 14 | "density": "1.0" 15 | }, 16 | { 17 | "src": "/android-icon-72x72.png", 18 | "sizes": "72x72", 19 | "type": "image/png", 20 | "density": "1.5" 21 | }, 22 | { 23 | "src": "/android-icon-96x96.png", 24 | "sizes": "96x96", 25 | "type": "image/png", 26 | "density": "2.0" 27 | }, 28 | { 29 | "src": "/android-icon-144x144.png", 30 | "sizes": "144x144", 31 | "type": "image/png", 32 | "density": "3.0" 33 | }, 34 | { 35 | "src": "/android-icon-192x192.png", 36 | "sizes": "192x192", 37 | "type": "image/png", 38 | "density": "4.0" 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /client/src/components/RecentlyPlayed.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { getRecentlyPlayed } from '../spotify'; 3 | import { catchErrors } from '../utils'; 4 | 5 | import Loader from './Loader'; 6 | import TrackItem from './TrackItem'; 7 | 8 | import styled from 'styled-components/macro'; 9 | import { Main } from '../styles'; 10 | 11 | const TracksContainer = styled.ul` 12 | margin-top: 50px; 13 | `; 14 | 15 | const RecentlyPlayed = () => { 16 | const [recentlyPlayed, setRecentlyPlayed] = useState(null); 17 | 18 | useEffect(() => { 19 | const fetchData = async () => { 20 | const { data } = await getRecentlyPlayed(); 21 | setRecentlyPlayed(data); 22 | }; 23 | catchErrors(fetchData()); 24 | }, []); 25 | 26 | return ( 27 | 28 | Recently Played Tracks 29 | 30 | {recentlyPlayed ? ( 31 | recentlyPlayed.items.map(({ track }, i) => ) 32 | ) : ( 33 | 34 | )} 35 | 36 | 37 | ); 38 | }; 39 | 40 | export default RecentlyPlayed; 41 | -------------------------------------------------------------------------------- /client/src/components/icons/microphone.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const IconMicrophone = () => ( 4 | 11 | Microphone 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | 19 | export default IconMicrophone; 20 | -------------------------------------------------------------------------------- /client/src/components/LoginScreen.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components/macro'; 3 | import { theme, mixins, Main } from '../styles'; 4 | const { colors, fontSizes } = theme; 5 | 6 | const LOGIN_URI = 7 | process.env.NODE_ENV !== 'production' 8 | ? 'http://localhost:8888/login' 9 | : 'https://spotify-profile.herokuapp.com/login'; 10 | 11 | const Login = styled(Main)` 12 | ${mixins.flexCenter}; 13 | flex-direction: column; 14 | min-height: 100vh; 15 | h1 { 16 | font-size: ${fontSizes.xxl}; 17 | } 18 | `; 19 | const LoginButton = styled.a` 20 | display: inline-block; 21 | background-color: ${colors.green}; 22 | color: ${colors.white}; 23 | border-radius: 30px; 24 | padding: 17px 35px; 25 | margin: 20px 0 70px; 26 | min-width: 160px; 27 | font-weight: 700; 28 | letter-spacing: 2px; 29 | text-transform: uppercase; 30 | text-align: center; 31 | &:hover, 32 | &:focus { 33 | background-color: ${colors.offGreen}; 34 | } 35 | `; 36 | 37 | const LoginScreen = () => ( 38 | 39 | Spotify Profile 40 | Log in to Spotify 41 | 42 | ); 43 | 44 | export default LoginScreen; 45 | -------------------------------------------------------------------------------- /client/src/components/Loader.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { keyframes } from 'styled-components/macro'; 3 | import { theme, mixins } from '../styles'; 4 | const { colors } = theme; 5 | 6 | const Container = styled.div` 7 | ${mixins.flexCenter}; 8 | width: 100%; 9 | height: 90vh; 10 | `; 11 | const dance = keyframes` 12 | from { 13 | height: 10px; 14 | } 15 | to { 16 | height: 100%; 17 | } 18 | `; 19 | const Bars = styled.div` 20 | display: flex; 21 | justify-content: center; 22 | align-items: flex-end; 23 | overflow: hidden; 24 | width: 100px; 25 | min-width: 100px; 26 | height: 50px; 27 | margin: 0 auto; 28 | z-index: 2; 29 | position: relative; 30 | left: 0; 31 | right: 0; 32 | `; 33 | const Bar = styled.div` 34 | width: 10px; 35 | height: 5px; 36 | margin: 0 2px; 37 | background-color: ${colors.grey}; 38 | animation-name: ${dance}; 39 | animation-duration: 400ms; 40 | animation-play-state: running; 41 | animation-direction: alternate; 42 | animation-timing-function: linear; 43 | animation-iteration-count: infinite; 44 | animation-delay: ${props => props.delay || '0ms'}; 45 | `; 46 | 47 | const Loader = () => ( 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | ); 58 | 59 | export default Loader; 60 | -------------------------------------------------------------------------------- /client/src/components/Profile.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Router } from '@reach/router'; 3 | 4 | import ScrollToTop from './ScrollToTop'; 5 | import Nav from './Nav'; 6 | import User from './User'; 7 | import RecentlyPlayed from './RecentlyPlayed'; 8 | import TopArtists from './TopArtists'; 9 | import TopTracks from './TopTracks'; 10 | import Playlists from './Playlists'; 11 | import Playlist from './Playlist'; 12 | import Recommendations from './Recommendations'; 13 | import Track from './Track'; 14 | import Artist from './Artist'; 15 | 16 | import styled from 'styled-components/macro'; 17 | import { theme, media } from '../styles'; 18 | 19 | const SiteWrapper = styled.div` 20 | padding-left: ${theme.navWidth}; 21 | ${media.tablet` 22 | padding-left: 0; 23 | padding-bottom: 50px; 24 | `}; 25 | `; 26 | 27 | const Profile = () => ( 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | ); 45 | 46 | export default Profile; 47 | -------------------------------------------------------------------------------- /client/src/styles/theme.js: -------------------------------------------------------------------------------- 1 | const theme = { 2 | colors: { 3 | green: '#1DB954', 4 | offGreen: '#1ed760', 5 | blue: '#509bf5', 6 | navBlack: '#040306', 7 | black: '#181818', 8 | white: '#FFFFFF', 9 | lightestGrey: '#b3b3b3', 10 | lightGrey: '#9B9B9B', 11 | grey: '#404040', 12 | darkGrey: '#282828', 13 | }, 14 | 15 | fonts: { 16 | primary: 'Circular Std, system, -apple-system, BlinkMacSystemFont, sans-serif', 17 | }, 18 | 19 | fontSizes: { 20 | base: `16px`, 21 | xs: `12px`, 22 | sm: `14px`, 23 | md: `20px`, 24 | lg: `24px`, 25 | xl: `28px`, 26 | xxl: `32px`, 27 | }, 28 | 29 | spacing: { 30 | base: `20px`, 31 | xs: `5px`, 32 | sm: `10px`, 33 | md: `30px`, 34 | lg: `50px`, 35 | xl: `100px`, 36 | }, 37 | 38 | easing: { 39 | easeInCubic: `cubic-bezier(0.55, 0.055, 0.675, 0.19)`, 40 | easeOutCubic: `cubic-bezier(0.215, 0.61, 0.355, 1)`, 41 | easeInOutCubic: `cubic-bezier(0.215, 0.61, 0.355, 1)`, 42 | easeInExpo: `cubic-bezier(0.95, 0.05, 0.795, 0.035)`, 43 | easeOutExpo: `cubic-bezier(0.19, 1, 0.22, 1)`, 44 | easeInOutExpo: `cubic-bezier(0.19, 1, 0.22, 1)`, 45 | easeInBack: `cubic-bezier(0.6, -0.28, 0.735, 0.045)`, 46 | easeOutBack: `cubic-bezier(0.175, 0.885, 0.32, 1.275)`, 47 | easeInOutBack: `cubic-bezier(0.68, -0.55, 0.265, 1.55)`, 48 | }, 49 | 50 | transition: `all 0.25s cubic-bezier(0.3, 0, 0.4, 1);`, 51 | 52 | navWidth: '100px', 53 | navHeight: '70px', 54 | }; 55 | 56 | export default theme; 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spotify Profile 2 | 3 | > A web app for visualizing personalized Spotify data 4 | 5 | Built with a bunch of things, but to name a few: 6 | 7 | - [Spotify Web API](https://developer.spotify.com/documentation/web-api/) 8 | - [Create React App](https://github.com/facebook/create-react-app) 9 | - [Express](https://expressjs.com/) 10 | - [Reach Router](https://reach.tech/router) 11 | - [Styled Components](https://www.styled-components.com/) 12 | 13 | ## Setup 14 | 15 | 1. [Register a Spotify App](https://developer.spotify.com/dashboard/applications) and add `http://localhost:8888/callback` as a Redirect URI in the app settings 16 | 1. Create an `.env` file in the root of the project based on `.env.example` 17 | 1. `nvm use` 18 | 1. `yarn && yarn client:install` 19 | 1. `yarn dev` 20 | 21 | ## Deploying to Heroku 22 | 23 | 1. Create new heroku app 24 | 25 | ```bash 26 | heroku create app-name 27 | ``` 28 | 29 | 2. Set Heroku environment variables 30 | 31 | ```bash 32 | heroku config:set CLIENT_ID=XXXXX 33 | heroku config:set CLIENT_SECRET=XXXXX 34 | heroku config:set REDIRECT_URI=https://app-name.herokuapp.com/callback 35 | heroku config:set FRONTEND_URI=https://app-name.herokuapp.com 36 | ``` 37 | 38 | 3. Push to Heroku 39 | 40 | ```bash 41 | git push heroku master 42 | ``` 43 | 44 | 4. Add `http://app-name.herokuapp.com/callback` as a Redirect URI in the spotify application settings 45 | 46 | 5. Once the app is live on Heroku, hitting http://app-name.herokuapp.com/login should be the same as hitting http://localhost:8888/login 47 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spotify-profile", 3 | "version": "0.1.0", 4 | "private": true, 5 | "eslintConfig": { 6 | "extends": "react-app" 7 | }, 8 | "browserslist": [ 9 | ">0.2%", 10 | "not dead", 11 | "not ie <= 11", 12 | "not op_mini all" 13 | ], 14 | "engines": { 15 | "node": "10.13.0" 16 | }, 17 | "babelMacros": { 18 | "styledComponents": { 19 | "pure": true 20 | } 21 | }, 22 | "homepage": "https://spotify-profile.herokuapp.com", 23 | "proxy": "http://localhost:8888", 24 | "scripts": { 25 | "start": "react-scripts start", 26 | "build": "react-scripts build", 27 | "test": "react-scripts test", 28 | "eject": "react-scripts eject" 29 | }, 30 | "lint-staged": { 31 | "*.{js,css,json,md}": [ 32 | "prettier --write" 33 | ], 34 | "*.{js}": [ 35 | "eslint --fix" 36 | ] 37 | }, 38 | "husky": { 39 | "hooks": { 40 | "pre-commit": "lint-staged" 41 | } 42 | }, 43 | "dependencies": { 44 | "@reach/router": "^1.2.1", 45 | "axios": "^0.21.1", 46 | "babel-plugin-macros": "^2.6.1", 47 | "chart.js": "^2.8.0", 48 | "prop-types": "^15.7.2", 49 | "react": "^16.9.0", 50 | "react-dom": "^16.9.0", 51 | "react-scripts": "3.1.1", 52 | "styled-components": "^4.3.2" 53 | }, 54 | "devDependencies": { 55 | "@upstatement/eslint-config": "^0.4.2", 56 | "babel-plugin-styled-components": "^1.10.6", 57 | "eslint-config-prettier": "^6.3.0", 58 | "eslint-plugin-jsx-a11y": "^6.2.3", 59 | "eslint-plugin-react": "^7.14.3", 60 | "husky": "^3.0.5", 61 | "lint-staged": "^9.2.5" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /client/src/components/icons/spotify.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const IconSpotify = () => ( 4 | 11 | 26 | 27 | ); 28 | 29 | export default IconSpotify; 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spotify-profile", 3 | "version": "0.1.0", 4 | "description": "Spotify Profile", 5 | "main": "server/index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/bchiang7/spotify-profile.git" 9 | }, 10 | "keywords": [], 11 | "author": "Brittany Chiang ", 12 | "license": "ISC", 13 | "bugs": { 14 | "url": "https://github.com/bchiang7/spotify-profile/issues" 15 | }, 16 | "homepage": "https://github.com/bchiang7/spotify-profile#readme", 17 | "engines": { 18 | "node": "10.13.0" 19 | }, 20 | "scripts": { 21 | "client:install": "cd client && yarn", 22 | "client": "cd client && yarn start", 23 | "server": "node server", 24 | "dev": "concurrently --kill-others-on-fail \"yarn server\" \"yarn client\"", 25 | "start": "node server", 26 | "heroku-postbuild": "cd client/ && yarn && yarn install --production && yarn build" 27 | }, 28 | "lint-staged": { 29 | "*.{js,css,json,md}": [ 30 | "prettier --write" 31 | ], 32 | "*.{js}": [ 33 | "eslint --fix" 34 | ] 35 | }, 36 | "husky": { 37 | "hooks": { 38 | "pre-commit": "lint-staged" 39 | } 40 | }, 41 | "dependencies": { 42 | "connect-history-api-fallback": "^1.6.0", 43 | "cookie-parser": "1.4.4", 44 | "cors": "^2.8.5", 45 | "dotenv": "^8.1.0", 46 | "express": "~4.17.1", 47 | "querystring": "~0.2.0", 48 | "request": "~2.88.0" 49 | }, 50 | "devDependencies": { 51 | "@upstatement/eslint-config": "^0.4.3", 52 | "@upstatement/prettier-config": "^0.3.0", 53 | "concurrently": "^5.1.0", 54 | "eslint-config-prettier": "^6.3.0", 55 | "eslint-plugin-jsx-a11y": "^6.2.3", 56 | "eslint-plugin-react": "^7.14.3", 57 | "husky": "^4.2.3", 58 | "lint-staged": "^10.1.1", 59 | "prettier": "^2.0.2" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /client/src/styles/mixins.js: -------------------------------------------------------------------------------- 1 | import { css } from 'styled-components/macro'; 2 | import theme from './theme'; 3 | const { colors, fontSizes } = theme; 4 | 5 | const mixins = { 6 | flexCenter: css` 7 | display: flex; 8 | justify-content: center; 9 | align-items: center; 10 | `, 11 | 12 | flexBetween: css` 13 | display: flex; 14 | justify-content: space-between; 15 | align-items: center; 16 | `, 17 | 18 | engulf: css` 19 | position: absolute; 20 | top: 0; 21 | bottom: 0; 22 | left: 0; 23 | right: 0; 24 | width: 100%; 25 | height: 100%; 26 | `, 27 | 28 | outline: css` 29 | outline: 1px solid red; 30 | `, 31 | 32 | overflowEllipsis: css` 33 | overflow: hidden; 34 | text-overflow: ellipsis; 35 | white-space: nowrap; 36 | padding-right: 1px; 37 | `, 38 | 39 | coverShadow: css` 40 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); 41 | `, 42 | 43 | button: css` 44 | display: inline-block; 45 | color: ${colors.white}; 46 | font-weight: 700; 47 | font-size: ${fontSizes.xs}; 48 | letter-spacing: 1px; 49 | text-transform: uppercase; 50 | border: 1px solid ${colors.white}; 51 | border-radius: 50px; 52 | padding: 11px 24px; 53 | cursor: pointer; 54 | transition: ${theme.transition}; 55 | 56 | &:hover, 57 | &:focus { 58 | color: ${colors.black}; 59 | background: ${colors.white}; 60 | outline: 0; 61 | } 62 | `, 63 | 64 | greenButton: css` 65 | display: inline-block; 66 | background-color: ${colors.green}; 67 | color: ${colors.white}; 68 | font-weight: 700; 69 | font-size: ${fontSizes.xs}; 70 | letter-spacing: 1px; 71 | text-transform: uppercase; 72 | border-radius: 50px; 73 | padding: 11px 24px; 74 | margin: 20px 0; 75 | cursor: pointer; 76 | transition: ${theme.transition}; 77 | 78 | &:hover, 79 | &:focus { 80 | background-color: ${colors.offGreen}; 81 | outline: 0; 82 | } 83 | `, 84 | }; 85 | 86 | export default mixins; 87 | -------------------------------------------------------------------------------- /client/src/utils/index.js: -------------------------------------------------------------------------------- 1 | // Get the query params off the window's URL 2 | export const getHashParams = () => { 3 | const hashParams = {}; 4 | let e; 5 | const r = /([^&;=]+)=?([^&;]*)/g; 6 | const q = window.location.hash.substring(1); 7 | while ((e = r.exec(q))) { 8 | hashParams[e[1]] = decodeURIComponent(e[2]); 9 | } 10 | return hashParams; 11 | }; 12 | 13 | // Format milliseconds into MM:SS 14 | export const formatDuration = millis => { 15 | const minutes = Math.floor(millis / 60000); 16 | const seconds = ((millis % 60000) / 1000).toFixed(0); 17 | return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`; 18 | }; 19 | 20 | // Format milliseconds into X minutes and Y seconds 21 | export const formatDurationForHumans = millis => { 22 | const minutes = Math.floor(millis / 60000); 23 | const seconds = ((millis % 60000) / 1000).toFixed(0); 24 | return `${minutes} Mins ${seconds} Secs`; 25 | }; 26 | 27 | // Get year from YYYY-MM-DD 28 | export const getYear = date => date.split('-')[0]; 29 | 30 | // Transform Pitch Class Notation to string 31 | export const parsePitchClass = note => { 32 | let key = note; 33 | 34 | switch (note) { 35 | case 0: 36 | key = 'C'; 37 | break; 38 | case 1: 39 | key = 'D♭'; 40 | break; 41 | case 2: 42 | key = 'D'; 43 | break; 44 | case 3: 45 | key = 'E♭'; 46 | break; 47 | case 4: 48 | key = 'E'; 49 | break; 50 | case 5: 51 | key = 'F'; 52 | break; 53 | case 6: 54 | key = 'G♭'; 55 | break; 56 | case 7: 57 | key = 'G'; 58 | break; 59 | case 8: 60 | key = 'A♭'; 61 | break; 62 | case 9: 63 | key = 'A'; 64 | break; 65 | case 10: 66 | key = 'B♭'; 67 | break; 68 | case 11: 69 | key = 'B'; 70 | break; 71 | default: 72 | return null; 73 | } 74 | 75 | return key; 76 | }; 77 | 78 | export const formatWithCommas = n => n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); 79 | 80 | // Higher-order function for async/await error handling 81 | export const catchErrors = fn => 82 | function(...args) { 83 | return fn(...args).catch(err => { 84 | console.error(err); 85 | }); 86 | }; 87 | -------------------------------------------------------------------------------- /client/src/components/icons/github.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const IconGithub = () => ( 4 | 5 | Github 6 | 27 | 28 | ); 29 | 30 | export default IconGithub; 31 | -------------------------------------------------------------------------------- /client/src/components/TopTracks.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { getTopTracksShort, getTopTracksMedium, getTopTracksLong } from '../spotify'; 3 | import { catchErrors } from '../utils'; 4 | 5 | import Loader from './Loader'; 6 | import TrackItem from './TrackItem'; 7 | 8 | import styled from 'styled-components/macro'; 9 | import { theme, mixins, media, Main } from '../styles'; 10 | const { colors, fontSizes } = theme; 11 | 12 | const Header = styled.header` 13 | ${mixins.flexBetween}; 14 | ${media.tablet` 15 | display: block; 16 | `}; 17 | h2 { 18 | margin: 0; 19 | } 20 | `; 21 | const Ranges = styled.div` 22 | display: flex; 23 | margin-right: -11px; 24 | ${media.tablet` 25 | justify-content: space-around; 26 | margin: 30px 0 0; 27 | `}; 28 | `; 29 | const RangeButton = styled.button` 30 | background-color: transparent; 31 | color: ${props => (props.isActive ? colors.white : colors.lightGrey)}; 32 | font-size: ${fontSizes.base}; 33 | font-weight: 500; 34 | padding: 10px; 35 | ${media.phablet` 36 | font-size: ${fontSizes.sm}; 37 | `}; 38 | span { 39 | padding-bottom: 2px; 40 | border-bottom: 1px solid ${props => (props.isActive ? colors.white : `transparent`)}; 41 | line-height: 1.5; 42 | white-space: nowrap; 43 | } 44 | `; 45 | const TracksContainer = styled.ul` 46 | margin-top: 50px; 47 | `; 48 | 49 | const TopTracks = () => { 50 | const [topTracks, setTopTracks] = useState(null); 51 | const [activeRange, setActiveRange] = useState('long'); 52 | 53 | const apiCalls = { 54 | long: getTopTracksLong(), 55 | medium: getTopTracksMedium(), 56 | short: getTopTracksShort(), 57 | }; 58 | 59 | useEffect(() => { 60 | const fetchData = async () => { 61 | const { data } = await getTopTracksLong(); 62 | setTopTracks(data); 63 | }; 64 | catchErrors(fetchData()); 65 | }, []); 66 | 67 | const changeRange = async range => { 68 | const { data } = await apiCalls[range]; 69 | setTopTracks(data); 70 | setActiveRange(range); 71 | }; 72 | 73 | const setRangeData = range => catchErrors(changeRange(range)); 74 | 75 | return ( 76 | 77 | 78 | Top Tracks 79 | 80 | setRangeData('long')}> 81 | All Time 82 | 83 | setRangeData('medium')}> 84 | Last 6 Months 85 | 86 | setRangeData('short')}> 87 | Last 4 Weeks 88 | 89 | 90 | 91 | 92 | {topTracks ? ( 93 | topTracks.items.map((track, i) => ) 94 | ) : ( 95 | 96 | )} 97 | 98 | 99 | ); 100 | }; 101 | 102 | export default TopTracks; 103 | -------------------------------------------------------------------------------- /client/src/components/icons/loader.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components/macro'; 3 | import { theme } from '../../styles'; 4 | const { colors } = theme; 5 | 6 | const Loader = styled.div` 7 | margin: 0 0 2em; 8 | height: 100px; 9 | width: 50px; 10 | text-align: center; 11 | padding: 1em; 12 | margin: 0 auto 1em; 13 | display: inline-block; 14 | vertical-align: top; 15 | 16 | svg path, 17 | svg rect { 18 | fill: ${colors.grey}; 19 | } 20 | `; 21 | 22 | const IconLoader = () => ( 23 | 24 | 34 | 35 | 43 | 51 | 59 | 60 | 61 | 69 | 77 | 85 | 86 | 87 | 95 | 103 | 111 | 112 | 113 | 114 | ); 115 | 116 | export default IconLoader; 117 | -------------------------------------------------------------------------------- /client/src/components/TrackItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Link } from '@reach/router'; 4 | import { formatDuration } from '../utils'; 5 | 6 | import { IconInfo } from './icons'; 7 | 8 | import styled from 'styled-components/macro'; 9 | import { theme, mixins, media } from '../styles'; 10 | const { colors, fontSizes, spacing } = theme; 11 | 12 | const TrackLeft = styled.span` 13 | ${mixins.overflowEllipsis}; 14 | `; 15 | const TrackRight = styled.span``; 16 | const TrackArtwork = styled.div` 17 | display: inline-block; 18 | position: relative; 19 | width: 50px; 20 | min-width: 50px; 21 | margin-right: ${spacing.base}; 22 | `; 23 | const Mask = styled.div` 24 | ${mixins.flexCenter}; 25 | position: absolute; 26 | width: 100%; 27 | height: 100%; 28 | background-color: rgba(0, 0, 0, 0.5); 29 | top: 0; 30 | bottom: 0; 31 | left: 0; 32 | right: 0; 33 | color: ${colors.white}; 34 | opacity: 0; 35 | transition: ${theme.transition}; 36 | svg { 37 | width: 25px; 38 | } 39 | `; 40 | const TrackContainer = styled(Link)` 41 | display: grid; 42 | grid-template-columns: auto 1fr; 43 | align-items: center; 44 | margin-bottom: ${spacing.md}; 45 | ${media.tablet` 46 | margin-bottom: ${spacing.base}; 47 | `}; 48 | &:hover, 49 | &:focus { 50 | ${Mask} { 51 | opacity: 1; 52 | } 53 | } 54 | `; 55 | const TrackMeta = styled.div` 56 | display: grid; 57 | grid-template-columns: 1fr max-content; 58 | grid-gap: 10px; 59 | `; 60 | const TrackName = styled.span` 61 | margin-bottom: 5px; 62 | border-bottom: 1px solid transparent; 63 | &:hover, 64 | &:focus { 65 | border-bottom: 1px solid ${colors.white}; 66 | } 67 | `; 68 | const TrackAlbum = styled.div` 69 | ${mixins.overflowEllipsis}; 70 | color: ${colors.lightGrey}; 71 | font-size: ${fontSizes.sm}; 72 | margin-top: 3px; 73 | `; 74 | const TrackDuration = styled.span` 75 | color: ${colors.lightGrey}; 76 | font-size: ${fontSizes.sm}; 77 | `; 78 | 79 | const TrackItem = ({ track }) => ( 80 | 81 | 82 | 83 | 84 | {track.album.images.length && } 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | {track.name && {track.name}} 93 | {track.artists && track.album && ( 94 | 95 | {track.artists && 96 | track.artists.map(({ name }, i) => ( 97 | 98 | {name} 99 | {track.artists.length > 0 && i === track.artists.length - 1 ? '' : ','} 100 | 101 | ))} 102 | · 103 | {track.album.name} 104 | 105 | )} 106 | 107 | 108 | {track.duration_ms && {formatDuration(track.duration_ms)}} 109 | 110 | 111 | 112 | 113 | ); 114 | 115 | TrackItem.propTypes = { 116 | track: PropTypes.object.isRequired, 117 | }; 118 | 119 | export default TrackItem; 120 | -------------------------------------------------------------------------------- /client/src/components/Artist.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { formatWithCommas, catchErrors } from '../utils'; 4 | import { getArtist } from '../spotify'; 5 | 6 | import Loader from './Loader'; 7 | 8 | import styled from 'styled-components/macro'; 9 | import { theme, mixins, media, Main } from '../styles'; 10 | const { colors, fontSizes, spacing } = theme; 11 | 12 | const ArtistContainer = styled(Main)` 13 | ${mixins.flexCenter}; 14 | flex-direction: column; 15 | height: 100%; 16 | text-align: center; 17 | `; 18 | const Artwork = styled.div` 19 | ${mixins.coverShadow}; 20 | border-radius: 100%; 21 | img { 22 | object-fit: cover; 23 | border-radius: 100%; 24 | width: 300px; 25 | height: 300px; 26 | ${media.tablet` 27 | width: 200px; 28 | height: 200px; 29 | `}; 30 | } 31 | `; 32 | const ArtistName = styled.h1` 33 | font-size: 70px; 34 | margin-top: ${spacing.md}; 35 | ${media.tablet` 36 | font-size: 7vw; 37 | `}; 38 | `; 39 | const Stats = styled.div` 40 | display: grid; 41 | grid-template-columns: 1fr 1fr 1fr; 42 | grid-gap: 10px; 43 | margin-top: ${spacing.md}; 44 | text-align: center; 45 | `; 46 | const Stat = styled.div``; 47 | const Number = styled.div` 48 | color: ${colors.blue}; 49 | font-weight: 700; 50 | font-size: ${fontSizes.lg}; 51 | text-transform: capitalize; 52 | ${media.tablet` 53 | font-size: ${fontSizes.md}; 54 | `}; 55 | `; 56 | const Genre = styled.div` 57 | font-size: ${fontSizes.md}; 58 | `; 59 | const NumLabel = styled.p` 60 | color: ${colors.lightGrey}; 61 | font-size: ${fontSizes.xs}; 62 | text-transform: uppercase; 63 | letter-spacing: 1px; 64 | margin-top: ${spacing.xs}; 65 | `; 66 | 67 | const Artist = props => { 68 | const { artistId } = props; 69 | const [artist, setArtist] = useState(null); 70 | 71 | useEffect(() => { 72 | const fetchData = async () => { 73 | const { data } = await getArtist(artistId); 74 | setArtist(data); 75 | }; 76 | catchErrors(fetchData()); 77 | }, [artistId]); 78 | 79 | return ( 80 | 81 | {artist ? ( 82 | 83 | 84 | 85 | 86 | 87 | {artist.name} 88 | 89 | 90 | {formatWithCommas(artist.followers.total)} 91 | Followers 92 | 93 | {artist.genres && ( 94 | 95 | 96 | {artist.genres.map(genre => ( 97 | {genre} 98 | ))} 99 | 100 | Genres 101 | 102 | )} 103 | {artist.popularity && ( 104 | 105 | {artist.popularity}% 106 | Popularity 107 | 108 | )} 109 | 110 | 111 | 112 | ) : ( 113 | 114 | )} 115 | 116 | ); 117 | }; 118 | 119 | Artist.propTypes = { 120 | artistId: PropTypes.string, 121 | }; 122 | 123 | export default Artist; 124 | -------------------------------------------------------------------------------- /client/src/components/Playlists.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Link } from '@reach/router'; 3 | import { getPlaylists } from '../spotify'; 4 | import { catchErrors } from '../utils'; 5 | 6 | import Loader from './Loader'; 7 | import { IconMusic } from './icons'; 8 | 9 | import styled from 'styled-components/macro'; 10 | import { theme, mixins, media, Main } from '../styles'; 11 | const { colors, fontSizes, spacing } = theme; 12 | 13 | const Wrapper = styled.div` 14 | ${mixins.flexBetween}; 15 | align-items: flex-start; 16 | `; 17 | const PlaylistsContainer = styled.div` 18 | display: grid; 19 | grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 20 | grid-gap: ${spacing.md}; 21 | width: 100%; 22 | margin-top: 50px; 23 | ${media.tablet` 24 | grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); 25 | `}; 26 | ${media.phablet` 27 | grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); 28 | `}; 29 | `; 30 | const Playlist = styled.div` 31 | display: flex; 32 | flex-direction: column; 33 | text-align: center; 34 | `; 35 | const PlaylistMask = styled.div` 36 | ${mixins.flexCenter}; 37 | position: absolute; 38 | width: 100%; 39 | height: 100%; 40 | background-color: rgba(0, 0, 0, 0.5); 41 | top: 0; 42 | bottom: 0; 43 | left: 0; 44 | right: 0; 45 | font-size: 30px; 46 | color: ${colors.white}; 47 | opacity: 0; 48 | transition: ${theme.transition}; 49 | `; 50 | const PlaylistImage = styled.img` 51 | object-fit: cover; 52 | `; 53 | const PlaylistCover = styled(Link)` 54 | ${mixins.coverShadow}; 55 | position: relative; 56 | width: 100%; 57 | margin-bottom: ${spacing.base}; 58 | &:hover, 59 | &:focus { 60 | ${PlaylistMask} { 61 | opacity: 1; 62 | } 63 | } 64 | `; 65 | const PlaceholderArtwork = styled.div` 66 | ${mixins.flexCenter}; 67 | position: relative; 68 | width: 100%; 69 | padding-bottom: 100%; 70 | background-color: ${colors.darkGrey}; 71 | svg { 72 | width: 50px; 73 | height: 50px; 74 | } 75 | `; 76 | const PlaceholderContent = styled.div` 77 | ${mixins.flexCenter}; 78 | position: absolute; 79 | top: 0; 80 | bottom: 0; 81 | left: 0; 82 | right: 0; 83 | `; 84 | const PlaylistName = styled(Link)` 85 | display: inline; 86 | border-bottom: 1px solid transparent; 87 | &:hover, 88 | &:focus { 89 | border-bottom: 1px solid ${colors.white}; 90 | } 91 | `; 92 | const TotalTracks = styled.div` 93 | text-transform: uppercase; 94 | margin: 5px 0; 95 | color: ${colors.lightGrey}; 96 | font-size: ${fontSizes.xs}; 97 | letter-spacing: 1px; 98 | `; 99 | 100 | const Playlists = () => { 101 | const [playlists, setPlaylists] = useState(null); 102 | 103 | useEffect(() => { 104 | const fetchData = async () => { 105 | const { data } = await getPlaylists(); 106 | setPlaylists(data); 107 | }; 108 | catchErrors(fetchData()); 109 | }, []); 110 | 111 | return ( 112 | 113 | Your Playlists 114 | 115 | 116 | {playlists ? ( 117 | playlists.items.map(({ id, images, name, tracks }, i) => ( 118 | 119 | 120 | {images.length ? ( 121 | 122 | ) : ( 123 | 124 | 125 | 126 | 127 | 128 | )} 129 | 130 | 131 | 132 | 133 | 134 | {name} 135 | {tracks.total} Tracks 136 | 137 | 138 | )) 139 | ) : ( 140 | 141 | )} 142 | 143 | 144 | 145 | ); 146 | }; 147 | 148 | export default Playlists; 149 | -------------------------------------------------------------------------------- /client/src/components/FeatureChart.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Chart from 'chart.js'; 4 | 5 | import styled from 'styled-components/macro'; 6 | import { theme } from '../styles'; 7 | const { fonts } = theme; 8 | 9 | const properties = [ 10 | 'acousticness', 11 | 'danceability', 12 | 'energy', 13 | 'instrumentalness', 14 | 'liveness', 15 | 'speechiness', 16 | 'valence', 17 | ]; 18 | 19 | const Container = styled.div` 20 | position: relative; 21 | width: 100%; 22 | max-width: 700px; 23 | 24 | #chart { 25 | margin: 0 auto; 26 | margin-top: -30px; 27 | } 28 | `; 29 | 30 | const FeatureChart = props => { 31 | const avg = arr => arr.reduce((a, b) => a + b, 0) / arr.length; 32 | 33 | useEffect(() => { 34 | const createDataset = features => { 35 | const dataset = {}; 36 | properties.forEach(prop => { 37 | dataset[prop] = features.length 38 | ? avg(features.map(feat => feat && feat[prop])) 39 | : features[prop]; 40 | }); 41 | return dataset; 42 | }; 43 | 44 | const createChart = dataset => { 45 | const { type } = props; 46 | const ctx = document.getElementById('chart'); 47 | const labels = Object.keys(dataset); 48 | const data = Object.values(dataset); 49 | 50 | new Chart(ctx, { 51 | type: type || 'bar', 52 | data: { 53 | labels, 54 | datasets: [ 55 | { 56 | label: '', 57 | data, 58 | backgroundColor: [ 59 | 'rgba(255, 99, 132, 0.3)', 60 | 'rgba(255, 159, 64, 0.3)', 61 | 'rgba(255, 206, 86, 0.3)', 62 | 'rgba(75, 192, 192, 0.3)', 63 | 'rgba(54, 162, 235, 0.3)', 64 | 'rgba(104, 132, 245, 0.3)', 65 | 'rgba(153, 102, 255, 0.3)', 66 | ], 67 | borderColor: [ 68 | 'rgba(255,99,132,1)', 69 | 'rgba(255, 159, 64, 1)', 70 | 'rgba(255, 206, 86, 1)', 71 | 'rgba(75, 192, 192, 1)', 72 | 'rgba(54, 162, 235, 1)', 73 | 'rgba(104, 132, 245, 1)', 74 | 'rgba(153, 102, 255, 1)', 75 | ], 76 | borderWidth: 1, 77 | }, 78 | ], 79 | }, 80 | options: { 81 | layout: { 82 | padding: { 83 | left: 0, 84 | right: 0, 85 | top: 0, 86 | bottom: 0, 87 | }, 88 | }, 89 | title: { 90 | display: true, 91 | text: `Audio Features`, 92 | fontSize: 18, 93 | fontFamily: `${fonts.primary}`, 94 | fontColor: '#ffffff', 95 | padding: 30, 96 | }, 97 | legend: { 98 | display: false, 99 | }, 100 | scales: { 101 | xAxes: [ 102 | { 103 | gridLines: { 104 | color: 'rgba(255, 255, 255, 0.3)', 105 | }, 106 | ticks: { 107 | fontFamily: `${fonts.primary}`, 108 | fontSize: 12, 109 | }, 110 | }, 111 | ], 112 | yAxes: [ 113 | { 114 | gridLines: { 115 | color: 'rgba(255, 255, 255, 0.3)', 116 | }, 117 | ticks: { 118 | beginAtZero: true, 119 | fontFamily: `${fonts.primary}`, 120 | fontSize: 12, 121 | }, 122 | }, 123 | ], 124 | }, 125 | }, 126 | }); 127 | }; 128 | 129 | const parseData = () => { 130 | const { features } = props; 131 | const dataset = createDataset(features); 132 | createChart(dataset); 133 | }; 134 | 135 | parseData(); 136 | }, [props]); 137 | 138 | return ( 139 | 140 | 141 | 142 | ); 143 | }; 144 | 145 | FeatureChart.propTypes = { 146 | features: PropTypes.oneOfType([PropTypes.array, PropTypes.object]).isRequired, 147 | type: PropTypes.string, 148 | }; 149 | 150 | export default FeatureChart; 151 | -------------------------------------------------------------------------------- /client/src/components/Playlist.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Link } from '@reach/router'; 3 | import PropTypes from 'prop-types'; 4 | import { getPlaylist, getAudioFeaturesForTracks } from '../spotify'; 5 | import { catchErrors } from '../utils'; 6 | 7 | import Loader from './Loader'; 8 | import TrackItem from './TrackItem'; 9 | import FeatureChart from './FeatureChart'; 10 | 11 | import styled from 'styled-components/macro'; 12 | import { theme, mixins, media, Main } from '../styles'; 13 | const { colors, fontSizes, spacing } = theme; 14 | 15 | const PlaylistContainer = styled.div` 16 | display: flex; 17 | ${media.tablet` 18 | display: block; 19 | `}; 20 | `; 21 | const Left = styled.div` 22 | width: 30%; 23 | text-align: center; 24 | min-width: 200px; 25 | ${media.tablet` 26 | width: 100%; 27 | min-width: auto; 28 | `}; 29 | `; 30 | const Right = styled.div` 31 | flex-grow: 1; 32 | margin-left: 50px; 33 | ${media.tablet` 34 | margin: 50px 0 0; 35 | `}; 36 | `; 37 | const PlaylistCover = styled.div` 38 | ${mixins.coverShadow}; 39 | width: 100%; 40 | max-width: 300px; 41 | margin: 0 auto; 42 | ${media.tablet` 43 | display: none; 44 | `}; 45 | `; 46 | const Name = styled.h3` 47 | font-weight: 700; 48 | font-size: ${fontSizes.xl}; 49 | margin-top: 20px; 50 | `; 51 | const Description = styled.p` 52 | font-size: ${fontSizes.sm}; 53 | color: ${colors.lightGrey}; 54 | a { 55 | color: ${colors.white}; 56 | border-bottom: 1px solid transparent; 57 | &:hover, 58 | &:focus { 59 | border-bottom: 1px solid ${colors.white}; 60 | } 61 | } 62 | `; 63 | const RecButton = styled(Link)` 64 | ${mixins.greenButton}; 65 | margin-bottom: ${spacing.lg}; 66 | `; 67 | const Owner = styled.p` 68 | font-size: ${fontSizes.sm}; 69 | color: ${colors.lightGrey}; 70 | `; 71 | const TotalTracks = styled.p` 72 | font-size: ${fontSizes.sm}; 73 | color: ${colors.white}; 74 | margin-top: 20px; 75 | `; 76 | 77 | const Playlist = props => { 78 | const { playlistId } = props; 79 | 80 | const [playlist, setPlaylist] = useState(null); 81 | const [audioFeatures, setAudioFeatures] = useState(null); 82 | 83 | useEffect(() => { 84 | const fetchData = async () => { 85 | const { data } = await getPlaylist(playlistId); 86 | setPlaylist(data); 87 | }; 88 | catchErrors(fetchData()); 89 | }, [playlistId]); 90 | 91 | useEffect(() => { 92 | const fetchData = async () => { 93 | if (playlist) { 94 | const { data } = await getAudioFeaturesForTracks(playlist.tracks.items); 95 | setAudioFeatures(data); 96 | } 97 | }; 98 | catchErrors(fetchData()); 99 | }, [playlist]); 100 | 101 | return ( 102 | 103 | {playlist ? ( 104 | 105 | 106 | 107 | {playlist.images.length && ( 108 | 109 | 110 | 111 | )} 112 | 113 | 114 | {playlist.name} 115 | 116 | 117 | By {playlist.owner.display_name} 118 | 119 | {playlist.description && ( 120 | 121 | )} 122 | 123 | {playlist.tracks.total} Tracks 124 | 125 | Get Recommendations 126 | 127 | {audioFeatures && ( 128 | 129 | )} 130 | 131 | 132 | 133 | {playlist.tracks && 134 | playlist.tracks.items.map(({ track }, i) => )} 135 | 136 | 137 | 138 | 139 | ) : ( 140 | 141 | )} 142 | 143 | ); 144 | }; 145 | 146 | Playlist.propTypes = { 147 | playlistId: PropTypes.string, 148 | }; 149 | 150 | export default Playlist; 151 | -------------------------------------------------------------------------------- /client/src/components/Nav.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from '@reach/router'; 3 | 4 | import { 5 | IconSpotify, 6 | IconUser, 7 | IconTime, 8 | IconMicrophone, 9 | IconPlaylist, 10 | IconMusic, 11 | IconGithub, 12 | } from './icons'; 13 | 14 | import styled from 'styled-components/macro'; 15 | import { theme, mixins, media } from '../styles'; 16 | const { colors } = theme; 17 | 18 | const Container = styled.nav` 19 | ${mixins.coverShadow}; 20 | ${mixins.flexBetween}; 21 | flex-direction: column; 22 | min-height: 100vh; 23 | position: fixed; 24 | top: 0; 25 | left: 0; 26 | width: ${theme.navWidth}; 27 | background-color: ${colors.navBlack}; 28 | text-align: center; 29 | z-index: 99; 30 | ${media.tablet` 31 | top: auto; 32 | bottom: 0; 33 | right: 0; 34 | width: 100%; 35 | min-height: ${theme.navHeight}; 36 | height: ${theme.navHeight}; 37 | flex-direction: row; 38 | `}; 39 | & > * { 40 | width: 100%; 41 | ${media.tablet` 42 | height: 100%; 43 | `}; 44 | } 45 | `; 46 | const Logo = styled.div` 47 | color: ${colors.green}; 48 | margin-top: 30px; 49 | width: 70px; 50 | height: 70px; 51 | transition: ${theme.transition}; 52 | ${media.tablet` 53 | display: none; 54 | `}; 55 | &:hover, 56 | &:focus { 57 | color: ${colors.offGreen}; 58 | } 59 | svg { 60 | width: 50px; 61 | } 62 | `; 63 | const Github = styled.div` 64 | color: ${colors.lightGrey}; 65 | width: 45px; 66 | height: 45px; 67 | margin-bottom: 30px; 68 | ${media.tablet` 69 | display: none; 70 | `}; 71 | a { 72 | &:hover, 73 | &:focus, 74 | &.active { 75 | color: ${colors.blue}; 76 | } 77 | svg { 78 | width: 30px; 79 | } 80 | } 81 | `; 82 | const Menu = styled.ul` 83 | display: flex; 84 | flex-direction: column; 85 | ${media.tablet` 86 | flex-direction: row; 87 | align-items: flex-end; 88 | justify-content: center; 89 | `}; 90 | `; 91 | const MenuItem = styled.li` 92 | color: ${colors.lightGrey}; 93 | font-size: 11px; 94 | ${media.tablet` 95 | flex-grow: 1; 96 | flex-basis: 100%; 97 | height: 100%; 98 | `}; 99 | a { 100 | display: block; 101 | padding: 15px 0; 102 | border-left: 5px solid transparent; 103 | width: 100%; 104 | height: 100%; 105 | ${media.tablet` 106 | ${mixins.flexCenter}; 107 | flex-direction: column; 108 | padding: 0; 109 | border-left: 0; 110 | border-top: 3px solid transparent; 111 | `}; 112 | &:hover, 113 | &:focus, 114 | &.active { 115 | color: ${colors.white}; 116 | background-color: ${colors.black}; 117 | border-left: 5px solid ${colors.offGreen}; 118 | ${media.tablet` 119 | border-left: 0; 120 | border-top: 3px solid ${colors.offGreen}; 121 | `}; 122 | } 123 | } 124 | svg { 125 | width: 20px; 126 | height: 20px; 127 | margin-bottom: 7px; 128 | } 129 | `; 130 | 131 | const isActive = ({ isCurrent }) => (isCurrent ? { className: 'active' } : null); 132 | 133 | const NavLink = props => ; 134 | 135 | const Nav = () => ( 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | Profile 147 | 148 | 149 | 150 | 151 | 152 | Top Artists 153 | 154 | 155 | 156 | 157 | 158 | Top Tracks 159 | 160 | 161 | 162 | 163 | 164 | Recent 165 | 166 | 167 | 168 | 169 | 170 | Playlists 171 | 172 | 173 | 174 | 175 | 179 | 180 | 181 | 182 | 183 | ); 184 | 185 | export default Nav; 186 | -------------------------------------------------------------------------------- /client/src/components/Recommendations.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useMemo } from 'react'; 2 | import { Link } from '@reach/router'; 3 | import PropTypes from 'prop-types'; 4 | import { 5 | getPlaylist, 6 | getRecommendationsForTracks, 7 | getUser, 8 | createPlaylist, 9 | addTracksToPlaylist, 10 | followPlaylist, 11 | doesUserFollowPlaylist, 12 | } from '../spotify'; 13 | import { catchErrors } from '../utils'; 14 | 15 | import TrackItem from './TrackItem'; 16 | 17 | import styled from 'styled-components/macro'; 18 | import { theme, mixins, media, Main } from '../styles'; 19 | const { colors } = theme; 20 | 21 | const PlaylistHeading = styled.div` 22 | ${mixins.flexBetween}; 23 | ${media.tablet` 24 | flex-direction: column; 25 | 26 | `}; 27 | h2 { 28 | margin-bottom: 0; 29 | } 30 | `; 31 | const SaveButton = styled.button` 32 | ${mixins.greenButton}; 33 | `; 34 | const OpenButton = styled.a` 35 | ${mixins.button}; 36 | `; 37 | const TracksContainer = styled.ul` 38 | margin-top: 50px; 39 | `; 40 | const PlaylistLink = styled(Link)` 41 | &:hover, 42 | &:focus { 43 | color: ${colors.offGreen}; 44 | } 45 | `; 46 | 47 | const Recommendations = props => { 48 | const { playlistId } = props; 49 | 50 | const [playlist, setPlaylist] = useState(null); 51 | const [recommendations, setRecommmendations] = useState(null); 52 | const [recPlaylistId, setRecPlaylistId] = useState(null); 53 | const [userId, setUserId] = useState(null); 54 | const [isFollowing, setIsFollowing] = useState(false); 55 | 56 | useEffect(() => { 57 | const fetchPlaylistData = async () => { 58 | const { data } = await getPlaylist(playlistId); 59 | setPlaylist(data); 60 | }; 61 | catchErrors(fetchPlaylistData()); 62 | 63 | const fetchUserData = async () => { 64 | const { data } = await getUser(); 65 | setUserId(data.id); 66 | }; 67 | catchErrors(fetchUserData()); 68 | }, [playlistId]); 69 | 70 | useMemo(() => { 71 | const fetchData = async () => { 72 | if (playlist) { 73 | const { data } = await getRecommendationsForTracks(playlist.tracks.items); 74 | setRecommmendations(data); 75 | } 76 | }; 77 | catchErrors(fetchData()); 78 | }, [playlist]); 79 | 80 | // If recPlaylistId has been set, add tracks to playlist and follow 81 | useMemo(() => { 82 | const isUserFollowingPlaylist = async plistId => { 83 | const { data } = await doesUserFollowPlaylist(plistId, userId); 84 | setIsFollowing(data[0]); 85 | }; 86 | 87 | const addTracksAndFollow = async () => { 88 | const uris = recommendations.tracks.map(({ uri }) => uri).join(','); 89 | const { data } = await addTracksToPlaylist(recPlaylistId, uris); 90 | 91 | // Then follow playlist 92 | if (data) { 93 | await followPlaylist(recPlaylistId); 94 | // Check if user is following so we can change the save to spotify button to open on spotify 95 | catchErrors(isUserFollowingPlaylist(recPlaylistId)); 96 | } 97 | }; 98 | 99 | if (recPlaylistId && recommendations && userId) { 100 | catchErrors(addTracksAndFollow(recPlaylistId)); 101 | } 102 | }, [recPlaylistId, recommendations, userId]); 103 | 104 | const createPlaylistOnSave = async () => { 105 | if (!userId) { 106 | return; 107 | } 108 | 109 | const name = `Recommended Tracks Based on ${playlist.name}`; 110 | const { data } = await createPlaylist(userId, name); 111 | setRecPlaylistId(data.id); 112 | }; 113 | 114 | return ( 115 | 116 | {playlist && ( 117 | 118 | 119 | Recommended Tracks Based On{' '} 120 | {playlist.name} 121 | 122 | {isFollowing && recPlaylistId ? ( 123 | 127 | Open in Spotify 128 | 129 | ) : ( 130 | Save to Spotify 131 | )} 132 | 133 | )} 134 | 135 | {recommendations && 136 | recommendations.tracks.map((track, i) => )} 137 | 138 | 139 | ); 140 | }; 141 | 142 | Recommendations.propTypes = { 143 | playlistId: PropTypes.string, 144 | }; 145 | 146 | export default Recommendations; 147 | -------------------------------------------------------------------------------- /client/src/styles/GlobalStyle.js: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components/macro'; 2 | import theme from './theme'; 3 | const { colors, fontSizes, fonts } = theme; 4 | 5 | const GlobalStyle = createGlobalStyle` 6 | @font-face { 7 | font-family: 'Circular Std'; 8 | src: local('Circular Std Medium'), local('CircularStd-Medium'), 9 | url('../fonts/CircularStd-Medium.woff2') format('woff2'), 10 | url('../fonts/CircularStd-Medium.woff') format('woff'); 11 | font-weight: 500; 12 | font-style: normal; 13 | } 14 | 15 | @font-face { 16 | font-family: 'Circular Std'; 17 | src: local('Circular Std Book'), local('CircularStd-Book'), 18 | url('../fonts/CircularStd-Book.woff2') format('woff2'), 19 | url('../fonts/CircularStd-Book.woff') format('woff'); 20 | font-weight: 400; 21 | font-style: normal; 22 | } 23 | 24 | @font-face { 25 | font-family: 'Circular Std'; 26 | src: local('Circular Std Medium Italic'), local('CircularStd-MediumItalic'), 27 | url('../fonts/CircularStd-MediumItalic.woff2') format('woff2'), 28 | url('../fonts/CircularStd-MediumItalic.woff') format('woff'); 29 | font-weight: 500; 30 | font-style: italic; 31 | } 32 | 33 | @font-face { 34 | font-family: 'Circular Std'; 35 | src: local('Circular Std Black'), local('CircularStd-Black'), 36 | url('../fonts/CircularStd-Black.woff2') format('woff2'), 37 | url('../fonts/CircularStd-Black.woff') format('woff'); 38 | font-weight: 900; 39 | font-style: normal; 40 | } 41 | 42 | @font-face { 43 | font-family: 'Circular Std'; 44 | src: local('Circular Std Bold'), local('CircularStd-Bold'), 45 | url('../fonts/CircularStd-Bold.woff2') format('woff2'), 46 | url('../fonts/CircularStd-Bold.woff') format('woff'); 47 | font-weight: 700; 48 | font-style: normal; 49 | } 50 | 51 | @font-face { 52 | font-family: 'Circular Std'; 53 | src: local('Circular Std Bold Italic'), local('CircularStd-BoldItalic'), 54 | url('../fonts/CircularStd-BoldItalic.woff2') format('woff2'), 55 | url('../fonts/CircularStd-BoldItalic.woff') format('woff'); 56 | font-weight: 700; 57 | font-style: italic; 58 | } 59 | 60 | @font-face { 61 | font-family: 'Circular Std'; 62 | src: local('Circular Std Book Italic'), local('CircularStd-BookItalic'), 63 | url('../fonts/CircularStd-BookItalic.woff2') format('woff2'), 64 | url('../fonts/CircularStd-BookItalic.woff') format('woff'); 65 | font-weight: 400; 66 | font-style: italic; 67 | } 68 | 69 | @font-face { 70 | font-family: 'Circular Std'; 71 | src: local('Circular Std Black Italic'), local('CircularStd-BlackItalic'), 72 | url('../fonts/CircularStd-BlackItalic.woff2') format('woff2'), 73 | url('../fonts/CircularStd-BlackItalic.woff') format('woff'); 74 | font-weight: 900; 75 | font-style: italic; 76 | } 77 | 78 | html { 79 | box-sizing: border-box; 80 | } 81 | 82 | *, 83 | *:before, 84 | *:after { 85 | box-sizing: inherit; 86 | } 87 | 88 | html, 89 | body { 90 | margin: 0; 91 | padding: 0; 92 | width: 100%; 93 | max-width: 100%; 94 | } 95 | 96 | body { 97 | min-height: 100%; 98 | overflow-x: hidden; 99 | -moz-osx-font-smoothing: grayscale; 100 | -webkit-font-smoothing: antialiased; 101 | font-family: ${fonts.primary}; 102 | font-size: ${fontSizes.base}; 103 | background-color: ${colors.black}; 104 | color: ${colors.white}; 105 | } 106 | 107 | #root { 108 | min-height: 100%; 109 | } 110 | 111 | h1, h2, h3, h4, h5, h6 { 112 | letter-spacing: -.025em; 113 | margin: 0 0 10px; 114 | font-weight: 700; 115 | } 116 | 117 | h1, h2, h3 { 118 | font-weight: 900; 119 | } 120 | 121 | p { 122 | margin: 0 0 10px; 123 | } 124 | 125 | ol, ul { 126 | padding: 0; 127 | margin: 0; 128 | list-style: none; 129 | } 130 | 131 | a { 132 | display: inline-block; 133 | text-decoration: none; 134 | color: inherit; 135 | transition: ${theme.transition}; 136 | cursor: pointer; 137 | } 138 | 139 | img { 140 | width: 100%; 141 | max-width: 100%; 142 | vertical-align: middle; 143 | } 144 | 145 | svg { 146 | fill: currentColor; 147 | vertical-align: middle; 148 | } 149 | 150 | input { 151 | border-radius: 0; 152 | outline: 0; 153 | &::placeholder { 154 | opacity: 0.7; 155 | } 156 | &:focus, 157 | &:active { 158 | &::placeholder { 159 | opacity: 0.5; 160 | } 161 | } 162 | } 163 | 164 | button { 165 | display: inline-block; 166 | color: ${colors.lightestGrey}; 167 | font-family: ${fonts.primary}; 168 | font-size: ${fontSizes.base}; 169 | font-weight: 700; 170 | border-radius: 50px; 171 | border: 0; 172 | padding: 10px 20px; 173 | cursor: pointer; 174 | transition: ${theme.transition}; 175 | 176 | &:hover, 177 | &:focus { 178 | color: ${colors.white}; 179 | outline: 0; 180 | } 181 | } 182 | `; 183 | 184 | export default GlobalStyle; 185 | -------------------------------------------------------------------------------- /client/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | /* eslint-disable */ 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/), 19 | ); 20 | 21 | export function register(config) { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener('load', () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (isLocalhost) { 36 | // This is running on localhost. Let's check if a service worker still exists or not. 37 | checkValidServiceWorker(swUrl, config); 38 | 39 | // Add some additional logging to localhost, pointing developers to the 40 | // service worker/PWA documentation. 41 | navigator.serviceWorker.ready.then(() => { 42 | console.log( 43 | 'This web app is being served cache-first by a service ' + 44 | 'worker. To learn more, visit https://goo.gl/SC7cgQ', 45 | ); 46 | }); 47 | } else { 48 | // Is not local host. Just register service worker 49 | registerValidSW(swUrl, config); 50 | } 51 | }); 52 | } 53 | } 54 | 55 | function registerValidSW(swUrl, config) { 56 | navigator.serviceWorker 57 | .register(swUrl) 58 | .then(registration => { 59 | registration.onupdatefound = () => { 60 | const installingWorker = registration.installing; 61 | installingWorker.onstatechange = () => { 62 | if (installingWorker.state === 'installed') { 63 | if (navigator.serviceWorker.controller) { 64 | // At this point, the old content will have been purged and 65 | // the fresh content will have been added to the cache. 66 | // It's the perfect time to display a "New content is 67 | // available; please refresh." message in your web app. 68 | console.log('New content is available; please refresh.'); 69 | 70 | // Execute callback 71 | if (config.onUpdate) { 72 | config.onUpdate(registration); 73 | } 74 | } else { 75 | // At this point, everything has been precached. 76 | // It's the perfect time to display a 77 | // "Content is cached for offline use." message. 78 | console.log('Content is cached for offline use.'); 79 | 80 | // Execute callback 81 | if (config.onSuccess) { 82 | config.onSuccess(registration); 83 | } 84 | } 85 | } 86 | }; 87 | }; 88 | }) 89 | .catch(error => { 90 | console.error('Error during service worker registration:', error); 91 | }); 92 | } 93 | 94 | function checkValidServiceWorker(swUrl, config) { 95 | // Check if the service worker can be found. If it can't reload the page. 96 | fetch(swUrl) 97 | .then(response => { 98 | // Ensure service worker exists, and that we really are getting a JS file. 99 | if ( 100 | response.status === 404 || 101 | response.headers.get('content-type').indexOf('javascript') === -1 102 | ) { 103 | // No service worker found. Probably a different app. Reload the page. 104 | navigator.serviceWorker.ready.then(registration => { 105 | registration.unregister().then(() => { 106 | window.location.reload(); 107 | }); 108 | }); 109 | } else { 110 | // Service worker found. Proceed as normal. 111 | registerValidSW(swUrl, config); 112 | } 113 | }) 114 | .catch(() => { 115 | console.log('No internet connection found. App is running in offline mode.'); 116 | }); 117 | } 118 | 119 | export function unregister() { 120 | if ('serviceWorker' in navigator) { 121 | navigator.serviceWorker.ready.then(registration => { 122 | registration.unregister(); 123 | }); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /client/src/components/TopArtists.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Link } from '@reach/router'; 3 | import { getTopArtistsShort, getTopArtistsMedium, getTopArtistsLong } from '../spotify'; 4 | import { catchErrors } from '../utils'; 5 | 6 | import { IconInfo } from './icons'; 7 | import Loader from './Loader'; 8 | 9 | import styled from 'styled-components/macro'; 10 | import { theme, mixins, media, Main } from '../styles'; 11 | const { colors, fontSizes, spacing } = theme; 12 | 13 | const Header = styled.header` 14 | ${mixins.flexBetween}; 15 | ${media.tablet` 16 | display: block; 17 | `}; 18 | h2 { 19 | margin: 0; 20 | } 21 | `; 22 | const Ranges = styled.div` 23 | display: flex; 24 | margin-right: -11px; 25 | ${media.tablet` 26 | justify-content: space-around; 27 | margin: 30px 0 0; 28 | `}; 29 | `; 30 | const RangeButton = styled.button` 31 | background-color: transparent; 32 | color: ${props => (props.isActive ? colors.white : colors.lightGrey)}; 33 | font-size: ${fontSizes.base}; 34 | font-weight: 500; 35 | padding: 10px; 36 | ${media.phablet` 37 | font-size: ${fontSizes.sm}; 38 | `}; 39 | span { 40 | padding-bottom: 2px; 41 | border-bottom: 1px solid ${props => (props.isActive ? colors.white : `transparent`)}; 42 | line-height: 1.5; 43 | white-space: nowrap; 44 | } 45 | `; 46 | const ArtistsContainer = styled.div` 47 | display: grid; 48 | grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 49 | grid-gap: 20px; 50 | margin-top: 50px; 51 | ${media.tablet` 52 | grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); 53 | `}; 54 | ${media.phablet` 55 | grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); 56 | `}; 57 | `; 58 | const Artist = styled.div` 59 | display: flex; 60 | flex-direction: column; 61 | align-items: center; 62 | text-align: center; 63 | `; 64 | const Mask = styled.div` 65 | ${mixins.flexCenter}; 66 | position: absolute; 67 | width: 100%; 68 | height: 100%; 69 | background-color: rgba(0, 0, 0, 0.5); 70 | top: 0; 71 | bottom: 0; 72 | left: 0; 73 | right: 0; 74 | border-radius: 100%; 75 | font-size: 20px; 76 | color: ${colors.white}; 77 | opacity: 0; 78 | transition: ${theme.transition}; 79 | svg { 80 | width: 25px; 81 | } 82 | `; 83 | const ArtistArtwork = styled(Link)` 84 | display: inline-block; 85 | position: relative; 86 | width: 200px; 87 | height: 200px; 88 | ${media.tablet` 89 | width: 150px; 90 | height: 150px; 91 | `}; 92 | ${media.phablet` 93 | width: 120px; 94 | height: 120px; 95 | `}; 96 | &:hover, 97 | &:focus { 98 | ${Mask} { 99 | opacity: 1; 100 | } 101 | } 102 | img { 103 | border-radius: 100%; 104 | object-fit: cover; 105 | width: 200px; 106 | height: 200px; 107 | ${media.tablet` 108 | width: 150px; 109 | height: 150px; 110 | `}; 111 | ${media.phablet` 112 | width: 120px; 113 | height: 120px; 114 | `}; 115 | } 116 | `; 117 | const ArtistName = styled.a` 118 | margin: ${spacing.base} 0; 119 | border-bottom: 1px solid transparent; 120 | &:hover, 121 | &:focus { 122 | border-bottom: 1px solid ${colors.white}; 123 | } 124 | `; 125 | 126 | const TopArtists = () => { 127 | const [topArtists, setTopArtists] = useState(null); 128 | const [activeRange, setActiveRange] = useState('long'); 129 | 130 | const apiCalls = { 131 | long: getTopArtistsLong(), 132 | medium: getTopArtistsMedium(), 133 | short: getTopArtistsShort(), 134 | }; 135 | 136 | useEffect(() => { 137 | const fetchData = async () => { 138 | const { data } = await getTopArtistsLong(); 139 | setTopArtists(data); 140 | }; 141 | catchErrors(fetchData()); 142 | }, []); 143 | 144 | const changeRange = async range => { 145 | const { data } = await apiCalls[range]; 146 | setTopArtists(data); 147 | setActiveRange(range); 148 | }; 149 | 150 | const setRangeData = range => catchErrors(changeRange(range)); 151 | 152 | return ( 153 | 154 | 155 | Top Artists 156 | 157 | setRangeData('long')}> 158 | All Time 159 | 160 | setRangeData('medium')}> 161 | Last 6 Months 162 | 163 | setRangeData('short')}> 164 | Last 4 Weeks 165 | 166 | 167 | 168 | 169 | {topArtists ? ( 170 | topArtists.items.map(({ id, external_urls, images, name }, i) => ( 171 | 172 | 173 | {images.length && } 174 | 175 | 176 | 177 | 178 | 179 | {name} 180 | 181 | 182 | )) 183 | ) : ( 184 | 185 | )} 186 | 187 | 188 | ); 189 | }; 190 | 191 | export default TopArtists; 192 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 21 | Spotify Profile 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 46 | 47 | 48 | 49 | 54 | 59 | 64 | 69 | 74 | 79 | 84 | 89 | 94 | 100 | 106 | 112 | 118 | 122 | 123 | 124 | 125 | 126 | You need to enable JavaScript to run this app. 127 | 128 | 138 | 139 | 140 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | // SPOTIFY WEB API AUTHORIZATION CODE FLOW 2 | // https://developer.spotify.com/documentation/general/guides/authorization-guide/ 3 | // https://github.com/spotify/web-api-auth-examples 4 | 5 | require('dotenv').config(); 6 | 7 | const CLIENT_ID = process.env.CLIENT_ID; 8 | const CLIENT_SECRET = process.env.CLIENT_SECRET; 9 | let REDIRECT_URI = process.env.REDIRECT_URI || 'http://localhost:8888/callback'; 10 | let FRONTEND_URI = process.env.FRONTEND_URI || 'http://localhost:3000'; 11 | const PORT = process.env.PORT || 8888; 12 | 13 | if (process.env.NODE_ENV !== 'production') { 14 | REDIRECT_URI = 'http://localhost:8888/callback'; 15 | FRONTEND_URI = 'http://localhost:3000'; 16 | } 17 | 18 | const express = require('express'); 19 | const request = require('request'); 20 | const cors = require('cors'); 21 | const querystring = require('querystring'); 22 | const cookieParser = require('cookie-parser'); 23 | const path = require('path'); 24 | const cluster = require('cluster'); 25 | const numCPUs = require('os').cpus().length; 26 | const history = require('connect-history-api-fallback'); 27 | 28 | /** 29 | * Generates a random string containing numbers and letters 30 | * @param {number} length The length of the string 31 | * @return {string} The generated string 32 | */ 33 | const generateRandomString = length => { 34 | let text = ''; 35 | const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 36 | for (let i = 0; i < length; i++) { 37 | text += possible.charAt(Math.floor(Math.random() * possible.length)); 38 | } 39 | return text; 40 | }; 41 | 42 | const stateKey = 'spotify_auth_state'; 43 | 44 | // Multi-process to utilize all CPU cores. 45 | if (cluster.isMaster) { 46 | console.warn(`Node cluster master ${process.pid} is running`); 47 | 48 | // Fork workers. 49 | for (let i = 0; i < numCPUs; i++) { 50 | cluster.fork(); 51 | } 52 | 53 | cluster.on('exit', (worker, code, signal) => { 54 | console.error( 55 | `Node cluster worker ${worker.process.pid} exited: code ${code}, signal ${signal}`, 56 | ); 57 | }); 58 | } else { 59 | const app = express(); 60 | 61 | // Priority serve any static files. 62 | app.use(express.static(path.resolve(__dirname, '../client/build'))); 63 | 64 | app 65 | .use(express.static(path.resolve(__dirname, '../client/build'))) 66 | .use(cors()) 67 | .use(cookieParser()) 68 | .use( 69 | history({ 70 | verbose: true, 71 | rewrites: [ 72 | { from: /\/login/, to: '/login' }, 73 | { from: /\/callback/, to: '/callback' }, 74 | { from: /\/refresh_token/, to: '/refresh_token' }, 75 | ], 76 | }), 77 | ) 78 | .use(express.static(path.resolve(__dirname, '../client/build'))); 79 | 80 | app.get('/', function (req, res) { 81 | res.render(path.resolve(__dirname, '../client/build/index.html')); 82 | }); 83 | 84 | app.get('/login', function (req, res) { 85 | const state = generateRandomString(16); 86 | res.cookie(stateKey, state); 87 | 88 | // your application requests authorization 89 | const scope = 90 | 'user-read-private user-read-email user-read-recently-played user-top-read user-follow-read user-follow-modify playlist-read-private playlist-read-collaborative playlist-modify-public'; 91 | 92 | res.redirect( 93 | `https://accounts.spotify.com/authorize?${querystring.stringify({ 94 | response_type: 'code', 95 | client_id: CLIENT_ID, 96 | scope: scope, 97 | redirect_uri: REDIRECT_URI, 98 | state: state, 99 | })}`, 100 | ); 101 | }); 102 | 103 | app.get('/callback', function (req, res) { 104 | // your application requests refresh and access tokens 105 | // after checking the state parameter 106 | 107 | const code = req.query.code || null; 108 | const state = req.query.state || null; 109 | const storedState = req.cookies ? req.cookies[stateKey] : null; 110 | 111 | if (state === null || state !== storedState) { 112 | res.redirect(`/#${querystring.stringify({ error: 'state_mismatch' })}`); 113 | } else { 114 | res.clearCookie(stateKey); 115 | const authOptions = { 116 | url: 'https://accounts.spotify.com/api/token', 117 | form: { 118 | code: code, 119 | redirect_uri: REDIRECT_URI, 120 | grant_type: 'authorization_code', 121 | }, 122 | headers: { 123 | Authorization: `Basic ${new Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString( 124 | 'base64', 125 | )}`, 126 | }, 127 | json: true, 128 | }; 129 | 130 | request.post(authOptions, function (error, response, body) { 131 | if (!error && response.statusCode === 200) { 132 | const access_token = body.access_token; 133 | const refresh_token = body.refresh_token; 134 | 135 | // we can also pass the token to the browser to make requests from there 136 | res.redirect( 137 | `${FRONTEND_URI}/#${querystring.stringify({ 138 | access_token, 139 | refresh_token, 140 | })}`, 141 | ); 142 | } else { 143 | res.redirect(`/#${querystring.stringify({ error: 'invalid_token' })}`); 144 | } 145 | }); 146 | } 147 | }); 148 | 149 | app.get('/refresh_token', function (req, res) { 150 | // requesting access token from refresh token 151 | const refresh_token = req.query.refresh_token; 152 | const authOptions = { 153 | url: 'https://accounts.spotify.com/api/token', 154 | headers: { 155 | Authorization: `Basic ${new Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString( 156 | 'base64', 157 | )}`, 158 | }, 159 | form: { 160 | grant_type: 'refresh_token', 161 | refresh_token, 162 | }, 163 | json: true, 164 | }; 165 | 166 | request.post(authOptions, function (error, response, body) { 167 | if (!error && response.statusCode === 200) { 168 | const access_token = body.access_token; 169 | res.send({ access_token }); 170 | } 171 | }); 172 | }); 173 | 174 | // All remaining requests return the React app, so it can handle routing. 175 | app.get('*', function (request, response) { 176 | response.sendFile(path.resolve(__dirname, '../client/public', 'index.html')); 177 | }); 178 | 179 | app.listen(PORT, function () { 180 | console.warn(`Node cluster worker ${process.pid}: listening on port ${PORT}`); 181 | }); 182 | } 183 | -------------------------------------------------------------------------------- /client/src/components/Track.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { formatDuration, getYear, parsePitchClass, catchErrors } from '../utils'; 4 | import { getTrackInfo } from '../spotify'; 5 | 6 | import Loader from './Loader'; 7 | import FeatureChart from './FeatureChart'; 8 | 9 | import styled from 'styled-components/macro'; 10 | import { theme, mixins, media, Main } from '../styles'; 11 | const { colors, fontSizes } = theme; 12 | 13 | const TrackContainer = styled.div` 14 | display: flex; 15 | margin-bottom: 70px; 16 | ${media.phablet` 17 | flex-direction: column; 18 | align-items: center; 19 | margin-bottom: 30px; 20 | `}; 21 | `; 22 | const Artwork = styled.div` 23 | ${mixins.coverShadow}; 24 | max-width: 250px; 25 | margin-right: 40px; 26 | ${media.tablet` 27 | max-width: 200px; 28 | `}; 29 | ${media.phablet` 30 | margin: 0 auto; 31 | `}; 32 | `; 33 | const Info = styled.div` 34 | flex-grow: 1; 35 | ${media.phablet` 36 | text-align: center; 37 | margin-top: 30px; 38 | `}; 39 | `; 40 | const PlayTrackButton = styled.a` 41 | ${mixins.greenButton}; 42 | `; 43 | const Title = styled.h1` 44 | font-size: 42px; 45 | margin: 0 0 5px; 46 | ${media.tablet` 47 | font-size: 30px; 48 | `}; 49 | `; 50 | const ArtistName = styled.h2` 51 | color: ${colors.lightestGrey}; 52 | font-weight: 700; 53 | text-align: left !important; 54 | ${media.tablet` 55 | font-size: 20px; 56 | `}; 57 | ${media.phablet` 58 | text-align: center !important; 59 | `}; 60 | `; 61 | const Album = styled.h3` 62 | color: ${colors.lightGrey}; 63 | font-weight: 400; 64 | font-size: 16px; 65 | `; 66 | const AudioFeatures = styled.div` 67 | ${mixins.flexCenter}; 68 | flex-direction: column; 69 | `; 70 | const Features = styled.div` 71 | display: grid; 72 | grid-template-columns: repeat(5, minmax(100px, 1fr)); 73 | width: 100%; 74 | margin-bottom: 50px; 75 | text-align: center; 76 | border-top: 1px solid ${colors.grey}; 77 | border-left: 1px solid ${colors.grey}; 78 | ${media.thone` 79 | grid-template-columns: repeat(2, minmax(100px, 1fr)); 80 | `}; 81 | ${media.phablet` 82 | grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); 83 | `}; 84 | `; 85 | const Feature = styled.div` 86 | padding: 15px 10px; 87 | border-bottom: 1px solid ${colors.grey}; 88 | border-right: 1px solid ${colors.grey}; 89 | `; 90 | const FeatureText = styled.h4` 91 | color: ${colors.lightestGrey}; 92 | font-size: 30px; 93 | font-weight: 700; 94 | margin-bottom: 0; 95 | ${media.tablet` 96 | font-size: 24px; 97 | `}; 98 | `; 99 | const FeatureLabel = styled.p` 100 | color: ${colors.lightestGrey}; 101 | font-size: ${fontSizes.xs}; 102 | margin-bottom: 0; 103 | `; 104 | const DescriptionLink = styled.a` 105 | color: ${colors.lightestGrey}; 106 | margin: 20px auto 0; 107 | border-bottom: 1px solid transparent; 108 | &:hover, 109 | &:focus { 110 | color: ${colors.white}; 111 | border-bottom: 1px solid ${colors.white}; 112 | } 113 | `; 114 | 115 | const Track = props => { 116 | const { trackId } = props; 117 | 118 | const [track, setTrack] = useState(null); 119 | const [audioAnalysis, setAudioAnalysis] = useState(null); 120 | const [audioFeatures, setAudioFeatures] = useState(null); 121 | 122 | useEffect(() => { 123 | const fetchData = async () => { 124 | const data = await getTrackInfo(trackId); 125 | setTrack(data.track); 126 | setAudioAnalysis(data.audioAnalysis); 127 | setAudioFeatures(data.audioFeatures); 128 | }; 129 | catchErrors(fetchData()); 130 | }, [trackId]); 131 | 132 | return ( 133 | 134 | {track ? ( 135 | 136 | 137 | 138 | 139 | 140 | 141 | {track.name} 142 | 143 | {track.artists && 144 | track.artists.map(({ name }, i) => ( 145 | 146 | {name} 147 | {track.artists.length > 0 && i === track.artists.length - 1 ? '' : ','} 148 | 149 | 150 | ))} 151 | 152 | 153 | 157 | {track.album.name} 158 | {' '} 159 | · {getYear(track.album.release_date)} 160 | 161 | 165 | Play on Spotify 166 | 167 | 168 | 169 | 170 | {audioFeatures && audioAnalysis && ( 171 | 172 | 173 | 174 | {formatDuration(audioFeatures.duration_ms)} 175 | Duration 176 | 177 | 178 | {parsePitchClass(audioFeatures.key)} 179 | Key 180 | 181 | 182 | {audioFeatures.mode === 1 ? 'Major' : 'Minor'} 183 | Modality 184 | 185 | 186 | {audioFeatures.time_signature} 187 | Time Signature 188 | 189 | 190 | {Math.round(audioFeatures.tempo)} 191 | Tempo (BPM) 192 | 193 | 194 | {track.popularity}% 195 | Popularity 196 | 197 | 198 | {audioAnalysis.bars.length} 199 | Bars 200 | 201 | 202 | {audioAnalysis.beats.length} 203 | Beats 204 | 205 | 206 | {audioAnalysis.sections.length} 207 | Sections 208 | 209 | 210 | {audioAnalysis.segments.length} 211 | Segments 212 | 213 | 214 | 215 | 216 | 217 | 221 | Full Description of Audio Features 222 | 223 | 224 | )} 225 | 226 | ) : ( 227 | 228 | )} 229 | 230 | ); 231 | }; 232 | 233 | Track.propTypes = { 234 | trackId: PropTypes.string, 235 | }; 236 | 237 | export default Track; 238 | -------------------------------------------------------------------------------- /client/src/components/User.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Link } from '@reach/router'; 3 | import { getUserInfo, logout } from '../spotify'; 4 | import { catchErrors } from '../utils'; 5 | 6 | import { IconUser, IconInfo } from './icons'; 7 | import Loader from './Loader'; 8 | import TrackItem from './TrackItem'; 9 | 10 | import styled from 'styled-components/macro'; 11 | import { theme, mixins, media, Main } from '../styles'; 12 | const { colors, fontSizes, spacing } = theme; 13 | 14 | const Header = styled.header` 15 | ${mixins.flexCenter}; 16 | flex-direction: column; 17 | position: relative; 18 | `; 19 | const Avatar = styled.div` 20 | width: 150px; 21 | height: 150px; 22 | img { 23 | border-radius: 100%; 24 | } 25 | `; 26 | const NoAvatar = styled.div` 27 | border: 2px solid currentColor; 28 | border-radius: 100%; 29 | padding: ${spacing.md}; 30 | `; 31 | const UserName = styled.a` 32 | &:hover, 33 | &:focus { 34 | color: ${colors.offGreen}; 35 | } 36 | `; 37 | const Name = styled.h1` 38 | font-size: 50px; 39 | font-weight: 700; 40 | margin: 20px 0 0; 41 | ${media.tablet` 42 | font-size: 40px; 43 | `}; 44 | ${media.phablet` 45 | font-size: 8vw; 46 | `}; 47 | `; 48 | const Stats = styled.div` 49 | display: grid; 50 | grid-template-columns: repeat(3, 1fr); 51 | grid-gap: 30px; 52 | margin-top: ${spacing.base}; 53 | `; 54 | const Stat = styled.div` 55 | text-align: center; 56 | `; 57 | const Number = styled.div` 58 | color: ${colors.green}; 59 | font-weight: 700; 60 | font-size: ${fontSizes.md}; 61 | `; 62 | const NumLabel = styled.p` 63 | color: ${colors.lightGrey}; 64 | font-size: ${fontSizes.xs}; 65 | text-transform: uppercase; 66 | letter-spacing: 1px; 67 | margin-top: ${spacing.xs}; 68 | `; 69 | const LogoutButton = styled.a` 70 | background-color: transparent; 71 | color: ${colors.white}; 72 | border: 1px solid ${colors.white}; 73 | border-radius: 30px; 74 | margin-top: 30px; 75 | padding: 12px 30px; 76 | font-size: ${fontSizes.xs}; 77 | font-weight: 700; 78 | letter-spacing: 1px; 79 | text-transform: uppercase; 80 | text-align: center; 81 | &:hover, 82 | &:focus { 83 | background-color: ${colors.white}; 84 | color: ${colors.black}; 85 | } 86 | `; 87 | const Preview = styled.section` 88 | display: grid; 89 | grid-template-columns: 1fr 1fr; 90 | grid-gap: 70px; 91 | width: 100%; 92 | margin-top: 100px; 93 | ${media.tablet` 94 | display: block; 95 | margin-top: 70px; 96 | `}; 97 | `; 98 | const Tracklist = styled.div` 99 | ${media.tablet` 100 | &:last-of-type { 101 | margin-top: 50px; 102 | } 103 | `}; 104 | `; 105 | const TracklistHeading = styled.div` 106 | ${mixins.flexBetween}; 107 | margin-bottom: 40px; 108 | h3 { 109 | display: inline-block; 110 | margin: 0; 111 | } 112 | `; 113 | const MoreButton = styled(Link)` 114 | ${mixins.button}; 115 | text-align: center; 116 | white-space: nowrap; 117 | ${media.phablet` 118 | padding: 11px 20px; 119 | font-sizes: ${fontSizes.xs}; 120 | `}; 121 | `; 122 | const Mask = styled.div` 123 | ${mixins.flexCenter}; 124 | position: absolute; 125 | width: 100%; 126 | height: 100%; 127 | background-color: rgba(0, 0, 0, 0.5); 128 | top: 0; 129 | bottom: 0; 130 | left: 0; 131 | right: 0; 132 | color: ${colors.white}; 133 | opacity: 0; 134 | transition: ${theme.transition}; 135 | svg { 136 | width: 25px; 137 | } 138 | `; 139 | const Artist = styled.li` 140 | display: flex; 141 | align-items: center; 142 | margin-bottom: ${spacing.md}; 143 | ${media.tablet` 144 | margin-bottom: ${spacing.base}; 145 | `}; 146 | &:hover, 147 | &:focus { 148 | ${Mask} { 149 | opacity: 1; 150 | } 151 | } 152 | `; 153 | const ArtistArtwork = styled(Link)` 154 | display: inline-block; 155 | position: relative; 156 | width: 50px; 157 | min-width: 50px; 158 | margin-right: ${spacing.base}; 159 | img { 160 | width: 50px; 161 | min-width: 50px; 162 | height: 50px; 163 | margin-right: ${spacing.base}; 164 | border-radius: 100%; 165 | } 166 | `; 167 | 168 | const ArtistName = styled(Link)` 169 | flex-grow: 1; 170 | span { 171 | border-bottom: 1px solid transparent; 172 | &:hover, 173 | &:focus { 174 | border-bottom: 1px solid ${colors.white}; 175 | } 176 | } 177 | `; 178 | 179 | const User = () => { 180 | const [user, setUser] = useState(null); 181 | const [followedArtists, setFollowedArtists] = useState(null); 182 | const [playlists, setPlaylists] = useState(null); 183 | const [topArtists, setTopArtists] = useState(null); 184 | const [topTracks, setTopTracks] = useState(null); 185 | 186 | useEffect(() => { 187 | const fetchData = async () => { 188 | const { user, followedArtists, playlists, topArtists, topTracks } = await getUserInfo(); 189 | setUser(user); 190 | setFollowedArtists(followedArtists); 191 | setPlaylists(playlists); 192 | setTopArtists(topArtists); 193 | setTopTracks(topTracks); 194 | }; 195 | catchErrors(fetchData()); 196 | }, []); 197 | 198 | const totalPlaylists = playlists ? playlists.total : 0; 199 | 200 | return ( 201 | 202 | {user ? ( 203 | 204 | 205 | 206 | {user.images.length > 0 ? ( 207 | 208 | ) : ( 209 | 210 | 211 | 212 | )} 213 | 214 | 215 | {user.display_name} 216 | 217 | 218 | 219 | {user.followers.total} 220 | Followers 221 | 222 | {followedArtists && ( 223 | 224 | {followedArtists.artists.items.length} 225 | Following 226 | 227 | )} 228 | {totalPlaylists && ( 229 | 230 | 231 | {totalPlaylists} 232 | Playlists 233 | 234 | 235 | )} 236 | 237 | Logout 238 | 239 | 240 | 241 | 242 | 243 | Top Artists of All Time 244 | See More 245 | 246 | 247 | {topArtists ? ( 248 | 249 | {topArtists.items.slice(0, 10).map((artist, i) => ( 250 | 251 | 252 | {artist.images.length && } 253 | 254 | 255 | 256 | 257 | 258 | {artist.name} 259 | 260 | 261 | ))} 262 | 263 | ) : ( 264 | 265 | )} 266 | 267 | 268 | 269 | 270 | 271 | Top Tracks of All Time 272 | See More 273 | 274 | 275 | {topTracks ? ( 276 | topTracks.items 277 | .slice(0, 10) 278 | .map((track, i) => ) 279 | ) : ( 280 | 281 | )} 282 | 283 | 284 | 285 | 286 | ) : ( 287 | 288 | )} 289 | 290 | ); 291 | }; 292 | 293 | export default User; 294 | -------------------------------------------------------------------------------- /client/src/spotify/index.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { getHashParams } from '../utils'; 3 | 4 | // TOKENS ****************************************************************************************** 5 | const EXPIRATION_TIME = 3600 * 1000; // 3600 seconds * 1000 = 1 hour in milliseconds 6 | 7 | const setTokenTimestamp = () => window.localStorage.setItem('spotify_token_timestamp', Date.now()); 8 | const setLocalAccessToken = token => { 9 | setTokenTimestamp(); 10 | window.localStorage.setItem('spotify_access_token', token); 11 | }; 12 | const setLocalRefreshToken = token => window.localStorage.setItem('spotify_refresh_token', token); 13 | const getTokenTimestamp = () => window.localStorage.getItem('spotify_token_timestamp'); 14 | const getLocalAccessToken = () => window.localStorage.getItem('spotify_access_token'); 15 | const getLocalRefreshToken = () => window.localStorage.getItem('spotify_refresh_token'); 16 | 17 | // Refresh the token 18 | const refreshAccessToken = async () => { 19 | try { 20 | const { data } = await axios.get(`/refresh_token?refresh_token=${getLocalRefreshToken()}`); 21 | const { access_token } = data; 22 | setLocalAccessToken(access_token); 23 | window.location.reload(); 24 | return; 25 | } catch (e) { 26 | console.error(e); 27 | } 28 | }; 29 | 30 | // Get access token off of query params (called on application init) 31 | export const getAccessToken = () => { 32 | const { error, access_token, refresh_token } = getHashParams(); 33 | 34 | if (error) { 35 | console.error(error); 36 | refreshAccessToken(); 37 | } 38 | 39 | // If token has expired 40 | if (Date.now() - getTokenTimestamp() > EXPIRATION_TIME) { 41 | console.warn('Access token has expired, refreshing...'); 42 | refreshAccessToken(); 43 | } 44 | 45 | const localAccessToken = getLocalAccessToken(); 46 | 47 | // If there is no ACCESS token in local storage, set it and return `access_token` from params 48 | if ((!localAccessToken || localAccessToken === 'undefined') && access_token) { 49 | setLocalAccessToken(access_token); 50 | setLocalRefreshToken(refresh_token); 51 | return access_token; 52 | } 53 | 54 | return localAccessToken; 55 | }; 56 | 57 | export const token = getAccessToken(); 58 | 59 | export const logout = () => { 60 | window.localStorage.removeItem('spotify_token_timestamp'); 61 | window.localStorage.removeItem('spotify_access_token'); 62 | window.localStorage.removeItem('spotify_refresh_token'); 63 | window.location.reload(); 64 | }; 65 | 66 | // API CALLS *************************************************************************************** 67 | 68 | const headers = { 69 | Authorization: `Bearer ${token}`, 70 | 'Content-Type': 'application/json', 71 | }; 72 | 73 | /** 74 | * Get Current User's Profile 75 | * https://developer.spotify.com/documentation/web-api/reference/users-profile/get-current-users-profile/ 76 | */ 77 | export const getUser = () => axios.get('https://api.spotify.com/v1/me', { headers }); 78 | 79 | /** 80 | * Get User's Followed Artists 81 | * https://developer.spotify.com/documentation/web-api/reference/follow/get-followed/ 82 | */ 83 | export const getFollowing = () => 84 | axios.get('https://api.spotify.com/v1/me/following?type=artist', { headers }); 85 | 86 | /** 87 | * Get Current User's Recently Played Tracks 88 | * https://developer.spotify.com/documentation/web-api/reference/player/get-recently-played/ 89 | */ 90 | export const getRecentlyPlayed = () => 91 | axios.get('https://api.spotify.com/v1/me/player/recently-played', { headers }); 92 | 93 | /** 94 | * Get a List of Current User's Playlists 95 | * https://developer.spotify.com/documentation/web-api/reference/playlists/get-a-list-of-current-users-playlists/ 96 | */ 97 | export const getPlaylists = () => axios.get('https://api.spotify.com/v1/me/playlists', { headers }); 98 | 99 | /** 100 | * Get a User's Top Artists 101 | * https://developer.spotify.com/documentation/web-api/reference/personalization/get-users-top-artists-and-tracks/ 102 | */ 103 | export const getTopArtistsShort = () => 104 | axios.get('https://api.spotify.com/v1/me/top/artists?limit=50&time_range=short_term', { 105 | headers, 106 | }); 107 | export const getTopArtistsMedium = () => 108 | axios.get('https://api.spotify.com/v1/me/top/artists?limit=50&time_range=medium_term', { 109 | headers, 110 | }); 111 | export const getTopArtistsLong = () => 112 | axios.get('https://api.spotify.com/v1/me/top/artists?limit=50&time_range=long_term', { headers }); 113 | 114 | /** 115 | * Get a User's Top Tracks 116 | * https://developer.spotify.com/documentation/web-api/reference/personalization/get-users-top-artists-and-tracks/ 117 | */ 118 | export const getTopTracksShort = () => 119 | axios.get('https://api.spotify.com/v1/me/top/tracks?limit=50&time_range=short_term', { headers }); 120 | export const getTopTracksMedium = () => 121 | axios.get('https://api.spotify.com/v1/me/top/tracks?limit=50&time_range=medium_term', { 122 | headers, 123 | }); 124 | export const getTopTracksLong = () => 125 | axios.get('https://api.spotify.com/v1/me/top/tracks?limit=50&time_range=long_term', { headers }); 126 | 127 | /** 128 | * Get an Artist 129 | * https://developer.spotify.com/documentation/web-api/reference/artists/get-artist/ 130 | */ 131 | export const getArtist = artistId => 132 | axios.get(`https://api.spotify.com/v1/artists/${artistId}`, { headers }); 133 | 134 | /** 135 | * Follow an Artist 136 | * https://developer.spotify.com/documentation/web-api/reference/follow/follow-artists-users/ 137 | */ 138 | export const followArtist = artistId => { 139 | const url = `https://api.spotify.com/v1/me/following?type=artist&ids=${artistId}`; 140 | return axios({ method: 'put', url, headers }); 141 | }; 142 | 143 | /** 144 | * Check if Current User Follows Artists 145 | * https://developer.spotify.com/documentation/web-api/reference/follow/follow-artists-users/ 146 | */ 147 | export const doesUserFollowArtist = artistId => 148 | axios.get(`https://api.spotify.com/v1/me/following/contains?type=artist&ids=${artistId}`, { 149 | headers, 150 | }); 151 | 152 | /** 153 | * Check if Users Follow a Playlist 154 | * https://developer.spotify.com/documentation/web-api/reference/follow/follow-artists-users/ 155 | */ 156 | export const doesUserFollowPlaylist = (playlistId, userId) => 157 | axios.get(`https://api.spotify.com/v1/playlists/${playlistId}/followers/contains?ids=${userId}`, { 158 | headers, 159 | }); 160 | 161 | /** 162 | * Create a Playlist (The playlist will be empty until you add tracks) 163 | * https://developer.spotify.com/documentation/web-api/reference/playlists/create-playlist/ 164 | */ 165 | export const createPlaylist = (userId, name) => { 166 | const url = `https://api.spotify.com/v1/users/${userId}/playlists`; 167 | const data = JSON.stringify({ name }); 168 | return axios({ method: 'post', url, headers, data }); 169 | }; 170 | 171 | /** 172 | * Add Tracks to a Playlist 173 | * https://developer.spotify.com/documentation/web-api/reference/playlists/add-tracks-to-playlist/ 174 | */ 175 | export const addTracksToPlaylist = (playlistId, uris) => { 176 | const url = `https://api.spotify.com/v1/playlists/${playlistId}/tracks?uris=${uris}`; 177 | return axios({ method: 'post', url, headers }); 178 | }; 179 | 180 | /** 181 | * Follow a Playlist 182 | * https://developer.spotify.com/documentation/web-api/reference/follow/follow-playlist/ 183 | */ 184 | export const followPlaylist = playlistId => { 185 | const url = `https://api.spotify.com/v1/playlists/${playlistId}/followers`; 186 | return axios({ method: 'put', url, headers }); 187 | }; 188 | 189 | /** 190 | * Get a Playlist 191 | * https://developer.spotify.com/documentation/web-api/reference/playlists/get-playlist/ 192 | */ 193 | export const getPlaylist = playlistId => 194 | axios.get(`https://api.spotify.com/v1/playlists/${playlistId}`, { headers }); 195 | 196 | /** 197 | * Get a Playlist's Tracks 198 | * https://developer.spotify.com/documentation/web-api/reference/playlists/get-playlists-tracks/ 199 | */ 200 | export const getPlaylistTracks = playlistId => 201 | axios.get(`https://api.spotify.com/v1/playlists/${playlistId}/tracks`, { headers }); 202 | 203 | /** 204 | * Return a comma separated string of track IDs from the given array of tracks 205 | */ 206 | const getTrackIds = tracks => tracks.map(({ track }) => track.id).join(','); 207 | 208 | /** 209 | * Get Audio Features for Several Tracks 210 | * https://developer.spotify.com/documentation/web-api/reference/tracks/get-several-audio-features/ 211 | */ 212 | export const getAudioFeaturesForTracks = tracks => { 213 | const ids = getTrackIds(tracks); 214 | return axios.get(`https://api.spotify.com/v1/audio-features?ids=${ids}`, { headers }); 215 | }; 216 | 217 | /** 218 | * Get Recommendations Based on Seeds 219 | * https://developer.spotify.com/documentation/web-api/reference/browse/get-recommendations/ 220 | */ 221 | export const getRecommendationsForTracks = tracks => { 222 | const shuffledTracks = tracks.sort(() => 0.5 - Math.random()); 223 | const seed_tracks = getTrackIds(shuffledTracks.slice(0, 5)); 224 | const seed_artists = ''; 225 | const seed_genres = ''; 226 | 227 | return axios.get( 228 | `https://api.spotify.com/v1/recommendations?seed_tracks=${seed_tracks}&seed_artists=${seed_artists}&seed_genres=${seed_genres}`, 229 | { 230 | headers, 231 | }, 232 | ); 233 | }; 234 | 235 | /** 236 | * Get a Track 237 | * https://developer.spotify.com/documentation/web-api/reference/tracks/get-track/ 238 | */ 239 | export const getTrack = trackId => 240 | axios.get(`https://api.spotify.com/v1/tracks/${trackId}`, { headers }); 241 | 242 | /** 243 | * Get Audio Analysis for a Track 244 | * https://developer.spotify.com/documentation/web-api/reference/tracks/get-audio-analysis/ 245 | */ 246 | export const getTrackAudioAnalysis = trackId => 247 | axios.get(`https://api.spotify.com/v1/audio-analysis/${trackId}`, { headers }); 248 | 249 | /** 250 | * Get Audio Features for a Track 251 | * https://developer.spotify.com/documentation/web-api/reference/tracks/get-audio-features/ 252 | */ 253 | export const getTrackAudioFeatures = trackId => 254 | axios.get(`https://api.spotify.com/v1/audio-features/${trackId}`, { headers }); 255 | 256 | export const getUserInfo = () => 257 | axios 258 | .all([getUser(), getFollowing(), getPlaylists(), getTopArtistsLong(), getTopTracksLong()]) 259 | .then( 260 | axios.spread((user, followedArtists, playlists, topArtists, topTracks) => ({ 261 | user: user.data, 262 | followedArtists: followedArtists.data, 263 | playlists: playlists.data, 264 | topArtists: topArtists.data, 265 | topTracks: topTracks.data, 266 | })), 267 | ); 268 | 269 | export const getTrackInfo = trackId => 270 | axios 271 | .all([getTrack(trackId), getTrackAudioAnalysis(trackId), getTrackAudioFeatures(trackId)]) 272 | .then( 273 | axios.spread((track, audioAnalysis, audioFeatures) => ({ 274 | track: track.data, 275 | audioAnalysis: audioAnalysis.data, 276 | audioFeatures: audioFeatures.data, 277 | })), 278 | ); 279 | --------------------------------------------------------------------------------