├── .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 | 
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 |
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 |
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 |
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 |
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 |
46 | Create Playlist
47 |
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 |
21 |
{title}
22 |
25 |
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 |
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 | onSelect(option)}
12 | className={classnames('time-option', { 'time--active': option === selected })}
13 | >
14 | {labels[option]}
15 |
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 =>
)}
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 |
23 |
24 |
25 |
Replay your Spotify Hits
26 |
27 | Sign in with Spotify
28 |
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 |
30 |
31 |
32 |
33 |
34 | On a good day (Metropolis)
35 | Above & Beyond
36 |
37 |
38 |
{
40 | this.musicPlayer = input;
41 | }}
42 | loop
43 | >
44 |
48 |
49 |
50 |
51 |
52 |
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 |
29 |
30 | )}
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
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 |
--------------------------------------------------------------------------------