├── .gitignore ├── .prettierrc ├── README.md ├── docs └── replayify.png ├── package-lock.json ├── package.json ├── public ├── 404.html ├── CNAME ├── app-icon.png ├── favicon.ico ├── icon.png ├── index.html └── manifest.json └── src ├── assets └── images │ ├── chilicorn.png │ ├── discover-hq.jpg │ ├── discover.jpg │ ├── product-hunt-logo.png │ ├── recently.jpg │ ├── replayify-icon--green.png │ ├── replayify-icon.png │ ├── top-artists.jpg │ └── top-tracks.jpg ├── components ├── AppHelp │ ├── AppHelp.css │ ├── AppHelp.scss │ └── index.jsx ├── AppIcon │ ├── AppIcon.css │ ├── AppIcon.scss │ └── index.jsx ├── AppInfo │ ├── AppInfo.css │ ├── AppInfo.scss │ └── index.jsx ├── AppNavigation │ ├── AppNavigation.css │ ├── AppNavigation.scss │ └── index.jsx ├── Header │ ├── Header.css │ ├── Header.scss │ └── index.jsx ├── ListActionPanel │ ├── ListActionPanel.css │ ├── ListActionPanel.scss │ └── index.jsx ├── ListItemCoverImage │ ├── ListItemCoverImage.css │ ├── ListItemCoverImage.scss │ └── index.jsx ├── ListPage │ ├── ListPage.css │ ├── ListPage.scss │ └── index.jsx ├── Modal │ ├── Modal.css │ ├── Modal.scss │ └── index.jsx ├── PlayHistory │ ├── PlayHistory.css │ ├── PlayHistory.scss │ └── index.jsx ├── PlayHistoryItem │ ├── PlayHistoryItem.css │ ├── PlayHistoryItem.scss │ └── index.js ├── ScrollTopRoute │ └── index.jsx ├── TimeRangeSelector │ ├── TimeRangeSelector.css │ ├── TimeRangeSelector.scss │ └── index.jsx ├── TopHistory │ ├── PlayHistory.css │ ├── TopHistory.css │ ├── TopHistory.scss │ └── index.jsx ├── TopHistoryArtist │ ├── PlayHistoryItem.css │ ├── TopHistoryArtist.css │ ├── TopHistoryArtist.scss │ ├── TopHistoryItem.css │ ├── TopHistoryTrack.css │ └── index.js └── TopHistoryTrack │ ├── PlayHistoryItem.css │ ├── TopHistoryItem.css │ ├── TopHistoryTrack.css │ ├── TopHistoryTrack.scss │ └── index.js ├── concepts ├── app-view.js ├── app.js ├── auth.js ├── play-history.js ├── playlist-popup.js ├── playlist.js ├── route.js ├── share.js ├── top-history.js └── user.js ├── config └── index.js ├── constants ├── PlaylistTypes.js ├── ThemeColors.js └── TimeRanges.js ├── containers ├── App │ ├── App.css │ └── index.js ├── AppView │ ├── AppView.css │ ├── AppView.scss │ └── index.js ├── Callback │ └── index.js ├── LoginView │ ├── AppView.css │ ├── LoginView.css │ ├── LoginView.scss │ └── index.js ├── MusicPlayer │ ├── MusicPlayer.css │ ├── MusicPlayer.scss │ └── index.jsx └── PlaylistPopup │ ├── PlaylistPopup.css │ ├── PlaylistPopup.scss │ └── index.jsx ├── env.example.js ├── index.css ├── index.js ├── index.scss ├── reducers.js ├── registerServiceWorker.js ├── services ├── api.js ├── auth.js ├── axios.js ├── change-theme.js ├── history.js ├── playlist-name.js ├── query-parametrize.js └── response.js └── styles ├── animations.css ├── animations.scss ├── buttons.css ├── buttons.scss ├── font.css ├── font.scss ├── variables.css └── variables.scss /.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.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | env.js 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | # ignore built css files 25 | .css 26 | 27 | # editor 28 | *.sublime-* -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Replayify 2 | 3 | > Replay your Spotify favorites! 4 | 5 | ![](docs/replayify.png) 6 | 7 | This application uses Spotify Web API to discover users most listened tracks and artists from Spotify. User can also create playlist from their favorite tracks and artists. 8 | 9 | [Try out replayify.com](https://replayify.com) 10 | 11 | ## Spotify API 12 | 13 | Application uses followig parts of Spotify Web API 14 | 15 | - [Authorization](https://developer.spotify.com/documentation/general/guides/authorization-guide/#implicit-grant-flow) 16 | - [Get users Top Tracks and Artists](https://developer.spotify.com/documentation/web-api/reference/personalization/get-users-top-artists-and-tracks/) 17 | - [Get Top Tracks for Artist](https://developer.spotify.com/documentation/web-api/reference/artists/get-artists-top-tracks/) 18 | - [Get Recently played tracks for user](https://developer.spotify.com/documentation/web-api/reference/player/get-recently-played/) 19 | - [Creating playlist](https://developer.spotify.com/documentation/web-api/reference/playlists/create-playlist/) 20 | - [Adding tracks to playlist](https://developer.spotify.com/documentation/web-api/reference/playlists/add-tracks-to-playlist/) 21 | 22 | ### Create Spotify App 23 | 24 | Go to https://developer.spotify.com/dashboard/, log in and create a new App. 25 | 26 | Add `localhost:3000/callback` as _Redirect URI_ in your Spotify App Settings. 27 | 28 | Grab the _Client Id_ that will be added to env.js. 29 | 30 | ## Development 31 | 32 | - `npm install` 33 | - `cp src/env.example.js src/env.js` and fill `SPOTIFY_CLIENT_ID` 34 | - `npm start` 35 | 36 | Application is based on [create-react-app](https://github.com/facebook/create-react-app) 37 | 38 | ## Photo Credits 39 | 40 | **Pink headphones** 41 | Photo by [Icons8 team](https://unsplash.com/photos/7LNatQYMzm4) on [Unsplash](https://unsplash.com/) 42 | 43 | **Top Artists** 44 | Photo by [Joshua Fuller](https://unsplash.com/photos/ta7rN3NcWyM) on [Unsplash](https://unsplash.com/) 45 | 46 | **Top Tracks** 47 | Photo by [Feliphe Schiarolli](https://unsplash.com/photos/WJ4kTDv8lyg) on [Unsplash](https://unsplash.com/) 48 | 49 | **Recent Plays** 50 | Photo by [Bruce Mars](https://unsplash.com/photos/DBGwy7s3QY0) on [Unsplash](https://unsplash.com/) 51 | 52 | ## License 53 | 54 | MIT 55 | -------------------------------------------------------------------------------- /docs/replayify.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palampinen/replayify/01eaebc825936553aa83c722dc07eb6a7a7b98fe/docs/replayify.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Replayify", 3 | "version": "1.0.0", 4 | "private": true, 5 | "homepage": "https://replayify.com", 6 | "dependencies": { 7 | "autobind-decorator": "^2.1.0", 8 | "axios": "^0.18.0", 9 | "classnames": "^2.2.6", 10 | "immutable": "^3.8.2", 11 | "local-storage": "^1.4.2", 12 | "lodash": "^4.17.10", 13 | "moment": "^2.22.2", 14 | "node-sass-chokidar": "^1.3.0", 15 | "npm-run-all": "^4.1.3", 16 | "react": "^16.4.1", 17 | "react-dom": "^16.4.1", 18 | "react-redux": "^5.0.7", 19 | "react-router": "^4.3.1", 20 | "react-router-dom": "^4.3.1", 21 | "react-router-redux": "^5.0.0-alpha.9", 22 | "react-scripts": "1.1.4", 23 | "redux": "^4.0.0", 24 | "redux-axios-middleware": "^4.0.0", 25 | "redux-thunk": "^2.3.0", 26 | "reselect": "^3.0.1" 27 | }, 28 | "scripts": { 29 | "build-css": 30 | "node-sass-chokidar --include-path ./src --include-path ./node_modules src/ -o src/", 31 | "watch-css": 32 | "npm run build-css && node-sass-chokidar --include-path ./src --include-path ./node_modules src/ -o src/ --watch --recursive", 33 | "start-js": "react-scripts start", 34 | "start": "npm-run-all -p watch-css start-js", 35 | "build-js": "react-scripts build", 36 | "build": "npm-run-all build-css build-js", 37 | "test": "react-scripts test --env=jsdom", 38 | "eject": "react-scripts eject", 39 | "predeploy": "npm run build", 40 | "deploy": "gh-pages -d build" 41 | }, 42 | "devDependencies": { 43 | "gh-pages": "^1.2.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Replayify 7 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /public/CNAME: -------------------------------------------------------------------------------- 1 | replayify.com -------------------------------------------------------------------------------- /public/app-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palampinen/replayify/01eaebc825936553aa83c722dc07eb6a7a7b98fe/public/app-icon.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palampinen/replayify/01eaebc825936553aa83c722dc07eb6a7a7b98fe/public/favicon.ico -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palampinen/replayify/01eaebc825936553aa83c722dc07eb6a7a7b98fe/public/icon.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | Replayify 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 31 | 32 | 33 | 34 | 35 | 36 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Replayify", 3 | "name": "Replay your Spotify Hits", 4 | "icons": [ 5 | { 6 | "src": "app-icon.png", 7 | "sizes": "128x128", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#C6E1DC", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/assets/images/chilicorn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palampinen/replayify/01eaebc825936553aa83c722dc07eb6a7a7b98fe/src/assets/images/chilicorn.png -------------------------------------------------------------------------------- /src/assets/images/discover-hq.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palampinen/replayify/01eaebc825936553aa83c722dc07eb6a7a7b98fe/src/assets/images/discover-hq.jpg -------------------------------------------------------------------------------- /src/assets/images/discover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palampinen/replayify/01eaebc825936553aa83c722dc07eb6a7a7b98fe/src/assets/images/discover.jpg -------------------------------------------------------------------------------- /src/assets/images/product-hunt-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palampinen/replayify/01eaebc825936553aa83c722dc07eb6a7a7b98fe/src/assets/images/product-hunt-logo.png -------------------------------------------------------------------------------- /src/assets/images/recently.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palampinen/replayify/01eaebc825936553aa83c722dc07eb6a7a7b98fe/src/assets/images/recently.jpg -------------------------------------------------------------------------------- /src/assets/images/replayify-icon--green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palampinen/replayify/01eaebc825936553aa83c722dc07eb6a7a7b98fe/src/assets/images/replayify-icon--green.png -------------------------------------------------------------------------------- /src/assets/images/replayify-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palampinen/replayify/01eaebc825936553aa83c722dc07eb6a7a7b98fe/src/assets/images/replayify-icon.png -------------------------------------------------------------------------------- /src/assets/images/top-artists.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palampinen/replayify/01eaebc825936553aa83c722dc07eb6a7a7b98fe/src/assets/images/top-artists.jpg -------------------------------------------------------------------------------- /src/assets/images/top-tracks.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palampinen/replayify/01eaebc825936553aa83c722dc07eb6a7a7b98fe/src/assets/images/top-tracks.jpg -------------------------------------------------------------------------------- /src/components/AppHelp/AppHelp.css: -------------------------------------------------------------------------------- 1 | .app-help { 2 | display: block; 3 | max-width: 615.2px; 4 | margin: 3em auto; 5 | animation: mic-drop 0.75s; } 6 | 7 | .app-help__buttons { 8 | margin: 2em 0; } 9 | 10 | .scope-list li { 11 | font-weight: bold; } 12 | 13 | .app-help__logo { 14 | text-align: left; } 15 | 16 | .app-help__footer { 17 | text-align: center; 18 | margin: 6em auto 1em; 19 | display: flex; 20 | justify-content: center; 21 | align-items: center; } 22 | 23 | .footer__link { 24 | font-size: 40px; 25 | line-height: 40px; 26 | width: 40px; 27 | height: 40px; 28 | color: #50496d; 29 | display: flex; 30 | justify-content: center; 31 | align-items: center; 32 | margin: 0em 0.4em; 33 | opacity: 0.8; 34 | transition: all 0.15s; 35 | transform-origin: 50% 50%; } 36 | .footer__link img { 37 | max-width: 40px; 38 | float: left; } 39 | .footer__link img.img--ph { 40 | max-width: 36px; } 41 | .footer__link.footer__link--replayify { 42 | border-radius: 50%; 43 | background: rgba(0, 0, 0, 0.03); 44 | min-width: 40px; } 45 | .footer__link.footer__link--replayify img { 46 | max-width: 30px; } 47 | .footer__link:hover { 48 | opacity: 1; 49 | transform: scale(1.05); } 50 | 51 | @media (min-width: 769px) { 52 | .footer__link { 53 | margin: 0em 0.75em; } } 54 | -------------------------------------------------------------------------------- /src/components/AppHelp/AppHelp.scss: -------------------------------------------------------------------------------- 1 | @import 'styles/variables.scss'; 2 | 3 | .app-help { 4 | display: block; 5 | max-width: $breakpoint-small * 0.8; 6 | margin: 3em auto; 7 | 8 | animation: mic-drop 0.75s; 9 | } 10 | 11 | .app-help__buttons { 12 | margin: 2em 0; 13 | } 14 | 15 | .scope-list li { 16 | font-weight: bold; 17 | } 18 | 19 | .app-help__logo { 20 | text-align: left; 21 | } 22 | 23 | .app-help__footer { 24 | text-align: center; 25 | margin: 6em auto 1em; 26 | display: flex; 27 | justify-content: center; 28 | align-items: center; 29 | } 30 | 31 | .footer__link { 32 | font-size: 40px; 33 | line-height: 40px; 34 | width: 40px; 35 | height: 40px; 36 | color: $dark-grey; 37 | 38 | display: flex; 39 | justify-content: center; 40 | align-items: center; 41 | 42 | margin: 0em 0.4em; 43 | opacity: 0.8; 44 | transition: all 0.15s; 45 | transform-origin: 50% 50%; 46 | 47 | img { 48 | max-width: 40px; 49 | float: left; 50 | &.img--ph { 51 | max-width: 36px; 52 | } 53 | } 54 | 55 | &.footer__link--replayify { 56 | border-radius: 50%; 57 | background: rgba(black, 0.03); 58 | min-width: 40px; 59 | img { 60 | max-width: 30px; 61 | } 62 | } 63 | 64 | &:hover { 65 | opacity: 1; 66 | transform: scale(1.05); 67 | } 68 | } 69 | 70 | @media (min-width: $breakpoint-small) { 71 | .footer__link { 72 | margin: 0em 0.75em; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/components/AppHelp/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import './AppHelp.css'; 3 | 4 | class Apphelp extends Component { 5 | render() { 6 | return ( 7 |
8 |
9 |

Replayify

10 |

11 | With Replayify you can find your old Spotify gems. Some of the songs that you may have 12 | already forgotten. Perhaps an old crush that you played repeatedly during summer weeks 13 | before you had to move on. 14 |

15 |

16 | Refresh your memories and create Spotify playlists from your favorite Tracks and 17 | Artists. 18 |

19 |

Keep replayin' replayin' replayin'

20 | Disclaimer: Since this app encourages you to listen your old favorites, this will keep 21 | your music taste and listening habits static. Rememeber to listen also new music once in a 22 | while, so you'll find new favorites. And when you need a bit of nostalgia again, here you 23 | will find it! 24 |

Spotify access

25 |

26 | Application requires a Spotify account. It also needs access to your Spotify account. 27 | Application works as client side only and your Spotify data is not stored. 28 |

29 |

30 | I logged in with wrong Spotify account 31 | 😬 32 |

33 |

34 | No worries, just go to{' '} 35 | 36 | accounts.spotify.com 37 | {' '} 38 | and press Log out -button. Then open{' '} 39 | replayify.com/login and sign in with different 40 | account. 41 |

42 |
43 | 48 | Replayify 49 | 50 | 57 | 58 | 59 | 66 | Spice Program 67 | 68 | 75 | Product Hunt 80 | 81 |
82 |
83 |
84 | ); 85 | } 86 | } 87 | 88 | export default Apphelp; 89 | -------------------------------------------------------------------------------- /src/components/AppIcon/AppIcon.css: -------------------------------------------------------------------------------- 1 | .appicon { 2 | display: block; 3 | color: #f9adac; 4 | margin: 0; 5 | font-weight: 900; 6 | position: relative; } 7 | .appicon img { 8 | width: 66px; } 9 | .appicon.appicon--default { 10 | color: #f9adac; } 11 | .appicon.appicon--white { 12 | color: white; } 13 | -------------------------------------------------------------------------------- /src/components/AppIcon/AppIcon.scss: -------------------------------------------------------------------------------- 1 | @import 'styles/variables.scss'; 2 | 3 | .appicon { 4 | display: block; 5 | color: $brand-pink; 6 | margin: 0; 7 | font-weight: 900; 8 | position: relative; 9 | 10 | img { 11 | width: 66px; 12 | } 13 | 14 | &.appicon--default { 15 | color: $brand-pink; 16 | } 17 | &.appicon--white { 18 | color: white; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/components/AppIcon/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classnames from 'classnames'; 3 | 4 | import './AppIcon.css'; 5 | const appIcon = require('../../assets/images/replayify-icon.png'); 6 | 7 | const AppIcon = ({ theme }) => ( 8 | 9 | Replayify 10 | 11 | ); 12 | 13 | AppIcon.defaultProps = { 14 | theme: 'default', 15 | }; 16 | 17 | export default AppIcon; 18 | -------------------------------------------------------------------------------- /src/components/AppInfo/AppInfo.css: -------------------------------------------------------------------------------- 1 | .app-info { 2 | display: block; 3 | max-width: 615.2px; 4 | margin: 0 auto; 5 | animation: mic-drop 0.75s; } 6 | 7 | .app-info__buttons { 8 | margin: 2em 0; } 9 | 10 | .scope-list li { 11 | font-weight: bold; } 12 | 13 | .app-info__logo { 14 | text-align: left; } 15 | -------------------------------------------------------------------------------- /src/components/AppInfo/AppInfo.scss: -------------------------------------------------------------------------------- 1 | @import 'styles/variables.scss'; 2 | 3 | .app-info { 4 | display: block; 5 | max-width: $breakpoint-small * 0.8; 6 | margin: 0 auto; 7 | 8 | animation: mic-drop 0.75s; 9 | } 10 | 11 | .app-info__buttons { 12 | margin: 2em 0; 13 | } 14 | 15 | .scope-list li { 16 | font-weight: bold; 17 | } 18 | 19 | .app-info__logo { 20 | text-align: left; 21 | } 22 | -------------------------------------------------------------------------------- /src/components/AppInfo/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | import config from '../../config'; 5 | 6 | import Modal from '../Modal'; 7 | import AppIcon from '../AppIcon'; 8 | import './AppInfo.css'; 9 | 10 | class AppInfo extends Component { 11 | render() { 12 | const scopes = config.SPOTIFY_AUTH_SCOPES.split(' '); 13 | return ( 14 | 15 |
16 |
17 | 18 |
19 |

Replayify App

20 |

21 | This is an Application to Discover your Spotify usage and creating playlist from your 22 | Top Artists and Tracks. It uses Spotify Web API. 23 |

24 |

Required Spotify access

25 |

26 | Application requires access to your Spotify account. We use Spotify Implicit Grant Flow 27 | for user Authorization. Application works as client side only and your Spotify data is 28 | not stored to any server. 29 |

30 |

Used Scopes

31 |

32 | Scopes enable the application to access specific Spotify API endpoints. The set of 33 | scopes that are required for you to access this Application: 34 |

    {scopes.map(scope =>
  • {scope}
  • )}
35 |

36 | 37 |
38 | 43 | Read more about Spotify scopes 44 | 45 |
46 | 47 | OK, got it{' '} 48 | 49 | 👌🏻 50 | 51 | 52 |
53 |
54 |
55 | ); 56 | } 57 | } 58 | 59 | export default AppInfo; 60 | -------------------------------------------------------------------------------- /src/components/AppNavigation/AppNavigation.css: -------------------------------------------------------------------------------- 1 | .App-navigation { 2 | background: #fff; 3 | position: fixed; 4 | bottom: 0; 5 | left: 0; 6 | right: 0; 7 | z-index: 101; 8 | height: 54px; 9 | display: flex; 10 | align-items: center; 11 | justify-content: space-around; 12 | padding: 0 8vw; 13 | box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.05); } 14 | .App-navigation .app-title, 15 | .App-navigation .app-icon { 16 | display: none; } 17 | .App-navigation .App-navigation__link { 18 | font-size: 10px; 19 | color: rgba(58, 53, 78, 0.45); 20 | text-align: center; 21 | padding: 7px 0px; 22 | display: flex; 23 | flex-direction: column; 24 | align-items: center; 25 | justify-content: center; 26 | flex: 1; 27 | bottom: 0; 28 | z-index: 99; 29 | position: relative; 30 | cursor: pointer; 31 | transition: all 0.1s; } 32 | .App-navigation .App-navigation__link .icon { 33 | font-size: 22px; 34 | display: block; 35 | margin: -2px 0; } 36 | .App-navigation .App-navigation__link .navigation__label { 37 | display: block; } 38 | .App-navigation .App-navigation__link.active, .App-navigation .App-navigation__link:active { 39 | color: #3a354e; } 40 | 41 | @media (min-width: 769px) { 42 | .App-navigation { 43 | left: 0; 44 | top: 0; 45 | width: 100px; 46 | padding: 40px 0 0; 47 | right: auto; 48 | height: auto; 49 | flex-direction: column; 50 | justify-content: flex-start; 51 | box-shadow: 10px 0 30px rgba(0, 0, 0, 0.05); } 52 | .App-navigation .app-icon { 53 | display: inline-block; 54 | margin: 5px 0 20px 0px; } 55 | .App-navigation .app-icon img { 56 | max-width: 60px; } 57 | .App-navigation .App-navigation__link { 58 | padding: 0; 59 | margin: 0; 60 | display: flex; 61 | align-items: center; 62 | justify-content: center; 63 | text-align: center; 64 | flex-direction: column; 65 | width: 100px; 66 | height: 100px; 67 | transition: color 0.1s; 68 | font-size: 12px; 69 | flex: none; } 70 | .App-navigation .App-navigation__link .icon { 71 | font-size: 33px; 72 | margin: -2px 0 -4px; } 73 | .App-navigation .App-navigation__link:hover { 74 | color: #3a354e; } } 75 | -------------------------------------------------------------------------------- /src/components/AppNavigation/AppNavigation.scss: -------------------------------------------------------------------------------- 1 | @import 'styles/variables.scss'; 2 | 3 | .App-navigation { 4 | background: #fff; 5 | position: fixed; 6 | bottom: 0; 7 | left: 0; 8 | right: 0; 9 | z-index: 101; 10 | height: $navigation-size-mobile; 11 | 12 | display: flex; 13 | align-items: center; 14 | justify-content: space-around; 15 | padding: 0 8vw; 16 | box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.05); 17 | 18 | .app-title, 19 | .app-icon { 20 | display: none; 21 | } 22 | 23 | .App-navigation__link { 24 | font-size: 10px; 25 | color: rgba($brand-dark, 0.45); 26 | text-align: center; 27 | padding: 7px 0px; 28 | 29 | display: flex; 30 | flex-direction: column; 31 | align-items: center; 32 | justify-content: center; 33 | flex: 1; 34 | 35 | bottom: 0; 36 | z-index: 99; 37 | position: relative; 38 | cursor: pointer; 39 | transition: all 0.1s; 40 | 41 | .icon { 42 | font-size: 22px; 43 | display: block; 44 | margin: -2px 0; 45 | } 46 | .navigation__label { 47 | display: block; 48 | } 49 | &.active, 50 | &:active { 51 | color: $brand-dark; 52 | } 53 | } 54 | } 55 | 56 | @media (min-width: $breakpoint-small) { 57 | .App-navigation { 58 | left: 0; 59 | top: 0; 60 | width: $navigation-size; 61 | padding: 40px 0 0; 62 | right: auto; 63 | height: auto; 64 | flex-direction: column; 65 | justify-content: flex-start; 66 | box-shadow: 10px 0 30px rgba(0, 0, 0, 0.05); 67 | 68 | .app-icon { 69 | display: inline-block; 70 | margin: 5px 0 20px 0px; 71 | img { 72 | max-width: 60px; 73 | } 74 | } 75 | 76 | .App-navigation__link { 77 | padding: 0; 78 | margin: 0; 79 | display: flex; 80 | align-items: center; 81 | justify-content: center; 82 | text-align: center; 83 | flex-direction: column; 84 | width: 100px; 85 | height: 100px; 86 | transition: color 0.1s; 87 | font-size: 12px; 88 | flex: none; 89 | 90 | .icon { 91 | font-size: 33px; 92 | margin: -2px 0 -4px; 93 | } 94 | 95 | &:hover { 96 | color: $brand-dark; 97 | } 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/components/AppNavigation/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NavLink, Link } from 'react-router-dom'; 3 | 4 | import './AppNavigation.css'; 5 | 6 | export default () => ( 7 |
8 | 9 | Replayify 10 | 11 | 12 | 13 | Top Artists 14 | 15 | 16 | 17 | Top Tracks 18 | 19 | 20 | 21 | Recent 22 | 23 |
24 | ); 25 | 26 | -------------------------------------------------------------------------------- /src/components/Header/Header.css: -------------------------------------------------------------------------------- 1 | .header { 2 | height: 60px; 3 | padding: 0 10px; 4 | display: block; 5 | color: #e550a7; 6 | position: fixed; 7 | left: 0; 8 | right: 0; 9 | top: 0; 10 | z-index: 999; 11 | background: rgba(255, 255, 255, 0); 12 | box-shadow: none; 13 | transition: all 0.2s; } 14 | .header.header--scrolled { 15 | height: 60px; 16 | transform: translate3d(0, -100%, 0); } 17 | .header.header--scrolled .header__title { 18 | font-size: 1.5em; } 19 | 20 | .container { 21 | height: 100%; 22 | display: flex; 23 | justify-content: space-between; 24 | align-items: center; 25 | max-width: 985px; 26 | margin: 0 auto; } 27 | 28 | .header__title { 29 | font-size: 2.6em; 30 | font-weight: 700; 31 | transition: all 0.2s; } 32 | 33 | .header__title__icon { 34 | margin-right: 0.075em; 35 | transform: scale(0.96); 36 | display: inline-block; } 37 | 38 | .header__user { 39 | font-size: 0.8em; 40 | font-weight: bold; 41 | display: flex; 42 | justify-content: space-between; 43 | align-items: center; 44 | color: #ddd; } 45 | .header__user:hover { 46 | color: #fff; } 47 | 48 | .header__avatar { 49 | float: left; 50 | width: 24px; 51 | height: 24px; 52 | margin: 0; 53 | position: relative; 54 | top: 1px; 55 | background: rgba(221, 221, 221, 0.1); 56 | color: #ddd; 57 | border-radius: 50%; 58 | display: flex; 59 | text-align: center; 60 | justify-content: center; 61 | align-items: center; 62 | overflow: hidden; } 63 | .header__avatar img { 64 | width: 24px; 65 | max-height: 24px; 66 | z-index: 2; 67 | position: absolute; 68 | left: 0; 69 | right: 0; 70 | top: 0; 71 | bottom: 0; } 72 | .header__avatar .header__avatar__fallback { 73 | position: absolute; 74 | z-index: 1; 75 | width: 24px; 76 | height: 24px; 77 | background: #fff; } 78 | .header__avatar:before { 79 | margin: 0; 80 | padding: 0; } 81 | 82 | @media (min-width: 769px) { 83 | .header { 84 | left: 100px; } } 85 | -------------------------------------------------------------------------------- /src/components/Header/Header.scss: -------------------------------------------------------------------------------- 1 | @import 'styles/variables.scss'; 2 | 3 | .header { 4 | height: $header-height; 5 | padding: 0 10px; 6 | 7 | display: block; 8 | color: $brand-primary; 9 | 10 | // position: relative; 11 | position: fixed; 12 | left: 0; 13 | right: 0; 14 | top: 0; 15 | z-index: 999; 16 | 17 | background: rgba(#fff, 0); 18 | box-shadow: none; 19 | 20 | transition: all 0.2s; 21 | 22 | &.header--scrolled { 23 | height: $header-height; 24 | // box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05); 25 | transform: translate3d(0, -100%, 0); 26 | 27 | .header__title { 28 | font-size: 1.5em; 29 | } 30 | } 31 | } 32 | 33 | .container { 34 | height: 100%; 35 | display: flex; 36 | justify-content: space-between; 37 | align-items: center; 38 | 39 | max-width: $breakpoint-medium - 40px; 40 | margin: 0 auto; 41 | } 42 | 43 | .header__title { 44 | font-size: 2.6em; 45 | font-weight: 700; 46 | transition: all 0.2s; 47 | } 48 | 49 | .header__title__icon { 50 | margin-right: 0.075em; 51 | transform: scale(0.96); 52 | display: inline-block; 53 | } 54 | 55 | .header__user { 56 | font-size: 0.8em; 57 | font-weight: bold; 58 | display: flex; 59 | justify-content: space-between; 60 | align-items: center; 61 | color: #ddd; 62 | 63 | &:hover { 64 | color: #fff; 65 | } 66 | } 67 | 68 | .header__avatar { 69 | float: left; 70 | width: 24px; 71 | height: 24px; 72 | margin: 0; 73 | position: relative; 74 | top: 1px; 75 | 76 | background: rgba(#ddd, 0.1); 77 | color: #ddd; 78 | border-radius: 50%; 79 | display: flex; 80 | text-align: center; 81 | justify-content: center; 82 | align-items: center; 83 | overflow: hidden; 84 | 85 | img { 86 | width: 24px; 87 | max-height: 24px; 88 | z-index: 2; 89 | position: absolute; 90 | left: 0; 91 | right: 0; 92 | top: 0; 93 | bottom: 0; 94 | } 95 | 96 | .header__avatar__fallback { 97 | position: absolute; 98 | z-index: 1; 99 | width: 24px; 100 | height: 24px; 101 | background: #fff; 102 | } 103 | 104 | &:before { 105 | margin: 0; 106 | padding: 0; 107 | } 108 | } 109 | 110 | @media (min-width: $breakpoint-small) { 111 | .header { 112 | left: $navigation-size; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/components/Header/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import classnames from 'classnames'; 3 | import AppIcon from '../AppIcon'; 4 | 5 | import './Header.css'; 6 | 7 | const scrollTarget = window; 8 | 9 | class Header extends Component { 10 | constructor(props) { 11 | super(props); 12 | this.state = { isOnTop: true }; 13 | this.scrollWatcher = this.scrollWatcher.bind(this); 14 | } 15 | 16 | componentDidMount() { 17 | scrollTarget.addEventListener('scroll', this.scrollWatcher); 18 | } 19 | 20 | componentWillUnmount() { 21 | scrollTarget.removeEventListener('scroll', this.scrollWatcher); 22 | } 23 | 24 | scrollWatcher() { 25 | const { isOnTop } = this.state; 26 | const scrollPosition = scrollTarget.pageYOffset || 0; 27 | 28 | if (isOnTop && scrollPosition > 0) { 29 | this.setState({ isOnTop: false }); 30 | } else if (!isOnTop && scrollPosition === 0) { 31 | this.setState({ isOnTop: true }); 32 | } 33 | } 34 | 35 | render() { 36 | const { user } = this.props; 37 | const { isOnTop } = this.state; 38 | 39 | return ( 40 |
41 |
42 | {/**/} 43 |
44 | 45 |
46 | 47 | 48 | 49 |
50 |
51 |
52 | ); 53 | } 54 | } 55 | 56 | export default Header; 57 | -------------------------------------------------------------------------------- /src/components/ListActionPanel/ListActionPanel.css: -------------------------------------------------------------------------------- 1 | .action-buttons { 2 | margin: 2.5em 0 0; 3 | text-align: center; 4 | color: #aba5c3; 5 | font-size: 0.8em; } 6 | .action-buttons .btn { 7 | margin-top: 1em; } 8 | 9 | .action-buttons__title { 10 | color: #50496d; 11 | font-weight: bold; 12 | margin-bottom: 0.5em; 13 | font-size: 1.2em; } 14 | 15 | @media (min-width: 1200px) { 16 | .action-buttons { 17 | color: #aba5c3; 18 | font-size: 0.8em; 19 | position: fixed; 20 | left: 0; 21 | right: 0; 22 | bottom: 0; 23 | background: rgba(255, 255, 255, 0.98); 24 | padding: 1.5em 2em; 25 | border-top: 1px solid rgba(0, 0, 0, 0.05); 26 | box-shadow: 0 -3px 6px rgba(0, 0, 0, 0.02); 27 | overflow-x: hidden; 28 | width: 1025px; 29 | left: calc(50% + 50px); 30 | margin-left: -512px; 31 | opacity: 0.75; 32 | transform: translate3d(0, 100%, 0); 33 | transition: all 0.3s cubic-bezier(0.87, 0.38, 0.27, 0.95); 34 | display: flex; 35 | align-items: center; 36 | justify-content: space-between; } 37 | .action-buttons.action-buttons--scrolled { 38 | opacity: 1; 39 | transform: translate3d(0, 0, 0); } 40 | .action-buttons .action-buttons__info { 41 | text-align: left; 42 | flex: 2; } 43 | .action-buttons .action-buttons__button { 44 | flex: 2; 45 | text-align: right; } 46 | .action-buttons .action-buttons__button .btn { 47 | margin: 0; 48 | float: right; } } 49 | -------------------------------------------------------------------------------- /src/components/ListActionPanel/ListActionPanel.scss: -------------------------------------------------------------------------------- 1 | @import 'styles/variables.scss'; 2 | 3 | .action-buttons { 4 | margin: 2.5em 0 0; 5 | text-align: center; 6 | color: $mid-grey; 7 | font-size: 0.8em; 8 | 9 | .btn { 10 | margin-top: 1em; 11 | } 12 | } 13 | 14 | .action-buttons__title { 15 | color: $dark-grey; 16 | font-weight: bold; 17 | margin-bottom: 0.5em; 18 | font-size: 1.2em; 19 | } 20 | 21 | // Fixed playlist button on larger devices 22 | @media (min-width: 1200px) { 23 | .action-buttons { 24 | color: #aba5c3; 25 | font-size: 0.8em; 26 | position: fixed; 27 | left: 0; 28 | right: 0; 29 | bottom: 0; 30 | background: rgba(white, 0.98); 31 | padding: 1.5em 2em; 32 | border-top: 1px solid rgba(0, 0, 0, 0.05); 33 | box-shadow: 0 -3px 6px rgba(0, 0, 0, 0.02); 34 | overflow-x: hidden; 35 | width: $breakpoint-medium; 36 | left: calc(50% + 50px); 37 | margin-left: -512px; 38 | 39 | opacity: 0.75; 40 | transform: translate3d(0, 100%, 0); 41 | transition: all 0.3s $cubic-bezier; 42 | 43 | display: flex; 44 | align-items: center; 45 | justify-content: space-between; 46 | 47 | &.action-buttons--scrolled { 48 | opacity: 1; 49 | transform: translate3d(0, 0, 0); 50 | } 51 | 52 | .action-buttons__info { 53 | text-align: left; 54 | flex: 2; 55 | } 56 | 57 | .action-buttons__button { 58 | flex: 2; 59 | text-align: right; 60 | .btn { 61 | margin: 0; 62 | float: right; 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/components/ListActionPanel/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import classnames from 'classnames'; 3 | 4 | import './ListActionPanel.css'; 5 | 6 | const scrollTarget = window; 7 | 8 | class Header extends Component { 9 | constructor(props) { 10 | super(props); 11 | this.state = { isOnTop: true }; 12 | this.scrollWatcher = this.scrollWatcher.bind(this); 13 | } 14 | 15 | componentDidMount() { 16 | scrollTarget.addEventListener('scroll', this.scrollWatcher); 17 | } 18 | 19 | componentWillUnmount() { 20 | scrollTarget.removeEventListener('scroll', this.scrollWatcher); 21 | } 22 | 23 | scrollWatcher() { 24 | const { isOnTop } = this.state; 25 | const scrollPosition = scrollTarget.pageYOffset || 0; 26 | 27 | if (isOnTop && scrollPosition > 0) { 28 | this.setState({ isOnTop: false }); 29 | } else if (!isOnTop && scrollPosition === 0) { 30 | this.setState({ isOnTop: true }); 31 | } 32 | } 33 | 34 | render() { 35 | const { title, description, onActionClick } = this.props; 36 | const { isOnTop } = this.state; 37 | 38 | return ( 39 |
40 |
41 |
{title}
42 | {description} 43 |
44 |
45 | 48 |
49 |
50 | ); 51 | } 52 | } 53 | 54 | export default Header; 55 | -------------------------------------------------------------------------------- /src/components/ListItemCoverImage/ListItemCoverImage.css: -------------------------------------------------------------------------------- 1 | .track__cover { 2 | width: 40px; 3 | min-width: 40px; 4 | height: 50px; 5 | border-radius: 5px; 6 | margin-right: 1em; 7 | background: #fafafa; 8 | float: left; 9 | flex: 40px 0 0; 10 | background-size: cover; 11 | background-repeat: no-repeat; 12 | background-position: 50% 50%; } 13 | 14 | @media (min-width: 769px) { 15 | .track__cover { 16 | width: 50px; 17 | min-width: 50px; 18 | height: 64px; 19 | flex: 50px 0 0; } } 20 | -------------------------------------------------------------------------------- /src/components/ListItemCoverImage/ListItemCoverImage.scss: -------------------------------------------------------------------------------- 1 | @import 'styles/variables.scss'; 2 | 3 | .track__cover { 4 | width: 40px; 5 | min-width: 40px; 6 | height: 50px; 7 | border-radius: 5px; 8 | margin-right: 1em; 9 | background: $light-grey; 10 | float: left; 11 | 12 | flex: 40px 0 0; 13 | 14 | background-size: cover; 15 | background-repeat: no-repeat; 16 | background-position: 50% 50%; 17 | } 18 | 19 | @media (min-width: $breakpoint-small) { 20 | .track__cover { 21 | width: 50px; 22 | min-width: 50px; 23 | height: 64px; 24 | 25 | flex: 50px 0 0; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/components/ListItemCoverImage/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './ListItemCoverImage.css'; 4 | 5 | const ListItemCoverImage = ({ src }) => ( 6 | 7 | ); 8 | 9 | export default ListItemCoverImage; 10 | -------------------------------------------------------------------------------- /src/components/ListPage/ListPage.css: -------------------------------------------------------------------------------- 1 | .list-page { 2 | display: block; 3 | padding-top: 30vh; } 4 | 5 | .list-page__image { 6 | height: 45vh; 7 | position: fixed; 8 | left: 0; 9 | right: 0; 10 | top: 0; 11 | z-index: 1; 12 | animation: scale-to 0.4s ease-out; 13 | transform-origin: 50% 0%; 14 | background-size: cover; 15 | background-repeat: no-repeat; 16 | background-position: 50% 50%; } 17 | 18 | .list-page__title { 19 | position: relative; 20 | z-index: 2; 21 | color: #fff; 22 | padding-bottom: 0.5em; 23 | top: -1.15vw; } 24 | 25 | .list-page__content { 26 | position: relative; 27 | z-index: 3; 28 | background: #fff; 29 | min-height: 100px; 30 | margin: 0 -1.5em; 31 | padding: 20px 1.5em; } 32 | .list-page__content:before { 33 | content: ''; 34 | display: block; 35 | position: absolute; 36 | left: 0; 37 | right: 0; 38 | overflow-x: hidden; 39 | top: -7vw; 40 | height: 10vw; 41 | background: #fff; 42 | z-index: 3; 43 | transform: translate3d(0, 0, 0) skewY(-3deg); 44 | outline: 1px solid transparent; } 45 | 46 | .list-page__list { 47 | position: relative; 48 | z-index: 4; } 49 | 50 | .share-link { 51 | position: absolute; 52 | right: 15px; 53 | top: 15px; 54 | width: 40px; 55 | height: 40px; 56 | z-index: 99; 57 | text-align: center; 58 | color: rgba(255, 255, 255, 0.95); 59 | line-height: 40px; 60 | font-size: 1.8em; 61 | border-radius: 50%; 62 | transition: all 0.1s; 63 | background: transparent; 64 | border: none; } 65 | .share-link:active { 66 | background: rgba(0, 0, 0, 0.075); } 67 | 68 | @media (min-width: 500px) { 69 | .list-page__content:before { 70 | top: -4vw; 71 | height: 6vw; 72 | transform: translate3d(0, 0, 0) skewY(-2deg); } } 73 | 74 | @media (min-width: 769px) { 75 | .list-page { 76 | display: block; 77 | width: 100%; 78 | max-width: 1025px; 79 | position: relative; 80 | margin: 0 auto 1.5em; 81 | padding: 400px 1.5em 0em; 82 | padding: calc(55vh - 100px) 1.5em 2em; 83 | background: #fff; 84 | min-height: 105vh; } 85 | .list-page__title { 86 | padding-bottom: 0; 87 | top: -0.75em; } 88 | .list-page__image { 89 | height: 500px; 90 | height: 55vh; 91 | position: absolute; 92 | animation: none; } 93 | .list-page__content { 94 | margin: 0 -1.5em; 95 | padding: 35px 2em; } 96 | .list-page__content:before { 97 | top: -25px; 98 | height: 45px; 99 | transform: translate3d(0, 0, 0) skewY(-1.5deg); } } 100 | 101 | @media (min-width: 1025px) { 102 | body { 103 | background: #f9f9f9; } 104 | .list-page { 105 | box-shadow: 0 2px 40px rgba(80, 80, 80, 0.05); 106 | margin: 0 auto; 107 | padding: calc(55vh - 100px) 1.5em 5.5em; } 108 | .share-link:hover { 109 | background: rgba(0, 0, 0, 0.05); } } 110 | -------------------------------------------------------------------------------- /src/components/ListPage/ListPage.scss: -------------------------------------------------------------------------------- 1 | @import 'styles/variables.scss'; 2 | 3 | .list-page { 4 | display: block; 5 | padding-top: 30vh; 6 | } 7 | 8 | .list-page__image { 9 | height: 45vh; 10 | position: fixed; 11 | left: 0; 12 | right: 0; 13 | top: 0; 14 | z-index: 1; 15 | 16 | animation: scale-to 0.4s ease-out; 17 | transform-origin: 50% 0%; 18 | 19 | background-size: cover; 20 | background-repeat: no-repeat; 21 | background-position: 50% 50%; 22 | } 23 | 24 | .list-page__title { 25 | position: relative; 26 | z-index: 2; 27 | color: #fff; 28 | padding-bottom: 0.5em; 29 | top: -1.15vw; 30 | } 31 | 32 | .list-page__content { 33 | position: relative; 34 | z-index: 3; 35 | background: #fff; 36 | 37 | min-height: 100px; 38 | 39 | margin: 0 -1.5em; 40 | padding: 20px 1.5em; 41 | 42 | &:before { 43 | content: ''; 44 | display: block; 45 | position: absolute; 46 | left: 0; 47 | right: 0; 48 | overflow-x: hidden; 49 | top: -7vw; 50 | height: 10vw; 51 | 52 | background: #fff; 53 | z-index: 3; 54 | transform: translate3d(0, 0, 0) skewY(-3deg); 55 | // Better anti-aliasing on mobile chrome with transparent outline rule 56 | outline: 1px solid transparent; 57 | } 58 | } 59 | 60 | .list-page__list { 61 | position: relative; 62 | z-index: 4; 63 | } 64 | 65 | .share-link { 66 | position: absolute; 67 | right: 15px; 68 | top: 15px; 69 | width: 40px; 70 | height: 40px; 71 | z-index: 99; 72 | text-align: center; 73 | color: rgba(white, 0.95); 74 | line-height: 40px; 75 | font-size: 1.8em; 76 | border-radius: 50%; 77 | transition: all 0.1s; 78 | background: transparent; 79 | border: none; 80 | 81 | &:active { 82 | background: rgba(0, 0, 0, 0.075); 83 | } 84 | } 85 | 86 | @media (min-width: 500px) { 87 | .list-page__content:before { 88 | top: -4vw; 89 | height: 6vw; 90 | transform: translate3d(0, 0, 0) skewY(-2deg); 91 | } 92 | } 93 | 94 | @media (min-width: $breakpoint-small) { 95 | .list-page { 96 | display: block; 97 | width: 100%; 98 | max-width: $breakpoint-medium; 99 | position: relative; 100 | 101 | margin: 0 auto 1.5em; 102 | padding: 400px 1.5em 0em; 103 | padding: calc(55vh - 100px) 1.5em 2em; 104 | background: #fff; 105 | min-height: 105vh; // to enable scroll which reveals action panel 106 | } 107 | 108 | .list-page__title { 109 | padding-bottom: 0; 110 | top: -0.75em; 111 | } 112 | 113 | .list-page__image { 114 | height: 500px; 115 | height: 55vh; 116 | position: absolute; 117 | animation: none; 118 | } 119 | 120 | .list-page__content { 121 | margin: 0 -1.5em; 122 | padding: 35px 2em; 123 | 124 | &:before { 125 | top: -25px; 126 | height: 45px; 127 | transform: translate3d(0, 0, 0) skewY(-1.5deg); 128 | } 129 | } 130 | } 131 | 132 | @media (min-width: $breakpoint-medium) { 133 | body { 134 | background: $bg-grey; 135 | } 136 | .list-page { 137 | box-shadow: 0 2px 40px rgba(80, 80, 80, 0.05); 138 | margin: 0 auto; 139 | padding: calc(55vh - 100px) 1.5em 5.5em; 140 | } 141 | 142 | .share-link { 143 | &:hover { 144 | background: rgba(0, 0, 0, 0.05); 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/components/ListPage/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import changeThemeColor from '../../services/change-theme'; 4 | import './ListPage.css'; 5 | 6 | class ListPage extends Component { 7 | componentDidMount() { 8 | const { themeColor } = this.props; 9 | if (themeColor) { 10 | changeThemeColor(themeColor); 11 | } 12 | } 13 | 14 | render() { 15 | const { headerImageSrc, title, downloadImage, children } = this.props; 16 | 17 | return ( 18 |
19 |
20 |
26 | ); 27 | } 28 | } 29 | 30 | export default ListPage; 31 | -------------------------------------------------------------------------------- /src/components/Modal/Modal.css: -------------------------------------------------------------------------------- 1 | @keyframes fade-in { 2 | 0% { 3 | opacity: 0; } 4 | 100% { 5 | opacity: 1; } } 6 | 7 | @keyframes scale-in { 8 | 0% { 9 | transform: scale(0); } 10 | 100% { 11 | transform: scale(1); } } 12 | 13 | @keyframes scaleX-in { 14 | 0% { 15 | transform: scaleX(0); } 16 | 100% { 17 | transform: scaleX(1); } } 18 | 19 | @keyframes scale-to { 20 | 0% { 21 | transform: scale(1.015) translate3d(0, 0, 0); } 22 | 100% { 23 | transform: scale(1) translate3d(0, 0, 0); } } 24 | 25 | @keyframes mic-drop { 26 | 0% { 27 | transform: translate3d(0, -4px, 0); 28 | opacity: 0; } 29 | 100% { 30 | transform: translate3d(0, 0px, 0); 31 | opacity: 1; } } 32 | 33 | @keyframes appear-from-left { 34 | 0% { 35 | transform: translate3d(-100%, 0, 0); } 36 | 100% { 37 | transform: translate3d(0, 0, 0); } } 38 | 39 | @keyframes flash-from-bottom { 40 | 0% { 41 | opacity: 0; 42 | transform: translate3d(0, 100%, 0); } 43 | 100% { 44 | opacity: 1; 45 | transform: translate3d(0, 0, 0); } } 46 | 47 | .modal { 48 | animation: fade-in 0.15s; 49 | background: rgba(242, 242, 242, 0.9); 50 | position: fixed; 51 | top: 0; 52 | bottom: 0; 53 | left: 0; 54 | right: 0; 55 | z-index: 101; 56 | display: flex; 57 | justify-content: center; 58 | align-items: flex-start; 59 | overflow-y: auto; } 60 | 61 | .modal__content { 62 | background: #fff; 63 | position: absolute; 64 | left: 0em; 65 | right: 0em; 66 | top: 0em; 67 | bottom: 0em; 68 | padding: 1.5em; 69 | overflow-y: auto; 70 | border-radius: 10px; } 71 | 72 | @media (min-width: 1025px) { 73 | .modal { 74 | align-items: center; } 75 | .modal__content { 76 | position: static; 77 | max-width: 769px; 78 | margin: 0 auto; 79 | padding: 1.5em 2em 2em; } } 80 | -------------------------------------------------------------------------------- /src/components/Modal/Modal.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/variables.scss'; 2 | @import '../../styles/animations.scss'; 3 | 4 | .modal { 5 | animation: fade-in 0.15s; 6 | background: rgba(#f2f2f2, 0.9); 7 | 8 | position: fixed; 9 | top: 0; 10 | bottom: 0; 11 | left: 0; 12 | right: 0; 13 | z-index: 101; 14 | 15 | display: flex; 16 | justify-content: center; 17 | align-items: flex-start; 18 | 19 | overflow-y: auto; 20 | } 21 | 22 | .modal__content { 23 | background: #fff; 24 | 25 | position: absolute; 26 | left: 0em; 27 | right: 0em; 28 | top: 0em; 29 | bottom: 0em; 30 | padding: 1.5em; 31 | overflow-y: auto; 32 | border-radius: 10px; 33 | } 34 | 35 | @media (min-width: $breakpoint-medium) { 36 | .modal { 37 | align-items: center; 38 | } 39 | 40 | .modal__content { 41 | position: static; 42 | max-width: $breakpoint-small; 43 | margin: 0 auto; 44 | padding: 1.5em 2em 2em; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/components/Modal/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import './Modal.css'; 5 | 6 | const Modal = ({ children }) => ( 7 |
8 |
{children}
9 |
10 | ); 11 | 12 | Modal.propTypes = { 13 | children: PropTypes.node, 14 | }; 15 | 16 | Modal.defaultProps = { 17 | children: undefined, 18 | }; 19 | 20 | export default Modal; 21 | -------------------------------------------------------------------------------- /src/components/PlayHistory/PlayHistory.css: -------------------------------------------------------------------------------- 1 | .play-history { 2 | padding: 0 0 1em; 3 | max-width: 1025px; 4 | margin: 0 auto; } 5 | 6 | @media (min-width: 1025px) { 7 | .play-history { 8 | padding: 0; } } 9 | -------------------------------------------------------------------------------- /src/components/PlayHistory/PlayHistory.scss: -------------------------------------------------------------------------------- 1 | @import 'styles/variables.scss'; 2 | 3 | .play-history { 4 | padding: 0 0 1em; 5 | max-width: $breakpoint-medium; 6 | margin: 0 auto; 7 | } 8 | 9 | @media (min-width: $breakpoint-medium) { 10 | .play-history { 11 | padding: 0; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/components/PlayHistory/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import ListPage from '../ListPage'; 4 | import PlayHistoryItem from '../PlayHistoryItem'; 5 | import ListActionPanel from '../ListActionPanel'; 6 | import ThemeColors from '../../constants/ThemeColors'; 7 | import './PlayHistory.css'; 8 | 9 | const showMax = 50; 10 | const playImg = require('../../assets/images/recently.jpg'); 11 | 12 | class PlayHistory extends Component { 13 | componentDidMount() { 14 | this.props.updatePlayHistory(); 15 | } 16 | 17 | render() { 18 | const { plays, downloadImage, createRecentlyPlaylist } = this.props; 19 | return ( 20 |
21 | 27 |
28 | {plays 29 | .slice(0, showMax) 30 | .map(play => )} 31 | 32 | {plays.size > 0 && ( 33 | 38 | )} 39 |
40 |
41 |
42 | ); 43 | } 44 | } 45 | 46 | export default PlayHistory; 47 | -------------------------------------------------------------------------------- /src/components/PlayHistoryItem/PlayHistoryItem.css: -------------------------------------------------------------------------------- 1 | .play-history__item { 2 | padding: 1em; 3 | text-align: left; 4 | font-size: 1.1em; 5 | margin: 0 -1em; 6 | max-width: 1025px; 7 | display: flex; 8 | align-items: center; 9 | color: #50496d; 10 | transform: translate3d(0, -4px, 0); 11 | opacity: 0; 12 | animation-name: mic-drop; 13 | animation-timing-function: ease; 14 | animation-fill-mode: forwards; 15 | animation-duration: 1s; } 16 | .play-history__item:hover { 17 | background: #fafafa; } 18 | .play-history__item:nth-child(1) { 19 | animation-delay: 198ms; } 20 | .play-history__item:nth-child(2) { 21 | animation-delay: 292ms; } 22 | .play-history__item:nth-child(3) { 23 | animation-delay: 382ms; } 24 | .play-history__item:nth-child(4) { 25 | animation-delay: 468ms; } 26 | .play-history__item:nth-child(5) { 27 | animation-delay: 550ms; } 28 | .play-history__item:nth-child(6) { 29 | animation-delay: 628ms; } 30 | .play-history__item:nth-child(7) { 31 | animation-delay: 702ms; } 32 | .play-history__item:nth-child(8) { 33 | animation-delay: 772ms; } 34 | .play-history__item:nth-child(9) { 35 | animation-delay: 838ms; } 36 | .play-history__item:nth-child(10) { 37 | animation-delay: 900ms; } 38 | .play-history__item:nth-child(11) { 39 | animation-delay: 958ms; } 40 | .play-history__item:nth-child(12) { 41 | animation-delay: 1012ms; } 42 | .play-history__item:nth-child(13) { 43 | animation-delay: 1062ms; } 44 | .play-history__item:nth-child(14) { 45 | animation-delay: 1108ms; } 46 | .play-history__item:nth-child(15) { 47 | animation-delay: 1150ms; } 48 | .play-history__item:nth-child(16) { 49 | animation-delay: 1188ms; } 50 | .play-history__item:nth-child(17) { 51 | animation-delay: 1222ms; } 52 | .play-history__item:nth-child(18) { 53 | animation-delay: 1252ms; } 54 | .play-history__item:nth-child(19) { 55 | animation-delay: 1278ms; } 56 | .play-history__item:nth-child(20) { 57 | animation-delay: 1300ms; } 58 | .play-history__item:nth-child(21) { 59 | animation-delay: 1318ms; } 60 | .play-history__item:nth-child(22) { 61 | animation-delay: 1332ms; } 62 | .play-history__item:nth-child(23) { 63 | animation-delay: 1342ms; } 64 | .play-history__item:nth-child(24) { 65 | animation-delay: 1348ms; } 66 | .play-history__item:nth-child(25) { 67 | animation-delay: 1350ms; } 68 | .play-history__item:nth-child(26) { 69 | animation-delay: 1348ms; } 70 | .play-history__item:nth-child(27) { 71 | animation-delay: 1342ms; } 72 | .play-history__item:nth-child(28) { 73 | animation-delay: 1332ms; } 74 | .play-history__item:nth-child(29) { 75 | animation-delay: 1318ms; } 76 | .play-history__item:nth-child(30) { 77 | animation-delay: 1300ms; } 78 | .play-history__item:nth-child(31) { 79 | animation-delay: 1278ms; } 80 | .play-history__item:nth-child(32) { 81 | animation-delay: 1252ms; } 82 | .play-history__item:nth-child(33) { 83 | animation-delay: 1222ms; } 84 | .play-history__item:nth-child(34) { 85 | animation-delay: 1188ms; } 86 | .play-history__item:nth-child(35) { 87 | animation-delay: 1150ms; } 88 | .play-history__item:nth-child(36) { 89 | animation-delay: 1108ms; } 90 | .play-history__item:nth-child(37) { 91 | animation-delay: 1062ms; } 92 | .play-history__item:nth-child(38) { 93 | animation-delay: 1012ms; } 94 | .play-history__item:nth-child(39) { 95 | animation-delay: 958ms; } 96 | .play-history__item:nth-child(40) { 97 | animation-delay: 900ms; } 98 | .play-history__item:nth-child(41) { 99 | animation-delay: 838ms; } 100 | .play-history__item:nth-child(42) { 101 | animation-delay: 772ms; } 102 | .play-history__item:nth-child(43) { 103 | animation-delay: 702ms; } 104 | .play-history__item:nth-child(44) { 105 | animation-delay: 628ms; } 106 | .play-history__item:nth-child(45) { 107 | animation-delay: 550ms; } 108 | .play-history__item:nth-child(46) { 109 | animation-delay: 468ms; } 110 | .play-history__item:nth-child(47) { 111 | animation-delay: 382ms; } 112 | .play-history__item:nth-child(48) { 113 | animation-delay: 292ms; } 114 | .play-history__item:nth-child(49) { 115 | animation-delay: 198ms; } 116 | .play-history__item:nth-child(50) { 117 | animation-delay: 100ms; } 118 | 119 | .play__info { 120 | display: flex; 121 | align-items: center; 122 | flex: 3; } 123 | 124 | .play__summary { 125 | display: flex; 126 | flex-direction: column-reverse; } 127 | 128 | .play__separator { 129 | color: #ddd; 130 | margin: 0 0.5em; 131 | display: none; } 132 | 133 | .play__track-name { 134 | font-weight: bold; 135 | color: #3a354e; } 136 | 137 | .play__time { 138 | color: #aba5c3; 139 | text-align: right; 140 | flex: 60px; 141 | font-size: 11px; } 142 | 143 | @media (min-width: 769px) { 144 | .play-history__item { 145 | padding: 1em 1.5em; 146 | margin: 0 -1.8em; } 147 | .play__track-name { 148 | font-weight: 500; } 149 | .play__time { 150 | text-align: right; 151 | flex: 140px 0 0; 152 | font-size: 1em; } } 153 | -------------------------------------------------------------------------------- /src/components/PlayHistoryItem/PlayHistoryItem.scss: -------------------------------------------------------------------------------- 1 | @import 'styles/variables.scss'; 2 | 3 | .play-history__item { 4 | padding: 1em; 5 | text-align: left; 6 | font-size: 1.1em; 7 | margin: 0 -1em; 8 | max-width: $breakpoint-medium; 9 | 10 | display: flex; 11 | align-items: center; 12 | color: $dark-grey; 13 | 14 | &:hover { 15 | background: $light-grey; 16 | } 17 | 18 | transform: translate3d(0, -4px, 0); 19 | opacity: 0; 20 | animation-name: mic-drop; 21 | animation-timing-function: ease; 22 | animation-fill-mode: forwards; 23 | animation-duration: 1s; 24 | 25 | @for $i from 1 through $animation-max-items { 26 | &:nth-child(#{$i}) { 27 | animation-delay: $i * (100ms - ($i * 2ms)) + $animation-initial-delay; 28 | } 29 | } 30 | } 31 | 32 | .play__info { 33 | display: flex; 34 | align-items: center; 35 | flex: 3; 36 | } 37 | 38 | .play__summary { 39 | display: flex; 40 | flex-direction: column-reverse; 41 | } 42 | 43 | .play__artist { 44 | } 45 | 46 | .play__separator { 47 | color: #ddd; 48 | margin: 0 0.5em; 49 | display: none; 50 | } 51 | 52 | .play__track-name { 53 | font-weight: bold; 54 | color: $brand-dark; 55 | } 56 | 57 | .play__time { 58 | color: $mid-grey; 59 | text-align: right; 60 | flex: 60px; 61 | font-size: 11px; 62 | } 63 | 64 | @media (min-width: $breakpoint-small) { 65 | .play-history__item { 66 | padding: 1em 1.5em; 67 | margin: 0 -1.8em; 68 | } 69 | 70 | .play__track-name { 71 | font-weight: 500; 72 | } 73 | 74 | .play__time { 75 | text-align: right; 76 | flex: 140px 0 0; 77 | font-size: 1em; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/components/PlayHistoryItem/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import moment from 'moment'; 3 | import ListItemCoverImage from '../ListItemCoverImage'; 4 | 5 | import './PlayHistoryItem.css'; 6 | 7 | const formatPlayTime = timestamp => { 8 | const today = moment(); 9 | const playTime = moment(timestamp); 10 | 11 | const isPlayedToday = today.isSame(playTime, 'day'); 12 | const isPlayedThisWeek = today.isSame(playTime, 'week'); 13 | const isPlayedThisYear = today.isSame(playTime, 'year'); 14 | 15 | let format = 'ddd DD.MM.YYYY HH.mm'; 16 | 17 | if (isPlayedToday) { 18 | format = 'HH:mm'; 19 | } else if (isPlayedThisWeek) { 20 | format = 'ddd HH:mm'; 21 | } else if (isPlayedThisYear) { 22 | format = 'ddd DD.MM. HH:mm'; 23 | } 24 | 25 | return playTime.format(format); 26 | }; 27 | 28 | const PlayHistoryItem = ({ play }) => ( 29 | 30 | 31 | 32 | 33 | 34 | {play.getIn(['track', 'artists', 0, 'name'])} 35 | 36 | {play.getIn(['track', 'name'])} 37 | 38 | 39 | {formatPlayTime(play.getIn(['played_at']))} 40 | 41 | ); 42 | 43 | export default PlayHistoryItem; 44 | -------------------------------------------------------------------------------- /src/components/ScrollTopRoute/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Route, withRouter } from 'react-router-dom'; 4 | 5 | class ScrollTopRoute extends Component { 6 | componentDidUpdate(prevProps) { 7 | if (this.props.location.pathname !== prevProps.location.pathname) { 8 | window.scrollTo(0, 0); 9 | } 10 | } 11 | 12 | render() { 13 | return ; 14 | } 15 | } 16 | 17 | ScrollTopRoute.propTypes = { 18 | location: PropTypes.shape({ 19 | pathname: PropTypes.string, 20 | }).isRequired, 21 | }; 22 | 23 | export default withRouter(ScrollTopRoute); 24 | -------------------------------------------------------------------------------- /src/components/TimeRangeSelector/TimeRangeSelector.css: -------------------------------------------------------------------------------- 1 | .time-range-selector { 2 | display: flex; 3 | justify-content: flex-start; 4 | padding: 0; 5 | margin: -1em -0.75em 1em; 6 | flex-wrap: nowrap; 7 | white-space: nowrap; 8 | overflow-x: auto; } 9 | .time-range-selector .time-option { 10 | color: #aba5c3; 11 | user-select: none; 12 | cursor: pointer; 13 | font-weight: 600; 14 | background: transparent; 15 | border: none; 16 | padding: 1em; 17 | display: inline-block; 18 | position: relative; } 19 | .time-range-selector .time-option:active { 20 | color: #50496d; } 21 | .time-range-selector .time--active { 22 | color: #50496d; } 23 | .time-range-selector .time--active:after { 24 | position: absolute; 25 | left: 1em; 26 | right: 1em; 27 | bottom: 0.6em; 28 | content: ''; 29 | display: block; 30 | height: 2px; 31 | background: #50496d; 32 | transform: scaleX(0); 33 | animation: scaleX-in 0.2s; 34 | animation-fill-mode: forwards; } 35 | 36 | @media (min-width: 769px) { 37 | .time-range-selector { 38 | margin: -1em -1.25em 1em; } 39 | .time-range-selector .time-option { 40 | font-size: 1.2em; } 41 | .time-range-selector .time-option:hover { 42 | color: #50496d; } 43 | .time-range-selector .time--active:after { 44 | height: 3px; 45 | bottom: 0.7em; } } 46 | -------------------------------------------------------------------------------- /src/components/TimeRangeSelector/TimeRangeSelector.scss: -------------------------------------------------------------------------------- 1 | @import 'styles/variables.scss'; 2 | 3 | .time-range-selector { 4 | display: flex; 5 | justify-content: flex-start; 6 | padding: 0; 7 | margin: -1em -0.75em 1em; 8 | 9 | flex-wrap: nowrap; 10 | white-space: nowrap; 11 | overflow-x: auto; 12 | 13 | .time-option { 14 | color: $mid-grey; 15 | user-select: none; 16 | cursor: pointer; 17 | font-weight: 600; 18 | background: transparent; 19 | border: none; 20 | padding: 1em; 21 | display: inline-block; 22 | position: relative; 23 | &:active { 24 | color: $dark-grey; 25 | } 26 | } 27 | 28 | .time--active { 29 | // color: $mid-grey; 30 | color: $dark-grey; 31 | 32 | &:after { 33 | position: absolute; 34 | left: 1em; 35 | right: 1em; 36 | bottom: 0.6em; 37 | content: ''; 38 | display: block; 39 | height: 2px; 40 | background: $dark-grey; 41 | transform: scaleX(0); 42 | animation: scaleX-in 0.2s; 43 | animation-fill-mode: forwards; 44 | } 45 | } 46 | } 47 | 48 | @media (min-width: $breakpoint-small) { 49 | .time-range-selector { 50 | margin: -1em -1.25em 1em; 51 | .time-option { 52 | font-size: 1.2em; 53 | 54 | &:hover { 55 | color: $dark-grey; 56 | } 57 | } 58 | .time--active { 59 | &:after { 60 | height: 3px; 61 | bottom: 0.7em; 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/components/TimeRangeSelector/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classnames from 'classnames'; 3 | 4 | import { options, labels } from '../../constants/TimeRanges'; 5 | import './TimeRangeSelector.css'; 6 | 7 | export default ({ selected, onSelect }) => ( 8 |
9 | {options.map(option => ( 10 | 16 | ))} 17 |
18 | ); 19 | -------------------------------------------------------------------------------- /src/components/TopHistory/PlayHistory.css: -------------------------------------------------------------------------------- 1 | .play-history { 2 | padding: 20px 0; 3 | max-width: 1024px; 4 | margin: 0 auto; } 5 | -------------------------------------------------------------------------------- /src/components/TopHistory/TopHistory.css: -------------------------------------------------------------------------------- 1 | .top-history { 2 | max-width: 1025px; 3 | padding: 0 0 1em; 4 | margin: 0 auto; } 5 | -------------------------------------------------------------------------------- /src/components/TopHistory/TopHistory.scss: -------------------------------------------------------------------------------- 1 | @import 'styles/variables.scss'; 2 | 3 | .top-history { 4 | max-width: $breakpoint-medium; 5 | padding: 0 0 1em; 6 | margin: 0 auto; 7 | } 8 | 9 | -------------------------------------------------------------------------------- /src/components/TopHistory/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import ListPage from '../ListPage'; 4 | import TopHistoryTrack from '../TopHistoryTrack'; 5 | import TopHistoryArtist from '../TopHistoryArtist'; 6 | import TimeRangeSelector from '../TimeRangeSelector'; 7 | import ListActionPanel from '../ListActionPanel'; 8 | import ThemeColors from '../../constants/ThemeColors'; 9 | import { labels } from '../../constants/TimeRanges'; 10 | import PlaylistTypes from '../../constants/PlaylistTypes'; 11 | import './TopHistory.css'; 12 | 13 | const showMax = 50; 14 | const artistImg = require('../../assets/images/top-artists.jpg'); 15 | const trackImg = require('../../assets/images/top-tracks.jpg'); 16 | 17 | const TopHistory = ({ 18 | timeRange, 19 | topHistory, 20 | createArtistPlaylist, 21 | createTracksPlaylist, 22 | updateTimeRange, 23 | downloadImage, 24 | type = PlaylistTypes.ARTIST, 25 | }) => ( 26 |
27 | {type === PlaylistTypes.ARTIST && ( 28 | 34 |
35 | 36 | 37 | {topHistory 38 | .get('artists') 39 | .slice(0, showMax) 40 | .map((artist, index) => ( 41 | 46 | ))} 47 | 48 | {topHistory.get('artists').size > 0 && ( 49 | 57 | )} 58 |
59 |
60 | )} 61 | {type === PlaylistTypes.TRACK && ( 62 | 68 |
69 | 70 | {topHistory 71 | .get('tracks') 72 | .slice(0, showMax) 73 | .map((track, index) => ( 74 | 79 | ))} 80 | 81 | {topHistory.get('tracks').size > 0 && ( 82 | 87 | )} 88 |
89 |
90 | )} 91 |
92 | ); 93 | 94 | export default TopHistory; 95 | -------------------------------------------------------------------------------- /src/components/TopHistoryArtist/PlayHistoryItem.css: -------------------------------------------------------------------------------- 1 | .play-history__item { 2 | padding: 1em; 3 | text-align: left; 4 | font-size: 1.1em; 5 | margin: 0 -1em; 6 | display: flex; 7 | align-items: center; 8 | color: #333; } 9 | .play-history__item:hover { 10 | background: #fafafa; } 11 | 12 | .play__info { 13 | display: flex; 14 | align-items: center; 15 | flex: 3; } 16 | 17 | .play__cover { 18 | width: 40px; 19 | height: 40px; 20 | border-radius: 50%; 21 | margin-right: 1em; 22 | background: #fafafa; 23 | float: left; } 24 | 25 | .play__separator { 26 | color: #ddd; 27 | margin: 0 0.5em; } 28 | 29 | .play__track-name { 30 | color: #000; } 31 | 32 | .play__time { 33 | color: #aaa; 34 | text-align: left; 35 | flex: 1; } 36 | -------------------------------------------------------------------------------- /src/components/TopHistoryArtist/TopHistoryArtist.css: -------------------------------------------------------------------------------- 1 | .artist-history__item { 2 | padding: 1em 0.75em; 3 | text-align: left; 4 | font-size: 1.1em; 5 | margin: 0 -1em; 6 | display: flex; 7 | align-items: center; 8 | color: #50496d; 9 | transform: translate3d(0, -4px, 0); 10 | opacity: 0; 11 | animation-name: mic-drop; 12 | animation-timing-function: ease; 13 | animation-fill-mode: forwards; 14 | animation-duration: 1s; } 15 | .artist-history__item:hover { 16 | background: #fafafa; } 17 | .artist-history__item:nth-child(1) { 18 | animation-delay: 198ms; } 19 | .artist-history__item:nth-child(2) { 20 | animation-delay: 292ms; } 21 | .artist-history__item:nth-child(3) { 22 | animation-delay: 382ms; } 23 | .artist-history__item:nth-child(4) { 24 | animation-delay: 468ms; } 25 | .artist-history__item:nth-child(5) { 26 | animation-delay: 550ms; } 27 | .artist-history__item:nth-child(6) { 28 | animation-delay: 628ms; } 29 | .artist-history__item:nth-child(7) { 30 | animation-delay: 702ms; } 31 | .artist-history__item:nth-child(8) { 32 | animation-delay: 772ms; } 33 | .artist-history__item:nth-child(9) { 34 | animation-delay: 838ms; } 35 | .artist-history__item:nth-child(10) { 36 | animation-delay: 900ms; } 37 | .artist-history__item:nth-child(11) { 38 | animation-delay: 958ms; } 39 | .artist-history__item:nth-child(12) { 40 | animation-delay: 1012ms; } 41 | .artist-history__item:nth-child(13) { 42 | animation-delay: 1062ms; } 43 | .artist-history__item:nth-child(14) { 44 | animation-delay: 1108ms; } 45 | .artist-history__item:nth-child(15) { 46 | animation-delay: 1150ms; } 47 | .artist-history__item:nth-child(16) { 48 | animation-delay: 1188ms; } 49 | .artist-history__item:nth-child(17) { 50 | animation-delay: 1222ms; } 51 | .artist-history__item:nth-child(18) { 52 | animation-delay: 1252ms; } 53 | .artist-history__item:nth-child(19) { 54 | animation-delay: 1278ms; } 55 | .artist-history__item:nth-child(20) { 56 | animation-delay: 1300ms; } 57 | .artist-history__item:nth-child(21) { 58 | animation-delay: 1318ms; } 59 | .artist-history__item:nth-child(22) { 60 | animation-delay: 1332ms; } 61 | .artist-history__item:nth-child(23) { 62 | animation-delay: 1342ms; } 63 | .artist-history__item:nth-child(24) { 64 | animation-delay: 1348ms; } 65 | .artist-history__item:nth-child(25) { 66 | animation-delay: 1350ms; } 67 | .artist-history__item:nth-child(26) { 68 | animation-delay: 1348ms; } 69 | .artist-history__item:nth-child(27) { 70 | animation-delay: 1342ms; } 71 | .artist-history__item:nth-child(28) { 72 | animation-delay: 1332ms; } 73 | .artist-history__item:nth-child(29) { 74 | animation-delay: 1318ms; } 75 | .artist-history__item:nth-child(30) { 76 | animation-delay: 1300ms; } 77 | .artist-history__item:nth-child(31) { 78 | animation-delay: 1278ms; } 79 | .artist-history__item:nth-child(32) { 80 | animation-delay: 1252ms; } 81 | .artist-history__item:nth-child(33) { 82 | animation-delay: 1222ms; } 83 | .artist-history__item:nth-child(34) { 84 | animation-delay: 1188ms; } 85 | .artist-history__item:nth-child(35) { 86 | animation-delay: 1150ms; } 87 | .artist-history__item:nth-child(36) { 88 | animation-delay: 1108ms; } 89 | .artist-history__item:nth-child(37) { 90 | animation-delay: 1062ms; } 91 | .artist-history__item:nth-child(38) { 92 | animation-delay: 1012ms; } 93 | .artist-history__item:nth-child(39) { 94 | animation-delay: 958ms; } 95 | .artist-history__item:nth-child(40) { 96 | animation-delay: 900ms; } 97 | .artist-history__item:nth-child(41) { 98 | animation-delay: 838ms; } 99 | .artist-history__item:nth-child(42) { 100 | animation-delay: 772ms; } 101 | .artist-history__item:nth-child(43) { 102 | animation-delay: 702ms; } 103 | .artist-history__item:nth-child(44) { 104 | animation-delay: 628ms; } 105 | .artist-history__item:nth-child(45) { 106 | animation-delay: 550ms; } 107 | .artist-history__item:nth-child(46) { 108 | animation-delay: 468ms; } 109 | .artist-history__item:nth-child(47) { 110 | animation-delay: 382ms; } 111 | .artist-history__item:nth-child(48) { 112 | animation-delay: 292ms; } 113 | .artist-history__item:nth-child(49) { 114 | animation-delay: 198ms; } 115 | .artist-history__item:nth-child(50) { 116 | animation-delay: 100ms; } 117 | 118 | .artist__info { 119 | display: flex; 120 | align-items: center; 121 | flex: 3; } 122 | 123 | .artist__name { 124 | color: #50496d; 125 | font-weight: bold; } 126 | 127 | .artist__summary { 128 | display: flex; 129 | flex-direction: column; 130 | flex: 1; } 131 | 132 | .artist__genres { 133 | color: #aba5c3; 134 | text-align: left; 135 | font-size: 0.7em; 136 | text-transform: capitalize; } 137 | 138 | .order-number { 139 | flex: 12px 0 0; 140 | text-align: right; 141 | margin-right: 20px; 142 | font-weight: bold; 143 | color: #aba5c3; 144 | font-size: 0.85em; } 145 | 146 | @media (min-width: 769px) { 147 | .artist-history__item { 148 | padding: 1em 1.5em; 149 | margin: 0 -1.8em; } 150 | .artist__name { 151 | font-weight: 500; } 152 | .order-number { 153 | flex: 35px 0 0; 154 | min-width: 35px; 155 | margin-right: 0; 156 | text-align: right; 157 | padding-right: 23px; } } 158 | -------------------------------------------------------------------------------- /src/components/TopHistoryArtist/TopHistoryArtist.scss: -------------------------------------------------------------------------------- 1 | @import 'styles/variables.scss'; 2 | 3 | .artist-history__item { 4 | padding: 1em 0.75em; 5 | text-align: left; 6 | font-size: 1.1em; 7 | margin: 0 -1em; 8 | 9 | display: flex; 10 | align-items: center; 11 | color: $dark-grey; 12 | 13 | &:hover { 14 | background: $light-grey; 15 | } 16 | 17 | transform: translate3d(0, -4px, 0); 18 | opacity: 0; 19 | animation-name: mic-drop; 20 | animation-timing-function: ease; 21 | animation-fill-mode: forwards; 22 | animation-duration: 1s; 23 | 24 | @for $i from 1 through $animation-max-items { 25 | &:nth-child(#{$i}) { 26 | animation-delay: $i * (100ms - ($i * 2ms)) + $animation-initial-delay; 27 | } 28 | } 29 | } 30 | 31 | .artist__info { 32 | display: flex; 33 | align-items: center; 34 | flex: 3; 35 | } 36 | 37 | .artist__name { 38 | color: $dark-grey; 39 | font-weight: bold; 40 | } 41 | 42 | .artist__summary { 43 | display: flex; 44 | flex-direction: column; 45 | flex: 1; 46 | } 47 | 48 | .artist__genres { 49 | color: $mid-grey; 50 | text-align: left; 51 | font-size: 0.7em; 52 | text-transform: capitalize; 53 | } 54 | 55 | .order-number { 56 | flex: 12px 0 0; 57 | text-align: right; 58 | margin-right: 20px; 59 | 60 | font-weight: bold; 61 | color: $mid-grey; 62 | font-size: 0.85em; 63 | } 64 | 65 | @media (min-width: $breakpoint-small) { 66 | .artist-history__item { 67 | padding: 1em 1.5em; 68 | margin: 0 -1.8em; 69 | } 70 | 71 | .artist__name { 72 | font-weight: 500; 73 | } 74 | 75 | .order-number { 76 | flex: 35px 0 0; 77 | min-width: 35px; 78 | 79 | margin-right: 0; 80 | text-align: right; 81 | padding-right: 23px; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/components/TopHistoryArtist/TopHistoryItem.css: -------------------------------------------------------------------------------- 1 | .play-history__item { 2 | padding: 1em; 3 | text-align: left; 4 | font-size: 1.1em; 5 | margin: 0 -1em; 6 | display: flex; 7 | align-items: center; 8 | color: #333; } 9 | .play-history__item:hover { 10 | background: #fafafa; } 11 | 12 | .play__info { 13 | display: flex; 14 | align-items: center; 15 | flex: 3; } 16 | 17 | .play__cover { 18 | width: 40px; 19 | height: 40px; 20 | border-radius: 50%; 21 | margin-right: 1em; 22 | background: #fafafa; 23 | float: left; } 24 | 25 | .play__separator { 26 | color: #ddd; 27 | margin: 0 0.5em; } 28 | 29 | .play__track-name { 30 | color: #000; } 31 | 32 | .play__time { 33 | color: #aaa; 34 | text-align: left; 35 | flex: 1; } 36 | -------------------------------------------------------------------------------- /src/components/TopHistoryArtist/TopHistoryTrack.css: -------------------------------------------------------------------------------- 1 | .play-history__item { 2 | padding: 1em; 3 | text-align: left; 4 | font-size: 1.1em; 5 | margin: 0 -1em; 6 | display: flex; 7 | align-items: center; 8 | color: #333; } 9 | .play-history__item:hover { 10 | background: #fafafa; } 11 | 12 | .play__info { 13 | display: flex; 14 | align-items: center; 15 | flex: 3; } 16 | 17 | .play__cover { 18 | width: 40px; 19 | height: 40px; 20 | border-radius: 50%; 21 | margin-right: 1em; 22 | background: #fafafa; 23 | float: left; } 24 | 25 | .play__separator { 26 | color: #ddd; 27 | margin: 0 0.5em; } 28 | 29 | .play__track-name { 30 | color: #000; } 31 | 32 | .play__time { 33 | color: #aaa; 34 | text-align: left; 35 | flex: 1; } 36 | -------------------------------------------------------------------------------- /src/components/TopHistoryArtist/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ListItemCoverImage from '../ListItemCoverImage'; 3 | 4 | import './TopHistoryArtist.css'; 5 | 6 | const TopHistoryArtist = ({ artist, orderNumber }) => ( 7 | 8 | {orderNumber} 9 | 10 | 11 | 12 | {artist.get('name')} 13 | 14 | {artist 15 | .get('genres') 16 | .slice(0, 3) 17 | .join(', ')} 18 | 19 | 20 | 21 | 22 | ); 23 | 24 | export default TopHistoryArtist; 25 | -------------------------------------------------------------------------------- /src/components/TopHistoryTrack/PlayHistoryItem.css: -------------------------------------------------------------------------------- 1 | .play-history__item { 2 | padding: 1em; 3 | text-align: left; 4 | font-size: 1.1em; 5 | margin: 0 -1em; 6 | display: flex; 7 | align-items: center; 8 | color: #333; } 9 | .play-history__item:hover { 10 | background: #fafafa; } 11 | 12 | .play__info { 13 | display: flex; 14 | align-items: center; 15 | flex: 3; } 16 | 17 | .play__cover { 18 | width: 40px; 19 | height: 40px; 20 | border-radius: 50%; 21 | margin-right: 1em; 22 | background: #fafafa; 23 | float: left; } 24 | 25 | .play__separator { 26 | color: #ddd; 27 | margin: 0 0.5em; } 28 | 29 | .play__track-name { 30 | color: #000; } 31 | 32 | .play__time { 33 | color: #aaa; 34 | text-align: left; 35 | flex: 1; } 36 | -------------------------------------------------------------------------------- /src/components/TopHistoryTrack/TopHistoryItem.css: -------------------------------------------------------------------------------- 1 | .play-history__item { 2 | padding: 1em; 3 | text-align: left; 4 | font-size: 1.1em; 5 | margin: 0 -1em; 6 | display: flex; 7 | align-items: center; 8 | color: #333; } 9 | .play-history__item:hover { 10 | background: #fafafa; } 11 | 12 | .play__info { 13 | display: flex; 14 | align-items: center; 15 | flex: 3; } 16 | 17 | .play__cover { 18 | width: 40px; 19 | height: 40px; 20 | border-radius: 50%; 21 | margin-right: 1em; 22 | background: #fafafa; 23 | float: left; } 24 | 25 | .play__separator { 26 | color: #ddd; 27 | margin: 0 0.5em; } 28 | 29 | .play__track-name { 30 | color: #000; } 31 | 32 | .play__time { 33 | color: #aaa; 34 | text-align: left; 35 | flex: 1; } 36 | -------------------------------------------------------------------------------- /src/components/TopHistoryTrack/TopHistoryTrack.css: -------------------------------------------------------------------------------- 1 | .track-history__item { 2 | padding: 1em 0.75em; 3 | text-align: left; 4 | font-size: 1.1em; 5 | line-height: 1.2; 6 | margin: 0 -1em; 7 | display: flex; 8 | align-items: center; 9 | color: #50496d; 10 | transform: translate3d(0, -4px, 0); 11 | opacity: 0; 12 | animation-name: mic-drop; 13 | animation-timing-function: ease; 14 | animation-fill-mode: forwards; 15 | animation-duration: 1s; } 16 | .track-history__item:hover { 17 | background: #fafafa; } 18 | .track-history__item:nth-child(1) { 19 | animation-delay: 198ms; } 20 | .track-history__item:nth-child(2) { 21 | animation-delay: 292ms; } 22 | .track-history__item:nth-child(3) { 23 | animation-delay: 382ms; } 24 | .track-history__item:nth-child(4) { 25 | animation-delay: 468ms; } 26 | .track-history__item:nth-child(5) { 27 | animation-delay: 550ms; } 28 | .track-history__item:nth-child(6) { 29 | animation-delay: 628ms; } 30 | .track-history__item:nth-child(7) { 31 | animation-delay: 702ms; } 32 | .track-history__item:nth-child(8) { 33 | animation-delay: 772ms; } 34 | .track-history__item:nth-child(9) { 35 | animation-delay: 838ms; } 36 | .track-history__item:nth-child(10) { 37 | animation-delay: 900ms; } 38 | .track-history__item:nth-child(11) { 39 | animation-delay: 958ms; } 40 | .track-history__item:nth-child(12) { 41 | animation-delay: 1012ms; } 42 | .track-history__item:nth-child(13) { 43 | animation-delay: 1062ms; } 44 | .track-history__item:nth-child(14) { 45 | animation-delay: 1108ms; } 46 | .track-history__item:nth-child(15) { 47 | animation-delay: 1150ms; } 48 | .track-history__item:nth-child(16) { 49 | animation-delay: 1188ms; } 50 | .track-history__item:nth-child(17) { 51 | animation-delay: 1222ms; } 52 | .track-history__item:nth-child(18) { 53 | animation-delay: 1252ms; } 54 | .track-history__item:nth-child(19) { 55 | animation-delay: 1278ms; } 56 | .track-history__item:nth-child(20) { 57 | animation-delay: 1300ms; } 58 | .track-history__item:nth-child(21) { 59 | animation-delay: 1318ms; } 60 | .track-history__item:nth-child(22) { 61 | animation-delay: 1332ms; } 62 | .track-history__item:nth-child(23) { 63 | animation-delay: 1342ms; } 64 | .track-history__item:nth-child(24) { 65 | animation-delay: 1348ms; } 66 | .track-history__item:nth-child(25) { 67 | animation-delay: 1350ms; } 68 | .track-history__item:nth-child(26) { 69 | animation-delay: 1348ms; } 70 | .track-history__item:nth-child(27) { 71 | animation-delay: 1342ms; } 72 | .track-history__item:nth-child(28) { 73 | animation-delay: 1332ms; } 74 | .track-history__item:nth-child(29) { 75 | animation-delay: 1318ms; } 76 | .track-history__item:nth-child(30) { 77 | animation-delay: 1300ms; } 78 | .track-history__item:nth-child(31) { 79 | animation-delay: 1278ms; } 80 | .track-history__item:nth-child(32) { 81 | animation-delay: 1252ms; } 82 | .track-history__item:nth-child(33) { 83 | animation-delay: 1222ms; } 84 | .track-history__item:nth-child(34) { 85 | animation-delay: 1188ms; } 86 | .track-history__item:nth-child(35) { 87 | animation-delay: 1150ms; } 88 | .track-history__item:nth-child(36) { 89 | animation-delay: 1108ms; } 90 | .track-history__item:nth-child(37) { 91 | animation-delay: 1062ms; } 92 | .track-history__item:nth-child(38) { 93 | animation-delay: 1012ms; } 94 | .track-history__item:nth-child(39) { 95 | animation-delay: 958ms; } 96 | .track-history__item:nth-child(40) { 97 | animation-delay: 900ms; } 98 | .track-history__item:nth-child(41) { 99 | animation-delay: 838ms; } 100 | .track-history__item:nth-child(42) { 101 | animation-delay: 772ms; } 102 | .track-history__item:nth-child(43) { 103 | animation-delay: 702ms; } 104 | .track-history__item:nth-child(44) { 105 | animation-delay: 628ms; } 106 | .track-history__item:nth-child(45) { 107 | animation-delay: 550ms; } 108 | .track-history__item:nth-child(46) { 109 | animation-delay: 468ms; } 110 | .track-history__item:nth-child(47) { 111 | animation-delay: 382ms; } 112 | .track-history__item:nth-child(48) { 113 | animation-delay: 292ms; } 114 | .track-history__item:nth-child(49) { 115 | animation-delay: 198ms; } 116 | .track-history__item:nth-child(50) { 117 | animation-delay: 100ms; } 118 | 119 | .track__info { 120 | display: flex; 121 | align-items: center; 122 | flex: 3; } 123 | 124 | .track__summary { 125 | display: flex; 126 | flex-direction: column-reverse; } 127 | 128 | .track__separator { 129 | display: none; } 130 | 131 | .track__artist { 132 | white-space: nowrap; } 133 | 134 | .track__track-name { 135 | color: #3a354e; 136 | margin-bottom: 0.2em; 137 | font-weight: bold; } 138 | 139 | .track__time { 140 | color: #aba5c3; 141 | text-align: left; 142 | flex: 1; } 143 | 144 | @media (min-width: 769px) { 145 | .track-history__item { 146 | padding: 1em 1.5em; 147 | margin: 0 -1.8em; } 148 | .track__track-name { 149 | font-weight: 500; } } 150 | -------------------------------------------------------------------------------- /src/components/TopHistoryTrack/TopHistoryTrack.scss: -------------------------------------------------------------------------------- 1 | @import 'styles/variables.scss'; 2 | 3 | .track-history__item { 4 | padding: 1em 0.75em; 5 | text-align: left; 6 | font-size: 1.1em; 7 | line-height: 1.2; 8 | margin: 0 -1em; 9 | 10 | display: flex; 11 | align-items: center; 12 | color: $dark-grey; 13 | 14 | &:hover { 15 | background: $light-grey; 16 | } 17 | 18 | transform: translate3d(0, -4px, 0); 19 | opacity: 0; 20 | animation-name: mic-drop; 21 | animation-timing-function: ease; 22 | animation-fill-mode: forwards; 23 | animation-duration: 1s; 24 | 25 | @for $i from 1 through $animation-max-items { 26 | &:nth-child(#{$i}) { 27 | animation-delay: $i * (100ms - ($i * 2ms)) + $animation-initial-delay; 28 | } 29 | } 30 | } 31 | 32 | .track__info { 33 | display: flex; 34 | align-items: center; 35 | flex: 3; 36 | } 37 | 38 | .track__summary { 39 | display: flex; 40 | flex-direction: column-reverse; 41 | } 42 | 43 | .track__separator { 44 | display: none; 45 | } 46 | 47 | .track__artist { 48 | white-space: nowrap; 49 | } 50 | 51 | .track__track-name { 52 | color: $brand-dark; 53 | margin-bottom: 0.2em; 54 | font-weight: bold; 55 | } 56 | 57 | .track__time { 58 | color: $mid-grey; 59 | text-align: left; 60 | flex: 1; 61 | } 62 | 63 | @media (min-width: $breakpoint-small) { 64 | .track-history__item { 65 | padding: 1em 1.5em; 66 | margin: 0 -1.8em; 67 | } 68 | 69 | .track__track-name { 70 | font-weight: 500; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/components/TopHistoryTrack/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import ListItemCoverImage from '../ListItemCoverImage'; 4 | 5 | import './TopHistoryTrack.css'; 6 | 7 | const TopHistoryTrack = ({ track, orderNumber }) => ( 8 | 9 | {orderNumber} 10 | 11 | 12 | 13 | 14 | {track.getIn(['artists', 0, 'name'])} 15 | 16 | {track.getIn(['name'])} 17 | 18 | 19 | 20 | ); 21 | 22 | export default TopHistoryTrack; 23 | -------------------------------------------------------------------------------- /src/concepts/app-view.js: -------------------------------------------------------------------------------- 1 | // # App view concept 2 | // 3 | // This concept does not have reducer and it will work just as a combining 4 | // "view-concept" for "core-concepts" 5 | 6 | import { createStructuredSelector } from 'reselect'; 7 | 8 | import { checkLogin } from './auth'; 9 | import { fetchUserProfile, getUser } from './user'; 10 | import { downloadCoverImages } from './share'; 11 | import { fetchPlayHistory, getPlayHistory } from './play-history'; 12 | import { 13 | fetchTopHistory, 14 | fetchTop, 15 | getTopHistory, 16 | getTimeRange, 17 | setTimeRange, 18 | } from './top-history'; 19 | import { 20 | createTopArtistPlaylist, 21 | createTopTracksPlaylist, 22 | createRecentlyPlayedPlaylist, 23 | } from './playlist'; 24 | 25 | // # Selectors 26 | export const getAppViewData = createStructuredSelector({ 27 | user: getUser, 28 | playHistory: getPlayHistory, 29 | topHistory: getTopHistory, 30 | timeRange: getTimeRange, 31 | }); 32 | 33 | // # Action creators 34 | export const startAppView = () => dispatch => { 35 | console.log('Starting app view...'); 36 | 37 | dispatch(checkLogin()); 38 | 39 | dispatch(fetchUserProfile()); 40 | // this fetch is somewhat redundant since app is updating play history 41 | // everytime playhistory is mounted. OTOH fetching this on start will 42 | // speed up first rendering of view 43 | dispatch(fetchPlayHistory()); 44 | dispatch(fetchTopHistory()); 45 | }; 46 | 47 | export const updateRecentlyPlayed = fetchPlayHistory; 48 | export const updateTimeRange = type => timeRange => dispatch => { 49 | dispatch(setTimeRange(type)(timeRange)); 50 | dispatch(fetchTop(type)()); 51 | }; 52 | 53 | export const updateArtistsTimeRange = updateTimeRange('artists'); 54 | export const updateTracksTimeRange = updateTimeRange('tracks'); 55 | 56 | export const createArtistPlaylist = createTopArtistPlaylist; 57 | export const createTracksPlaylist = createTopTracksPlaylist; 58 | export const createRecentlyPlaylist = createRecentlyPlayedPlaylist; 59 | 60 | export const shareImage = downloadCoverImages; 61 | -------------------------------------------------------------------------------- /src/concepts/app.js: -------------------------------------------------------------------------------- 1 | // # App concept 2 | 3 | import { fromJS } from 'immutable'; 4 | 5 | // # Action Types 6 | 7 | // # Selectors 8 | 9 | // # Action Creators 10 | 11 | // # Reducer 12 | 13 | const initialState = fromJS({}); 14 | 15 | export default function reducer(state = initialState, action) { 16 | switch (action.type) { 17 | default: { 18 | return state; 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/concepts/auth.js: -------------------------------------------------------------------------------- 1 | // # Auth concept 2 | 3 | import { fromJS } from 'immutable'; 4 | import localStorage from 'local-storage'; 5 | 6 | import config from '../config'; 7 | import queryParametrize from '../services/query-parametrize'; 8 | import parseAccessToken from '../services/auth'; 9 | import history from '../services/history'; 10 | 11 | // # Action Types 12 | const SET_USER_LOGGED_IN = 'auth/SET_USER_LOGGED_IN'; 13 | 14 | // # Selectors 15 | 16 | // # Action Creators 17 | export const authorizeUser = () => dispatch => { 18 | const loginOpts = { 19 | client_id: config.SPOTIFY_CLIENT_ID, 20 | redirect_uri: config.CALLBACK_URL, 21 | scope: config.SPOTIFY_AUTH_SCOPES, 22 | response_type: 'token', 23 | }; 24 | const loginUrl = queryParametrize(config.SPOTIFY_AUTHORIZE_URL, loginOpts); 25 | 26 | window.location.href = loginUrl; 27 | }; 28 | 29 | export const checkLogin = () => dispatch => { 30 | const accessToken = localStorage.get('accessToken'); 31 | 32 | if (!accessToken) { 33 | history.replace('/login'); 34 | } 35 | 36 | return; 37 | }; 38 | 39 | export const saveLogin = () => dispatch => { 40 | const accessToken = parseAccessToken(); 41 | 42 | // redirect 43 | if (accessToken) { 44 | localStorage.set('accessToken', accessToken); 45 | 46 | // try to get redirect from local storage 47 | let redirectTo = localStorage.get('redirectTo') || '/'; 48 | localStorage.remove('redirectTo'); 49 | 50 | // we dont want to redirect to login anymore 51 | if (redirectTo === '/login') { 52 | redirectTo = '/'; 53 | } 54 | 55 | history.replace(redirectTo); 56 | } else { 57 | history.replace('/login'); 58 | } 59 | 60 | return dispatch({ type: SET_USER_LOGGED_IN }); 61 | }; 62 | 63 | // # Reducer 64 | const initialState = fromJS({ 65 | isLoggedIn: false, 66 | }); 67 | 68 | export default function reducer(state = initialState, action) { 69 | switch (action.type) { 70 | case SET_USER_LOGGED_IN: { 71 | return state.set('isLoggedIn', true); 72 | } 73 | 74 | default: { 75 | return state; 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/concepts/play-history.js: -------------------------------------------------------------------------------- 1 | // # Play history concept 2 | 3 | import { fromJS } from 'immutable'; 4 | import { createSelector } from 'reselect'; 5 | 6 | import { apiCall } from '../services/api'; 7 | 8 | // # Action Types 9 | // const FETCH_RECENTLY_PLAYED = 'history/FETCH_RECENTLY_PLAYED'; 10 | // const FETCH_RECENTLY_PLAYED_SUCCESS = 'history/FETCH_RECENTLY_PLAYED_SUCCESS'; 11 | // const FETCH_RECENTLY_PLAYED_FAIL = 'history/FETCH_RECENTLY_PLAYED_FAIL'; 12 | 13 | const FETCH_PLAY_HISTORY = 'history/FETCH_PLAY_HISTORY'; 14 | const FETCH_PLAY_HISTORY_SUCCESS = 'history/FETCH_PLAY_HISTORY_SUCCESS'; 15 | // const FETCH_PLAY_HISTORY_FAIL = 'history/FETCH_PLAY_HISTORY_FAIL'; 16 | 17 | // # Selectors 18 | export const getPlayHistory = state => state.playHistory.get('history'); 19 | 20 | export const getRecentlyPlayedUris = createSelector(getPlayHistory, tracks => 21 | tracks.map(track => track.getIn(['track', 'uri'])) 22 | ); 23 | 24 | const getFirstImage = target => target.getIn(['track', 'album', 'images', 0, 'url']); 25 | export const getPlayHistoryImages = createSelector(getPlayHistory, playHistory => 26 | playHistory.map(getFirstImage) 27 | ); 28 | 29 | // # Action Creators 30 | export const fetchRecentlyPlayed = (params = {}) => 31 | apiCall({ 32 | type: FETCH_PLAY_HISTORY, 33 | url: '/me/player/recently-played', 34 | params: Object.assign({}, { limit: 50 }, params), 35 | }); 36 | 37 | export const fetchPlayHistory = () => dispatch => { 38 | return dispatch(fetchRecentlyPlayed()); 39 | }; 40 | 41 | // # Reducer 42 | 43 | const initialState = fromJS({ 44 | history: {}, 45 | isLoadingHistory: false, 46 | }); 47 | 48 | export default function reducer(state = initialState, action) { 49 | switch (action.type) { 50 | case FETCH_PLAY_HISTORY_SUCCESS: { 51 | return state.set('history', fromJS(action.payload.data.items)); 52 | } 53 | 54 | default: { 55 | return state; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/concepts/playlist-popup.js: -------------------------------------------------------------------------------- 1 | // # Popup concept 2 | import { fromJS } from 'immutable'; 3 | import { createSelector, createStructuredSelector } from 'reselect'; 4 | import { isNil } from 'lodash'; 5 | 6 | // # Action Types 7 | const OPEN_PLAYLIST_POPUP = 'playlistPopup/OPEN_PLAYLIST_POPUP'; 8 | const CLOSE_PLAYLIST_POPUP = 'playlistPopup/CLOSE_PLAYLIST_POPUP'; 9 | const SET_PLAYLIST_IMAGES = 'playlistPopup/SET_PLAYLIST_IMAGES'; 10 | 11 | // # Selectors 12 | export const getPlaylistPopupUri = state => state.playlistPopup.get('uri'); 13 | export const getPlaylistPopupVisibility = createSelector(getPlaylistPopupUri, uri => !isNil(uri)); 14 | 15 | export const getPlaylistImages = state => state.playlistPopup.get('playlistImages'); 16 | export const getPlaylistImage = createSelector(getPlaylistImages, imageList => { 17 | if (!imageList || imageList.size === 0) { 18 | return null; 19 | } 20 | 21 | // prefer image at index 1 which is 300px, fallback to first image which is 640px 22 | return imageList.getIn([1, 'url']) || imageList.getIn([0, 'url']); 23 | }); 24 | 25 | export const getPopupData = createStructuredSelector({ 26 | playlistUri: getPlaylistPopupUri, 27 | playlistImage: getPlaylistImage, 28 | isVisible: getPlaylistPopupVisibility, 29 | }); 30 | 31 | // # Action Creators 32 | export const openPlaylistPopup = uri => ({ type: OPEN_PLAYLIST_POPUP, payload: uri }); 33 | export const closePlaylistPopup = () => ({ type: CLOSE_PLAYLIST_POPUP }); 34 | export const setPlaylistImages = imageList => ({ type: SET_PLAYLIST_IMAGES, payload: imageList }); 35 | 36 | // # Reducer 37 | const initialState = fromJS({ 38 | uri: null, 39 | playlistImages: [], 40 | }); 41 | 42 | export default function reducer(state = initialState, action) { 43 | switch (action.type) { 44 | case OPEN_PLAYLIST_POPUP: { 45 | return state.set('uri', action.payload); 46 | } 47 | 48 | case CLOSE_PLAYLIST_POPUP: { 49 | return state.set('uri', null).set('playlistImages', fromJS([])); 50 | } 51 | 52 | case SET_PLAYLIST_IMAGES: { 53 | return state.set('playlistImages', fromJS(action.payload)); 54 | } 55 | 56 | default: { 57 | return state; 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/concepts/playlist.js: -------------------------------------------------------------------------------- 1 | // # Playlist concept 2 | 3 | import { fromJS } from 'immutable'; 4 | import { get, isNil, flatten, shuffle } from 'lodash'; 5 | 6 | import { getUser } from './user'; 7 | import { fetchTopArtistsTopTracks, getTopTracksUris, getTimeRange } from './top-history'; 8 | import { getRecentlyPlayedUris } from './play-history'; 9 | import { openPlaylistPopup, setPlaylistImages } from './playlist-popup'; 10 | import { apiCall } from '../services/api'; 11 | import getPlaylistName from '../services/playlist-name'; 12 | import PlaylistTypes from '../constants/PlaylistTypes'; 13 | 14 | // # Action Types 15 | const CREATE_PLAYLIST = 'playlist/CREATE_PLAYLIST'; 16 | const CREATE_PLAYLIST_SUCCESS = 'playlist/CREATE_PLAYLIST_SUCCESS'; 17 | 18 | const GET_PLAYLIST_IMAGE = 'playlist/GET_PLAYLIST_IMAGE'; 19 | 20 | const ADD_TRACKS_TO_PLAYLIST = 'playlist/ADD_TRACKS_TO_PLAYLIST'; 21 | 22 | // # Selectors 23 | export const getCreatingPlayListStatus = state => state.playList.get('isCreatingPlaylist'); 24 | export const getPlaylistImages = state => state.playList.get('playlistImages'); 25 | 26 | // # Action Creators 27 | export const createPlaylist = (params = {}) => (dispatch, getState) => { 28 | const user = getUser(getState()); 29 | const userId = user.get('id'); 30 | 31 | if (!userId) { 32 | return null; 33 | } 34 | 35 | return dispatch( 36 | apiCall({ 37 | type: CREATE_PLAYLIST, 38 | url: `/users/${userId}/playlists`, 39 | method: 'POST', 40 | data: params, 41 | }) 42 | ); 43 | }; 44 | 45 | export const fetchPlaylistImages = playlistId => (dispatch, getState) => { 46 | const user = getUser(getState()); 47 | const userId = user.get('id'); 48 | 49 | if (!userId) { 50 | return null; 51 | } 52 | 53 | return dispatch( 54 | apiCall({ 55 | type: GET_PLAYLIST_IMAGE, 56 | url: `/users/${userId}/playlists/${playlistId}/images`, 57 | method: 'GET', 58 | }) 59 | ); 60 | }; 61 | 62 | export const fetchNewPlaylistImage = playlistId => dispatch => 63 | dispatch(fetchPlaylistImages(playlistId)).then(action => 64 | dispatch(setPlaylistImages(action.payload.data)) 65 | ); 66 | 67 | export const addTracksToPlayList = (playlistId, tracks) => (dispatch, getState) => { 68 | const user = getUser(getState()); 69 | const userId = user.get('id'); 70 | 71 | if (!userId) { 72 | return null; 73 | } 74 | 75 | return dispatch( 76 | apiCall({ 77 | type: ADD_TRACKS_TO_PLAYLIST, 78 | url: `users/${userId}/playlists/${playlistId}/tracks`, 79 | method: 'POST', 80 | data: { uris: tracks }, 81 | }) 82 | ); 83 | }; 84 | 85 | const topPerArtist = 5; 86 | export const createTopArtistPlaylist = () => (dispatch, getState) => { 87 | let tracks; 88 | 89 | const timeRange = getTimeRange(getState()).get('artists'); 90 | 91 | return dispatch(fetchTopArtistsTopTracks()) 92 | .then(responses => { 93 | const tracksPerArtist = responses.map(response => get(response, 'payload.data.tracks')); 94 | 95 | const trackUris = tracksPerArtist.map(artistTracks => 96 | artistTracks.slice(0, topPerArtist).map(track => get(track, 'uri')) 97 | ); 98 | 99 | tracks = shuffle(flatten(trackUris)); 100 | 101 | if (!tracks.length) { 102 | return Promise.reject(null); 103 | } 104 | }) 105 | .then(() => 106 | dispatch( 107 | createPlaylist({ 108 | name: getPlaylistName({ type: PlaylistTypes.ARTIST, timeRange }), 109 | description: 'Top-5 tracks from each of my Top-20 artists.', 110 | }) 111 | ) 112 | ) 113 | .then(response => { 114 | const playlist = get(response, 'payload.data'); 115 | const playlistId = get(playlist, 'id'); 116 | const playlistUri = get(playlist, 'uri'); 117 | 118 | if (isNil(playlistId) || !tracks.length) { 119 | return null; 120 | } 121 | 122 | dispatch(addTracksToPlayList(playlistId, tracks)).then(() => { 123 | dispatch(openPlaylistPopup(playlistUri)); 124 | dispatch(fetchNewPlaylistImage(playlistId)); 125 | }); 126 | }); 127 | }; 128 | 129 | export const createTopTracksPlaylist = () => (dispatch, getState) => { 130 | const state = getState(); 131 | const tracks = getTopTracksUris(state); 132 | const timeRange = getTimeRange(state).get('tracks'); 133 | 134 | if (!tracks.size) { 135 | return; 136 | } 137 | 138 | return dispatch( 139 | createPlaylist({ 140 | name: getPlaylistName({ type: PlaylistTypes.TRACK, timeRange }), 141 | }) 142 | ).then(response => { 143 | const playlist = get(response, 'payload.data'); 144 | const playlistId = get(playlist, 'id'); 145 | const playlistUri = get(playlist, 'uri'); 146 | 147 | if (isNil(playlistId) || !tracks.size) { 148 | return null; 149 | } 150 | 151 | dispatch(addTracksToPlayList(playlistId, tracks.toJS())).then(() => { 152 | dispatch(openPlaylistPopup(playlistUri)); 153 | dispatch(fetchNewPlaylistImage(playlistId)); 154 | }); 155 | }); 156 | }; 157 | 158 | export const createRecentlyPlayedPlaylist = () => (dispatch, getState) => { 159 | const tracks = getRecentlyPlayedUris(getState()); 160 | 161 | if (!tracks.size) { 162 | return; 163 | } 164 | 165 | return dispatch( 166 | createPlaylist({ 167 | name: getPlaylistName({ type: PlaylistTypes.RECENT }), 168 | }) 169 | ).then(response => { 170 | const playlist = get(response, 'payload.data'); 171 | const playlistId = get(playlist, 'id'); 172 | const playlistUri = get(playlist, 'uri'); 173 | 174 | if (isNil(playlistId) || !tracks.size) { 175 | return null; 176 | } 177 | 178 | dispatch(addTracksToPlayList(playlistId, tracks.toJS())).then(() => { 179 | dispatch(openPlaylistPopup(playlistUri)); 180 | dispatch(fetchNewPlaylistImage(playlistId)); 181 | }); 182 | }); 183 | }; 184 | 185 | // # Reducer 186 | const initialState = fromJS({ 187 | isCreatingPlaylist: false, 188 | }); 189 | 190 | export default function reducer(state = initialState, action) { 191 | switch (action.type) { 192 | case CREATE_PLAYLIST: { 193 | return state.set('isCreatingPlaylist', true); 194 | } 195 | 196 | case CREATE_PLAYLIST_SUCCESS: { 197 | return state.set('isCreatingPlaylist', false); 198 | } 199 | 200 | default: { 201 | return state; 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/concepts/route.js: -------------------------------------------------------------------------------- 1 | // react-router-redux 2 | import { get } from 'lodash'; 3 | 4 | export const getCurrentPathName = state => get(state, ['routing', 'location', 'pathname']); 5 | -------------------------------------------------------------------------------- /src/concepts/share.js: -------------------------------------------------------------------------------- 1 | import { slice, uniq } from 'lodash'; 2 | 3 | import { getPlayHistoryImages } from './play-history'; 4 | import { getArtistImages, getTrackImages } from './top-history'; 5 | import PlaylistTypes from '../constants/PlaylistTypes'; 6 | 7 | const GOLDEN_RATIO = 1.61803398875; 8 | const BASE_IMG_SIZE = 640; 9 | const IMG_COUNT = 10; 10 | 11 | export const downloadCoverImages = type => (dispatch, getState) => { 12 | let images = []; 13 | let filename = ''; 14 | 15 | switch (type) { 16 | case PlaylistTypes.ARTIST: { 17 | images = getArtistImages(getState()); 18 | filename = 'Top-artists'; 19 | break; 20 | } 21 | 22 | case PlaylistTypes.TRACK: { 23 | images = getTrackImages(getState()); 24 | filename = 'Top-tracks'; 25 | break; 26 | } 27 | 28 | case PlaylistTypes.RECENT: { 29 | images = getPlayHistoryImages(getState()); 30 | filename = 'Recent-tracks'; 31 | break; 32 | } 33 | 34 | default: { 35 | break; 36 | } 37 | } 38 | 39 | if (!images.size) { 40 | return; 41 | } 42 | 43 | // unique and slice 44 | const imageArray = slice(uniq(images.toJS()), 0, IMG_COUNT); 45 | 46 | const imageElements = imageArray.map(src => { 47 | const imageElement = new Image(); 48 | 49 | imageElement.crossOrigin = ''; 50 | imageElement.src = src; 51 | 52 | return imageElement; 53 | }); 54 | 55 | let imageLoadCounter = 0; 56 | 57 | imageElements.map( 58 | imageElement => 59 | (imageElement.onload = () => { 60 | imageLoadCounter++; 61 | 62 | if (imageLoadCounter === imageElements.length) { 63 | const dataUrl = createStackLayout(imageElements); 64 | downloadImage(dataUrl, filename); 65 | } 66 | }) 67 | ); 68 | 69 | return null; 70 | }; 71 | 72 | // Creates iamge collage in golden ratio layout 73 | const createGoldenRatioLayout = images => { 74 | // create canvas 75 | const canvas = document.createElement('canvas'); 76 | const ctx = canvas.getContext('2d'); 77 | 78 | // calclulate canvas size 79 | canvas.width = BASE_IMG_SIZE * GOLDEN_RATIO; 80 | canvas.height = BASE_IMG_SIZE; 81 | 82 | // fill with white 83 | ctx.fillStyle = '#fff'; 84 | ctx.fillRect(0, 0, canvas.width, canvas.height); 85 | 86 | // Image 1 87 | drawCroppedImage(ctx, images[0], 0, 0, BASE_IMG_SIZE, BASE_IMG_SIZE); 88 | 89 | // Image 2 90 | const img2size = BASE_IMG_SIZE / GOLDEN_RATIO; 91 | drawCroppedImage(ctx, images[1], BASE_IMG_SIZE, BASE_IMG_SIZE - img2size, img2size, img2size); 92 | 93 | // Image 3 94 | const img3size = BASE_IMG_SIZE - img2size; 95 | drawCroppedImage(ctx, images[2], BASE_IMG_SIZE * GOLDEN_RATIO - img3size, 0, img3size, img3size); 96 | 97 | // Image 4 98 | const img4size = img2size - img3size; 99 | drawCroppedImage(ctx, images[3], BASE_IMG_SIZE, 0, img4size, img4size); 100 | 101 | // Image 5 102 | const img5size = img3size - img4size; 103 | drawCroppedImage(ctx, images[4], BASE_IMG_SIZE, img4size, img5size, img5size); 104 | 105 | // Image 6 106 | const img6 = images[5]; 107 | const img6width = img4size - img5size; 108 | const img6height = img5size; 109 | drawCroppedImage(ctx, img6, BASE_IMG_SIZE + img5size, img4size, img6width, img6height); 110 | 111 | return canvas.toDataURL('image/jpeg', 0.7); 112 | }; 113 | 114 | const createStackLayout = images => { 115 | const padding = 3; 116 | const drawWithPadding = cropImage(padding); 117 | 118 | // create canvas 119 | const canvas = document.createElement('canvas'); 120 | const ctx = canvas.getContext('2d'); 121 | 122 | // calclulate canvas size 123 | canvas.width = BASE_IMG_SIZE * 2 + padding; 124 | canvas.height = BASE_IMG_SIZE * 2 + padding; 125 | 126 | // fill with white 127 | ctx.fillStyle = '#fff'; 128 | ctx.fillRect(0, 0, canvas.width, canvas.height); 129 | 130 | // Image 1 131 | drawWithPadding(ctx, images[0], 0, 0, BASE_IMG_SIZE, BASE_IMG_SIZE); 132 | 133 | // Image 2 134 | const img2size = BASE_IMG_SIZE; 135 | drawWithPadding( 136 | ctx, 137 | images[1], 138 | BASE_IMG_SIZE, 139 | BASE_IMG_SIZE - img2size, 140 | BASE_IMG_SIZE, 141 | BASE_IMG_SIZE 142 | ); 143 | 144 | // Image 3 145 | const smallImgSize = BASE_IMG_SIZE / 2; 146 | drawWithPadding(ctx, images[2], 0, BASE_IMG_SIZE, smallImgSize, smallImgSize); 147 | 148 | // Image 4 149 | drawWithPadding(ctx, images[3], smallImgSize, BASE_IMG_SIZE, smallImgSize, smallImgSize); 150 | 151 | // Image 5 152 | drawWithPadding(ctx, images[4], smallImgSize * 2, BASE_IMG_SIZE, smallImgSize, smallImgSize); 153 | 154 | // Image 6 155 | drawWithPadding(ctx, images[5], smallImgSize * 3, BASE_IMG_SIZE, smallImgSize, smallImgSize); 156 | 157 | // Image 7 158 | drawWithPadding(ctx, images[6], 0, smallImgSize + BASE_IMG_SIZE, smallImgSize, smallImgSize); 159 | 160 | // Image 8 161 | drawWithPadding( 162 | ctx, 163 | images[7], 164 | smallImgSize, 165 | smallImgSize + BASE_IMG_SIZE, 166 | smallImgSize, 167 | smallImgSize 168 | ); 169 | 170 | // Image 9 171 | drawWithPadding( 172 | ctx, 173 | images[8], 174 | smallImgSize * 2, 175 | smallImgSize + BASE_IMG_SIZE, 176 | smallImgSize, 177 | smallImgSize 178 | ); 179 | 180 | // Image 10 181 | drawWithPadding( 182 | ctx, 183 | images[9], 184 | smallImgSize * 3, 185 | smallImgSize + BASE_IMG_SIZE, 186 | smallImgSize, 187 | smallImgSize 188 | ); 189 | 190 | return canvas.toDataURL('image/jpeg', 0.7); 191 | }; 192 | 193 | const downloadImage = (dataUrl, filename) => { 194 | var link = document.createElement('a'); 195 | link.download = `${filename}.jpg`; 196 | link.href = dataUrl; 197 | link.click(); 198 | 199 | link.remove(); 200 | }; 201 | 202 | const cropImage = padding => (...params) => drawCroppedImage(...params, padding); 203 | 204 | const drawCroppedImage = (ctx, image, x, y, width, height, padding = 0) => { 205 | if (!image) { 206 | return; 207 | } 208 | 209 | const originalWidth = image.width; 210 | const originalHeight = image.height; 211 | const fromRatio = originalWidth / originalHeight; 212 | const toRatio = width / height; 213 | 214 | let sx, sy, cropWidth, cropHeight; 215 | 216 | if (fromRatio > toRatio) { 217 | cropWidth = originalHeight * toRatio; 218 | cropHeight = originalHeight; 219 | sx = (originalWidth - cropWidth) / 2; 220 | sy = 0; 221 | } else { 222 | cropWidth = originalWidth; 223 | cropHeight = originalWidth / toRatio; 224 | sx = 0; 225 | sy = (originalHeight - cropHeight) / 2; 226 | } 227 | 228 | return ctx.drawImage( 229 | image, 230 | sx, 231 | sy, 232 | cropWidth, 233 | cropHeight, 234 | x + padding, 235 | y + padding, 236 | width - padding, 237 | height - padding 238 | ); 239 | }; 240 | -------------------------------------------------------------------------------- /src/concepts/top-history.js: -------------------------------------------------------------------------------- 1 | // # Play history concept 2 | 3 | import { fromJS, Map } from 'immutable'; 4 | import { createSelector } from 'reselect'; 5 | 6 | import { apiCall } from '../services/api'; 7 | import { getRequestTarget } from '../services/response'; 8 | import config from '../config'; 9 | import TimeRanges from '../constants/TimeRanges'; 10 | 11 | // # Action Types 12 | const FETCH_TOP_HISTORY = 'history/FETCH_TOP_HISTORY'; 13 | const FETCH_TOP_HISTORY_SUCCESS = 'history/FETCH_TOP_HISTORY_SUCCESS'; 14 | // const FETCH_TOP_HISTORY_FAIL = 'history/FETCH_TOP_HISTORY_FAIL'; 15 | 16 | const FETCH_ARTIST_TOP_TRACKS = 'history/FETCH_ARTIST_TOP_TRACKS'; 17 | const SET_TIME_RANGE = 'history/SET_TIME_RANGE'; 18 | 19 | // # Selectors 20 | export const getTopArtists = state => state.topHistory.get('artists'); 21 | export const getTopTracks = state => state.topHistory.get('tracks'); 22 | export const getTimeRange = state => state.topHistory.get('timeRange'); 23 | 24 | export const getTopHistory = createSelector(getTopArtists, getTopTracks, (artists, tracks) => 25 | fromJS({ artists, tracks }) 26 | ); 27 | 28 | export const getTopArtistsIds = createSelector(getTopArtists, artists => 29 | artists.map(artist => artist.get('id')) 30 | ); 31 | 32 | export const getTopTracksUris = createSelector(getTopTracks, tracks => 33 | tracks.map(track => track.get('uri')) 34 | ); 35 | 36 | const getFirstImage = target => target.getIn(['images', 0, 'url']); 37 | const getFirstTrackImage = target => target.getIn(['album', 'images', 0, 'url']); 38 | export const getArtistImages = createSelector(getTopArtists, artists => artists.map(getFirstImage)); 39 | export const getTrackImages = createSelector(getTopTracks, tracks => 40 | tracks.map(getFirstTrackImage) 41 | ); 42 | 43 | // # Action Creators 44 | export const fetchTop = type => (params = {}) => (dispatch, getState) => { 45 | const timeRanges = getTimeRange(getState()); 46 | const timeRange = timeRanges.get(type); 47 | 48 | dispatch( 49 | apiCall({ 50 | type: FETCH_TOP_HISTORY, 51 | url: `/me/top/${type}`, 52 | params: Object.assign({}, { limit: 50, time_range: timeRange }, params), 53 | payload: { target: type }, 54 | }) 55 | ); 56 | }; 57 | 58 | const fetchTopArtists = fetchTop('artists'); 59 | const fetchTopTracks = fetchTop('tracks'); 60 | 61 | export const fetchTopHistory = () => dispatch => { 62 | return Promise.all([dispatch(fetchTopArtists()), dispatch(fetchTopTracks())]); 63 | }; 64 | 65 | export const fetchArtistTopTracks = artistId => 66 | apiCall({ 67 | type: FETCH_ARTIST_TOP_TRACKS, 68 | url: `/artists/${artistId}/top-tracks`, 69 | params: { country: config.DEFAULT_COUNTRY_CODE }, 70 | }); 71 | 72 | export const fetchTopArtistsTopTracks = (count = 20) => (dispatch, getState) => { 73 | const artistIds = getTopArtistsIds(getState()).slice(0, count); 74 | 75 | if (artistIds.size === 0) { 76 | return null; 77 | } 78 | 79 | return Promise.all(artistIds.toJS().map(id => dispatch(fetchArtistTopTracks(id)))); 80 | }; 81 | 82 | export const setTimeRange = target => timeRange => ({ 83 | type: SET_TIME_RANGE, 84 | payload: { target, timeRange }, 85 | }); 86 | 87 | export const setArtistsTimeRange = setTimeRange('artists'); 88 | export const setTracksTimeRange = setTimeRange('tracks'); 89 | 90 | // # Reducer 91 | const initialState = fromJS({ 92 | artists: {}, 93 | tracks: {}, 94 | timeRange: { 95 | artists: TimeRanges.LONG, 96 | tracks: TimeRanges.LONG, 97 | }, 98 | isLoading: false, 99 | }); 100 | 101 | export default function reducer(state = initialState, action) { 102 | switch (action.type) { 103 | case FETCH_TOP_HISTORY: { 104 | // Clear on fetch 105 | const target = getRequestTarget(action); 106 | return state.set(target, Map()); 107 | } 108 | 109 | case FETCH_TOP_HISTORY_SUCCESS: { 110 | const target = getRequestTarget(action); 111 | return state.set(target, fromJS(action.payload.data.items)); 112 | } 113 | 114 | case SET_TIME_RANGE: { 115 | return state.setIn(['timeRange', action.payload.target], action.payload.timeRange); 116 | } 117 | 118 | default: { 119 | return state; 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/concepts/user.js: -------------------------------------------------------------------------------- 1 | // # User concept 2 | 3 | import { fromJS } from 'immutable'; 4 | import { apiCall } from '../services/api'; 5 | 6 | // # Action Types 7 | const FETCH_USER_PROFILE = 'user/FETCH_USER_PROFILE'; 8 | const FETCH_USER_PROFILE_SUCCESS = 'user/FETCH_USER_PROFILE_SUCCESS'; 9 | // const FETCH_USER_PROFILE_FAIL = 'user/FETCH_USER_PROFILE_FAIL'; 10 | 11 | // # Selectors 12 | export const getUser = state => state.user.get('user'); 13 | 14 | // # Action Creators 15 | export const fetchUserProfile = () => 16 | apiCall({ 17 | type: FETCH_USER_PROFILE, 18 | url: '/me', 19 | }); 20 | 21 | // # Reducer 22 | 23 | const initialState = fromJS({ 24 | user: {}, 25 | isLoadingUser: false, 26 | }); 27 | 28 | export default function reducer(state = initialState, action) { 29 | switch (action.type) { 30 | case FETCH_USER_PROFILE_SUCCESS: { 31 | return state.set('user', fromJS(action.payload.data)); 32 | } 33 | 34 | default: { 35 | return state; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/config/index.js: -------------------------------------------------------------------------------- 1 | import ENV from '../env'; 2 | 3 | const config = { 4 | API_URL: 'https://api.spotify.com/v1', 5 | SPOTIFY_AUTHORIZE_URL: 'https://accounts.spotify.com/authorize', 6 | SPOTIFY_AUTH_SCOPES: 'user-read-recently-played user-top-read playlist-modify-public', 7 | SPOTIFY_CLIENT_ID: ENV.SPOTIFY_CLIENT_ID, 8 | CALLBACK_URL: `${window.location.origin}/callback`, 9 | 10 | // Default Country used for artists top track query 11 | // https://developer.spotify.com/documentation/web-api/reference/artists/get-artists-top-tracks/ 12 | // This could be dynamic, but userinfo for instance does not include this information 13 | DEFAULT_COUNTRY_CODE: 'FI', 14 | }; 15 | 16 | export default config; 17 | -------------------------------------------------------------------------------- /src/constants/PlaylistTypes.js: -------------------------------------------------------------------------------- 1 | export default { 2 | ARTIST: 'artists', 3 | TRACK: 'tracks', 4 | RECENT: 'recently', 5 | }; 6 | -------------------------------------------------------------------------------- /src/constants/ThemeColors.js: -------------------------------------------------------------------------------- 1 | export default { 2 | DEFAULT: '#C6E1DC', 3 | PINK: '#e550a7', 4 | BLUE: '#5d42e5', 5 | YELLOW: '#ba8e00', 6 | }; 7 | -------------------------------------------------------------------------------- /src/constants/TimeRanges.js: -------------------------------------------------------------------------------- 1 | const timeRanges = { 2 | LONG: 'long_term', 3 | MEDIUM: 'medium_term', 4 | SHORT: 'short_term', 5 | }; 6 | 7 | export const options = [timeRanges.LONG, timeRanges.MEDIUM, timeRanges.SHORT]; 8 | 9 | export const labels = { 10 | [timeRanges.LONG]: 'All time', 11 | [timeRanges.MEDIUM]: 'Last 6 months', 12 | [timeRanges.SHORT]: 'Last month', 13 | }; 14 | 15 | export default timeRanges; 16 | -------------------------------------------------------------------------------- /src/containers/App/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; } 3 | 4 | .App-header { 5 | background-color: #000; 6 | height: 60px; 7 | padding: 0 20px; 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | color: white; } 12 | 13 | .App-title { 14 | font-size: 1.5em; } 15 | 16 | .App-intro { 17 | font-size: large; } 18 | -------------------------------------------------------------------------------- /src/containers/App/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { createStore, applyMiddleware, combineReducers } from 'redux'; 3 | import { Provider } from 'react-redux'; 4 | import { Route, Switch } from 'react-router-dom'; 5 | import { ConnectedRouter, routerReducer, routerMiddleware } from 'react-router-redux'; 6 | import thunk from 'redux-thunk'; 7 | 8 | import * as reducers from '../../reducers'; 9 | import { axiosApiMiddleware } from '../../services/axios'; 10 | import history from '../../services/history'; 11 | 12 | import AppView from '../AppView'; 13 | import LoginView from '../LoginView'; 14 | import AppInfo from '../../components/AppInfo'; 15 | import Callback from '../Callback'; 16 | 17 | const historyMiddleware = routerMiddleware(history); 18 | const middlewares = [thunk, historyMiddleware, axiosApiMiddleware]; 19 | 20 | const createStoreWithMiddleware = applyMiddleware.apply(this, middlewares)(createStore); 21 | const reducer = combineReducers({ ...reducers, routing: routerReducer }); 22 | const store = createStoreWithMiddleware( 23 | reducer, 24 | window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__() // eslint-ignore-line 25 | ); 26 | 27 | class App extends Component { 28 | render() { 29 | return ( 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | ); 41 | } 42 | } 43 | 44 | export default App; 45 | -------------------------------------------------------------------------------- /src/containers/AppView/AppView.css: -------------------------------------------------------------------------------- 1 | .App { 2 | padding-top: 0; } 3 | 4 | .App-content { 5 | padding: 0 1.5em 1.5em; } 6 | 7 | .App-container { 8 | position: relative; 9 | min-height: 100%; 10 | padding: 0 0 46px; 11 | z-index: 99; } 12 | 13 | .preload-images { 14 | display: none; 15 | visibility: hidden; 16 | opacity: 0; } 17 | .preload-images img { 18 | height: 0; 19 | width: 0; } 20 | 21 | @media (min-width: 769px) { 22 | .App-container { 23 | padding: 0 0 0 100px; } 24 | .App-content { 25 | padding: 0 0 2em; } } 26 | 27 | @media (min-width: 1025px) { 28 | .App-content { 29 | padding: 0 2em; } } 30 | -------------------------------------------------------------------------------- /src/containers/AppView/AppView.scss: -------------------------------------------------------------------------------- 1 | @import 'styles/variables.scss'; 2 | 3 | .App { 4 | // padding-top: $header-height + $header-top-extra; 5 | padding-top: 0; 6 | } 7 | 8 | .App-content { 9 | padding: 0 1.5em 1.5em; 10 | } 11 | 12 | .App-container { 13 | position: relative; 14 | min-height: 100%; 15 | padding: 0 0 46px; 16 | z-index: 99; 17 | } 18 | 19 | .preload-images { 20 | display: none; 21 | visibility: hidden; 22 | opacity: 0; 23 | img { 24 | height: 0; 25 | width: 0; 26 | } 27 | } 28 | 29 | @media (min-width: $breakpoint-small) { 30 | .App-container { 31 | padding: 0 0 0 100px; 32 | } 33 | 34 | .App-content { 35 | padding: 0 0 2em; 36 | } 37 | } 38 | 39 | @media (min-width: $breakpoint-medium) { 40 | .App-content { 41 | padding: 0 2em; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/containers/AppView/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Route, Redirect } from 'react-router-dom'; 4 | 5 | import { 6 | startAppView, 7 | createArtistPlaylist, 8 | createTracksPlaylist, 9 | createRecentlyPlaylist, 10 | getAppViewData, 11 | updateArtistsTimeRange, 12 | updateTracksTimeRange, 13 | updateRecentlyPlayed, 14 | shareImage, 15 | } from '../../concepts/app-view'; 16 | import PlaylistTypes from '../../constants/PlaylistTypes'; 17 | import PlaylistPopup from '../PlaylistPopup'; 18 | import PlayHistory from '../../components/PlayHistory'; 19 | import TopHistory from '../../components/TopHistory'; 20 | import ScrollTopRoute from '../../components/ScrollTopRoute'; 21 | import AppNavigation from '../../components/AppNavigation'; 22 | import AppHelp from '../../components/AppHelp'; 23 | 24 | import './AppView.css'; 25 | 26 | const artistImg = require('../../assets/images/top-artists.jpg'); 27 | const trackImg = require('../../assets/images/top-tracks.jpg'); 28 | const playImg = require('../../assets/images/recently.jpg'); 29 | 30 | const headerImgs = [artistImg, trackImg, playImg]; 31 | 32 | class AppView extends Component { 33 | componentDidMount() { 34 | this.props.startAppView(); 35 | } 36 | 37 | render() { 38 | const { 39 | topHistory, 40 | playHistory, 41 | timeRange, 42 | updateArtistsTimeRange, 43 | updateTracksTimeRange, 44 | shareImage, 45 | match, 46 | } = this.props; 47 | 48 | return ( 49 |
50 |
51 | 52 | 53 | 54 | 55 |
56 | } /> 57 | ( 61 | shareImage(PlaylistTypes.ARTIST)} 68 | /> 69 | )} 70 | /> 71 | ( 75 | shareImage(PlaylistTypes.TRACK)} 82 | /> 83 | )} 84 | /> 85 | ( 89 | shareImage(PlaylistTypes.RECENT)} 94 | /> 95 | )} 96 | /> 97 | 98 |
99 |
100 | 101 |
102 | {headerImgs.map(src => preloaded img)} 103 |
104 |
105 | ); 106 | } 107 | } 108 | 109 | const mapStateToProps = getAppViewData; 110 | const mapDispatchToProps = { 111 | startAppView, 112 | createArtistPlaylist, 113 | createTracksPlaylist, 114 | createRecentlyPlaylist, 115 | updateArtistsTimeRange, 116 | updateTracksTimeRange, 117 | updateRecentlyPlayed, 118 | shareImage, 119 | }; 120 | 121 | export default connect( 122 | mapStateToProps, 123 | mapDispatchToProps 124 | )(AppView); 125 | -------------------------------------------------------------------------------- /src/containers/Callback/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { saveLogin } from '../../concepts/auth'; 5 | 6 | class Callback extends Component { 7 | componentDidMount() { 8 | this.props.saveLogin(); 9 | } 10 | 11 | render() { 12 | return
Login OK
; 13 | } 14 | } 15 | 16 | const mapStateToProps = () => ({}); 17 | const mapDispatchToProps = { saveLogin }; 18 | 19 | export default connect( 20 | mapStateToProps, 21 | mapDispatchToProps 22 | )(Callback); 23 | -------------------------------------------------------------------------------- /src/containers/LoginView/AppView.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; } 3 | 4 | .App-header { 5 | background-color: #000; 6 | height: 60px; 7 | padding: 0 20px; 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | color: white; } 12 | 13 | .App-title { 14 | font-size: 1.5em; } 15 | 16 | .App-intro { 17 | font-size: large; } 18 | -------------------------------------------------------------------------------- /src/containers/LoginView/LoginView.css: -------------------------------------------------------------------------------- 1 | .login { 2 | text-align: left; 3 | padding: 0 2em; 4 | background: #f9adac; 5 | overflow: hidden; 6 | display: flex; 7 | justify-content: flex-start; 8 | align-items: flex-start; 9 | position: absolute; 10 | left: 0; 11 | right: 0; 12 | top: 0; 13 | bottom: 0; } 14 | 15 | .login__title { 16 | color: #fff; 17 | margin: 10vh 0 50vh; 18 | padding: 0; } 19 | 20 | .login__app-icon { 21 | display: none; } 22 | 23 | .login__content { 24 | position: relative; 25 | z-index: 2; } 26 | 27 | .login__background { 28 | position: absolute; 29 | z-index: 1; 30 | left: 0; 31 | top: 0; 32 | bottom: 0; 33 | right: 0; 34 | background: url("../../assets/images/discover.jpg"); 35 | background-repeat: no-repeat; 36 | background-size: cover; 37 | background-position: center; 38 | display: flex; 39 | flex-wrap: wrap; 40 | opacity: 1; } 41 | 42 | .login__background__image { 43 | width: 20vw; 44 | height: 20vw; 45 | background-size: cover; 46 | background-repeat: no-repeat; 47 | background-position: center; } 48 | 49 | .btn-login { 50 | animation: mic-drop 0.4s; } 51 | 52 | @media (min-width: 768px) { 53 | .login__content { 54 | max-width: 1025px; 55 | margin: 0 auto; } 56 | .login__app-icon { 57 | position: absolute; 58 | left: 20px; 59 | top: 45px; 60 | z-index: 99; 61 | opacity: 0.9; 62 | display: block; 63 | color: #9ee2d9; 64 | font-weight: 900; } 65 | .login__app-icon img { 66 | max-width: 60px; } } 67 | 68 | @media (max-width: 769px) and (orientation: landscape) { 69 | .login__content { 70 | margin: 0 auto; 71 | position: absolute; 72 | left: 0; 73 | right: 0; 74 | top: 0; 75 | bottom: 0; 76 | padding: 0 15%; 77 | overflow-y: auto; } 78 | .login__title { 79 | margin-bottom: 45vh; } } 80 | 81 | @media (min-width: 769px) and (orientation: landscape) { 82 | .login__title { 83 | color: #fff; 84 | margin: 10vh 0 65vh; } 85 | .btn-link { 86 | text-align: center; } } 87 | -------------------------------------------------------------------------------- /src/containers/LoginView/LoginView.scss: -------------------------------------------------------------------------------- 1 | @import 'styles/variables.scss'; 2 | 3 | .login { 4 | text-align: left; 5 | padding: 0 2em; 6 | background: $brand-pink; 7 | 8 | overflow: hidden; 9 | display: flex; 10 | justify-content: flex-start; 11 | align-items: flex-start; 12 | 13 | position: absolute; 14 | left: 0; 15 | right: 0; 16 | top: 0; 17 | bottom: 0; 18 | } 19 | 20 | .login__title { 21 | color: #fff; 22 | margin: 10vh 0 50vh; 23 | padding: 0; 24 | } 25 | 26 | .login__app-icon { 27 | display: none; 28 | } 29 | 30 | .login__content { 31 | position: relative; 32 | z-index: 2; 33 | } 34 | 35 | .login__background { 36 | position: absolute; 37 | z-index: 1; 38 | left: 0; 39 | top: 0; 40 | bottom: 0; 41 | right: 0; 42 | background: url('../../assets/images/discover.jpg'); 43 | background-repeat: no-repeat; 44 | background-size: cover; 45 | background-position: center; 46 | 47 | display: flex; 48 | flex-wrap: wrap; 49 | opacity: 1; 50 | } 51 | 52 | .login__background__image { 53 | width: 20vw; 54 | height: 20vw; 55 | background-size: cover; 56 | background-repeat: no-repeat; 57 | background-position: center; 58 | } 59 | 60 | .btn-login { 61 | animation: mic-drop 0.4s; 62 | } 63 | 64 | @media (min-width: $breakpoint-small - 1) { 65 | .login__content { 66 | max-width: $breakpoint-medium; 67 | margin: 0 auto; 68 | } 69 | 70 | .login__app-icon { 71 | position: absolute; 72 | left: 20px; 73 | top: 45px; 74 | z-index: 99; 75 | opacity: 0.9; 76 | 77 | display: block; 78 | color: $brand-green; 79 | font-weight: 900; 80 | 81 | img { 82 | max-width: 60px; 83 | } 84 | } 85 | } 86 | 87 | @media (max-width: $breakpoint-small) and (orientation: landscape) { 88 | .login__content { 89 | margin: 0 auto; 90 | position: absolute; 91 | left: 0; 92 | right: 0; 93 | top: 0; 94 | bottom: 0; 95 | padding: 0 15%; 96 | overflow-y: auto; 97 | } 98 | .login__title { 99 | margin-bottom: 45vh; 100 | } 101 | } 102 | 103 | @media (min-width: $breakpoint-small) and (orientation: landscape) { 104 | .login__title { 105 | color: #fff; 106 | margin: 10vh 0 65vh; 107 | } 108 | .btn-link { 109 | text-align: center; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/containers/LoginView/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Link } from 'react-router-dom'; 4 | 5 | import { authorizeUser } from '../../concepts/auth'; 6 | import changeThemeColor from '../../services/change-theme'; 7 | import ThemeColors from '../../constants/ThemeColors'; 8 | import './LoginView.css'; 9 | 10 | const appIcon = require('../../assets/images/replayify-icon--green.png'); 11 | 12 | class LoginView extends Component { 13 | componentDidMount() { 14 | changeThemeColor(ThemeColors.DEFAULT); 15 | } 16 | 17 | render() { 18 | return ( 19 | 20 |
21 | 22 | Replayify 23 | 24 |
25 |

Replay your Spotify Hits

26 | 29 | 30 | 31 | What is this? 32 | 33 |
34 | 35 |
36 |
37 | ); 38 | } 39 | } 40 | 41 | const mapStateToProps = () => ({}); 42 | const mapDispatchToProps = { authorizeUser }; 43 | 44 | export default connect( 45 | mapStateToProps, 46 | mapDispatchToProps 47 | )(LoginView); 48 | -------------------------------------------------------------------------------- /src/containers/MusicPlayer/MusicPlayer.css: -------------------------------------------------------------------------------- 1 | .music-player { 2 | text-align: left; 3 | padding: 0 54px; 4 | background: linear-gradient(120deg, #f9adac, #9ee2d9); 5 | background: #f7f7f7; 6 | animation: flash-from-bottom 0.5s; 7 | overflow: hidden; 8 | display: flex; 9 | justify-content: flex-start; 10 | align-items: flex-start; 11 | z-index: 999; 12 | position: fixed; 13 | left: 0; 14 | right: 0; 15 | bottom: 54px; 16 | bottom: 0; 17 | height: 54px; } 18 | 19 | .play-button, .close-player-button { 20 | font-size: 20px; 21 | border-radius: 50%; 22 | width: 54px; 23 | height: 54px; 24 | display: flex; 25 | justify-content: center; 26 | align-items: center; 27 | position: absolute; 28 | top: 0; 29 | border: none; 30 | background: transparent; 31 | color: #000; } 32 | 33 | .play-button { 34 | left: 0; } 35 | 36 | .close-player-button { 37 | right: 0; } 38 | 39 | .music-player__info { 40 | flex: 2; 41 | color: #000; 42 | height: 54px; 43 | display: flex; 44 | justify-content: center; 45 | align-items: center; 46 | flex-direction: column; } 47 | 48 | .music-player__info__track { 49 | font-weight: bold; } 50 | -------------------------------------------------------------------------------- /src/containers/MusicPlayer/MusicPlayer.scss: -------------------------------------------------------------------------------- 1 | @import 'styles/variables.scss'; 2 | 3 | $player-height: $navigation-size-mobile * 1; 4 | 5 | .music-player { 6 | text-align: left; 7 | padding: 0 $player-height; 8 | background: linear-gradient(120deg, $brand-pink, $brand-green); 9 | background: #f7f7f7; 10 | animation: flash-from-bottom 0.5s; 11 | 12 | overflow: hidden; 13 | display: flex; 14 | justify-content: flex-start; 15 | align-items: flex-start; 16 | 17 | z-index: 999; 18 | position: fixed; 19 | left: 0; 20 | right: 0; 21 | bottom: $navigation-size-mobile; 22 | bottom: 0; 23 | height: $player-height; 24 | } 25 | 26 | %player-button { 27 | font-size: 20px; 28 | border-radius: 50%; 29 | width: $player-height; 30 | height: $player-height; 31 | display: flex; 32 | justify-content: center; 33 | align-items: center; 34 | 35 | position: absolute; 36 | top: 0; 37 | 38 | border: none; 39 | background: transparent; 40 | color: $black; 41 | } 42 | 43 | .play-button { 44 | @extend %player-button; 45 | left: 0; 46 | } 47 | 48 | .close-player-button { 49 | @extend %player-button; 50 | right: 0; 51 | } 52 | 53 | .music-player__info { 54 | flex: 2; 55 | color: $black; 56 | 57 | height: $player-height; 58 | 59 | display: flex; 60 | justify-content: center; 61 | align-items: center; 62 | flex-direction: column; 63 | } 64 | 65 | .music-player__info__track { 66 | font-weight: bold; 67 | } 68 | -------------------------------------------------------------------------------- /src/containers/MusicPlayer/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import './MusicPlayer.css'; 5 | 6 | class MusicPlayer extends Component { 7 | constructor(props) { 8 | super(props); 9 | 10 | this.state = { isPlaying: false }; 11 | } 12 | 13 | componentDidMount() {} 14 | 15 | togglePlayState = () => { 16 | const { isPlaying } = this.state; 17 | this.setState({ isPlaying: !isPlaying }); 18 | 19 | if (isPlaying) { 20 | this.musicPlayer.pause(); 21 | } else { 22 | this.musicPlayer.play(); 23 | } 24 | }; 25 | 26 | render() { 27 | return ( 28 |
29 | 32 | 33 | 34 | On a good day (Metropolis) 35 | Above & Beyond 36 | 37 | 38 | 49 | 50 | 53 |
54 | ); 55 | } 56 | } 57 | 58 | const mapStateToProps = () => ({}); 59 | const mapDispatchToProps = {}; 60 | 61 | export default connect( 62 | mapStateToProps, 63 | mapDispatchToProps 64 | )(MusicPlayer); 65 | -------------------------------------------------------------------------------- /src/containers/PlaylistPopup/PlaylistPopup.css: -------------------------------------------------------------------------------- 1 | .playlist-popup { 2 | display: block; 3 | text-align: center; 4 | max-width: 500px; 5 | margin: 0 auto; 6 | animation: mic-drop 0.5s; } 7 | 8 | .playlist-popup__title { 9 | margin-bottom: 5px; } 10 | 11 | .playlist-popup__info { 12 | color: #aba5c3; 13 | margin: 0; 14 | padding: 0; } 15 | 16 | .playlist-popup__image-link { 17 | display: block; 18 | margin: 0 auto; 19 | width: 250px; 20 | height: 250px; 21 | position: relative; 22 | z-index: 2; 23 | border-radius: 20px; 24 | transform: scale(0); 25 | animation: scale-in 0.25s cubic-bezier(0.87, 0.38, 0.27, 0.95); 26 | animation-fill-mode: forwards; 27 | animation-delay: 1.2s; 28 | cursor: pointer; } 29 | .playlist-popup__image-link .playlist-popup__image { 30 | width: 100%; 31 | box-shadow: 0 10px 20px rgba(0, 0, 0, 0.075); 32 | border-radius: 20px; 33 | transition: all 0.15s; 34 | will-change: transform; 35 | transform-origin: 50% 100%; } 36 | .playlist-popup__image-link:hover .playlist-popup__image { 37 | transform: scale(1.015); 38 | box-shadow: 0 10px 30px rgba(0, 0, 0, 0.115); } 39 | 40 | .save-form-success { 41 | display: block; 42 | text-align: center; 43 | margin: 2em auto 3em; 44 | position: relative; 45 | min-height: 250px; } 46 | .save-form-success .ok-sign { 47 | width: 200px; 48 | height: 200px; 49 | margin: 0 auto; 50 | position: absolute; 51 | left: 0; 52 | right: 0; 53 | top: 25px; 54 | text-align: center; 55 | z-index: 1; } 56 | .save-form-success .icon { 57 | position: absolute; 58 | font-size: 70px; 59 | line-height: 1; 60 | color: #9ee2d9; 61 | left: 50%; 62 | top: 50%; 63 | width: 70px; 64 | height: 70px; 65 | text-align: center; 66 | margin: -35px 0 0 -35px; 67 | transform: scale(0); 68 | animation: scale-in 0.35s cubic-bezier(0.75, -0.5, 0, 1.75); 69 | animation-fill-mode: forwards; 70 | animation-delay: 0.55s; 71 | transform-origin: 50% 50%; } 72 | .save-form-success svg.progress { 73 | -webkit-transform: rotate(-90deg) scale(1.1); 74 | transform: rotate(-90deg) scale(1.1); 75 | transform-origin: 50% 50%; 76 | display: block; 77 | position: relative; 78 | background: transparent; 79 | margin: 0 auto; 80 | top: 25px; 81 | z-index: 10; } 82 | .save-form-success .circle_base { 83 | position: absolute; 84 | top: 0; 85 | left: 0; 86 | z-index: 1; 87 | stroke-dasharray: 452; 88 | stroke-dashoffset: 0; 89 | stroke-width: 6; 90 | stroke: transparent; } 91 | .save-form-success .circle_animation { 92 | position: relative; 93 | z-index: 2; 94 | stroke-dasharray: 452; 95 | stroke-dashoffset: 0; 96 | stroke: #9ee2d9; 97 | stroke-width: 6; 98 | animation: round 0.6s ease; 99 | animation-fill-mode: forwards; } 100 | 101 | .playlist__buttons { 102 | opacity: 0; 103 | transform: translate3d(0, -4px, 0); 104 | animation: mic-drop 0.4s; 105 | animation-delay: 0.6s; 106 | animation-fill-mode: forwards; } 107 | 108 | @keyframes round { 109 | from { 110 | stroke-dashoffset: 452; } 111 | to { 112 | stroke-dashoffset: 0; } } 113 | -------------------------------------------------------------------------------- /src/containers/PlaylistPopup/PlaylistPopup.scss: -------------------------------------------------------------------------------- 1 | @import 'styles/variables.scss'; 2 | 3 | .playlist-popup { 4 | display: block; 5 | text-align: center; 6 | max-width: 500px; 7 | 8 | margin: 0 auto; 9 | animation: mic-drop 0.5s; 10 | } 11 | 12 | .playlist-popup__title { 13 | margin-bottom: 5px; 14 | } 15 | 16 | .playlist-popup__info { 17 | color: $mid-grey; 18 | margin: 0; 19 | padding: 0; 20 | } 21 | 22 | .playlist-popup__image-link { 23 | display: block; 24 | margin: 0 auto; 25 | width: 250px; 26 | height: 250px; 27 | position: relative; 28 | z-index: 2; 29 | border-radius: 20px; 30 | // overflow: hidden; 31 | 32 | // initial animation 33 | transform: scale(0); 34 | animation: scale-in 0.25s $cubic-bezier; 35 | animation-fill-mode: forwards; 36 | animation-delay: 1.2s; 37 | cursor: pointer; 38 | 39 | .playlist-popup__image { 40 | width: 100%; 41 | box-shadow: 0 10px 20px rgba(0, 0, 0, 0.075); 42 | border-radius: 20px; 43 | 44 | // hover animation 45 | transition: all 0.15s; 46 | will-change: transform; 47 | transform-origin: 50% 100%; 48 | } 49 | 50 | &:hover .playlist-popup__image { 51 | transform: scale(1.015); 52 | box-shadow: 0 10px 30px rgba(0, 0, 0, 0.115); 53 | } 54 | } 55 | 56 | .save-form-success { 57 | display: block; 58 | text-align: center; 59 | margin: 2em auto 3em; 60 | position: relative; 61 | min-height: 250px; 62 | 63 | .ok-sign { 64 | width: 200px; 65 | height: 200px; 66 | margin: 0 auto; 67 | 68 | position: absolute; 69 | left: 0; 70 | right: 0; 71 | top: 25px; 72 | text-align: center; 73 | z-index: 1; 74 | } 75 | 76 | .icon { 77 | position: absolute; 78 | font-size: 70px; 79 | line-height: 1; 80 | color: $brand-green; 81 | left: 50%; 82 | top: 50%; 83 | width: 70px; 84 | height: 70px; 85 | text-align: center; 86 | margin: -35px 0 0 -35px; 87 | 88 | transform: scale(0); 89 | animation: scale-in 0.35s cubic-bezier(0.75, -0.5, 0, 1.75); 90 | animation-fill-mode: forwards; 91 | animation-delay: 0.55s; 92 | transform-origin: 50% 50%; 93 | } 94 | 95 | svg.progress { 96 | -webkit-transform: rotate(-90deg) scale(1.1); 97 | transform: rotate(-90deg) scale(1.1); 98 | transform-origin: 50% 50%; 99 | display: block; 100 | position: relative; 101 | background: transparent; 102 | margin: 0 auto; 103 | top: 25px; 104 | z-index: 10; 105 | } 106 | 107 | .circle_base { 108 | position: absolute; 109 | top: 0; 110 | left: 0; 111 | z-index: 1; 112 | 113 | stroke-dasharray: 452; 114 | stroke-dashoffset: 0; 115 | stroke-width: 6; 116 | stroke: transparent; 117 | } 118 | 119 | .circle_animation { 120 | position: relative; 121 | z-index: 2; 122 | stroke-dasharray: 452; 123 | stroke-dashoffset: 0; 124 | stroke: $brand-green; 125 | stroke-width: 6; 126 | animation: round 0.6s ease; 127 | animation-fill-mode: forwards; 128 | } 129 | } 130 | 131 | .playlist__buttons { 132 | // start state: invisible 133 | opacity: 0; 134 | transform: translate3d(0, -4px, 0); 135 | 136 | // animate to end state: visible 137 | animation: mic-drop 0.4s; 138 | animation-delay: 0.6s; 139 | animation-fill-mode: forwards; 140 | } 141 | 142 | @keyframes round { 143 | from { 144 | stroke-dashoffset: 452; 145 | } 146 | to { 147 | stroke-dashoffset: 0; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/containers/PlaylistPopup/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { closePlaylistPopup, getPopupData } from '../../concepts/playlist-popup'; 5 | import Modal from '../../components/Modal'; 6 | import './PlaylistPopup.css'; 7 | 8 | class PlaylistPopup extends Component { 9 | render() { 10 | const { playlistUri, playlistImage, isVisible } = this.props; 11 | 12 | if (!isVisible) { 13 | return null; 14 | } 15 | 16 | return ( 17 | 18 |
19 |

Yeah!

20 |

Your new Playlist is now available in Spotify.

21 |
22 | {!!playlistImage && ( 23 | 28 | Playlist cover 29 | 30 | )} 31 |
32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
41 |
42 | 43 |
44 | 49 | Open Playlist 50 | 51 | 54 |
55 |
56 |
57 | ); 58 | } 59 | } 60 | 61 | const mapStateToProps = getPopupData; 62 | const mapDispatchToProps = { 63 | closePlaylistPopup, 64 | }; 65 | 66 | export default connect( 67 | mapStateToProps, 68 | mapDispatchToProps 69 | )(PlaylistPopup); 70 | -------------------------------------------------------------------------------- /src/env.example.js: -------------------------------------------------------------------------------- 1 | export default { 2 | SPOTIFY_CLIENT_ID: '', 3 | }; 4 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css?family=Rubik:300,400,500,700,900,900i"); 2 | @import url("https://code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css"); 3 | @keyframes fade-in { 4 | 0% { 5 | opacity: 0; } 6 | 100% { 7 | opacity: 1; } } 8 | 9 | @keyframes scale-in { 10 | 0% { 11 | transform: scale(0); } 12 | 100% { 13 | transform: scale(1); } } 14 | 15 | @keyframes scaleX-in { 16 | 0% { 17 | transform: scaleX(0); } 18 | 100% { 19 | transform: scaleX(1); } } 20 | 21 | @keyframes scale-to { 22 | 0% { 23 | transform: scale(1.015) translate3d(0, 0, 0); } 24 | 100% { 25 | transform: scale(1) translate3d(0, 0, 0); } } 26 | 27 | @keyframes mic-drop { 28 | 0% { 29 | transform: translate3d(0, -4px, 0); 30 | opacity: 0; } 31 | 100% { 32 | transform: translate3d(0, 0px, 0); 33 | opacity: 1; } } 34 | 35 | @keyframes appear-from-left { 36 | 0% { 37 | transform: translate3d(-100%, 0, 0); } 38 | 100% { 39 | transform: translate3d(0, 0, 0); } } 40 | 41 | @keyframes flash-from-bottom { 42 | 0% { 43 | opacity: 0; 44 | transform: translate3d(0, 100%, 0); } 45 | 100% { 46 | opacity: 1; 47 | transform: translate3d(0, 0, 0); } } 48 | 49 | @keyframes fade-in { 50 | 0% { 51 | opacity: 0; } 52 | 100% { 53 | opacity: 1; } } 54 | 55 | @keyframes scale-in { 56 | 0% { 57 | transform: scale(0); } 58 | 100% { 59 | transform: scale(1); } } 60 | 61 | @keyframes scaleX-in { 62 | 0% { 63 | transform: scaleX(0); } 64 | 100% { 65 | transform: scaleX(1); } } 66 | 67 | @keyframes scale-to { 68 | 0% { 69 | transform: scale(1.015) translate3d(0, 0, 0); } 70 | 100% { 71 | transform: scale(1) translate3d(0, 0, 0); } } 72 | 73 | @keyframes mic-drop { 74 | 0% { 75 | transform: translate3d(0, -4px, 0); 76 | opacity: 0; } 77 | 100% { 78 | transform: translate3d(0, 0px, 0); 79 | opacity: 1; } } 80 | 81 | @keyframes appear-from-left { 82 | 0% { 83 | transform: translate3d(-100%, 0, 0); } 84 | 100% { 85 | transform: translate3d(0, 0, 0); } } 86 | 87 | @keyframes flash-from-bottom { 88 | 0% { 89 | opacity: 0; 90 | transform: translate3d(0, 100%, 0); } 91 | 100% { 92 | opacity: 1; 93 | transform: translate3d(0, 0, 0); } } 94 | 95 | .btn, .btn-primary, .btn-default, .btn-secondary, .btn-dark { 96 | display: block; 97 | width: 100%; 98 | padding: 1em 3em 0.98em; 99 | text-align: center; 100 | font-size: 4vw; 101 | border-width: 0px; 102 | border-style: solid; 103 | border-radius: 50px; 104 | white-space: nowrap; 105 | font-weight: bold; 106 | cursor: pointer; 107 | box-shadow: 0 10px 20px rgba(0, 0, 0, 0.075); 108 | transition: all 0.15s; 109 | user-select: none; } 110 | .btn:active, .btn-primary:active, .btn-default:active, .btn-secondary:active, .btn-dark:active { 111 | box-shadow: 0 12px 22px rgba(0, 0, 0, 0.1); } 112 | 113 | .btn-primary { 114 | color: #f9adac; 115 | background-color: #fff; 116 | border-color: #fff; } 117 | .btn-primary:focus { 118 | color: #f9adac; 119 | background-color: #f7f7f7; 120 | border-color: #f7f7f7; } 121 | .btn-primary:hover { 122 | color: #f9adac; 123 | background-color: #f7f7f7; 124 | border-color: #f7f7f7; } 125 | .btn-primary:active { 126 | color: #f9adac; 127 | background-color: #f0f0f0; 128 | border-color: #f0f0f0; } 129 | .btn-primary:active:hover, .btn-primary:active:focus { 130 | color: #f9adac; 131 | background-color: #f0f0f0; 132 | border-color: #f0f0f0; } 133 | .btn-primary:active { 134 | background-image: none; } 135 | .btn-primary[disabled]:hover, .btn-primary[disabled]:focus, .btn-primary[disabled].focus { 136 | background-color: #fff; 137 | border-color: #fff; } 138 | .btn-primary:hover, .btn-primary:focus, .btn-primary:active, .btn-primary:active:hover, .btn-primary:active:focus { 139 | background-color: #fff; } 140 | .btn-primary:active { 141 | color: #f7918f; } 142 | 143 | .btn-default { 144 | color: #50496d; 145 | background-color: #fff; 146 | border-color: #fff; } 147 | .btn-default:focus { 148 | color: #50496d; 149 | background-color: #f7f7f7; 150 | border-color: #f7f7f7; } 151 | .btn-default:hover { 152 | color: #50496d; 153 | background-color: #f7f7f7; 154 | border-color: #f7f7f7; } 155 | .btn-default:active { 156 | color: #50496d; 157 | background-color: #f0f0f0; 158 | border-color: #f0f0f0; } 159 | .btn-default:active:hover, .btn-default:active:focus { 160 | color: #50496d; 161 | background-color: #f0f0f0; 162 | border-color: #f0f0f0; } 163 | .btn-default:active { 164 | background-image: none; } 165 | .btn-default[disabled]:hover, .btn-default[disabled]:focus, .btn-default[disabled].focus { 166 | background-color: #fff; 167 | border-color: #fff; } 168 | .btn-default:hover, .btn-default:focus, .btn-default:active, .btn-default:active:hover, .btn-default:active:focus { 169 | background-color: #fff; } 170 | .btn-default:active { 171 | color: #433d5b; } 172 | 173 | .btn-secondary { 174 | color: #fff; 175 | background-color: #9ee2d9; 176 | border-color: #9ee2d9; } 177 | .btn-secondary:focus { 178 | color: #fff; 179 | background-color: #92ded4; 180 | border-color: #92ded4; } 181 | .btn-secondary:hover { 182 | color: #fff; 183 | background-color: #92ded4; 184 | border-color: #92ded4; } 185 | .btn-secondary:active { 186 | color: #fff; 187 | background-color: #86dbd0; 188 | border-color: #86dbd0; } 189 | .btn-secondary:active:hover, .btn-secondary:active:focus { 190 | color: #fff; 191 | background-color: #86dbd0; 192 | border-color: #86dbd0; } 193 | .btn-secondary:active { 194 | background-image: none; } 195 | .btn-secondary[disabled]:hover, .btn-secondary[disabled]:focus, .btn-secondary[disabled].focus { 196 | background-color: #9ee2d9; 197 | border-color: #9ee2d9; } 198 | 199 | .btn-dark { 200 | color: #fff; 201 | background-color: #50496d; 202 | border-color: #50496d; } 203 | .btn-dark:focus { 204 | color: #fff; 205 | background-color: #494364; 206 | border-color: #494364; } 207 | .btn-dark:hover { 208 | color: #fff; 209 | background-color: #494364; 210 | border-color: #494364; } 211 | .btn-dark:active { 212 | color: #fff; 213 | background-color: #433d5b; 214 | border-color: #433d5b; } 215 | .btn-dark:active:hover, .btn-dark:active:focus { 216 | color: #fff; 217 | background-color: #433d5b; 218 | border-color: #433d5b; } 219 | .btn-dark:active { 220 | background-image: none; } 221 | .btn-dark[disabled]:hover, .btn-dark[disabled]:focus, .btn-dark[disabled].focus { 222 | background-color: #50496d; 223 | border-color: #50496d; } 224 | 225 | .btn-link { 226 | font-size: 1.1em; 227 | display: block; 228 | width: 100%; 229 | padding: 1em 0em; 230 | text-align: left; 231 | border: none; 232 | color: #fff; 233 | text-decoration: underline; 234 | background: transparent; 235 | font-weight: bold; 236 | margin-top: 1em; } 237 | 238 | .btn-inline + .btn-inline { 239 | margin: 1.5em 0 0 0; } 240 | 241 | @media (max-width: 769px) and (orientation: landscape) { 242 | .btn, .btn-primary, .btn-default, .btn-secondary, .btn-dark { 243 | font-size: 1.2em; } } 244 | 245 | @media (min-width: 500px) { 246 | .btn, .btn-primary, .btn-default, .btn-secondary, .btn-dark { 247 | font-size: 22px; } } 248 | 249 | @media (min-width: 769px) { 250 | .btn, .btn-primary, .btn-default, .btn-secondary, .btn-dark { 251 | width: auto; 252 | margin: 0 auto; 253 | font-size: 1.2em; } 254 | .btn-inline { 255 | display: inline-block; } 256 | .btn-inline + .btn-inline { 257 | margin: 0 0 0 1em; } } 258 | 259 | @media (min-width: 1025px) { 260 | .btn:hover, .btn-primary:hover, .btn-default:hover, .btn-secondary:hover, .btn-dark:hover { 261 | box-shadow: 0 12px 22px rgba(0, 0, 0, 0.13); } } 262 | 263 | html, 264 | body { 265 | height: 100%; 266 | min-height: 100%; 267 | margin: 0; 268 | padding: 0; } 269 | 270 | body { 271 | font-family: "Rubik", sans-serif; 272 | font-size: 12px; 273 | -webkit-font-smoothing: antialiased; 274 | line-height: 1.4; 275 | color: #50496d; 276 | font-weight: 400; 277 | background: #fff; 278 | overflow-x: hidden; 279 | overflow-y: auto; 280 | -webkit-overflow-scrolling: touch; } 281 | 282 | a { 283 | text-decoration: none; 284 | color: #000; } 285 | 286 | *, 287 | *:before, 288 | *:after { 289 | box-sizing: border-box; 290 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 291 | outline: 0; } 292 | 293 | input, 294 | textarea, 295 | select, 296 | button { 297 | font-family: "Rubik", sans-serif; 298 | -webkit-font-smoothing: antialiased; } 299 | 300 | h1, 301 | h2, 302 | h3 { 303 | font-weight: bold; 304 | line-height: 1.2; } 305 | 306 | h1 { 307 | font-size: 2.6em; } 308 | 309 | h2 { 310 | font-size: 2em; } 311 | 312 | h3 { 313 | font-size: 1.5em; } 314 | 315 | @media (min-width: 769px) { 316 | body { 317 | font-size: 16px; } } 318 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './containers/App'; 5 | import registerServiceWorker from './registerServiceWorker'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | registerServiceWorker(); 9 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | @import 'styles/variables.scss'; 2 | @import 'styles/animations.scss'; 3 | @import 'styles/font.scss'; 4 | @import 'styles/buttons.scss'; 5 | 6 | html, 7 | body { 8 | height: 100%; 9 | min-height: 100%; 10 | margin: 0; 11 | padding: 0; 12 | } 13 | 14 | body { 15 | font-family: $font-family-sans-serif; 16 | font-size: $base-font-size-mobile; 17 | -webkit-font-smoothing: antialiased; 18 | line-height: 1.4; 19 | color: $dark-grey; 20 | font-weight: 400; 21 | 22 | background: #fff; 23 | 24 | overflow-x: hidden; 25 | overflow-y: auto; 26 | -webkit-overflow-scrolling: touch; 27 | } 28 | 29 | a { 30 | text-decoration: none; 31 | color: $black; 32 | } 33 | 34 | *, 35 | *:before, 36 | *:after { 37 | box-sizing: border-box; 38 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 39 | outline: 0; 40 | } 41 | 42 | input, 43 | textarea, 44 | select, 45 | button { 46 | font-family: $font-family-sans-serif; 47 | -webkit-font-smoothing: antialiased; 48 | } 49 | 50 | h1, 51 | h2, 52 | h3 { 53 | font-weight: bold; 54 | line-height: 1.2; 55 | } 56 | 57 | h1 { 58 | font-size: 2.6em; 59 | } 60 | 61 | h2 { 62 | font-size: 2em; 63 | } 64 | 65 | h3 { 66 | font-size: 1.5em; 67 | } 68 | 69 | @media (min-width: $breakpoint-small) { 70 | body { 71 | font-size: $base-font-size; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/reducers.js: -------------------------------------------------------------------------------- 1 | import app from './concepts/app'; 2 | import auth from './concepts/auth'; 3 | import user from './concepts/user'; 4 | import playHistory from './concepts/play-history'; 5 | import topHistory from './concepts/top-history'; 6 | import playlist from './concepts/playlist'; 7 | import playlistPopup from './concepts/playlist-popup'; 8 | 9 | export { app, auth, user, playHistory, topHistory, playlist, playlistPopup }; 10 | -------------------------------------------------------------------------------- /src/registerServiceWorker.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 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ); 20 | 21 | export default function register() { 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/facebookincubator/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. Lets check if a service worker still exists or not. 37 | checkValidServiceWorker(swUrl); 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); 50 | } 51 | }); 52 | } 53 | } 54 | 55 | function registerValidSW(swUrl) { 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 | } else { 70 | // At this point, everything has been precached. 71 | // It's the perfect time to display a 72 | // "Content is cached for offline use." message. 73 | console.log('Content is cached for offline use.'); 74 | } 75 | } 76 | }; 77 | }; 78 | }) 79 | .catch(error => { 80 | console.error('Error during service worker registration:', error); 81 | }); 82 | } 83 | 84 | function checkValidServiceWorker(swUrl) { 85 | // Check if the service worker can be found. If it can't reload the page. 86 | fetch(swUrl) 87 | .then(response => { 88 | // Ensure service worker exists, and that we really are getting a JS file. 89 | if ( 90 | response.status === 404 || 91 | response.headers.get('content-type').indexOf('javascript') === -1 92 | ) { 93 | // No service worker found. Probably a different app. Reload the page. 94 | navigator.serviceWorker.ready.then(registration => { 95 | registration.unregister().then(() => { 96 | window.location.reload(); 97 | }); 98 | }); 99 | } else { 100 | // Service worker found. Proceed as normal. 101 | registerValidSW(swUrl); 102 | } 103 | }) 104 | .catch(() => { 105 | console.log( 106 | 'No internet connection found. App is running in offline mode.' 107 | ); 108 | }); 109 | } 110 | 111 | export function unregister() { 112 | if ('serviceWorker' in navigator) { 113 | navigator.serviceWorker.ready.then(registration => { 114 | registration.unregister(); 115 | }); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/services/api.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | import localStorage from 'local-storage'; 3 | import { get, isNil } from 'lodash'; 4 | import { getErrorActionType } from './axios'; 5 | import history from './history'; 6 | import { authorizeUser } from '../concepts/auth'; 7 | import { getCurrentPathName } from '../concepts/route'; 8 | 9 | const getAccessToken = () => localStorage.get('accessToken'); 10 | 11 | const getAuthHeader = token => { 12 | if (token) { 13 | return { Authorization: `Bearer ${token}` }; 14 | } 15 | 16 | return {}; 17 | }; 18 | 19 | const isUnauthorized = status => status === 401; 20 | const redirectToLogin = () => (dispatch, getState) => { 21 | const accessToken = getAccessToken(); 22 | const state = getState(); 23 | const pathName = getCurrentPathName(state); 24 | 25 | // add current path to local storage 26 | // and redirect to it later after login 27 | localStorage.set('redirectTo', pathName); 28 | 29 | // Automatically login if token exists 30 | // and it is most probably expired 31 | if (!isNil(accessToken)) { 32 | return dispatch(authorizeUser()); 33 | } 34 | 35 | // otherwise redirect to login page 36 | history.replace('/login'); 37 | }; 38 | 39 | // https://github.com/svrcekmichal/redux-axios-middleware#middleware-options 40 | const handleApiError = response => { 41 | const status = get(response, 'error.response.status'); 42 | const { error, action, next, options, dispatch } = response; 43 | 44 | // On Unauthorized Request redirect to /login 45 | if (isUnauthorized(status)) { 46 | return dispatch(redirectToLogin()); 47 | } 48 | 49 | const errorObject = { 50 | text: get(error, 'response.statusText', error.message), 51 | code: get(error, 'response.status'), 52 | }; 53 | 54 | const nextAction = { 55 | type: getErrorActionType(action, options), 56 | error: errorObject, 57 | payload: get(action, 'payload'), 58 | }; 59 | 60 | next(nextAction); 61 | return nextAction; 62 | }; 63 | 64 | export const apiCall = ({ 65 | endpoint, 66 | type, 67 | types, 68 | payload, 69 | method = 'GET', 70 | ...opts 71 | }) => dispatch => { 72 | // Get access token from state 73 | const token = getAccessToken(); 74 | const authHeader = getAuthHeader(token); 75 | 76 | return dispatch({ 77 | type, 78 | types, 79 | payload: { 80 | ...payload, 81 | request: { 82 | url: endpoint, 83 | method, 84 | headers: { 85 | ...authHeader, 86 | }, 87 | ...opts, 88 | }, 89 | options: { 90 | onError: handleApiError, 91 | }, 92 | }, 93 | }); 94 | }; 95 | -------------------------------------------------------------------------------- /src/services/auth.js: -------------------------------------------------------------------------------- 1 | import { last } from 'lodash'; 2 | 3 | export const parseAccessToken = () => { 4 | const url = window.location.href; 5 | const urlParts = url.split('#access_token='); 6 | 7 | return last(urlParts); 8 | }; 9 | 10 | export default parseAccessToken; 11 | -------------------------------------------------------------------------------- /src/services/axios.js: -------------------------------------------------------------------------------- 1 | import axiosMiddleware from 'redux-axios-middleware'; 2 | import axios from 'axios'; 3 | import { last } from 'lodash'; 4 | import config from '../config'; 5 | 6 | export const client = axios.create({ 7 | baseURL: config.API_URL, 8 | responseType: 'json', 9 | headers: { 10 | Accept: 'application/json', 11 | 'Content-Type': 'application/json', 12 | }, 13 | }); 14 | 15 | export const axiosApiMiddleware = axiosMiddleware(client); 16 | 17 | export const getActionTypes = ( 18 | action, 19 | { errorSuffix = '_FAIL', successSuffix = '_SUCCESS' } = {} 20 | ) => { 21 | let types; 22 | if (typeof action.type !== 'undefined') { 23 | const { type } = action; 24 | types = [type, `${type}${successSuffix}`, `${type}${errorSuffix}`]; 25 | } else if (typeof action.types !== 'undefined') { 26 | const { types: _types } = action; 27 | types = _types; 28 | } else { 29 | throw new Error('Action needs to have "type" or "types" key which is not null'); 30 | } 31 | return types; 32 | }; 33 | 34 | export const getErrorActionType = (action, options) => last(getActionTypes(action, options)); 35 | -------------------------------------------------------------------------------- /src/services/change-theme.js: -------------------------------------------------------------------------------- 1 | // Set which changes color of 2 | // URL bar in mobile Chrome 3 | function changeThemeColor(color) { 4 | const themeMetaTag = document.querySelector('meta[name="theme-color"]'); 5 | if (themeMetaTag) { 6 | themeMetaTag.content = color; 7 | } 8 | } 9 | 10 | export default changeThemeColor; 11 | -------------------------------------------------------------------------------- /src/services/history.js: -------------------------------------------------------------------------------- 1 | import createHistory from 'history/createBrowserHistory'; 2 | 3 | const history = createHistory(); 4 | 5 | export default history; 6 | -------------------------------------------------------------------------------- /src/services/playlist-name.js: -------------------------------------------------------------------------------- 1 | // # Playlist name formatting based on playlist type and time range 2 | import moment from 'moment'; 3 | import { get, isNil } from 'lodash'; 4 | 5 | import PlaylistTypes from '../constants/PlaylistTypes'; 6 | import { labels } from '../constants/TimeRanges'; 7 | 8 | const playlistDateFormat = 'MMMM YYYY'; 9 | const playlistPrefix = 'Replay'; 10 | 11 | const addTimeRange = label => { 12 | if (!isNil(label)) { 13 | return `• ${label} `; 14 | } 15 | 16 | return ''; 17 | }; 18 | 19 | export default ({ type, timeRange }) => { 20 | const dateNow = moment(); 21 | const playlistDate = dateNow.format(playlistDateFormat); 22 | 23 | // # Recently Played 24 | if (type === PlaylistTypes.RECENT) { 25 | return `${playlistPrefix} 50 Tracks • ${playlistDate}`; 26 | } 27 | 28 | const timeRangeLabel = get(labels, timeRange); 29 | 30 | // # Top Artists 31 | if (type === PlaylistTypes.ARTIST) { 32 | return `${playlistPrefix} Top-20 Artists ${addTimeRange(timeRangeLabel)}• ${playlistDate}`; 33 | } 34 | 35 | // # Top Tracks 36 | if (type === PlaylistTypes.TRACK) { 37 | return `${playlistPrefix} Top-50 Tracks ${addTimeRange(timeRangeLabel)}• ${playlistDate}`; 38 | } 39 | 40 | return `${playlistPrefix} Playlist - ${playlistDate}`; 41 | }; 42 | -------------------------------------------------------------------------------- /src/services/query-parametrize.js: -------------------------------------------------------------------------------- 1 | import { isObject, isEmpty } from 'lodash'; 2 | 3 | const queryParametrize = (url, query) => { 4 | let queryParametrizedUrl = url; 5 | 6 | if (isObject(query) && !isEmpty(query)) { 7 | queryParametrizedUrl += 8 | '?' + 9 | Object.keys(query) 10 | .map(k => { 11 | return encodeURIComponent(k) + '=' + encodeURIComponent(query[k]); 12 | }) 13 | .join('&'); 14 | } 15 | 16 | return queryParametrizedUrl; 17 | }; 18 | 19 | export default queryParametrize; 20 | -------------------------------------------------------------------------------- /src/services/response.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | import { get } from 'lodash'; 3 | 4 | export const getRequestTarget = action => 5 | get(action, 'payload.config.reduxSourceAction.payload.target') || get(action, 'payload.target'); 6 | -------------------------------------------------------------------------------- /src/styles/animations.css: -------------------------------------------------------------------------------- 1 | @keyframes fade-in { 2 | 0% { 3 | opacity: 0; } 4 | 100% { 5 | opacity: 1; } } 6 | 7 | @keyframes scale-in { 8 | 0% { 9 | transform: scale(0); } 10 | 100% { 11 | transform: scale(1); } } 12 | 13 | @keyframes scaleX-in { 14 | 0% { 15 | transform: scaleX(0); } 16 | 100% { 17 | transform: scaleX(1); } } 18 | 19 | @keyframes scale-to { 20 | 0% { 21 | transform: scale(1.015) translate3d(0, 0, 0); } 22 | 100% { 23 | transform: scale(1) translate3d(0, 0, 0); } } 24 | 25 | @keyframes mic-drop { 26 | 0% { 27 | transform: translate3d(0, -4px, 0); 28 | opacity: 0; } 29 | 100% { 30 | transform: translate3d(0, 0px, 0); 31 | opacity: 1; } } 32 | 33 | @keyframes appear-from-left { 34 | 0% { 35 | transform: translate3d(-100%, 0, 0); } 36 | 100% { 37 | transform: translate3d(0, 0, 0); } } 38 | 39 | @keyframes flash-from-bottom { 40 | 0% { 41 | opacity: 0; 42 | transform: translate3d(0, 100%, 0); } 43 | 100% { 44 | opacity: 1; 45 | transform: translate3d(0, 0, 0); } } 46 | -------------------------------------------------------------------------------- /src/styles/animations.scss: -------------------------------------------------------------------------------- 1 | @keyframes fade-in { 2 | 0% { 3 | opacity: 0; 4 | } 5 | 100% { 6 | opacity: 1; 7 | } 8 | } 9 | 10 | @keyframes scale-in { 11 | 0% { 12 | transform: scale(0); 13 | } 14 | 100% { 15 | transform: scale(1); 16 | } 17 | } 18 | 19 | @keyframes scaleX-in { 20 | 0% { 21 | transform: scaleX(0); 22 | } 23 | 100% { 24 | transform: scaleX(1); 25 | } 26 | } 27 | 28 | @keyframes scale-to { 29 | 0% { 30 | transform: scale(1.015) translate3d(0, 0, 0); 31 | } 32 | 100% { 33 | transform: scale(1) translate3d(0, 0, 0); 34 | } 35 | } 36 | 37 | @keyframes mic-drop { 38 | 0% { 39 | transform: translate3d(0, -4px, 0); 40 | opacity: 0; 41 | } 42 | 100% { 43 | transform: translate3d(0, 0px, 0); 44 | opacity: 1; 45 | } 46 | } 47 | 48 | @keyframes appear-from-left { 49 | 0% { 50 | transform: translate3d(-100%, 0, 0); 51 | } 52 | 100% { 53 | transform: translate3d(0, 0, 0); 54 | } 55 | } 56 | 57 | @keyframes flash-from-bottom { 58 | 0% { 59 | opacity: 0; 60 | transform: translate3d(0, 100%, 0); 61 | } 62 | 100% { 63 | opacity: 1; 64 | transform: translate3d(0, 0, 0); 65 | } 66 | } 67 | 68 | -------------------------------------------------------------------------------- /src/styles/buttons.css: -------------------------------------------------------------------------------- 1 | @keyframes fade-in { 2 | 0% { 3 | opacity: 0; } 4 | 100% { 5 | opacity: 1; } } 6 | 7 | @keyframes scale-in { 8 | 0% { 9 | transform: scale(0); } 10 | 100% { 11 | transform: scale(1); } } 12 | 13 | @keyframes scaleX-in { 14 | 0% { 15 | transform: scaleX(0); } 16 | 100% { 17 | transform: scaleX(1); } } 18 | 19 | @keyframes scale-to { 20 | 0% { 21 | transform: scale(1.015) translate3d(0, 0, 0); } 22 | 100% { 23 | transform: scale(1) translate3d(0, 0, 0); } } 24 | 25 | @keyframes mic-drop { 26 | 0% { 27 | transform: translate3d(0, -4px, 0); 28 | opacity: 0; } 29 | 100% { 30 | transform: translate3d(0, 0px, 0); 31 | opacity: 1; } } 32 | 33 | @keyframes appear-from-left { 34 | 0% { 35 | transform: translate3d(-100%, 0, 0); } 36 | 100% { 37 | transform: translate3d(0, 0, 0); } } 38 | 39 | @keyframes flash-from-bottom { 40 | 0% { 41 | opacity: 0; 42 | transform: translate3d(0, 100%, 0); } 43 | 100% { 44 | opacity: 1; 45 | transform: translate3d(0, 0, 0); } } 46 | 47 | .btn, .btn-primary, .btn-default, .btn-secondary, .btn-dark { 48 | display: block; 49 | width: 100%; 50 | padding: 1em 3em 0.98em; 51 | text-align: center; 52 | font-size: 4vw; 53 | border-width: 0px; 54 | border-style: solid; 55 | border-radius: 50px; 56 | white-space: nowrap; 57 | font-weight: bold; 58 | cursor: pointer; 59 | box-shadow: 0 10px 20px rgba(0, 0, 0, 0.075); 60 | transition: all 0.15s; 61 | user-select: none; } 62 | .btn:active, .btn-primary:active, .btn-default:active, .btn-secondary:active, .btn-dark:active { 63 | box-shadow: 0 12px 22px rgba(0, 0, 0, 0.1); } 64 | 65 | .btn-primary { 66 | color: #f9adac; 67 | background-color: #fff; 68 | border-color: #fff; } 69 | .btn-primary:focus { 70 | color: #f9adac; 71 | background-color: #f7f7f7; 72 | border-color: #f7f7f7; } 73 | .btn-primary:hover { 74 | color: #f9adac; 75 | background-color: #f7f7f7; 76 | border-color: #f7f7f7; } 77 | .btn-primary:active { 78 | color: #f9adac; 79 | background-color: #f0f0f0; 80 | border-color: #f0f0f0; } 81 | .btn-primary:active:hover, .btn-primary:active:focus { 82 | color: #f9adac; 83 | background-color: #f0f0f0; 84 | border-color: #f0f0f0; } 85 | .btn-primary:active { 86 | background-image: none; } 87 | .btn-primary[disabled]:hover, .btn-primary[disabled]:focus, .btn-primary[disabled].focus { 88 | background-color: #fff; 89 | border-color: #fff; } 90 | .btn-primary:hover, .btn-primary:focus, .btn-primary:active, .btn-primary:active:hover, .btn-primary:active:focus { 91 | background-color: #fff; } 92 | .btn-primary:active { 93 | color: #f7918f; } 94 | 95 | .btn-default { 96 | color: #50496d; 97 | background-color: #fff; 98 | border-color: #fff; } 99 | .btn-default:focus { 100 | color: #50496d; 101 | background-color: #f7f7f7; 102 | border-color: #f7f7f7; } 103 | .btn-default:hover { 104 | color: #50496d; 105 | background-color: #f7f7f7; 106 | border-color: #f7f7f7; } 107 | .btn-default:active { 108 | color: #50496d; 109 | background-color: #f0f0f0; 110 | border-color: #f0f0f0; } 111 | .btn-default:active:hover, .btn-default:active:focus { 112 | color: #50496d; 113 | background-color: #f0f0f0; 114 | border-color: #f0f0f0; } 115 | .btn-default:active { 116 | background-image: none; } 117 | .btn-default[disabled]:hover, .btn-default[disabled]:focus, .btn-default[disabled].focus { 118 | background-color: #fff; 119 | border-color: #fff; } 120 | .btn-default:hover, .btn-default:focus, .btn-default:active, .btn-default:active:hover, .btn-default:active:focus { 121 | background-color: #fff; } 122 | .btn-default:active { 123 | color: #433d5b; } 124 | 125 | .btn-secondary { 126 | color: #fff; 127 | background-color: #9ee2d9; 128 | border-color: #9ee2d9; } 129 | .btn-secondary:focus { 130 | color: #fff; 131 | background-color: #92ded4; 132 | border-color: #92ded4; } 133 | .btn-secondary:hover { 134 | color: #fff; 135 | background-color: #92ded4; 136 | border-color: #92ded4; } 137 | .btn-secondary:active { 138 | color: #fff; 139 | background-color: #86dbd0; 140 | border-color: #86dbd0; } 141 | .btn-secondary:active:hover, .btn-secondary:active:focus { 142 | color: #fff; 143 | background-color: #86dbd0; 144 | border-color: #86dbd0; } 145 | .btn-secondary:active { 146 | background-image: none; } 147 | .btn-secondary[disabled]:hover, .btn-secondary[disabled]:focus, .btn-secondary[disabled].focus { 148 | background-color: #9ee2d9; 149 | border-color: #9ee2d9; } 150 | 151 | .btn-dark { 152 | color: #fff; 153 | background-color: #50496d; 154 | border-color: #50496d; } 155 | .btn-dark:focus { 156 | color: #fff; 157 | background-color: #494364; 158 | border-color: #494364; } 159 | .btn-dark:hover { 160 | color: #fff; 161 | background-color: #494364; 162 | border-color: #494364; } 163 | .btn-dark:active { 164 | color: #fff; 165 | background-color: #433d5b; 166 | border-color: #433d5b; } 167 | .btn-dark:active:hover, .btn-dark:active:focus { 168 | color: #fff; 169 | background-color: #433d5b; 170 | border-color: #433d5b; } 171 | .btn-dark:active { 172 | background-image: none; } 173 | .btn-dark[disabled]:hover, .btn-dark[disabled]:focus, .btn-dark[disabled].focus { 174 | background-color: #50496d; 175 | border-color: #50496d; } 176 | 177 | .btn-link { 178 | font-size: 1.1em; 179 | display: block; 180 | width: 100%; 181 | padding: 1em 0em; 182 | text-align: left; 183 | border: none; 184 | color: #fff; 185 | text-decoration: underline; 186 | background: transparent; 187 | font-weight: bold; 188 | margin-top: 1em; } 189 | 190 | .btn-inline + .btn-inline { 191 | margin: 1.5em 0 0 0; } 192 | 193 | @media (max-width: 769px) and (orientation: landscape) { 194 | .btn, .btn-primary, .btn-default, .btn-secondary, .btn-dark { 195 | font-size: 1.2em; } } 196 | 197 | @media (min-width: 500px) { 198 | .btn, .btn-primary, .btn-default, .btn-secondary, .btn-dark { 199 | font-size: 22px; } } 200 | 201 | @media (min-width: 769px) { 202 | .btn, .btn-primary, .btn-default, .btn-secondary, .btn-dark { 203 | width: auto; 204 | margin: 0 auto; 205 | font-size: 1.2em; } 206 | .btn-inline { 207 | display: inline-block; } 208 | .btn-inline + .btn-inline { 209 | margin: 0 0 0 1em; } } 210 | 211 | @media (min-width: 1025px) { 212 | .btn:hover, .btn-primary:hover, .btn-default:hover, .btn-secondary:hover, .btn-dark:hover { 213 | box-shadow: 0 12px 22px rgba(0, 0, 0, 0.13); } } 214 | -------------------------------------------------------------------------------- /src/styles/buttons.scss: -------------------------------------------------------------------------------- 1 | @import './variables.scss'; 2 | @import './animations.scss'; 3 | 4 | @mixin button-theme($color, $background, $border) { 5 | @extend .btn; 6 | color: $color; 7 | background-color: $background; 8 | border-color: $border; 9 | 10 | &:focus { 11 | color: $color; 12 | background-color: darken($background, 3%); 13 | border-color: darken($border, 3%); 14 | } 15 | &:hover { 16 | color: $color; 17 | background-color: darken($background, 3%); 18 | border-color: darken($border, 3%); 19 | } 20 | &:active { 21 | color: $color; 22 | background-color: darken($background, 6%); 23 | border-color: darken($border, 6%); 24 | 25 | &:hover, 26 | &:focus { 27 | color: $color; 28 | background-color: darken($background, 6%); 29 | border-color: darken($border, 6%); 30 | } 31 | } 32 | &:active { 33 | background-image: none; 34 | } 35 | &[disabled] { 36 | &:hover, 37 | &:focus, 38 | &.focus { 39 | background-color: $background; 40 | border-color: $border; 41 | } 42 | } 43 | } 44 | 45 | @mixin white-bg-button($color) { 46 | &:hover, 47 | &:focus, 48 | &:active, 49 | &:active:hover, 50 | &:active:focus { 51 | background-color: #fff; 52 | } 53 | &:active { 54 | color: darken($color, 6%); 55 | } 56 | } 57 | 58 | .btn { 59 | display: block; 60 | width: 100%; 61 | padding: 1em 3em 0.98em; 62 | text-align: center; 63 | font-size: 4vw; 64 | border-width: 0px; 65 | border-style: solid; 66 | border-radius: 50px; 67 | white-space: nowrap; 68 | 69 | font-weight: bold; 70 | cursor: pointer; 71 | 72 | box-shadow: 0 10px 20px rgba(0, 0, 0, 0.075); 73 | transition: all 0.15s; 74 | user-select: none; 75 | 76 | &:active { 77 | box-shadow: 0 12px 22px rgba(0, 0, 0, 0.1); 78 | } 79 | } 80 | 81 | .btn-primary { 82 | @include button-theme($brand-pink, #fff, #fff); 83 | @include white-bg-button($brand-pink); 84 | } 85 | 86 | .btn-default { 87 | @include button-theme($dark-grey, #fff, #fff); 88 | @include white-bg-button($dark-grey); 89 | } 90 | 91 | .btn-secondary { 92 | @include button-theme(#fff, $brand-green, $brand-green); 93 | } 94 | 95 | .btn-dark { 96 | @include button-theme(#fff, $dark-grey, $dark-grey); 97 | } 98 | 99 | .btn-link { 100 | font-size: 1.1em; 101 | display: block; 102 | width: 100%; 103 | padding: 1em 0em; 104 | text-align: left; 105 | border: none; 106 | color: #fff; 107 | text-decoration: underline; 108 | 109 | background: transparent; 110 | font-weight: bold; 111 | 112 | margin-top: 1em; 113 | } 114 | 115 | .btn-inline { 116 | & + & { 117 | margin: 1.5em 0 0 0; 118 | } 119 | } 120 | 121 | @media (max-width: $breakpoint-small) and (orientation: landscape) { 122 | .btn { 123 | font-size: 1.2em; 124 | } 125 | } 126 | 127 | @media (min-width: 500px) { 128 | .btn { 129 | font-size: 22px; 130 | } 131 | } 132 | 133 | @media (min-width: $breakpoint-small) { 134 | .btn { 135 | width: auto; 136 | margin: 0 auto; 137 | font-size: 1.2em; 138 | } 139 | 140 | .btn-inline { 141 | display: inline-block; 142 | & + & { 143 | margin: 0 0 0 1em; 144 | } 145 | } 146 | } 147 | 148 | @media (min-width: $breakpoint-medium) { 149 | .btn { 150 | &:hover { 151 | box-shadow: 0 12px 22px rgba(0, 0, 0, 0.13); 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/styles/font.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css?family=Rubik:300,400,500,700,900,900i"); 2 | @import url("https://code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css"); 3 | -------------------------------------------------------------------------------- /src/styles/font.scss: -------------------------------------------------------------------------------- 1 | // Rubik 2 | @import url('https://fonts.googleapis.com/css?family=Rubik:300,400,500,700,900,900i'); 3 | 4 | // Icon font 5 | @import url('https://code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css'); 6 | -------------------------------------------------------------------------------- /src/styles/variables.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palampinen/replayify/01eaebc825936553aa83c722dc07eb6a7a7b98fe/src/styles/variables.css -------------------------------------------------------------------------------- /src/styles/variables.scss: -------------------------------------------------------------------------------- 1 | // # Typography 2 | $font-family-sans-serif: 'Rubik', sans-serif; 3 | $base-font-size: 16px; 4 | $base-font-size-mobile: 12px; 5 | 6 | // # Colors 7 | $dark-grey: #50496d; 8 | $data-grey: #555; 9 | $mid-grey: lighten($dark-grey, 35%); 10 | $grey: #d2d2d2; 11 | $light-grey: #fafafa; 12 | $bg-grey: #f9f9f9; 13 | $black: #000; 14 | 15 | $brand-primary: #e550a7; 16 | $brand-dark: darken($dark-grey, 10%); 17 | $brand-success: green; 18 | $brand-alert: yellow; 19 | $brand-danger: red; 20 | 21 | $brand-pink: #f9adac; 22 | $brand-green: #9ee2d9; 23 | $brand-blue: #5d42e5; 24 | 25 | // # Element Sizes 26 | $header-height: 60px; 27 | $header-height-mobile: 52px; 28 | $header-top-extra: 60px; 29 | $container-width: 1200px; 30 | 31 | $navigation-size: 100px; 32 | $navigation-size-mobile: 54px; 33 | 34 | // # Button 35 | $button-border-radius: 0px; 36 | 37 | // Breakpoints 38 | $breakpoint-small: 769px; 39 | $breakpoint-medium: 1025px; 40 | $breakpoint-large: 1400px; 41 | 42 | // # Box-shadows 43 | $box-shadow-1: 0 2px 6px 0 rgba(0, 0, 0, 0.15); 44 | 45 | // # Animations 46 | $animation-max-items: 50; 47 | $animation-initial-delay: 100ms; 48 | $cubic-bezier: cubic-bezier(0.87, 0.38, 0.27, 0.95); 49 | --------------------------------------------------------------------------------