├── .gitignore
├── LICENSE
├── README.md
├── jsconfig.json
├── package-lock.json
├── package.json
├── public
├── favicon.ico
├── index.html
└── manifest.json
├── screenshots
├── 1.gif
├── 2.gif
├── 3.gif
├── 4.gif
└── 5.gif
└── src
├── App.js
├── App.scss
├── actions
├── AlbumActions.js
├── ArtistActions.js
├── CategoryActions.js
├── ChartActions.js
├── PlayerActions.js
├── PlaylistActions.js
├── SearchAction.js
└── TrackActions.js
├── components
├── Common
│ ├── Alert
│ │ ├── Alert.js
│ │ ├── AlertManager.js
│ │ └── AlertMessage.js
│ ├── Artist.js
│ ├── AuthorList.js
│ ├── Block.js
│ ├── BlockHeader.js
│ ├── Carousel.js
│ ├── Category.js
│ ├── Columns.js
│ ├── ContextMenu.js
│ ├── ContextMenuItems.js
│ ├── Empty.js
│ ├── InfiniteScroll.js
│ ├── LazyLoad.js
│ ├── LikeButton.js
│ ├── NewPlaylistBlock.js
│ ├── OpenContextMenuButton.js
│ ├── Track
│ │ ├── Track.js
│ │ ├── TrackCoverImage.js
│ │ ├── TrackInfo.js
│ │ ├── buttons
│ │ │ ├── MoreButton.js
│ │ │ ├── PauseButton.js
│ │ │ └── PlayButton.js
│ │ └── contextMenu
│ │ │ ├── ContextMenuOptions.js
│ │ │ └── ContextMenuPlaylists.js
│ └── Tracks.js
├── LoginCallback.js
├── Main.js
├── Navbar.js
├── Player
│ ├── Player.js
│ ├── PlayerProgressBar.js
│ ├── PlayerTrackInfo.js
│ ├── PlayerVolumeControl.js
│ ├── buttons
│ │ ├── NextButton.js
│ │ ├── PlayButton.js
│ │ ├── PrevButton.js
│ │ └── RepeatButton.js
│ └── sliders
│ │ ├── PlayerProgressBarSlider.js
│ │ └── PlayerVolumeSlider.js
├── Search
│ ├── Search.js
│ ├── SearchBtn.js
│ ├── SearchInput.js
│ └── SearchResults.js
├── Skeleton
│ ├── SkeletonArtists.js
│ ├── SkeletonBlockHeader.js
│ ├── SkeletonBlocks.js
│ ├── SkeletonCategories.js
│ └── SkeletonTracks.js
└── __tests__
│ ├── Artist.test.js
│ ├── AuthorList.test.js
│ ├── Block.test.js
│ ├── Columns.test.js
│ ├── ContextMenu.test.js
│ ├── ContextMenuItems.test.js
│ ├── InfiniteScroll.test.js
│ ├── LikeButton.test.js
│ ├── OpenContextMenuButton.test.js
│ ├── Player.test.js
│ ├── Search.test.js
│ ├── Track.test.js
│ ├── Tracks.test.js
│ └── __snapshots__
│ ├── Artist.test.js.snap
│ ├── AuthorList.test.js.snap
│ ├── Block.test.js.snap
│ ├── Columns.test.js.snap
│ ├── ContextMenuItems.test.js.snap
│ ├── OpenContextMenuButton.test.js.snap
│ └── Search.test.js.snap
├── constants
├── ActionConstants.js
├── AppConstants.js
├── PlaylistIds.js
└── RouteConstants.js
├── containers
├── Album
│ ├── AlbumContainer.js
│ ├── ArtistAlbumsContainer.js
│ ├── ArtistSinglesContainer.js
│ └── NewReleasesContainer.js
├── Artist
│ ├── ArtistHeaderContainer.js
│ ├── FollowedArtistsContainer.js
│ └── RelatedArtistsContainer.js
├── CategoriesContainer.js
├── ChartsContainer.js
├── PlayerContainer.js
├── Playlist
│ ├── CategoryPlaylistsContainer.js
│ ├── FeaturedPlaylistsContainer.js
│ ├── ListUserPlaylistsContainer.js
│ ├── PlaylistContainer.js
│ └── UserPlaylistsContainer.js
├── ProgressBarContainer.js
├── SearchResultsContainer.js
└── Tracks
│ ├── ArtistTopTracksContainer.js
│ ├── SavedTracksContainer.js
│ └── TopTracksContainer.js
├── images
├── Navbar
│ ├── Heart.svg
│ ├── Home.svg
│ ├── Label.svg
│ ├── Layers.svg
│ ├── LightBulb.svg
│ ├── Lightning.svg
│ └── User.svg
├── Player
│ ├── pause.svg
│ ├── play-next.svg
│ ├── play-previous.svg
│ └── play.svg
├── addPlaylist.svg
├── cd.png
├── clef.svg
├── loader.svg
├── music-note.svg
├── musician.png
└── spotify-logo.png
├── index.js
├── index.scss
├── pages
├── Album.js
├── Artist
│ ├── Artist.js
│ ├── ArtistAlbums.js
│ ├── ArtistHeader.js
│ ├── ArtistSingles.js
│ ├── ArtistTopTracks.js
│ └── RelatedArtists.js
├── ArtistAlbums.js
├── ArtistSingles.js
├── Categories.js
├── CategoryPlaylists.js
├── Charts
│ ├── ChartTracks.js
│ └── Charts.js
├── FollowedArtists.js
├── HomePage
│ ├── Categories.js
│ ├── CategoryPlaylists.js
│ ├── FeaturedPlaylists.js
│ ├── Home.js
│ ├── NewReleases.js
│ ├── RelatedArtists.js
│ ├── TopTracks.js
│ └── UserPlaylists.js
├── NewReleases.js
├── Page404.js
├── Playlist.js
├── SavedTracks.js
├── Tracklist
│ ├── TracklistEditableImage.js
│ ├── TracklistEditableName.js
│ ├── TracklistImage.js
│ ├── TracklistModal.js
│ ├── TracklistMoreButton.js
│ ├── TracklistName.js
│ ├── TracklistPlayButton.js
│ └── checkboxes
│ │ ├── CollaborativeCheckbox.js
│ │ └── PublicCheckbox.js
└── UserPlaylists.js
├── reducers
├── AlbumReducer.js
├── ArtistReducer.js
├── CategoriesReducer.js
├── ChartsReducer.js
├── PlayerReducer.js
├── PlaylistReducer.js
├── SearchReducer.js
├── TrackReducer.js
└── index.js
├── serviceWorker.js
├── setupTests.js
├── store
└── configureStore.js
├── styles
├── Artist.scss
├── Block.scss
├── Category.scss
├── Navbar.scss
├── Player.scss
├── Search.scss
├── Select.scss
├── Skeleton.scss
├── Tracklist.scss
├── Tracks.scss
├── _global.scss
├── _modularscale.scss
├── _normalize.css
├── _variables.scss
└── modularscale
│ ├── _function.scss
│ ├── _pow.scss
│ ├── _respond.scss
│ ├── _round-px.scss
│ ├── _settings.scss
│ ├── _sort.scss
│ ├── _strip-units.scss
│ ├── _sugar.scss
│ ├── _target.scss
│ └── _vars.scss
└── utils
├── auth.js
├── changeNumFormat.js
├── getCoords.js
├── getCountryList.js
├── hasTokenExpired.js
├── intersectionObserver.js
├── makeActionCreator.js
├── msToTime.js
├── playerAPI.js
├── spotifyApi.js
├── toggleLike.js
└── transformResponse.js
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Andrew
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React-Spotify
2 | Music app, built with React js
3 |
4 | ## Features
5 | - Single-Page Application
6 | - Music Player
7 | - Infinite Scroll
8 | - Live Search
9 | - Lazy Loading Images
10 | - Easy Playlist Customization
11 | - Easy to Create or Delete Playlists
12 | - Skeleton Loader
13 | - Filter Top Songs by Country
14 | - Easy to Add Song to Playlist
15 | - Responsive Design
16 |
17 | ## Screenshots
18 |
19 | ### №1
20 | 
21 |
22 |
23 |
24 |
25 |
26 | ### №2
27 | 
28 |
29 |
30 |
31 |
32 |
33 | ### №3
34 | 
35 |
36 |
37 |
38 |
39 |
40 | ### №4
41 | 
42 |
43 |
44 |
45 |
46 |
47 | ### №5
48 | 
49 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "src"
4 | },
5 | "include": ["src"]
6 | }
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrepv/spotify-react/3acfaf864b3a7c2ab7891140d877d237cc5efee6/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
14 | Spotify-React
15 |
16 |
17 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/screenshots/1.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrepv/spotify-react/3acfaf864b3a7c2ab7891140d877d237cc5efee6/screenshots/1.gif
--------------------------------------------------------------------------------
/screenshots/2.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrepv/spotify-react/3acfaf864b3a7c2ab7891140d877d237cc5efee6/screenshots/2.gif
--------------------------------------------------------------------------------
/screenshots/3.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrepv/spotify-react/3acfaf864b3a7c2ab7891140d877d237cc5efee6/screenshots/3.gif
--------------------------------------------------------------------------------
/screenshots/4.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrepv/spotify-react/3acfaf864b3a7c2ab7891140d877d237cc5efee6/screenshots/4.gif
--------------------------------------------------------------------------------
/screenshots/5.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrepv/spotify-react/3acfaf864b3a7c2ab7891140d877d237cc5efee6/screenshots/5.gif
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import { Route } from "react-router-dom";
3 |
4 | import * as RouteConstant from "constants/RouteConstants";
5 | import LoginCallback from "components/LoginCallback";
6 | import Navbar from "components/Navbar";
7 | import Player from "components/Player/Player";
8 | import Search from "components/Search/Search";
9 | import Main from "components/Main";
10 | import Auth from "utils/auth";
11 | import "./App.scss";
12 |
13 | export default class App extends Component {
14 | constructor() {
15 | super();
16 | Auth.setTokenToSpotify();
17 | }
18 |
19 | render() {
20 | return (
21 |
22 |
23 |
24 |
25 |
26 |
27 |
31 |
32 |
33 |
34 |
35 | );
36 | }
37 | }
--------------------------------------------------------------------------------
/src/App.scss:
--------------------------------------------------------------------------------
1 | @import "./styles/variables.scss";
2 | @import "./styles/global.scss";
3 |
4 | .app {
5 | &__container {
6 | display: flex;
7 | }
8 | &__navbar {
9 | width: 20.3%;
10 | padding: 40px 35px;
11 | border-right: 1px solid $border-color;
12 | &_mobile {
13 | position: fixed;
14 | right: -1000%;
15 | &_open {
16 | width: 100%;
17 | position: fixed;
18 | z-index: 999;
19 | background: $bg-primary;
20 | height: 100%;
21 | }
22 | }
23 | }
24 | &__content {
25 | width: 79.7%;
26 | padding: 40px 80px 140px;
27 | @media (max-width: 1024px) {
28 | width: 100%;
29 | }
30 | @media (max-width: 900px) {
31 | padding: 40px 50px 140px;
32 | }
33 | @media (max-width: 450px) {
34 | padding: 40px 30px 140px;
35 | }
36 | }
37 | }
--------------------------------------------------------------------------------
/src/actions/CategoryActions.js:
--------------------------------------------------------------------------------
1 | import { CATEGORIES } from "constants/ActionConstants";
2 | import { CATEGORIES_LIMIT, MESSAGES } from "constants/AppConstants";
3 | import spotifyQuery from "utils/spotifyApi";
4 | import makeActionCreator from "utils/makeActionCreator";
5 | import transformResponse from "utils/transformResponse";
6 | import alert from "components/Common/Alert/Alert";
7 |
8 | export function loadCategories() {
9 | return async dispatch => {
10 | const pending = makeActionCreator(CATEGORIES.PENDING);
11 | const success = makeActionCreator(CATEGORIES.SUCCESS, "payload");
12 | const error = makeActionCreator(CATEGORIES.ERROR);
13 | try {
14 | dispatch(pending());
15 | const categories = await spotifyQuery(
16 | "getCategories",
17 | [{limit: CATEGORIES_LIMIT}]
18 | );
19 | dispatch(
20 | success(transformResponse.categories(categories))
21 | );
22 | } catch (e) {
23 | console.error(e);
24 | if (e === MESSAGES.TOKEN_HAS_EXPIRED) {
25 | return;
26 | }
27 | dispatch(error());
28 | }
29 | };
30 | }
31 |
32 | export function loadMoreCategories(offset) {
33 | return async dispatch => {
34 | const pending = makeActionCreator(CATEGORIES.LOAD_MORE_PENDING);
35 | const success = makeActionCreator(CATEGORIES.LOAD_MORE_SUCCESS, "payload");
36 | const error = makeActionCreator(CATEGORIES.LOAD_MORE_ERROR);
37 | try {
38 | dispatch(pending());
39 | const categories = await spotifyQuery(
40 | "getCategories",
41 | [{limit: CATEGORIES_LIMIT, offset}]
42 | );
43 | dispatch(
44 | success(transformResponse.categories(categories))
45 | );
46 | } catch (e) {
47 | console.error(e);
48 | dispatch(error());
49 | alert.show(MESSAGES.ERROR);
50 | }
51 | };
52 | }
--------------------------------------------------------------------------------
/src/actions/ChartActions.js:
--------------------------------------------------------------------------------
1 | import makeActionCreator from "utils/makeActionCreator";
2 | import { loadTopTracks } from "actions/TrackActions";
3 | import { FILTER_BY_COUNTRY } from "constants/ActionConstants";
4 |
5 | export function filterByCountry(data) {
6 | return dispatch => {
7 | const filter = makeActionCreator(FILTER_BY_COUNTRY, "payload");
8 | dispatch(filter(data));
9 | dispatch(loadTopTracks([data.playlistId]));
10 | };
11 | }
--------------------------------------------------------------------------------
/src/actions/PlayerActions.js:
--------------------------------------------------------------------------------
1 | import { PLAYER } from "constants/ActionConstants";
2 | import makeActionCreator from "utils/makeActionCreator";
3 |
4 | export const playTrack = makeActionCreator(PLAYER.PLAY, "payload");
5 | export const pauseTrack = makeActionCreator(PLAYER.PAUSE);
6 | export const resumeTrack = makeActionCreator(PLAYER.RESUME);
7 | export const setContext = makeActionCreator(PLAYER.SET_CONTEXT, "payload");
8 | export const toggleReapeat = makeActionCreator(PLAYER.TOGGLE_REPEAT);
9 | export const updateContext = makeActionCreator(
10 | PLAYER.UPDATE_CONTEXT,
11 | "payload"
12 | );
13 | export const changeCurrentTime = makeActionCreator(
14 | PLAYER.CHANGE_CURRENT_TIME,
15 | "payload"
16 | );
--------------------------------------------------------------------------------
/src/actions/SearchAction.js:
--------------------------------------------------------------------------------
1 | import {
2 | FILTER_BY_TYPE,
3 | SEARCH_RESULTS,
4 | RESET_SEARCH_RESULTS,
5 | TOGGLE_SEARCH,
6 | } from "constants/ActionConstants";
7 | import spotifyQuery from "utils/spotifyApi";
8 | import makeActionCreator from "utils/makeActionCreator";
9 | import transformResponse from "utils/transformResponse";
10 |
11 | export const resetSearchResults = makeActionCreator(RESET_SEARCH_RESULTS);
12 | export const toggleSearch = makeActionCreator(TOGGLE_SEARCH);
13 | export const filterByType = makeActionCreator(FILTER_BY_TYPE, "payload");
14 |
15 | export function loadSearchResults(query, type) {
16 | return async dispatch => {
17 | if (!query) {
18 | dispatch(resetSearchResults());
19 | return;
20 | }
21 | const pending = makeActionCreator(SEARCH_RESULTS.PENDING);
22 | const success = makeActionCreator(SEARCH_RESULTS.SUCCESS, "payload");
23 | const error = makeActionCreator(SEARCH_RESULTS.ERROR);
24 | try {
25 | dispatch(pending());
26 | const response = await spotifyQuery("search", [query, [type]]);
27 | let modifiedResponse;
28 | switch (type) {
29 | case "artist":
30 | modifiedResponse = transformResponse.artists(response.artists.items);
31 | break;
32 | case "album":
33 | case "playlist":
34 | modifiedResponse = type === "album"
35 | ? transformResponse.albums(response.albums.items)
36 | : transformResponse.playlists(response.playlists.items);
37 | break;
38 | case "track":
39 | const tracks = response.tracks.items;
40 | const trackIds = tracks.map(track => track.id);
41 | const savedTracks = await spotifyQuery(
42 | "containsMySavedTracks",
43 | [trackIds]
44 | );
45 | modifiedResponse = transformResponse.tracks(tracks, savedTracks);
46 | break;
47 | // no default
48 | }
49 | setTimeout(() => {
50 | dispatch(success(modifiedResponse));
51 | }, 800);
52 | } catch (e) {
53 | console.error(e);
54 | dispatch(error());
55 | }
56 | };
57 | }
--------------------------------------------------------------------------------
/src/components/Common/Alert/Alert.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import AlertManager from "./AlertManager";
4 |
5 | class Alert {
6 | constructor() {
7 | let alertContainer;
8 | const existingAlertContainer = document.getElementById("alert");
9 | if (existingAlertContainer) {
10 | alertContainer = existingAlertContainer;
11 | } else {
12 | const container = document.createElement("div");
13 | container.id = "alert";
14 | container.className = "alert__container";
15 | document.body.appendChild(container);
16 | alertContainer = container;
17 | }
18 |
19 | ReactDOM.render(
20 | this.createAlert = fn} />,
21 | alertContainer
22 | );
23 | }
24 |
25 | show = message => {
26 | if (this.createAlert) {
27 | this.createAlert(message);
28 | }
29 | }
30 | }
31 |
32 | const alert = new Alert();
33 | export default alert;
--------------------------------------------------------------------------------
/src/components/Common/Alert/AlertManager.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import { CSSTransition, TransitionGroup } from "react-transition-group";
3 | import PropTypes from "prop-types";
4 |
5 | import AlertMessage from "./AlertMessage";
6 |
7 | export default class AlertManager extends Component {
8 | static idCounter = 0;
9 |
10 | constructor(props) {
11 | super(props);
12 | this.state = {
13 | items: [],
14 | };
15 | props.show(this.createAlert);
16 | }
17 |
18 | createAlert = message => {
19 | const id = ++AlertManager.idCounter;
20 | this.setState({
21 | items: [
22 | ...this.state.items,
23 | {id, message},
24 | ],
25 | });
26 | }
27 |
28 | removeAlert = id => {
29 | this.setState({
30 | items: this.state.items.filter(item => {
31 | return item.id !== id;
32 | }),
33 | });
34 | }
35 |
36 | render() {
37 | return (
38 |
39 | {this.state.items.map(item => {
40 | return (
41 |
46 |
53 |
54 | );
55 | })}
56 |
57 | );
58 | }
59 | }
60 |
61 | AlertManager.propTypes = {
62 | show: PropTypes.func.isRequired,
63 | };
--------------------------------------------------------------------------------
/src/components/Common/Alert/AlertMessage.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import { X } from "react-feather";
3 | import PropTypes from "prop-types";
4 |
5 | export default class AlertMessage extends Component {
6 | componentDidMount() {
7 | setTimeout(this.remove, 3000);
8 | }
9 |
10 | remove = () => {
11 | this.props.remove(this.props.id);
12 | }
13 |
14 | render() {
15 | const {message} = this.props;
16 | return (
17 |
18 | {message}
19 |
23 |
24 |
25 |
26 | );
27 | }
28 | }
29 |
30 | AlertMessage.propTypes = {
31 | id: PropTypes.number.isRequired,
32 | message: PropTypes.string,
33 | remove: PropTypes.func.isRequired,
34 | };
--------------------------------------------------------------------------------
/src/components/Common/Artist.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 | import { Link } from "react-router-dom";
4 |
5 | import musician from "images/musician.png";
6 | import { ARTIST } from "constants/RouteConstants";
7 | import LazyLoad from "components/Common/LazyLoad";
8 | import "styles/Artist.scss";
9 |
10 | export default class Artist extends Component {
11 | shouldComponentUpdate(nextProps) {
12 | return this.props.id !== nextProps.id;
13 | }
14 |
15 | render() {
16 | const {image, id, handler, name} = this.props;
17 | return (
18 |
19 |
20 |
26 |
27 |
31 |
32 | {name}
33 |
34 |
35 |
36 | );
37 | }
38 | }
39 |
40 | Artist.propTypes = {
41 | id: PropTypes.string.isRequired,
42 | image: PropTypes.string,
43 | name: PropTypes.string.isRequired,
44 | handler: PropTypes.func,
45 | };
--------------------------------------------------------------------------------
/src/components/Common/AuthorList.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import { Link } from "react-router-dom";
3 | import PropTypes from "prop-types";
4 |
5 | import { ARTIST } from "constants/RouteConstants";
6 |
7 | export default class AuthorList extends Component {
8 | shouldComponentUpdate(nextProps) {
9 | return this.props.authors !== nextProps.authors;
10 | }
11 |
12 | render() {
13 | const {authors, handler} = this.props;
14 | return authors.map((author, index) => {
15 | return (
16 |
17 |
18 |
19 | {author.name}
20 |
21 |
22 | {index + 1 !== authors.length && ", "}
23 |
24 | );
25 | });
26 | }
27 | }
28 |
29 | AuthorList.propTypes = {
30 | authors: PropTypes.array.isRequired,
31 | handler: PropTypes.func,
32 | };
--------------------------------------------------------------------------------
/src/components/Common/Block.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 | import { Link } from "react-router-dom";
4 |
5 | import cd from "images/cd.png";
6 | import AuthorList from "components/Common/AuthorList";
7 | import LazyLoad from "components/Common/LazyLoad";
8 | import "styles/Block.scss";
9 |
10 | export default class Block extends Component {
11 | shouldComponentUpdate(nextProps) {
12 | return (
13 | this.props.id !== nextProps.id ||
14 | this.props.name !== nextProps.name ||
15 | this.props.image !== nextProps.image ||
16 | this.props.meta !== nextProps.meta
17 | );
18 | }
19 |
20 | render() {
21 | const {name = "", id, image, meta, type, handler} = this.props;
22 | return (
23 |
24 |
34 |
35 |
39 |
43 | {name}
44 |
45 |
46 |
47 | {
48 | type === "album"
49 | ?
50 | : meta
51 | }
52 |
53 |
54 |
55 | );
56 | }
57 | }
58 |
59 | Block.propTypes = {
60 | image: PropTypes.string,
61 | name: PropTypes.string.isRequired,
62 | meta: PropTypes.oneOfType([
63 | PropTypes.string,
64 | PropTypes.array,
65 | ]).isRequired,
66 | type: PropTypes.string.isRequired,
67 | id: PropTypes.string.isRequired,
68 | handler: PropTypes.func,
69 | };
--------------------------------------------------------------------------------
/src/components/Common/BlockHeader.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 | import { ArrowRight } from "react-feather";
4 | import { Link } from "react-router-dom";
5 |
6 | export default function BlockHeader(props) {
7 | const {title = "", description = "", link, button = ""} = props;
8 | return (
9 |
10 |
11 |
{description}
12 |
{title}
13 |
14 | {link &&
15 |
16 | see all
17 |
18 |
}
19 | {button}
20 |
21 | );
22 | }
23 |
24 | BlockHeader.propTypes = {
25 | title: PropTypes.string,
26 | description: PropTypes.string,
27 | link: PropTypes.string,
28 | button: PropTypes.object,
29 | };
--------------------------------------------------------------------------------
/src/components/Common/Carousel.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import Slider from "react-slick";
3 | import PropTypes from "prop-types";
4 |
5 | import BlockHeader from "components/Common/BlockHeader";
6 | import Block from "components/Common/Block";
7 | import SkeletonBlocks from "components/Skeleton/SkeletonBlocks";
8 |
9 | export default class Carousel extends Component {
10 | carouselConfig() {
11 | return {
12 | dots: true,
13 | infinite: false,
14 | lazyLoad: true,
15 | speed: 500,
16 | slidesToShow: 4,
17 | slidesToScroll: 4,
18 | arrows: false,
19 | className: "carousel__wrapper",
20 | adaptiveHeight: true,
21 | responsive: [
22 | {
23 | breakpoint: 900,
24 | settings: {
25 | slidesToShow: 3,
26 | slidesToScroll: 3,
27 | },
28 | },
29 | {
30 | breakpoint: 700,
31 | settings: {
32 | slidesToShow: 2,
33 | slidesToScroll: 2,
34 | dots: false,
35 | },
36 | },
37 | ],
38 | };
39 | }
40 |
41 | renderSlider() {
42 | const {items, type} = this.props;
43 | if (!items.length) {
44 | return null;
45 | }
46 | const sliderItems = items.map((item, index) => {
47 | return (
48 |
54 | );
55 | });
56 | return (
57 |
58 | {sliderItems}
59 |
60 | );
61 | }
62 |
63 | render() {
64 | const {pending, blockHeader} = this.props;
65 | if (pending) {
66 | return (
67 |
72 | );
73 | }
74 | return (
75 |
76 |
77 |
78 | {this.renderSlider()}
79 |
80 |
81 | );
82 | }
83 |
84 | }
85 |
86 | Carousel.propTypes = {
87 | blockHeader: PropTypes.object,
88 | items: PropTypes.array.isRequired,
89 | type: PropTypes.string,
90 | pending: PropTypes.bool.isRequired,
91 | };
--------------------------------------------------------------------------------
/src/components/Common/Category.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 | import { Link } from "react-router-dom";
4 |
5 | import { CATEGORY_PLAYLISTS } from "constants/RouteConstants";
6 | import LazyLoad from "components/Common/LazyLoad";
7 | import "styles/Category.scss";
8 |
9 | export default class Category extends Component {
10 | shouldComponentUpdate(nextProps) {
11 | return this.props.id !== nextProps.id;
12 | }
13 |
14 | render() {
15 | const {id, image, name} = this.props;
16 | return (
17 |
21 |
22 |
28 |
29 |
30 | );
31 | }
32 | }
33 |
34 | Category.propTypes = {
35 | id: PropTypes.string,
36 | image: PropTypes.string,
37 | name: PropTypes.string,
38 | };
--------------------------------------------------------------------------------
/src/components/Common/Columns.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | export default class Columns extends Component {
5 | render() {
6 | const {amount = 1, children} = this.props;
7 | const columnCount = amount;
8 | if (columnCount === 1) {
9 | return children;
10 | }
11 |
12 | const itemCount = React.Children.count(children);
13 | const columnItems = Math.ceil(itemCount / columnCount);
14 | const items = React.Children.toArray(children);
15 |
16 | return [...Array(columnCount).keys()].map((column, index) => {
17 | return (
18 |
19 | {items.slice(column * columnItems, columnItems * (column + 1))}
20 |
21 | );
22 | });
23 | }
24 | }
25 |
26 | Columns.propTypes = {
27 | amount: PropTypes.number.isRequired,
28 | };
--------------------------------------------------------------------------------
/src/components/Common/ContextMenu.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | export default class ContextMenu extends Component {
5 | constructor(props) {
6 | super(props);
7 | this.getRef = this.getRef.bind(this);
8 | }
9 |
10 | state = {
11 | totalPages: 0,
12 | currentPageNum: 0,
13 | menuHeight: 0,
14 | }
15 |
16 | componentDidMount() {
17 | let {currentPageNum, totalPages} = this.props;
18 | this.menu && this.setState({menuHeight: this.menu.offsetHeight});
19 | if (currentPageNum && totalPages) {
20 | this.setState({currentPageNum, totalPages});
21 | }
22 | }
23 |
24 | componentDidUpdate(prevProps, prevState) {
25 | if (this.menu.offsetHeight !== prevState.menuHeight) {
26 | this.setState({menuHeight: this.menu.offsetHeight});
27 | }
28 | }
29 |
30 | navigateToPage = (page = 1) => {
31 | setTimeout(() => {
32 | this.setState({
33 | currentPageNum: page,
34 | });
35 | }, 200);
36 | }
37 |
38 | getRef(el) {
39 | this.menu = el;
40 | }
41 |
42 | getOffsetTop() {
43 | const {defaultTop, getOffsetTop, containerRef} = this.props;
44 | if (!containerRef) {
45 | return;
46 | }
47 | const menuHeight = this.state.menuHeight;
48 | const containerBottom = containerRef.getBoundingClientRect().bottom;
49 | const offsetTop = getOffsetTop(menuHeight);
50 | const top = menuHeight + containerBottom > window.innerHeight
51 | ? offsetTop
52 | : defaultTop;
53 | return `${top}px`;
54 | }
55 |
56 | render() {
57 | const {renderContent} = this.props;
58 | return (
59 |
64 | {renderContent(this.state.currentPageNum, this.navigateToPage)}
65 |
66 | );
67 | }
68 | }
69 |
70 | ContextMenu.propTypes = {
71 | currentPageNum: PropTypes.number,
72 | totalPages: PropTypes.number,
73 | containerRef: PropTypes.object,
74 | renderContent: PropTypes.func.isRequired,
75 | defaultTop: PropTypes.number,
76 | getOffsetTop: PropTypes.func,
77 | };
--------------------------------------------------------------------------------
/src/components/Common/Empty.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 | import { ReactComponent as Clef } from "images/clef.svg";
4 |
5 | export default function EmptyPage(props) {
6 | const {title, button = null} = props;
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
{title}
14 | {button}
15 |
16 |
17 | );
18 | }
19 |
20 | EmptyPage.propTypes = {
21 | title: PropTypes.string.isRequired,
22 | button: PropTypes.element,
23 | };
--------------------------------------------------------------------------------
/src/components/Common/InfiniteScroll.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 | import { ReactComponent as Loader } from "images/loader.svg";
4 |
5 | export default class InfiniteScroll extends Component {
6 | constructor(props) {
7 | super(props);
8 | this.handleOnScroll = this.handleOnScroll.bind(this);
9 | }
10 |
11 | componentDidMount() {
12 | setTimeout(() => {
13 | this.container = this.props.containerSelector
14 | ? document.querySelector(this.props.containerSelector)
15 | : window;
16 | if (!this.container) {
17 | return;
18 | }
19 | this.container.addEventListener("scroll", this.handleOnScroll);
20 | this.container.addEventListener("resize", this.handleOnScroll);
21 | }, 500)
22 | }
23 |
24 | componentWillUnmount() {
25 | if (!this.container) {
26 | return;
27 | }
28 | this.container.removeEventListener("scroll", this.handleOnScroll);
29 | this.container.removeEventListener("resize", this.handleOnScroll);
30 | }
31 |
32 | handleOnScroll() {
33 | const {
34 | disable,
35 | containerSelector,
36 | dataLength,
37 | total,
38 | loadData,
39 | pending,
40 | } = this.props;
41 | if (disable) {
42 | return;
43 | }
44 | const element = containerSelector
45 | ? this.container
46 | : document.documentElement;
47 | const scrollTop = element.scrollTop;
48 | const clientHeight = element.clientHeight;
49 | const scrollHeight = element.scrollHeight;
50 | if (scrollTop + clientHeight >= scrollHeight && dataLength < total) {
51 | !pending && loadData();
52 | }
53 | }
54 |
55 | render() {
56 | const {children, pending, hideLoader} = this.props;
57 | return (
58 |
59 | {children}
60 | {pending && !hideLoader &&
}
61 |
62 | );
63 | }
64 | }
65 |
66 | InfiniteScroll.propTypes = {
67 | containerSelector: PropTypes.string,
68 | dataLength: PropTypes.number.isRequired,
69 | total: PropTypes.number.isRequired,
70 | loadData: PropTypes.func.isRequired,
71 | pending: PropTypes.bool.isRequired,
72 | disable: PropTypes.bool,
73 | hideLoader: PropTypes.bool,
74 | };
--------------------------------------------------------------------------------
/src/components/Common/LazyLoad.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 | import isIntersectionObserverAvailable from "utils/intersectionObserver";
4 |
5 | export default class LazyLoad extends Component {
6 | state = {
7 | loaded: false,
8 | }
9 |
10 | load = () => {
11 | this.setState({
12 | loaded: true,
13 | });
14 | }
15 |
16 | componentDidMount() {
17 | if (!isIntersectionObserverAvailable() || this.state.loaded) {
18 | return;
19 | }
20 | this.observer = new IntersectionObserver(
21 | entries => {
22 | entries.forEach(entry => {
23 | if (entry.isIntersecting) {
24 | this.load();
25 | this.observer = this.observer.disconnect();
26 | }
27 | });
28 | }
29 | );
30 | this.observer.observe(this.placeholder);
31 | }
32 |
33 | componentWillUnmount() {
34 | if (this.placeholder && this.observer) {
35 | this.observer.unobserve(this.placeholder);
36 | }
37 | }
38 |
39 | render() {
40 | const {children, className} = this.props;
41 | if (this.state.loaded || !isIntersectionObserverAvailable()) {
42 | return children;
43 | }
44 | return (
45 | (this.placeholder = el)}
47 | className={`lazyload-placeholder ${className}`}
48 | >
49 | );
50 | }
51 | }
52 |
53 | LazyLoad.propTypes = {
54 | className: PropTypes.string,
55 | children: PropTypes.any.isRequired,
56 | };
--------------------------------------------------------------------------------
/src/components/Common/LikeButton.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 | import { Heart } from "react-feather";
4 |
5 | export default class LikeButton extends Component {
6 | render() {
7 | const {unlike, like, isActive} = this.props;
8 | return (
9 |
15 | );
16 | }
17 | }
18 |
19 | LikeButton.propTypes = {
20 | unlike: PropTypes.func.isRequired,
21 | like: PropTypes.func.isRequired,
22 | isActive: PropTypes.bool,
23 | };
--------------------------------------------------------------------------------
/src/components/Common/NewPlaylistBlock.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 | import { Redirect } from "react-router-dom";
4 | import { ReactComponent as AddPlaylistIcon } from "images/addPlaylist.svg";
5 |
6 | export default class NewPlaylistBlock extends Component {
7 | state = {
8 | newPlaylistPath: "",
9 | }
10 |
11 | componentDidUpdate(prevProps, prevState) {
12 | if (this.state.newPlaylistPath &&
13 | prevState.newPlaylistPath === this.state.newPlaylistPath) {
14 | this.setState({
15 | newPlaylistPath: "",
16 | });
17 | }
18 | }
19 |
20 | setPath = id => {
21 | this.setState({
22 | newPlaylistPath: `/playlist/${id}`,
23 | });
24 | }
25 |
26 | handleSubmit = event => {
27 | event.preventDefault();
28 | this.props.createPlaylist("New Playlist", this.setPath);
29 | }
30 |
31 | render() {
32 | const {isNewPlaylistOpen, renderContent} = this.props;
33 | if (this.state.newPlaylistPath && !isNewPlaylistOpen) {
34 | return (
35 |
46 | );
47 | }
48 | if (renderContent) {
49 | return renderContent(
50 | this.handleSubmit,
51 |
52 | );
53 | }
54 | return (
55 |
56 |
60 |
61 |
64 |
65 | Create New Playlist
66 |
67 |
68 |
69 |
70 | );
71 | }
72 | }
73 |
74 | NewPlaylistBlock.propTypes = {
75 | createPlaylist: PropTypes.func.isRequired,
76 | isNewPlaylistOpen: PropTypes.bool.isRequired,
77 | renderContent: PropTypes.func,
78 | };
--------------------------------------------------------------------------------
/src/components/Common/OpenContextMenuButton.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | export default class OpenContextMenuButton extends Component {
5 | constructor(props) {
6 | super(props);
7 | this.toggleContextMenu = this.toggleContextMenu.bind(this);
8 | this.closeContextMenu = this.closeContextMenu.bind(this);
9 | this.handleClickOutside = this.handleClickOutside.bind(this);
10 | }
11 |
12 | state = {
13 | isContextMenuOpen: false,
14 | }
15 |
16 | componentWillUnmount() {
17 | this.detachEventListeners();
18 | }
19 |
20 | handleClickOutside(e) {
21 | if (!this.button.contains(e.target)) {
22 | this.closeContextMenu();
23 | }
24 | }
25 |
26 | toggleContextMenu() {
27 | this.setState({
28 | isContextMenuOpen: !this.state.isContextMenuOpen,
29 | });
30 | if (!this.state.isContextMenuOpen) {
31 | this.attachEventListeners();
32 | } else {
33 | this.detachEventListeners();
34 | }
35 | }
36 |
37 | closeContextMenu() {
38 | this.setState({
39 | isContextMenuOpen: false,
40 | });
41 | this.detachEventListeners();
42 | }
43 |
44 | attachEventListeners() {
45 | document.addEventListener("click", this.handleClickOutside);
46 | window.addEventListener("scroll", this.closeContextMenu);
47 | }
48 |
49 | detachEventListeners() {
50 | document.removeEventListener("click", this.handleClickOutside);
51 | window.removeEventListener("scroll", this.closeContextMenu);
52 | }
53 |
54 | render() {
55 | const {renderContent, renderContextMenu, className = ""} = this.props;
56 | const {isContextMenuOpen} = this.state;
57 | const btnClassName = `more-btn ${className} ${
58 | isContextMenuOpen ? "more-btn_active" : ""
59 | }`;
60 | return (
61 |
68 | );
69 | }
70 | }
71 |
72 | OpenContextMenuButton.propTypes = {
73 | renderContextMenu: PropTypes.func.isRequired,
74 | renderContent: PropTypes.func.isRequired,
75 | className: PropTypes.string,
76 | };
--------------------------------------------------------------------------------
/src/components/Common/Track/Track.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import CoverImage from "./TrackCoverImage";
5 | import TrackInfo from "./TrackInfo";
6 | import MoreButton from "./buttons/MoreButton";
7 | import LikeButton from "components/Common/LikeButton";
8 | import playerAPI from "utils/playerAPI";
9 |
10 | export default class Track extends Component {
11 | shouldComponentUpdate(nextProps) {
12 | return (
13 | nextProps.track.saved !== this.props.track.saved ||
14 | nextProps.track.isActive !== this.props.track.isActive ||
15 | nextProps.source.name !== this.props.source.name ||
16 | nextProps.track.name !== this.props.track.name
17 | );
18 | }
19 |
20 | addToSavedTracks = ()=> {
21 | playerAPI.addToSavedTracks(this.props.track);
22 | }
23 |
24 | removeFromSavedTracks = () => {
25 | playerAPI.removeFromSavedTracks(this.props.track.id);
26 | }
27 |
28 | render() {
29 | const {
30 | trackList,
31 | charts,
32 | source,
33 | track,
34 | removeTrackFromPlaylist,
35 | } = this.props;
36 | const trackContext = {name: source.name, tracks: trackList};
37 | return (
38 |
39 |
43 |
44 |
45 |
50 |
55 |
56 |
57 |
58 | );
59 | }
60 | }
61 |
62 | Track.propTypes = {
63 | trackList: PropTypes.array.isRequired,
64 | source: PropTypes.object.isRequired,
65 | track: PropTypes.object.isRequired,
66 | removeTrackFromPlaylist: PropTypes.func,
67 | charts: PropTypes.bool,
68 | };
--------------------------------------------------------------------------------
/src/components/Common/Track/TrackCoverImage.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import PlayButton from "./buttons/PlayButton";
5 | import PauseButton from "./buttons/PauseButton";
6 | import LazyLoad from "components/Common/LazyLoad";
7 | import { connectPlayer } from "containers/PlayerContainer";
8 |
9 | export class CoverImage extends Component {
10 | shouldComponentUpdate(nextProps) {
11 | const {track, player} = this.props;
12 | return (
13 | track.image !== nextProps.track.image ||
14 | track.isActive !== nextProps.track.isActive ||
15 | (track.isActive && player.trackPaused !== nextProps.player.trackPaused)
16 | );
17 | }
18 |
19 | render() {
20 | const {track, trackContext, player} = this.props;
21 | const isAlbum = trackContext && trackContext.name.includes("album");
22 | return (
23 |
27 | {!player.trackPaused && track.isActive
28 | ?
29 | :
30 | }
31 | {!isAlbum &&
32 |
33 |
38 |
39 | }
40 |
41 | );
42 | }
43 | }
44 |
45 | CoverImage.propTypes = {
46 | track: PropTypes.object.isRequired,
47 | trackContext: PropTypes.object.isRequired,
48 | player: PropTypes.object.isRequired,
49 | };
50 |
51 | export default connectPlayer(CoverImage);
--------------------------------------------------------------------------------
/src/components/Common/Track/TrackInfo.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import AuthorList from "components/Common/AuthorList";
5 |
6 | export default function TrackInfo(props) {
7 | const {charts, track, children} = props;
8 | return (
9 |
10 | {charts &&
11 |
12 | {`${track.key < 10 ? "0" + track.key : track.key}`}
13 |
14 | }
15 |
16 |
17 |
{track.name}
18 |
19 |
20 |
21 |
22 |
{track.duration}
23 | {children}
24 |
25 |
26 | );
27 | }
28 |
29 | TrackInfo.propTypes = {
30 | track: PropTypes.object.isRequired,
31 | charts: PropTypes.bool,
32 | };
--------------------------------------------------------------------------------
/src/components/Common/Track/buttons/MoreButton.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 | import { MoreHorizontal, Plus } from "react-feather";
4 |
5 | import ContextMenu from "components/Common/ContextMenu";
6 | import OpenContextMenuButton from "components/Common/OpenContextMenuButton";
7 | import ContextMenuOptions from "../contextMenu/ContextMenuOptions";
8 | import ContextMenuPlaylists from "../contextMenu/ContextMenuPlaylists";
9 |
10 | export default class MoreButton extends Component {
11 | constructor(props) {
12 | super(props);
13 | this.renderContextMenuButton = this.renderContextMenuButton.bind(this);
14 | this.renderContextMenu = this.renderContextMenu.bind(this);
15 | }
16 |
17 | renderContextMenuButton(toggleContextMenu) {
18 | if (this.props.isMyPlaylist) {
19 | return ;
20 | }
21 | return ;
22 | }
23 |
24 | renderContextMenu(closeContextMenu) {
25 | const {isMyPlaylist, track, removeTrackFromPlaylist} = this.props;
26 | const trackRef = this.button.closest(".track");
27 | const currentPageNum = isMyPlaylist ? 1 : 2;
28 | const totalPages = isMyPlaylist ? 2 : 1;
29 | const getOffsetTop = menuHeight => menuHeight < 150 ? -100 : -260;
30 | const removeTrack = () => {
31 | removeTrackFromPlaylist(track.key - 1);
32 | closeContextMenu();
33 | };
34 | const renderContent = (currentPage, navigateToPage) => {
35 | const pagesContent = {
36 | 1: ,
40 | 2: ,
46 | };
47 | return pagesContent[currentPage];
48 | };
49 |
50 | return (
51 |
59 | );
60 | }
61 |
62 | render() {
63 | return (
64 | (this.button = el)}>
65 |
69 |
70 | );
71 | }
72 | }
73 |
74 | MoreButton.propTypes = {
75 | isMyPlaylist: PropTypes.bool,
76 | track: PropTypes.object,
77 | removeTrackFromPlaylist: PropTypes.func,
78 | };
--------------------------------------------------------------------------------
/src/components/Common/Track/buttons/PauseButton.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { Pause } from "react-feather";
4 | import playerAPI from "utils/playerAPI";
5 |
6 | export default function PauseButton() {
7 | return (
8 | playerAPI.pauseTrack()}
10 | className="track__icon"
11 | >
12 |
13 |
14 | );
15 | }
--------------------------------------------------------------------------------
/src/components/Common/Track/buttons/PlayButton.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 | import { Play } from "react-feather";
4 |
5 | import playerAPI from "utils/playerAPI";
6 |
7 | export default class PlayButton extends Component {
8 | constructor(props) {
9 | super(props);
10 | this.play = this.play.bind(this)
11 | }
12 |
13 | play() {
14 | const {context, track} = this.props;
15 | if (track.isActive) {
16 | return playerAPI.resumeTrack();
17 | }
18 | playerAPI.playTrack(track, context);
19 | }
20 |
21 | render() {
22 | const {track} = this.props;
23 | if (track.preview_url) {
24 | return (
25 |
29 |
30 |
31 | );
32 | }
33 | return (
34 |
35 |
36 |
37 | );
38 | }
39 | }
40 |
41 | PlayButton.propTypes = {
42 | context: PropTypes.object.isRequired,
43 | track: PropTypes.object.isRequired,
44 | };
--------------------------------------------------------------------------------
/src/components/Common/Track/contextMenu/ContextMenuOptions.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import { Plus, Trash } from "react-feather";
5 |
6 | export default function ContextMenuOptions(props) {
7 | const {navigateToPage, removeTrack} = props;
8 | const navigateToNextPage = () => navigateToPage(2);
9 | return (
10 |
11 | -
12 | Add Track To Playlist
13 |
14 | -
15 | Remove From This Playlist
16 |
17 |
18 | );
19 | }
20 |
21 | ContextMenuOptions.propTypes = {
22 | navigateToPage: PropTypes.func.isRequired,
23 | removeTrack: PropTypes.func.isRequired,
24 | };
--------------------------------------------------------------------------------
/src/components/Common/Tracks.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import Columns from "./Columns";
5 | import Track from "./Track/Track";
6 | import playerAPI from "utils/playerAPI";
7 | import { connectPlayer } from "containers/PlayerContainer";
8 | import "styles/Tracks.scss";
9 |
10 | export class Tracks extends Component {
11 | shouldComponentUpdate(nextProps) {
12 | const {player, trackList, source} = this.props;
13 | return (
14 | nextProps.player.playingTrackId !== player.playingTrackId ||
15 | nextProps.trackList !== trackList ||
16 | nextProps.source.name !== source.name
17 | );
18 | }
19 |
20 | componentDidUpdate(prevProps) {
21 | const {player, source, trackList} = this.props;
22 | if (player.context.name !== source.name) {
23 | return;
24 | }
25 | if (player.context.tracks.length < trackList.length) {
26 | playerAPI.updateContext(trackList);
27 | }
28 | }
29 |
30 | render() {
31 | const {playingTrackId, context} = this.props.player;
32 | const {
33 | trackList,
34 | columns = 1,
35 | source,
36 | charts,
37 | removeTrackFromPlaylist,
38 | } = this.props;
39 | return (
40 |
41 | {trackList.map((item, index) => {
42 | let track = {
43 | ...item,
44 | isActive: item.id === playingTrackId &&
45 | context.name === source.name,
46 | key: index + 1,
47 | };
48 | return (
49 |
57 | );
58 | })}
59 |
60 | );
61 | }
62 | }
63 |
64 | export default connectPlayer(Tracks);
65 |
66 | Tracks.propTypes = {
67 | trackList: PropTypes.array.isRequired,
68 | source: PropTypes.object.isRequired,
69 | player: PropTypes.object.isRequired,
70 | columns: PropTypes.number,
71 | removeTrackFromPlaylist: PropTypes.func,
72 | charts: PropTypes.bool,
73 | };
--------------------------------------------------------------------------------
/src/components/LoginCallback.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import { Redirect } from "react-router-dom";
3 | import Auth from "utils/auth";
4 |
5 | export default class LoginCallback extends Component {
6 | getHashParams = () => {
7 | const {location} = this.props,
8 | hashParams = {};
9 | let e, r = /([^&;=]+)=?([^&;]*)/g,
10 | q = location.hash.substring(1);
11 | while (e = r.exec(q)) {
12 | hashParams[e[1]] = decodeURIComponent(e[2]);
13 | }
14 | return hashParams;
15 | }
16 |
17 | render() {
18 | const params = this.getHashParams(),
19 | access_token = params.access_token,
20 | state = params.state,
21 | storedState = localStorage.getItem("spotify_auth_state");
22 |
23 | if ( access_token && ( state == null || state !== storedState ) ) {
24 | console.error("There was an error during the authentication");
25 | } else {
26 | const tokenExpirationSec = ( new Date().getTime() / 1000 ) + 3600,
27 | tokenExpirationTime = new Date(tokenExpirationSec * 1000);
28 | Auth.setToken(access_token, tokenExpirationTime);
29 | localStorage.removeItem("spotify_auth_state");
30 | Auth.setTokenToSpotify();
31 | Auth.setUserId();
32 | }
33 | return ;
34 | }
35 | }
--------------------------------------------------------------------------------
/src/components/Player/PlayerProgressBar.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 |
3 | import playerAPI from "utils/playerAPI";
4 | import msToTime from "utils/msToTime";
5 | import ProgressBarSlider from "./sliders/PlayerProgressBarSlider";
6 | import { connectProgressBar } from "containers/ProgressBarContainer";
7 |
8 | export class ProgressBar extends Component {
9 | render() {
10 | const {currentTime = 0} = this.props.progressBar;
11 | const duration = playerAPI.duration || 30;
12 | const timeLeft = duration - currentTime > 0 ? duration - currentTime : 0;
13 | return (
14 |
15 |
16 | {msToTime(currentTime * 1000)}
17 |
18 |
19 |
20 | {msToTime(timeLeft * 1000)}
21 |
22 |
23 | );
24 | }
25 | }
26 |
27 | export default connectProgressBar(ProgressBar);
--------------------------------------------------------------------------------
/src/components/Player/PlayerTrackInfo.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 | import AuthorList from "components/Common/AuthorList";
4 |
5 | export default class TrackInfo extends Component {
6 | shouldComponentUpdate(nextProps) {
7 | return nextProps.trackInfo.id !== this.props.trackInfo.id;
8 | }
9 |
10 | render() {
11 | const {image, name = "", authors = []} = this.props.trackInfo;
12 | return (
13 |
14 | {image &&

}
15 |
16 |
{name}
17 |
18 |
19 |
20 |
21 |
22 | );
23 | }
24 | }
25 |
26 | TrackInfo.propTypes = {
27 | trackInfo: PropTypes.object.isRequired,
28 | };
--------------------------------------------------------------------------------
/src/components/Player/PlayerVolumeControl.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import { Volume1, Volume2, VolumeX } from "react-feather";
3 | import VolumeSlider from "./sliders/PlayerVolumeSlider";
4 |
5 | export default class VolumeControl extends Component {
6 | constructor(props) {
7 | super(props);
8 | this.toggleTooltip = this.toggleTooltip.bind(this);
9 | this.handleClickOutside = this.handleClickOutside.bind(this);
10 | this.getVolumeRef = this.getVolumeRef.bind(this);
11 | this.state = {
12 | volume: 1,
13 | isTooltipActive: false,
14 | }
15 | }
16 |
17 | shouldComponentUpdate(nextProps, nextState) {
18 | return (
19 | this.state.volume !== nextState.volume ||
20 | this.state.isTooltipActive !== nextState.isTooltipActive
21 | );
22 | }
23 |
24 | changeVolume = volume => {
25 | this.setState({
26 | volume: volume,
27 | });
28 | }
29 |
30 | getCurrentVolStatus = volume => {
31 | if (volume === 0) {
32 | return "mute";
33 | } else if (volume <= 0.5) {
34 | return "medium";
35 | }
36 | return "high";
37 | }
38 |
39 | toggleTooltip(e) {
40 | if (!this.state.isTooltipActive) {
41 | window.addEventListener("click", this.handleClickOutside);
42 | } else {
43 | window.removeEventListener("click", this.handleClickOutside);
44 | }
45 | this.setState({
46 | isTooltipActive: !this.state.isTooltipActive,
47 | });
48 | }
49 |
50 | getVolumeRef(el) {
51 | this.volumeBar = el;
52 | }
53 |
54 | handleClickOutside(e) {
55 | if (!this.volumeBar.contains(e.target)) {
56 | this.toggleTooltip();
57 | }
58 | }
59 |
60 | render() {
61 | const { volume, isTooltipActive } = this.state;
62 | const status = this.getCurrentVolStatus(volume);
63 | const statusIcons = {
64 | high : ,
65 | medium : ,
66 | mute: ,
67 | };
68 | return (
69 |
88 | );
89 | }
90 | }
--------------------------------------------------------------------------------
/src/components/Player/buttons/NextButton.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import { ReactComponent as PlayNext } from "images/Player/play-next.svg";
3 |
4 | export default class NextButton extends Component {
5 | shouldComponentUpdate(nextProps) {
6 | return false;
7 | }
8 |
9 | render() {
10 | return (
11 |
17 | );
18 | }
19 | }
--------------------------------------------------------------------------------
/src/components/Player/buttons/PlayButton.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import { ReactComponent as PlayIcon } from "images/Player/play.svg";
5 | import { ReactComponent as PauseIcons} from "images/Player/pause.svg";
6 |
7 | export default class PlayButton extends Component {
8 | shouldComponentUpdate(nextProps) {
9 | return (
10 | this.props.trackPaused !== nextProps.trackPaused ||
11 | this.props.trackPlaying !== nextProps.trackPlaying
12 | );
13 | }
14 |
15 | play() {
16 | const {trackPaused, trackPlaying, resumeTrack} = this.props;
17 | if (trackPaused && trackPlaying) {
18 | resumeTrack();
19 | }
20 | }
21 |
22 | render() {
23 | const {trackPlaying, trackPaused, pauseTrack} = this.props;
24 | return (
25 |
37 | );
38 | }
39 | }
40 |
41 | PlayButton.propTypes = {
42 | trackPaused: PropTypes.bool.isRequired,
43 | trackPlaying: PropTypes.bool.isRequired,
44 | resumeTrack: PropTypes.func.isRequired,
45 | pauseTrack: PropTypes.func.isRequired,
46 | };
--------------------------------------------------------------------------------
/src/components/Player/buttons/PrevButton.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import { ReactComponent as PlayPrevious }
3 | from "images/Player/play-previous.svg";
4 |
5 | export default class PrevButton extends Component {
6 | shouldComponentUpdate() {
7 | return false;
8 | }
9 |
10 | render() {
11 | return (
12 |
18 | );
19 | }
20 | }
--------------------------------------------------------------------------------
/src/components/Player/buttons/RepeatButton.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import { Repeat } from "react-feather";
3 | import PropTypes from "prop-types";
4 |
5 | export default class RepeatButton extends Component {
6 | shouldComponentUpdate(nextProps) {
7 | return nextProps.repeat !== this.props.repeat;
8 | }
9 |
10 | render() {
11 | const {repeat, toggleRepeat} = this.props;
12 | return (
13 |
21 | );
22 | }
23 | }
24 |
25 | RepeatButton.propTypes = {
26 | repeat: PropTypes.bool.isRequired,
27 | toggleRepeat: PropTypes.func.isRequired,
28 | };
--------------------------------------------------------------------------------
/src/components/Search/Search.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 | import { CSSTransition, TransitionGroup } from "react-transition-group";
4 |
5 | import { connectSearchResults } from "containers/SearchResultsContainer";
6 | import SearchResults from "./SearchResults";
7 | import SearchInput from "./SearchInput";
8 | import "styles/Search.scss";
9 |
10 | export class Search extends Component {
11 | state = {
12 | value: "",
13 | loading: false,
14 | }
15 |
16 | componentDidUpdate(prevProps, prevState) {
17 | const {type, pending} = this.props.searchResults;
18 | const {value, loading} = this.state;
19 | if (value !== prevState.value || loading) {
20 | if (pending) {
21 | !loading && this.setState({loading: true});
22 | return;
23 | }
24 | this.props.loadSearchResults(value, type);
25 | loading && this.setState({loading: false});
26 | }
27 | }
28 |
29 | switchTab = option => {
30 | this.props.filterByType(option);
31 | this.props.loadSearchResults(this.state.value, option);
32 | }
33 |
34 | updateSearchValue = e => {
35 | this.setState({
36 | value: e.target.value,
37 | });
38 | }
39 |
40 | render() {
41 | const {value} = this.state;
42 | const {toggleSearch} = this.props;
43 | const {isOpen, pending, type, items} = this.props.searchResults;
44 | return (
45 |
46 |
47 | {isOpen &&
48 |
52 |
58 |
59 | }
60 | {value !== "" && isOpen &&
61 |
65 |
73 |
74 | }
75 |
76 |
77 | );
78 | }
79 | }
80 |
81 | export default connectSearchResults(Search);
82 |
83 | Search.propTypes = {
84 | toggleSearch: PropTypes.func.isRequired,
85 | filterByType: PropTypes.func.isRequired,
86 | loadSearchResults: PropTypes.func.isRequired,
87 | searchResults: PropTypes.object.isRequired,
88 | };
--------------------------------------------------------------------------------
/src/components/Search/SearchBtn.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 | import { Search as Icon } from "react-feather";
4 | import { connectSearchResults } from "containers/SearchResultsContainer";
5 |
6 | export class SearchBtn extends Component {
7 | render() {
8 | const {toggleSearch, renderContent, className} = this.props;
9 | if (renderContent) {
10 | return renderContent(toggleSearch, );
11 | }
12 | return (
13 |
14 |
15 |
16 | );
17 | }
18 | }
19 |
20 | SearchBtn.propTypes = {
21 | toggleSearch: PropTypes.func.isRequired,
22 | renderContent: PropTypes.func,
23 | className: PropTypes.string,
24 | };
25 |
26 | export default connectSearchResults(SearchBtn);
--------------------------------------------------------------------------------
/src/components/Search/SearchInput.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 | import { X } from "react-feather";
4 | import { ReactComponent as Loader } from "images/loader.svg";
5 |
6 | export default class SearchInput extends Component {
7 | shouldComponentUpdate(nextProps) {
8 | return (
9 | nextProps.pending !== this.props.pending ||
10 | nextProps.value !== this.props.value
11 | );
12 | }
13 |
14 | render() {
15 | const {pending, value, updateSearchValue, close} = this.props;
16 | return (
17 |
18 |
19 |
What are you looking for?
20 |
21 |
28 | {pending &&
29 |
30 | }
31 |
35 |
36 |
37 |
38 |
39 |
40 | );
41 | }
42 | }
43 |
44 | SearchInput.propTypes = {
45 | close: PropTypes.func.isRequired,
46 | updateSearchValue: PropTypes.func.isRequired,
47 | pending: PropTypes.bool,
48 | value: PropTypes.string,
49 | };
--------------------------------------------------------------------------------
/src/components/Skeleton/SkeletonArtists.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import SkeletonBlockHeader from "./SkeletonBlockHeader";
5 | import "styles/Skeleton.scss";
6 |
7 | export default function SkeletonArtists(props) {
8 | const {
9 | containerClassName = "artists",
10 | itemCount = 10,
11 | headerWithDescription,
12 | } = props;
13 | return (
14 |
15 |
16 |
17 | {[...Array(itemCount).keys()].map((item, index) => {
18 | return (
19 |
23 | );
24 | })}
25 |
26 |
27 | );
28 | }
29 |
30 | SkeletonArtists.propTypes = {
31 | headerWithDescription: PropTypes.bool,
32 | itemCount: PropTypes.number,
33 | containerClassName: PropTypes.string,
34 | };
--------------------------------------------------------------------------------
/src/components/Skeleton/SkeletonBlockHeader.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default function SkeletonBlockHeader({description}) {
4 | return (
5 |
6 | {description &&
}
7 |
8 |
9 | );
10 | }
--------------------------------------------------------------------------------
/src/components/Skeleton/SkeletonBlocks.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import "styles/Skeleton.scss";
5 | import SkeletonBlockHeader from "./SkeletonBlockHeader";
6 |
7 | export default function SkeletonBlocks(props) {
8 | const {
9 | className = "grid",
10 | itemCount = 8,
11 | headerWithDescription,
12 | } = props;
13 | return (
14 |
15 |
16 |
20 | {[...Array(itemCount).keys()].map((item, index) => {
21 | return (
22 |
27 | );
28 | })}
29 |
30 |
31 | );
32 | }
33 |
34 | SkeletonBlocks.propTypes = {
35 | headerWithDescription: PropTypes.bool,
36 | itemCount: PropTypes.number,
37 | className: PropTypes.string,
38 | };
--------------------------------------------------------------------------------
/src/components/Skeleton/SkeletonCategories.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import SkeletonBlockHeader from "./SkeletonBlockHeader";
4 | import "styles/Skeleton.scss";
5 |
6 | export default function SkeletonCategories({preview}) {
7 | return (
8 |
11 |
12 |
13 | {[...Array(8).keys()].map((item, index) => {
14 | return (
15 |
19 | );
20 | })}
21 |
22 |
23 | );
24 | }
--------------------------------------------------------------------------------
/src/components/Skeleton/SkeletonTracks.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import SkeletonBlockHeader from "./SkeletonBlockHeader";
5 | import "styles/Skeleton.scss";
6 |
7 | export default function SkeletonTracks(props) {
8 | const {
9 | className = "songs",
10 | itemCount = 10,
11 | headerWithDescription,
12 | header,
13 | } = props;
14 | return (
15 |
16 | {header && }
17 |
18 | {[...Array(itemCount).keys()].map((item, index) => {
19 | return (
20 |
27 | );
28 | })}
29 |
30 |
31 | );
32 | }
33 |
34 | SkeletonTracks.propTypes = {
35 | headerWithDescription: PropTypes.bool,
36 | header: PropTypes.bool,
37 | itemCount: PropTypes.number,
38 | className: PropTypes.string,
39 | };
--------------------------------------------------------------------------------
/src/components/__tests__/Artist.test.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { shallow } from "enzyme";
3 | import renderer from "react-test-renderer";
4 | import { BrowserRouter as Router } from "react-router-dom";
5 |
6 | import Artist from "components/Common/Artist";
7 | import musician from "images/musician.png";
8 |
9 | describe("", () => {
10 | it("renders correctly", () => {
11 | const props = {
12 | id: "1",
13 | name: "test",
14 | handler: jest.fn(),
15 | image: "test.png",
16 | };
17 | const component = renderer.create(
18 |
19 | ).toJSON();
20 | expect(component).toMatchSnapshot();
21 | });
22 |
23 | it("check the component without an image", () => {
24 | const props = {
25 | id: "1",
26 | name: "test",
27 | image: "",
28 | };
29 | const wrapper = shallow();
30 | const target = wrapper.find(".artist__pic_root");
31 | expect(target.hasClass("bg-empty")).toEqual(true);
32 | expect(target.prop("style")).toHaveProperty(
33 | "backgroundImage", `url(${musician})`
34 | );
35 | });
36 | });
--------------------------------------------------------------------------------
/src/components/__tests__/AuthorList.test.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { shallow } from "enzyme";
3 | import { BrowserRouter as Router } from "react-router-dom";
4 | import renderer from "react-test-renderer";
5 | import AuthorList from "components/Common/AuthorList";
6 |
7 | describe("", () => {
8 | it("renders correctly", () => {
9 | const props = {
10 | authors: [
11 | {name: "John Doe", id: "1"},
12 | {name: "Mike", id: "2"},
13 | ],
14 | handler: jest.fn(),
15 | };
16 | const component = renderer.create(
17 |
18 | ).toJSON();
19 | expect(component).toMatchSnapshot();
20 | });
21 |
22 | it("check the component with the single author", () => {
23 | const props = {
24 | authors: [{name: "John Doe", id: "1"}],
25 | handler: jest.fn(),
26 | };
27 | const wrapper = shallow();
28 | expect(wrapper.html()).toEqual(
29 | "John Doe"
30 | );
31 | });
32 | });
--------------------------------------------------------------------------------
/src/components/__tests__/Block.test.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { shallow } from "enzyme";
3 | import renderer from "react-test-renderer";
4 | import { BrowserRouter as Router } from "react-router-dom";
5 |
6 | import Block from "components/Common/Block";
7 | import AuthorList from "components/Common/AuthorList";
8 | import cd from "images/cd.png";
9 |
10 | describe("", () => {
11 | it("renders correctly", () => {
12 | const props = {
13 | image: "test.png",
14 | name: "test",
15 | meta: "0 tracks",
16 | type: "playlist",
17 | id: "1",
18 | handler: jest.fn(),
19 | };
20 | const component = renderer.create(
21 |
22 | ).toJSON();
23 | expect(component).toMatchSnapshot();
24 | });
25 |
26 | it("check the component without an image", () => {
27 | const props = {
28 | image: "",
29 | name: "test",
30 | meta: "0 tracks",
31 | type: "playlist",
32 | id: "1",
33 | };
34 | const wrapper = shallow();
35 | const target = wrapper.find(".block__img_root");
36 |
37 | expect(target.hasClass("bg-empty")).toEqual(true);
38 | expect(target.prop("style")).toHaveProperty(
39 | "backgroundImage", `url(${cd})`
40 | );
41 | });
42 |
43 | it("should display the authors as metadata", () => {
44 | const props = {
45 | image: "test.png",
46 | name: "test",
47 | meta: [{name: "John Doe", id: "1"}],
48 | type: "album",
49 | id: "1",
50 | };
51 | const wrapper = shallow();
52 | expect(wrapper.find(AuthorList)).toHaveLength(1);
53 | });
54 | });
--------------------------------------------------------------------------------
/src/components/__tests__/Columns.test.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { mount } from "enzyme";
3 | import renderer from "react-test-renderer";
4 | import Columns from "components/Common/Columns";
5 |
6 | describe("", () => {
7 | it("renders correctly", () => {
8 | const component = renderer.create(
9 |
10 | Block 1
11 | Block 2
12 | Block 3
13 | Block 4
14 |
15 | ).toJSON();
16 | expect(component).toMatchSnapshot();
17 | });
18 |
19 | it("should not render columns", () => {
20 | const wrapper = mount(
21 |
22 | Block 1
23 | Block 2
24 | Block 3
25 |
26 | );
27 | expect(wrapper.find(".column")).toHaveLength(0);
28 | });
29 |
30 | it("should wrap every block in the single column", () => {
31 | const wrapper = mount(
32 |
33 | Block 1
34 | Block 2
35 | Block 3
36 |
37 | );
38 | expect(wrapper.find(".column")).toHaveLength(3);
39 | expect(wrapper.find(".column").everyWhere(n => {
40 | return n.children().length === 1;
41 | })).toEqual(true);
42 | });
43 |
44 | it("the first column should have the two blocks", () => {
45 | const wrapper = mount(
46 |
47 | Block 1
48 | Block 2
49 | Block 3
50 |
51 | );
52 | expect(wrapper.find(".column")).toHaveLength(2);
53 | expect(wrapper.find(".column:first-child").children()).toHaveLength(2);
54 | expect(wrapper.find(".column:last-child").children()).toHaveLength(1);
55 | });
56 | });
--------------------------------------------------------------------------------
/src/components/__tests__/ContextMenu.test.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { mount } from "enzyme";
3 | import ContextMenu from "components/Common/ContextMenu";
4 |
5 | describe("", () => {
6 | let props;
7 | beforeEach(() => {
8 | props = {
9 | currentPageNum: 1,
10 | totalPages: 2,
11 | renderContent: jest.fn(),
12 | defaultTop: 17,
13 | getOffsetTop: menuHeight => menuHeight < 150 ? -100 : -260,
14 | containerRef: {
15 | getBoundingClientRect: () => {
16 | return {
17 | bottom: 700,
18 | };
19 | },
20 | },
21 | };
22 | });
23 |
24 | it("the renderContent should accept the two args", () => {
25 | const wrapper = mount();
26 | expect(wrapper.state("totalPages")).toEqual(2);
27 | expect(wrapper.state("currentPageNum")).toEqual(1);
28 |
29 | expect(props.renderContent.mock.calls.length).toBe(2);
30 | expect(props.renderContent).lastCalledWith(1, expect.any(Function));
31 | });
32 |
33 | it("the menuHeight should be equal to the offsetHeight property", () => {
34 | jest
35 | .spyOn(ContextMenu.prototype, "getRef")
36 | .mockImplementationOnce(function(ref) {
37 | this.menu = {offsetHeight: 200};
38 | });
39 |
40 | const wrapper = mount();
41 | expect(wrapper.state("menuHeight")).toEqual(200);
42 |
43 | wrapper.instance().menu = {offsetHeight: 100};
44 | wrapper.setProps({});
45 | expect(wrapper.state("menuHeight")).toEqual(100);
46 | });
47 |
48 | it("menu position should be relative to the window", () => {
49 | jest
50 | .spyOn(ContextMenu.prototype, "getRef")
51 | .mockImplementationOnce(function(ref) {
52 | this.menu = {offsetHeight: 100};
53 | });
54 |
55 | const wrapper = mount();
56 | expect(wrapper.instance().getOffsetTop()).toEqual("-100px");
57 |
58 | wrapper.instance().menu = {offsetHeight: 30};
59 | wrapper.setProps({});
60 | expect(wrapper.instance().getOffsetTop()).toEqual("17px");
61 | wrapper.instance().menu = {offsetHeight: 200};
62 | wrapper.setProps({});
63 | expect(wrapper.instance().getOffsetTop()).toEqual("-260px");
64 | });
65 |
66 | it("the navigateToPage should change a value of state", () => {
67 | const wrapper = mount();
68 | const navigateToPage2 = () => wrapper.instance().navigateToPage(2);
69 | const button = mount(
70 |
73 | );
74 | button.find("button").simulate("click");
75 | setTimeout(() => {
76 | expect(wrapper.state("currentPageNum")).toEqual(2);
77 | }, 300);
78 | });
79 | });
--------------------------------------------------------------------------------
/src/components/__tests__/LikeButton.test.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { mount } from "enzyme";
3 | import LikeButton from "components/Common/LikeButton";
4 |
5 | describe("", () => {
6 | it("should work right", () => {
7 | const props = {
8 | unlike: jest.fn(),
9 | like: jest.fn(),
10 | isActive: true,
11 | };
12 | const wrapper = mount();
13 | expect(wrapper.find("svg.like-btn_active")).toHaveLength(1);
14 |
15 | wrapper.find("svg").simulate("click");
16 | expect(props.unlike.mock.calls.length).toBe(1);
17 |
18 | wrapper.setProps({isActive: false});
19 | expect(wrapper.find("svg.like-btn_active")).toHaveLength(0);
20 |
21 | wrapper.find("svg").simulate("click");
22 | expect(props.like.mock.calls.length).toBe(1);
23 | });
24 | });
--------------------------------------------------------------------------------
/src/components/__tests__/OpenContextMenuButton.test.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { shallow } from "enzyme";
3 | import renderer from "react-test-renderer";
4 | import OpenContextMenuButton from "components/Common/OpenContextMenuButton";
5 |
6 | describe("", () => {
7 | it("renders correctly", () => {
8 | const props = {
9 | renderContextMenu: closeContextMenu => {
10 | return (
11 |
12 | Menu
13 |
14 | );
15 | },
16 | renderContent: toggleContextMenu => {
17 | return (
18 |
19 | Open Menu
20 |
21 | );
22 | },
23 | className: "test",
24 | };
25 | const component = renderer.create(
26 |
27 | ).toJSON();
28 | expect(component).toMatchSnapshot();
29 | });
30 |
31 | it("should toggle context menu correctly", () => {
32 | const props = {
33 | renderContextMenu: jest.fn(),
34 | renderContent: jest.fn(),
35 | };
36 | const wrapper = shallow();
37 | expect(props.renderContent.mock.calls.length).toBe(1);
38 | expect(props.renderContent).lastCalledWith(expect.any(Function));
39 |
40 | wrapper.setState({isContextMenuOpen: true});
41 | expect(props.renderContextMenu.mock.calls.length).toBe(1);
42 | expect(props.renderContextMenu).lastCalledWith(expect.any(Function));
43 | expect(wrapper.find(".more-btn_active")).toHaveLength(1);
44 |
45 | wrapper.setState({isContextMenuOpen: false});
46 | expect(wrapper.find(".more-btn_active")).toHaveLength(0);
47 | expect(props.renderContextMenu.mock.calls.length).toBe(1);
48 | });
49 | });
--------------------------------------------------------------------------------
/src/components/__tests__/Tracks.test.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { shallow } from "enzyme";
3 |
4 | import { Tracks } from "components/Common/Tracks";
5 | import playerAPI from "utils/playerAPI";
6 | import Track from "components/Common/Track/Track";
7 | import { initialPlayerState } from "reducers/PlayerReducer";
8 |
9 | describe("", () => {
10 | it("count of tracks and trackList elements should be equal", () => {
11 | const props = {
12 | trackList: [{}, {}, {}],
13 | source: {name: "test"},
14 | player: initialPlayerState,
15 | };
16 | const wrapper = shallow();
17 | expect(wrapper.find(Track)).toHaveLength(3);
18 | });
19 |
20 | it("when to call the updateContext method", () => {
21 | const props = {
22 | trackList: [{}, {}, {}],
23 | source: {name: "test"},
24 | player: {
25 | ...initialPlayerState,
26 | context: {
27 | name: "test",
28 | tracks: [{}, {}, {}],
29 | },
30 | },
31 | };
32 | playerAPI.updateContext = jest.fn();
33 | const wrapper = shallow();
34 | wrapper.setProps({trackList: [{}, {}, {}, {}]});
35 | expect(playerAPI.updateContext.mock.calls.length).toBe(1);
36 |
37 | wrapper.setProps({
38 | player: {
39 | ...initialPlayerState,
40 | playingTrackId: "2",
41 | },
42 | });
43 | expect(playerAPI.updateContext.mock.calls.length).toBe(1);
44 | });
45 |
46 | it("when not to call the updateContext method", () => {
47 | const props = {
48 | trackList: [{}, {}, {}],
49 | source: {name: "test"},
50 | player: {
51 | ...initialPlayerState,
52 | context: {
53 | name: "test2",
54 | tracks: [{}, {}, {}],
55 | },
56 | },
57 | };
58 | playerAPI.updateContext = jest.fn();
59 | const wrapper = shallow();
60 | wrapper.setProps({trackList: [{}, {}, {}, {}]});
61 | expect(playerAPI.updateContext.mock.calls.length).toBe(0);
62 | });
63 | });
--------------------------------------------------------------------------------
/src/components/__tests__/__snapshots__/Artist.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` renders correctly 1`] = `
4 |
27 | `;
28 |
--------------------------------------------------------------------------------
/src/components/__tests__/__snapshots__/AuthorList.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` renders correctly 1`] = `
4 | Array [
5 |
9 |
12 | John Doe
13 |
14 | ,
15 | ", ",
16 |
20 |
23 | Mike
24 |
25 | ,
26 | ]
27 | `;
28 |
--------------------------------------------------------------------------------
/src/components/__tests__/__snapshots__/Block.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` renders correctly 1`] = `
4 |
40 | `;
41 |
--------------------------------------------------------------------------------
/src/components/__tests__/__snapshots__/Columns.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` renders correctly 1`] = `
4 | Array [
5 |
8 |
9 | Block 1
10 |
11 |
12 | Block 2
13 |
14 |
,
15 |
18 |
19 | Block 3
20 |
21 |
22 | Block 4
23 |
24 |
,
25 | ]
26 | `;
27 |
--------------------------------------------------------------------------------
/src/components/__tests__/__snapshots__/ContextMenuItems.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` renders correctly 1`] = `
4 |
7 |
10 |
11 | -
15 | item1
16 |
17 | -
21 | item2
22 |
23 |
24 |
25 |
28 |
35 |
36 |
37 | `;
38 |
--------------------------------------------------------------------------------
/src/components/__tests__/__snapshots__/OpenContextMenuButton.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` renders correctly 1`] = `
4 |
13 | `;
14 |
--------------------------------------------------------------------------------
/src/components/__tests__/__snapshots__/Search.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` renders correctly 1`] = `
4 |
5 |
8 |
11 |
12 | What are you looking for?
13 |
14 |
17 |
24 |
28 |
52 |
53 |
54 |
55 |
56 |
57 | `;
58 |
--------------------------------------------------------------------------------
/src/constants/AppConstants.js:
--------------------------------------------------------------------------------
1 | import Spotify from "spotify-web-api-js";
2 |
3 | export const CLIENT_ID = "";
4 | export const REDIRECT_URL = "";
5 | export const SCOPE = [
6 | "playlist-read-private",
7 | "playlist-read-collaborative",
8 | "playlist-modify-public",
9 | "playlist-modify-private",
10 | "streaming",
11 | "ugc-image-upload",
12 | "user-follow-modify",
13 | "user-follow-read",
14 | "user-library-read",
15 | "user-library-modify",
16 | "user-read-private",
17 | "user-read-birthdate",
18 | "user-read-email",
19 | "user-top-read",
20 | "user-read-playback-state",
21 | "user-modify-playback-state",
22 | "user-read-currently-playing",
23 | "user-read-recently-played",
24 | ].join(" ");
25 | export const TOKEN_NAME = "spotify_access_token";
26 | export const EXPIRATION_TIME = "spotify_expires_in";
27 | export const USER_ID = "spotify_user_id";
28 | export const SPOTIFY_API = new Spotify();
29 |
30 | export const NEW_RELEASES_LIMIT = 20;
31 | export const USER_PLAYLISTS_LIMIT = 19;
32 | export const CATEGORY_PLAYLISTS_LIMIT = 20;
33 | export const CATEGORIES_LIMIT = 20;
34 | export const FOLLOWED_ARTISTS_LIMIT = 20;
35 | export const SAVED_TRACKS_LIMIT = 20;
36 | export const PLAYLIST_TRACKS_LIMIT = 20;
37 | export const ALBUM_TRACKS_LIMIT = 50;
38 | export const ARTIST_ALBUMS_LIMIT = 20;
39 | export const ARTIST_SINGLES_LIMIT = 20;
40 | export const TOP_TRACKS_LIMIT = 25;
41 |
42 | export const MESSAGES = {
43 | SAVED_TO_LIBRARY: "Saved to Your Library",
44 | REMOVED_FROM_LIBRARY: "Removed from Your Library",
45 | ADDED_TO_PLAYLIST: "New Track Added to Playlist",
46 | ADDED_TO_FAV_TRACKS: "Added to Favorite Tracks",
47 | REMOVED_FROM_FAV_TRACKS: "Removed from Favorite Tracks",
48 | ERROR: "Something Went Wrong. Try Again Later",
49 | TOKEN_HAS_EXPIRED: "Token has expired",
50 | };
--------------------------------------------------------------------------------
/src/constants/PlaylistIds.js:
--------------------------------------------------------------------------------
1 | export const TOP_50 = {
2 | global: "37i9dQZEVXbMDoHDwVN2tF",
3 | united_states: "37i9dQZEVXbLRQDuF5jeBp",
4 | italy: "37i9dQZEVXbIQnj7RRhdSX",
5 | vietnam: "37i9dQZEVXbLdGSmz6xilI",
6 | united_kingdom: "37i9dQZEVXbLnolsZ8PSNw",
7 | france: "37i9dQZEVXbIPWwFssbupI",
8 | sweden: "37i9dQZEVXbLoATJ81JYXz",
9 | hungary: "37i9dQZEVXbNHwMxAkvmF8",
10 | germany: "37i9dQZEVXbJiZcmkrIHGU",
11 | iceland: "37i9dQZEVXbKMzVsSGQ49S",
12 | taiwan: "37i9dQZEVXbMnZEatlMSiu",
13 | singapore: "37i9dQZEVXbK4gjvS1FjPY",
14 | thailand: "37i9dQZEVXbMnz8KIWsvf9",
15 | belgium: "37i9dQZEVXbJNSeeHswcKB",
16 | poland: "37i9dQZEVXbN6itCcaL3Tt",
17 | denmark: "37i9dQZEVXbL3J0k32lWnN",
18 | austria: "37i9dQZEVXbKNHh6NIXu36",
19 | indonesia: "37i9dQZEVXbObFQZ3JLcXt",
20 | greese: "37i9dQZEVXbJqdarpmTJDL",
21 | finland: "37i9dQZEVXbMxcczTSoGwZ",
22 | netherlands: "37i9dQZEVXbKCF6dqVpDkS",
23 | romania: "37i9dQZEVXbNZbJ6TZelCq",
24 | hong_kong: "37i9dQZEVXbLwpL8TjsxOG",
25 | estonia: "37i9dQZEVXbLesry2Qw2xS",
26 | canada: "37i9dQZEVXbKj23U1GF4IR",
27 | japan: "37i9dQZEVXbKXQ4mDTEBXq",
28 | malaysia: "37i9dQZEVXbJlfUljuZExa",
29 | turkey: "37i9dQZEVXbIVYVBNw9D5K",
30 | india: "37i9dQZEVXbLZ52XmnySJg",
31 | norway: "37i9dQZEVXbJvfa0Yxg7E7",
32 | brazil: "37i9dQZEVXbMXbN3EUUhlg",
33 | costa_rica: "37i9dQZEVXbMZAjGMynsQX",
34 | argentina: "37i9dQZEVXbMMy2roB9myp",
35 | south_africa: "37i9dQZEVXbMH2jvi6jvjk",
36 | spain: "37i9dQZEVXbNFJfN1Vw8d9",
37 | };
--------------------------------------------------------------------------------
/src/constants/RouteConstants.js:
--------------------------------------------------------------------------------
1 | export const HOME = "/";
2 | export const PLAYLISTS = "/playlists";
3 | export const ARTISTS = "/artists";
4 | export const LIKED = "/liked";
5 |
6 | export const CHARTS = "/charts";
7 | export const NEW = "/new";
8 | export const GENRES = "/genres";
9 |
10 | export const ALBUM = "/album";
11 | export const PLAYLIST = "/playlist";
12 | export const ARTIST = "/artist";
13 | export const ALBUMS = "/albums";
14 | export const SINGLES = "/singles";
15 | export const CATEGORY_PLAYLISTS = "/view";
16 |
17 | export const LOGIN_CALLBACK = "/callback";
--------------------------------------------------------------------------------
/src/containers/Album/AlbumContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 | import {
3 | loadAlbum,
4 | addToMySavedAlbums,
5 | removeFromMySavedAlbums,
6 | } from "actions/AlbumActions";
7 |
8 | const mapStateToProps = store => {
9 | return {
10 | album: store.album,
11 | tracks: store.album.items,
12 | };
13 | };
14 |
15 | const mapDispatchToProps = dispatch => {
16 | return {
17 | loadAlbum: albumId => {
18 | dispatch(loadAlbum(albumId));
19 | },
20 | addToMySavedAlbums: albumIds => {
21 | dispatch(addToMySavedAlbums([albumIds]));
22 | },
23 | removeFromMySavedAlbums: albumIds => {
24 | dispatch(removeFromMySavedAlbums([albumIds]));
25 | },
26 | };
27 | };
28 |
29 | export const connectAlbum = connect(
30 | mapStateToProps,
31 | mapDispatchToProps
32 | );
--------------------------------------------------------------------------------
/src/containers/Album/ArtistAlbumsContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 | import {
3 | loadArtistAlbums,
4 | loadMoreArtistAlbums,
5 | } from "actions/AlbumActions";
6 |
7 | const mapStateToProps = store => {
8 | return {
9 | albums: store.artistAlbums,
10 | };
11 | };
12 |
13 | const mapDispatchToProps = dispatch => {
14 | return {
15 | loadAlbums: artistId => {
16 | dispatch(loadArtistAlbums(artistId));
17 | },
18 | loadMore: (artistId, offset) => {
19 | dispatch(loadMoreArtistAlbums(artistId, offset));
20 | },
21 | };
22 | };
23 |
24 | export const connectArtistAlbums = connect(
25 | mapStateToProps,
26 | mapDispatchToProps
27 | );
--------------------------------------------------------------------------------
/src/containers/Album/ArtistSinglesContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 | import {
3 | loadArtistSingles,
4 | loadMoreArtistSingles,
5 | } from "actions/AlbumActions";
6 |
7 | const mapStateToProps = store => {
8 | return {
9 | singles: store.artistSingles,
10 | };
11 | };
12 |
13 | const mapDispatchToProps = dispatch => {
14 | return {
15 | loadSingles: artistId => {
16 | dispatch(loadArtistSingles(artistId));
17 | },
18 | loadMore: (artistId, offset) => {
19 | dispatch(loadMoreArtistSingles(artistId, offset));
20 | },
21 | };
22 | };
23 |
24 | export const connectArtistSingles = connect(
25 | mapStateToProps,
26 | mapDispatchToProps
27 | );
--------------------------------------------------------------------------------
/src/containers/Album/NewReleasesContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 | import {
3 | loadNewReleases,
4 | loadMoreNewReleases,
5 | } from "actions/AlbumActions";
6 |
7 | const mapStateToProps = store => {
8 | return {
9 | newReleases: store.newReleases,
10 | };
11 | };
12 |
13 | const mapDispatchToProps = dispatch => {
14 | return {
15 | loadNewReleases: () => {
16 | dispatch(loadNewReleases());
17 | },
18 | loadMore: offset => {
19 | dispatch(loadMoreNewReleases(offset));
20 | },
21 | };
22 | };
23 |
24 | export const connectNewReleases = connect(
25 | mapStateToProps,
26 | mapDispatchToProps
27 | );
--------------------------------------------------------------------------------
/src/containers/Artist/ArtistHeaderContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 | import {
3 | loadArtist,
4 | followArtist,
5 | unfollowArtist,
6 | } from "actions/ArtistActions";
7 |
8 | const mapStateToProps = store => {
9 | return {
10 | artist: store.artist,
11 | };
12 | };
13 |
14 | const mapDispatchToProps = dispatch => {
15 | return {
16 | loadArtist: artistId => {
17 | dispatch(loadArtist(artistId));
18 | },
19 | followArtist: artist => {
20 | dispatch(followArtist(artist));
21 | },
22 | unfollowArtist: artistId => {
23 | dispatch(unfollowArtist(artistId));
24 | },
25 | };
26 | };
27 |
28 | export const connectArtistHeader = connect(
29 | mapStateToProps,
30 | mapDispatchToProps
31 | );
--------------------------------------------------------------------------------
/src/containers/Artist/FollowedArtistsContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 | import {
3 | loadFollowedArtists,
4 | loadMoreFollowedArtists,
5 | } from "actions/ArtistActions";
6 |
7 | const mapStateToProps = store => {
8 | return {
9 | artists: store.followedArtists,
10 | };
11 | };
12 |
13 | const mapDispatchToProps = dispatch => {
14 | return {
15 | loadArtists: () => {
16 | dispatch(loadFollowedArtists());
17 | },
18 | loadMore: lastArtistId => {
19 | dispatch(loadMoreFollowedArtists(lastArtistId));
20 | },
21 | };
22 | };
23 |
24 | export const connectFollowedArtists = connect(
25 | mapStateToProps,
26 | mapDispatchToProps
27 | );
--------------------------------------------------------------------------------
/src/containers/Artist/RelatedArtistsContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 | import { loadRelatedArtists } from "actions/ArtistActions";
3 |
4 | const mapStateToProps = store => {
5 | return {
6 | artists: store.relatedArtists,
7 | };
8 | };
9 |
10 | const mapDispatchToProps = dispatch => {
11 | return {
12 | loadRelatedArtists: artistId => {
13 | dispatch(loadRelatedArtists(artistId));
14 | },
15 | };
16 | };
17 |
18 | export const connectRelatedArtists = connect(
19 | mapStateToProps,
20 | mapDispatchToProps
21 | );
--------------------------------------------------------------------------------
/src/containers/CategoriesContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 | import {
3 | loadCategories,
4 | loadMoreCategories,
5 | } from "actions/CategoryActions";
6 |
7 | const mapStateToProps = store => {
8 | return {
9 | categories: store.categories,
10 | };
11 | };
12 |
13 | const mapDispatchToProps = dispatch => {
14 | return {
15 | loadCategories: limit => {
16 | dispatch(loadCategories(limit));
17 | },
18 | loadMore: offset => {
19 | dispatch(loadMoreCategories(offset));
20 | },
21 | };
22 | };
23 |
24 | export const connectCategories = connect(
25 | mapStateToProps,
26 | mapDispatchToProps
27 | );
--------------------------------------------------------------------------------
/src/containers/ChartsContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 |
3 | import { filterByCountry } from "actions/ChartActions";
4 | import { TOP_50 } from "constants/PlaylistIds";
5 | import { loadMoreTopTracks } from "actions/TrackActions";
6 | import countries from "utils/getCountryList";
7 |
8 | const mapStateToProps = store => {
9 | return {
10 | tracks: store.topTracks,
11 | charts: store.charts,
12 | countries: countries,
13 | };
14 | };
15 |
16 | const mapDispatchToProps = dispatch => {
17 | return {
18 | filterByCountry: (countryName = "global") => {
19 | const country = countryName.toLowerCase().split(" ").join("_");
20 | const playlistId = TOP_50[country];
21 | dispatch(
22 | filterByCountry({
23 | countryName,
24 | playlistId,
25 | })
26 | );
27 | },
28 | loadMore: (playlistId, offset) => {
29 | dispatch(loadMoreTopTracks(playlistId, offset));
30 | },
31 | };
32 | };
33 |
34 | export const connectCharts = connect(
35 | mapStateToProps,
36 | mapDispatchToProps
37 | );
--------------------------------------------------------------------------------
/src/containers/PlayerContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 |
3 | const mapStateToProps = store => {
4 | return {
5 | player: store.player,
6 | };
7 | };
8 |
9 | export const connectPlayer = connect(mapStateToProps);
--------------------------------------------------------------------------------
/src/containers/Playlist/CategoryPlaylistsContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 | import {
3 | loadCategoryPlaylists,
4 | loadMoreCategoryPlaylists,
5 | loadRandomCategoryPlaylists,
6 | } from "actions/PlaylistActions";
7 |
8 | const mapStateToProps = store => {
9 | return {
10 | playlists: store.categoryPlaylists,
11 | };
12 | };
13 |
14 | const mapDispatchToProps = dispatch => {
15 | return {
16 | loadPlaylists: playlistId => {
17 | dispatch(loadCategoryPlaylists(playlistId));
18 | },
19 | loadRandomPlaylists: () => {
20 | dispatch(loadRandomCategoryPlaylists());
21 | },
22 | loadMore: (id, offset) => {
23 | dispatch(loadMoreCategoryPlaylists(id, offset));
24 | },
25 | };
26 | };
27 |
28 | export const connectCategoryPlaylists = connect(
29 | mapStateToProps,
30 | mapDispatchToProps
31 | );
--------------------------------------------------------------------------------
/src/containers/Playlist/FeaturedPlaylistsContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 | import { loadFeaturedPlaylists } from "actions/PlaylistActions";
3 |
4 | const mapStateToProps = store => {
5 | return {
6 | playlists: store.featuredPlaylists,
7 | };
8 | };
9 |
10 | const mapDispatchToProps = dispatch => {
11 | return {
12 | loadFeaturedPlaylists: () => {
13 | dispatch(loadFeaturedPlaylists());
14 | },
15 | };
16 | };
17 |
18 | export const connectFeaturedPlaylists = connect(
19 | mapStateToProps,
20 | mapDispatchToProps
21 | );
--------------------------------------------------------------------------------
/src/containers/Playlist/ListUserPlaylistsContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 | import { LIST_USER_PLAYLISTS } from "constants/ActionConstants";
3 | import {
4 | loadUserPlaylists,
5 | loadMoreUserPlaylists,
6 | addTrackToPlaylist,
7 | } from "actions/PlaylistActions";
8 |
9 | const mapStateToProps = store => {
10 | return {
11 | playlists: store.listUserPlaylists,
12 | };
13 | };
14 |
15 | const mapDispatchToProps = dispatch => {
16 | return {
17 | loadUserPlaylists: () => {
18 | dispatch(loadUserPlaylists(LIST_USER_PLAYLISTS));
19 | },
20 | loadMore: offset => {
21 | dispatch(loadMoreUserPlaylists(LIST_USER_PLAYLISTS, offset));
22 | },
23 | addTrackToPlaylist: (playlistId, trackUri) => {
24 | dispatch(addTrackToPlaylist(playlistId, trackUri));
25 | },
26 | };
27 | };
28 |
29 | export const connectUserPlaylists = connect(
30 | mapStateToProps,
31 | mapDispatchToProps
32 | );
--------------------------------------------------------------------------------
/src/containers/Playlist/PlaylistContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 |
3 | import { loadMorePlaylistTracks } from "actions/TrackActions";
4 | import {
5 | loadPlaylist,
6 | followPlaylist,
7 | unfollowPlaylist,
8 | uploadCoverImage,
9 | changePlaylistDetails,
10 | removeTracksFromPlaylist,
11 | } from "actions/PlaylistActions";
12 |
13 | const mapStateToProps = store => {
14 | return {
15 | playlist: store.playlist,
16 | };
17 | };
18 |
19 | const mapDispatchToProps = dispatch => {
20 | return {
21 | loadPlaylist: playlistId => {
22 | dispatch(loadPlaylist(playlistId));
23 | },
24 | followPlaylist: (playlistId, playlist) => {
25 | dispatch(followPlaylist(playlistId, playlist));
26 | },
27 | unfollowPlaylist: playlistId => {
28 | dispatch(unfollowPlaylist(playlistId));
29 | },
30 | uploadCoverImage: (playlistId, image) => {
31 | dispatch(uploadCoverImage(playlistId, image));
32 | },
33 | changePlaylistDetails: (playlistId, data) => {
34 | dispatch(changePlaylistDetails(playlistId, data));
35 | },
36 | loadMoreTracks: (id, offset) => {
37 | dispatch(loadMorePlaylistTracks(id, offset));
38 | },
39 | removeTracksFromPlaylist: (playlistId, positions, snapshotId) => {
40 | dispatch(removeTracksFromPlaylist(playlistId, positions, snapshotId));
41 | },
42 | };
43 | };
44 |
45 | export const connectPlaylist = connect(
46 | mapStateToProps,
47 | mapDispatchToProps
48 | );
--------------------------------------------------------------------------------
/src/containers/Playlist/UserPlaylistsContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 |
3 | import { USER_PLAYLISTS } from "constants/ActionConstants";
4 | import {
5 | loadUserPlaylists,
6 | loadMoreUserPlaylists,
7 | createPlaylist,
8 | } from "actions/PlaylistActions";
9 |
10 | const mapStateToProps = store => {
11 | return {
12 | playlists: store.userPlaylists,
13 | };
14 | };
15 |
16 | const mapDispatchToProps = dispatch => {
17 | return {
18 | loadUserPlaylists: () => {
19 | dispatch(loadUserPlaylists(USER_PLAYLISTS));
20 | },
21 | loadMore: offset => {
22 | dispatch(loadMoreUserPlaylists(USER_PLAYLISTS, offset));
23 | },
24 | createPlaylist: (playlistName, redirect) => {
25 | dispatch(createPlaylist(playlistName, redirect));
26 | },
27 | };
28 | };
29 |
30 | export const connectUserPlaylists = connect(
31 | mapStateToProps,
32 | mapDispatchToProps
33 | );
--------------------------------------------------------------------------------
/src/containers/ProgressBarContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 |
3 | const mapStateToProps = store => {
4 | return {
5 | progressBar: store.progressBar,
6 | };
7 | };
8 |
9 | export const connectProgressBar = connect(mapStateToProps);
--------------------------------------------------------------------------------
/src/containers/SearchResultsContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 | import {
3 | filterByType,
4 | loadSearchResults,
5 | resetSearchResults,
6 | toggleSearch,
7 | } from "actions/SearchAction";
8 |
9 | const mapStateToProps = store => {
10 | return {
11 | searchResults: store.searchResults,
12 | };
13 | };
14 |
15 | const mapDispatchToProps = dispatch => {
16 | return {
17 | loadSearchResults: (value, options) => {
18 | dispatch(loadSearchResults(value, options));
19 | },
20 | filterByType: option => {
21 | dispatch(filterByType(option));
22 | },
23 | resetSearchResults: () => {
24 | dispatch(resetSearchResults());
25 | },
26 | toggleSearch: () => {
27 | dispatch(toggleSearch());
28 | },
29 | };
30 | };
31 |
32 | export const connectSearchResults = connect(
33 | mapStateToProps,
34 | mapDispatchToProps
35 | );
--------------------------------------------------------------------------------
/src/containers/Tracks/ArtistTopTracksContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 | import { loadArtistTopTracks } from "actions/TrackActions";
3 |
4 | const mapStateToProps = store => {
5 | return {
6 | tracks: store.artistTopTracks,
7 | };
8 | };
9 |
10 | const mapDispatchToProps = dispatch => {
11 | return {
12 | loadTopTracks: artistId => {
13 | dispatch(loadArtistTopTracks(artistId));
14 | },
15 | };
16 | };
17 |
18 | export const connectArtistTopTracks = connect(
19 | mapStateToProps,
20 | mapDispatchToProps
21 | );
--------------------------------------------------------------------------------
/src/containers/Tracks/SavedTracksContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 | import {
3 | loadMySavedTracks,
4 | loadMoreMySavedTracks,
5 | } from "actions/TrackActions";
6 |
7 | const mapStateToProps = store => {
8 | return {
9 | tracks: store.savedTracks,
10 | };
11 | };
12 |
13 | const mapDispatchToProps = dispatch => {
14 | return {
15 | loadMySavedTracks: () => {
16 | dispatch(loadMySavedTracks());
17 | },
18 | loadMore: offset => {
19 | dispatch(loadMoreMySavedTracks(offset));
20 | },
21 | };
22 | };
23 |
24 | export const connectSavedTracks = connect(
25 | mapStateToProps,
26 | mapDispatchToProps
27 | );
--------------------------------------------------------------------------------
/src/containers/Tracks/TopTracksContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 |
3 | import { filterByCountry } from "actions/ChartActions";
4 | import { loadMoreTopTracks } from "actions/TrackActions";
5 | import { TOP_50 } from "constants/PlaylistIds";
6 |
7 | const mapStateToProps = store => {
8 | return {
9 | tracks: store.topTracks,
10 | chartsCountry: store.charts.country,
11 | };
12 | };
13 |
14 | const mapDispatchToProps = dispatch => {
15 | return {
16 | loadTopTracks: () => {
17 | dispatch(
18 | filterByCountry({
19 | countryName: "global",
20 | playlistId: TOP_50.global,
21 | })
22 | );
23 | },
24 | loadMore: offset => {
25 | dispatch(loadMoreTopTracks(TOP_50.global, offset));
26 | },
27 | };
28 | };
29 |
30 | export const connectTopTracks = connect(
31 | mapStateToProps,
32 | mapDispatchToProps
33 | );
--------------------------------------------------------------------------------
/src/images/Navbar/Heart.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
44 |
--------------------------------------------------------------------------------
/src/images/Navbar/Home.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
40 |
--------------------------------------------------------------------------------
/src/images/Navbar/Label.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
41 |
--------------------------------------------------------------------------------
/src/images/Navbar/Layers.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/src/images/Navbar/LightBulb.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
48 |
--------------------------------------------------------------------------------
/src/images/Navbar/Lightning.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
43 |
--------------------------------------------------------------------------------
/src/images/Navbar/User.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/images/Player/pause.svg:
--------------------------------------------------------------------------------
1 |
36 |
--------------------------------------------------------------------------------
/src/images/Player/play-next.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
42 |
--------------------------------------------------------------------------------
/src/images/Player/play-previous.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
43 |
--------------------------------------------------------------------------------
/src/images/Player/play.svg:
--------------------------------------------------------------------------------
1 |
26 |
--------------------------------------------------------------------------------
/src/images/addPlaylist.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
47 |
--------------------------------------------------------------------------------
/src/images/cd.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrepv/spotify-react/3acfaf864b3a7c2ab7891140d877d237cc5efee6/src/images/cd.png
--------------------------------------------------------------------------------
/src/images/clef.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/images/loader.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/images/music-note.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
40 |
--------------------------------------------------------------------------------
/src/images/musician.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrepv/spotify-react/3acfaf864b3a7c2ab7891140d877d237cc5efee6/src/images/musician.png
--------------------------------------------------------------------------------
/src/images/spotify-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrepv/spotify-react/3acfaf864b3a7c2ab7891140d877d237cc5efee6/src/images/spotify-logo.png
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import { BrowserRouter } from "react-router-dom";
4 | import { Provider } from "react-redux";
5 | import { store } from "./store/configureStore";
6 | import App from "./App";
7 |
8 | import registerServiceWorker from "./serviceWorker";
9 |
10 | import "./index.scss";
11 |
12 | ReactDOM.render(
13 |
14 |
15 |
16 |
17 | ,
18 | document.getElementById("root")
19 | );
20 |
21 | registerServiceWorker();
--------------------------------------------------------------------------------
/src/index.scss:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrepv/spotify-react/3acfaf864b3a7c2ab7891140d877d237cc5efee6/src/index.scss
--------------------------------------------------------------------------------
/src/pages/Artist/Artist.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import { Route, Switch } from "react-router-dom";
3 |
4 | import { ALBUMS, SINGLES, ARTIST } from "constants/RouteConstants";
5 | import ArtistHeader from "./ArtistHeader";
6 | import ArtistTopTracks from "./ArtistTopTracks";
7 | import ArtistAlbums from "./ArtistAlbums";
8 | import ArtistSingles from "./ArtistSingles";
9 | import RelatedArtists from "./RelatedArtists";
10 | import Albums from "../ArtistAlbums";
11 | import Singles from "../ArtistSingles";
12 | import "styles/Artist.scss";
13 |
14 | export default class Artist extends Component {
15 | componentDidUpdate(prevProps) {
16 | if (!this.props.history.location.state) {
17 | window.scrollTo(0, 0);
18 | }
19 | }
20 |
21 | componentDidMount() {
22 | window.scrollTo(0, 0);
23 | }
24 |
25 | render() {
26 | const {id} = this.props.match.params;
27 | return (
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | {
41 | return ;
42 | }}
43 | />
44 | {
47 | return ;
48 | }}
49 | />
50 |
51 | );
52 | }
53 | }
--------------------------------------------------------------------------------
/src/pages/Artist/ArtistAlbums.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import Carousel from "components/Common/Carousel";
5 | import { ALBUMS, ARTIST } from "constants/RouteConstants";
6 | import { connectArtistAlbums } from "containers/Album/ArtistAlbumsContainer";
7 |
8 | export class ArtistAlbums extends Component {
9 | componentDidMount() {
10 | this.props.loadAlbums(this.props.id);
11 | }
12 |
13 | componentDidUpdate(prevProps) {
14 | const {id, loadAlbums} = this.props;
15 | if (id !== prevProps.id) {
16 | loadAlbums(id);
17 | }
18 | }
19 |
20 | render() {
21 | const {pending, items, total} = this.props.albums;
22 | const blockHeader = {title: "Albums"};
23 | if (items.length < total) {
24 | blockHeader.link = `${ARTIST}/${this.props.id}${ALBUMS}`;
25 | }
26 | if (!total) {
27 | return null;
28 | }
29 | return (
30 |
36 | );
37 | }
38 | }
39 |
40 | ArtistAlbums.propTypes = {
41 | albums: PropTypes.object.isRequired,
42 | id: PropTypes.string.isRequired,
43 | loadAlbums: PropTypes.func.isRequired,
44 | };
45 |
46 | export default connectArtistAlbums(ArtistAlbums);
--------------------------------------------------------------------------------
/src/pages/Artist/ArtistHeader.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 | import { connectArtistHeader } from "containers/Artist/ArtistHeaderContainer";
4 |
5 | export class ArtistHeader extends Component {
6 | componentDidMount() {
7 | this.props.loadArtist(this.props.id);
8 | }
9 |
10 | componentDidUpdate(prevProps) {
11 | const {id, loadArtist} = this.props;
12 | if (id !== prevProps.id) {
13 | loadArtist(id);
14 | }
15 | }
16 |
17 | follow = () => {
18 | this.props.followArtist(this.props.artist);
19 | }
20 |
21 | unfollow = () => {
22 | this.props.unfollowArtist(this.props.id);
23 | }
24 |
25 | render() {
26 | const {image, name, followers, isFollower} = this.props.artist;
27 | return (
28 |
32 |
33 |
{followers}
34 |
{name}
35 |
41 |
42 |
43 | );
44 | }
45 | }
46 |
47 | ArtistHeader.propTypes = {
48 | id: PropTypes.string.isRequired,
49 | artist: PropTypes.object.isRequired,
50 | loadArtist: PropTypes.func.isRequired,
51 | followArtist: PropTypes.func.isRequired,
52 | unfollowArtist: PropTypes.func.isRequired,
53 | };
54 |
55 | export default connectArtistHeader(ArtistHeader);
--------------------------------------------------------------------------------
/src/pages/Artist/ArtistSingles.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import { connectArtistSingles } from "containers/Album/ArtistSinglesContainer";
5 | import { SINGLES, ARTIST } from "constants/RouteConstants";
6 | import Carousel from "components/Common/Carousel";
7 |
8 | export class ArtistSingles extends Component {
9 | componentDidMount() {
10 | this.props.loadSingles(this.props.id);
11 | }
12 |
13 | componentDidUpdate(prevProps) {
14 | const {id, loadSingles} = this.props;
15 | if (id !== prevProps.id) {
16 | loadSingles(id);
17 | }
18 | }
19 |
20 | render() {
21 | const {pending, items, total} = this.props.singles;
22 | const blockHeader = {title: "Singles"};
23 | if (items.length < total) {
24 | blockHeader.link = `${ARTIST}/${this.props.id}${SINGLES}`;
25 | }
26 | if (!total) {
27 | return null;
28 | }
29 | return (
30 |
36 | );
37 | }
38 | }
39 |
40 | ArtistSingles.propTypes = {
41 | singles: PropTypes.object.isRequired,
42 | id: PropTypes.string.isRequired,
43 | loadSingles: PropTypes.func.isRequired,
44 | };
45 |
46 | export default connectArtistSingles(ArtistSingles);
--------------------------------------------------------------------------------
/src/pages/Artist/ArtistTopTracks.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import Tracks from "components/Common/Tracks";
5 | import BlockHeader from "components/Common/BlockHeader";
6 | import SkeletonTracks from "components/Skeleton/SkeletonTracks";
7 | import { connectArtistTopTracks }
8 | from "containers/Tracks/ArtistTopTracksContainer";
9 |
10 | export class ArtistTopTracks extends Component {
11 | componentDidMount() {
12 | this.props.loadTopTracks(this.props.id);
13 | }
14 |
15 | componentDidUpdate(prevProps) {
16 | const {id, loadTopTracks} = this.props;
17 | if (id !== prevProps.id) {
18 | loadTopTracks(id);
19 | }
20 | }
21 |
22 | render() {
23 | const {items, pending} = this.props.tracks;
24 | const source = {name: `ArtistTopTracks_${this.props.id}`};
25 | if (pending) {
26 | return (
27 |
31 | );
32 | }
33 | return (
34 |
44 | );
45 | }
46 | }
47 |
48 | ArtistTopTracks.propTypes = {
49 | tracks: PropTypes.object.isRequired,
50 | loadTopTracks: PropTypes.func.isRequired,
51 | id: PropTypes.string.isRequired,
52 | };
53 |
54 | export default connectArtistTopTracks(ArtistTopTracks);
--------------------------------------------------------------------------------
/src/pages/Artist/RelatedArtists.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import Artist from "components/Common/Artist";
5 | import BlockHeader from "components/Common/BlockHeader";
6 | import SkeletonArtists from "components/Skeleton/SkeletonArtists";
7 | import { connectRelatedArtists }
8 | from "containers/Artist/RelatedArtistsContainer";
9 |
10 | export class RelatedArtists extends Component {
11 | componentDidMount() {
12 | this.props.loadRelatedArtists(this.props.id);
13 | }
14 |
15 | componentDidUpdate(prevProps) {
16 | const {id, loadRelatedArtists} = this.props;
17 | if (id !== prevProps.id) {
18 | loadRelatedArtists(id);
19 | }
20 | }
21 |
22 | render() {
23 | const {pending, items} = this.props.artists;
24 | if (pending) {
25 | return (
26 |
31 | );
32 | }
33 | if (!items.length) {
34 | return null;
35 | }
36 | return (
37 |
38 |
39 |
40 | {items.map(item => {
41 | return (
42 |
48 | );
49 | })}
50 |
51 |
52 | );
53 | }
54 | }
55 |
56 | RelatedArtists.propTypes = {
57 | id: PropTypes.string.isRequired,
58 | artists: PropTypes.object.isRequired,
59 | loadRelatedArtists: PropTypes.func.isRequired,
60 | };
61 |
62 | export default connectRelatedArtists(RelatedArtists);
--------------------------------------------------------------------------------
/src/pages/ArtistAlbums.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import Block from "components/Common/Block";
5 | import BlockHeader from "components/Common/BlockHeader";
6 | import InfiniteScroll from "components/Common/InfiniteScroll";
7 | import SkeletonBlocks from "components/Skeleton/SkeletonBlocks";
8 | import { connectArtistAlbums } from "containers/Album/ArtistAlbumsContainer";
9 |
10 | export class ArtistAlbums extends Component {
11 | componentDidMount() {
12 | this.props.loadAlbums(this.props.id);
13 | }
14 |
15 | render() {
16 | const {id, loadMore} = this.props;
17 | const {pending, items, loadMorePending, total, error} = this.props.albums;
18 | const loadData = () => loadMore(id, items.length);
19 | if (pending || error) {
20 | return ;
21 | }
22 | return (
23 |
24 |
25 |
31 |
32 | {items.map(item => {
33 | return (
34 |
42 | );
43 | })}
44 |
45 |
46 |
47 | );
48 | }
49 | }
50 |
51 | ArtistAlbums.propTypes = {
52 | loadAlbums: PropTypes.func.isRequired,
53 | id: PropTypes.string.isRequired,
54 | loadMore: PropTypes.func.isRequired,
55 | albums: PropTypes.object.isRequired,
56 | };
57 |
58 | export default connectArtistAlbums(ArtistAlbums);
--------------------------------------------------------------------------------
/src/pages/ArtistSingles.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import Block from "components/Common/Block";
5 | import BlockHeader from "components/Common/BlockHeader";
6 | import InfiniteScroll from "components/Common/InfiniteScroll";
7 | import SkeletonBlocks from "components/Skeleton/SkeletonBlocks";
8 | import { connectArtistSingles } from "containers/Album/ArtistSinglesContainer";
9 |
10 | export class ArtistSingles extends Component {
11 | componentDidMount() {
12 | this.props.loadSingles(this.props.id);
13 | }
14 |
15 | render() {
16 | const {id, loadMore} = this.props;
17 | const {pending, items, loadMorePending, total, error} = this.props.singles;
18 | const loadData = () => loadMore(id, items.length);
19 | if (pending || error) {
20 | return ;
21 | }
22 | return (
23 |
24 |
25 |
31 |
32 | {items.map((item, index) => {
33 | return (
34 |
42 | );
43 | })}
44 |
45 |
46 |
47 | );
48 | }
49 | }
50 |
51 | ArtistSingles.propTypes = {
52 | loadSingles: PropTypes.func.isRequired,
53 | id: PropTypes.string.isRequired,
54 | loadMore: PropTypes.func.isRequired,
55 | singles: PropTypes.object.isRequired,
56 | };
57 |
58 | export default connectArtistSingles(ArtistSingles);
--------------------------------------------------------------------------------
/src/pages/Categories.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import BlockHeader from "components/Common/BlockHeader";
5 | import InfiniteScroll from "components/Common/InfiniteScroll";
6 | import Category from "components/Common/Category";
7 | import SkeletonCategories from "components/Skeleton/SkeletonCategories";
8 | import { connectCategories } from "containers/CategoriesContainer";
9 | import "styles/Category.scss";
10 |
11 | export class Categories extends Component {
12 | componentDidMount() {
13 | const {categories, loadCategories} = this.props;
14 | window.scrollTo(0, 0);
15 | if (!categories.items.length) {
16 | loadCategories();
17 | }
18 | }
19 |
20 | render() {
21 | const {pending, total, loadMorePending, items, error} = this.props.categories;
22 | const loadMore = () => this.props.loadMore(items.length);
23 | if (pending || error) {
24 | return ;
25 | }
26 | return (
27 |
28 |
29 |
35 |
36 | {items.map((item, index) => {
37 | return (
38 |
44 | );
45 | })}
46 |
47 |
48 |
49 | );
50 | }
51 | }
52 |
53 | Categories.propTypes = {
54 | categories: PropTypes.object.isRequired,
55 | loadCategories: PropTypes.func.isRequired,
56 | loadMore: PropTypes.func.isRequired,
57 | };
58 |
59 | export default connectCategories(Categories);
--------------------------------------------------------------------------------
/src/pages/CategoryPlaylists.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import Block from "components/Common/Block";
5 | import BlockHeader from "components/Common/BlockHeader";
6 | import InfiniteScroll from "components/Common/InfiniteScroll";
7 | import SkeletonBlocks from "components/Skeleton/SkeletonBlocks";
8 | import { connectCategoryPlaylists }
9 | from "containers/Playlist/CategoryPlaylistsContainer";
10 |
11 | export class CategoryPlaylists extends Component {
12 | componentDidMount() {
13 | const {loadPlaylists, match, playlists} = this.props;
14 | window.scrollTo(0, 0);
15 | if (match.params.id !== playlists.categoryId) {
16 | loadPlaylists({
17 | id: match.params.id,
18 | });
19 | }
20 | }
21 |
22 | render() {
23 | const {
24 | categoryName,
25 | total,
26 | loadMorePending,
27 | pending,
28 | items,
29 | error,
30 | } = this.props.playlists;
31 | const {id} = this.props.match.params;
32 | const loadMore = () => this.props.loadMore(id, items.length);
33 | if (pending || error) {
34 | return ;
35 | }
36 | return (
37 |
38 |
39 |
45 |
46 | {items.map((item, index) => {
47 | return (
48 |
56 | );
57 | })}
58 |
59 |
60 |
61 | );
62 | }
63 | }
64 |
65 | CategoryPlaylists.propTypes = {
66 | playlists: PropTypes.object.isRequired,
67 | loadPlaylists: PropTypes.func.isRequired,
68 | loadMore: PropTypes.func.isRequired,
69 | };
70 |
71 | export default connectCategoryPlaylists(CategoryPlaylists);
--------------------------------------------------------------------------------
/src/pages/Charts/ChartTracks.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 | import Tracks from "components/Common/Tracks";
4 |
5 | export default class ChartTracks extends Component {
6 | shouldComponentUpdate(nextProps) {
7 | return this.props.tracks !== nextProps.tracks;
8 | }
9 |
10 | render() {
11 | const {tracks, playlistId} = this.props;
12 | const source = {name: `ChartTracks_${playlistId}`};
13 | return (
14 |
19 | );
20 | }
21 | }
22 |
23 | ChartTracks.propTypes = {
24 | tracks: PropTypes.array.isRequired,
25 | playlistId: PropTypes.string.isRequired,
26 | };
--------------------------------------------------------------------------------
/src/pages/HomePage/Categories.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import BlockHeader from "components/Common/BlockHeader";
5 | import Category from "components/Common/Category";
6 | import SkeletonCategories from "components/Skeleton/SkeletonCategories";
7 | import { GENRES } from "constants/RouteConstants";
8 | import { connectCategories } from "containers/CategoriesContainer";
9 |
10 | export class Categories extends Component {
11 | componentDidMount() {
12 | const {categories, loadCategories} = this.props;
13 | if (!categories.items.length) {
14 | loadCategories();
15 | }
16 | }
17 |
18 | render() {
19 | const {pending, items} = this.props.categories;
20 | const categories = items.slice(0, 8);
21 | if (pending) {
22 | return ;
23 | }
24 | return (
25 |
26 |
31 |
32 | {categories.map((category, index) => {
33 | return (
34 |
40 | );
41 | })}
42 |
43 |
44 | );
45 | }
46 | }
47 |
48 | Categories.propTypes = {
49 | categories: PropTypes.object.isRequired,
50 | loadCategories: PropTypes.func.isRequired,
51 | };
52 |
53 | export default connectCategories(Categories);
--------------------------------------------------------------------------------
/src/pages/HomePage/CategoryPlaylists.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import Carousel from "components/Common/Carousel";
5 | import { CATEGORY_PLAYLISTS } from "constants/RouteConstants";
6 | import { connectCategoryPlaylists }
7 | from "containers/Playlist/CategoryPlaylistsContainer";
8 |
9 | export class CategoryPlaylists extends Component {
10 | componentDidMount() {
11 | this.props.loadRandomPlaylists();
12 | }
13 |
14 | render() {
15 | const {pending, items, categoryName, categoryId} = this.props.playlists;
16 | const blockHeader = {
17 | title: categoryName,
18 | description: "You May Like",
19 | link: `${CATEGORY_PLAYLISTS}/${categoryId}`,
20 | };
21 | return (
22 |
28 | );
29 | }
30 | }
31 |
32 | CategoryPlaylists.propTypes = {
33 | playlists: PropTypes.object.isRequired,
34 | loadRandomPlaylists: PropTypes.func.isRequired,
35 | };
36 |
37 | export default connectCategoryPlaylists(CategoryPlaylists);
--------------------------------------------------------------------------------
/src/pages/HomePage/FeaturedPlaylists.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import Carousel from "components/Common/Carousel";
5 | import { connectFeaturedPlaylists }
6 | from "containers/Playlist/FeaturedPlaylistsContainer";
7 |
8 | export class FeaturedPlaylists extends Component {
9 | componentDidMount() {
10 | const {playlists, loadFeaturedPlaylists} = this.props;
11 | if (!playlists.items.length) {
12 | loadFeaturedPlaylists();
13 | }
14 | }
15 |
16 | render() {
17 | const {pending, items, message} = this.props.playlists;
18 | const blockHeader = {
19 | title: "Featured Playlists",
20 | description: message,
21 | };
22 | return (
23 |
29 | );
30 | }
31 | }
32 |
33 | FeaturedPlaylists.propTypes = {
34 | playlists: PropTypes.object.isRequired,
35 | loadFeaturedPlaylists: PropTypes.func.isRequired,
36 | };
37 |
38 | export default connectFeaturedPlaylists(FeaturedPlaylists);
--------------------------------------------------------------------------------
/src/pages/HomePage/Home.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 |
3 | import TopTracks from "./TopTracks";
4 | import RelatedArtists from "./RelatedArtists";
5 | import UserPlaylists from "./UserPlaylists";
6 | import CategoryPlaylists from "./CategoryPlaylists";
7 | import Categories from "./Categories";
8 | import FeaturedPlaylists from "./FeaturedPlaylists";
9 | import NewReleases from "./NewReleases";
10 |
11 | export default class Home extends Component {
12 | componentDidMount() {
13 | window.scrollTo(0, 0);
14 | }
15 |
16 | render() {
17 | return (
18 |
19 |
20 |
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 | }
--------------------------------------------------------------------------------
/src/pages/HomePage/NewReleases.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import Carousel from "components/Common/Carousel";
5 | import { NEW } from "constants/RouteConstants";
6 | import { connectNewReleases } from "containers/Album/NewReleasesContainer";
7 | import { NEW_RELEASES_LIMIT } from "constants/AppConstants";
8 |
9 | export class NewReleases extends Component {
10 | componentDidMount() {
11 | const {newReleases, loadNewReleases} = this.props;
12 | if (!newReleases.items.length) {
13 | loadNewReleases();
14 | }
15 | }
16 |
17 | render() {
18 | const {pending, items} = this.props.newReleases;
19 | const blockHeader = {
20 | title: "Releases",
21 | description: "New",
22 | link: NEW,
23 | };
24 | const carouselItems = items.length > NEW_RELEASES_LIMIT
25 | ? items.slice(0, NEW_RELEASES_LIMIT)
26 | : items;
27 | return (
28 |
34 | );
35 | }
36 | }
37 |
38 | NewReleases.propTypes = {
39 | newReleases: PropTypes.object.isRequired,
40 | loadNewReleases: PropTypes.func.isRequired,
41 | };
42 |
43 | export default connectNewReleases(NewReleases);
--------------------------------------------------------------------------------
/src/pages/HomePage/RelatedArtists.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import BlockHeader from "components/Common/BlockHeader";
5 | import Artist from "components/Common/Artist";
6 | import { connectRelatedArtists }
7 | from "containers/Artist/RelatedArtistsContainer";
8 | import SkeletonArtists from "components/Skeleton/SkeletonArtists";
9 | import "styles/Artist.scss";
10 |
11 | export class RelatedArtists extends Component {
12 | componentDidMount() {
13 | const {artists, loadRelatedArtists} = this.props;
14 | if (!artists.items.length || !artists.artistName) {
15 | loadRelatedArtists();
16 | }
17 | }
18 |
19 | render() {
20 | const {pending, artistName, items} = this.props.artists;
21 | if (pending) {
22 | return (
23 |
28 | );
29 | }
30 | if (!artistName) {
31 | return null;
32 | }
33 | return (
34 |
35 |
39 |
40 | {items.map(item => {
41 | return (
42 |
48 | );
49 | })}
50 |
51 |
52 | );
53 | }
54 | }
55 |
56 | RelatedArtists.propTypes = {
57 | artists: PropTypes.object.isRequired,
58 | loadRelatedArtists: PropTypes.func.isRequired,
59 | };
60 |
61 | export default connectRelatedArtists(RelatedArtists);
--------------------------------------------------------------------------------
/src/pages/HomePage/TopTracks.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import BlockHeader from "components/Common/BlockHeader";
5 | import Tracks from "components/Common/Tracks";
6 | import { connectTopTracks } from "containers/Tracks/TopTracksContainer";
7 | import SkeletonTracks from "components/Skeleton/SkeletonTracks";
8 | import InfiniteScroll from "components/Common/InfiniteScroll";
9 |
10 | export class TopTracks extends Component {
11 | componentWillMount() {
12 | const tracks = this.props.tracks.items;
13 | const {chartsCountry, loadTopTracks} = this.props;
14 | if (!tracks.length || (tracks.length && chartsCountry !== "global")) {
15 | loadTopTracks();
16 | }
17 | }
18 |
19 | render() {
20 | const {pending, items, total, loadMorePending} = this.props.tracks;
21 | const loadMore = () => this.props.loadMore(items.length);
22 | const source = {name: "TopTracks"};
23 | if (pending) {
24 | return (
25 |
31 | );
32 | }
33 | return (
34 |
59 | );
60 | }
61 | }
62 |
63 | TopTracks.propTypes = {
64 | tracks: PropTypes.object.isRequired,
65 | loadTopTracks: PropTypes.func.isRequired,
66 | chartsCountry: PropTypes.string.isRequired,
67 | };
68 |
69 | export default connectTopTracks(TopTracks);
--------------------------------------------------------------------------------
/src/pages/HomePage/UserPlaylists.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import Carousel from "components/Common/Carousel";
5 | import { PLAYLISTS } from "constants/RouteConstants";
6 | import { USER_PLAYLISTS_LIMIT } from "constants/AppConstants";
7 | import { connectUserPlaylists }
8 | from "containers/Playlist/UserPlaylistsContainer";
9 |
10 | export class UserPlaylists extends Component {
11 | componentDidMount() {
12 | const {playlists, loadUserPlaylists} = this.props;
13 | if (!playlists.items.length) {
14 | loadUserPlaylists();
15 | }
16 | }
17 |
18 | render() {
19 | const {pending, items, total} = this.props.playlists;
20 | const blockHeader = {
21 | title: "My Collection",
22 | description: "Playlists",
23 | link: PLAYLISTS,
24 | };
25 | const carouselItems = items.length > USER_PLAYLISTS_LIMIT
26 | ? items.slice(0, USER_PLAYLISTS_LIMIT)
27 | : items;
28 | if (!total) {
29 | return null;
30 | }
31 | return (
32 |
38 | );
39 | }
40 | }
41 |
42 | UserPlaylists.propTypes = {
43 | playlists: PropTypes.object.isRequired,
44 | loadUserPlaylists: PropTypes.func.isRequired,
45 | };
46 |
47 | export default connectUserPlaylists(UserPlaylists);
--------------------------------------------------------------------------------
/src/pages/NewReleases.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import Block from "components/Common/Block";
5 | import BlockHeader from "components/Common/BlockHeader";
6 | import InfiniteScroll from "components/Common/InfiniteScroll";
7 | import SkeletonBlocks from "components/Skeleton/SkeletonBlocks";
8 | import { connectNewReleases } from "containers/Album/NewReleasesContainer";
9 |
10 | export class NewReleases extends Component {
11 | componentDidMount() {
12 | const {newReleases, loadNewReleases} = this.props;
13 | window.scrollTo(0, 0);
14 | if (!newReleases.items.length) {
15 | loadNewReleases();
16 | }
17 | }
18 |
19 | render() {
20 | const {pending, loadMorePending, total, items, error} = this.props.newReleases;
21 | const loadMore = () => this.props.loadMore(items.length);
22 | if (pending || error) {
23 | return ;
24 | }
25 | return (
26 |
27 |
28 |
34 |
35 | {items.map((item, index) => {
36 | return (
37 |
45 | );
46 | })}
47 |
48 |
49 |
50 | );
51 | }
52 | }
53 |
54 | NewReleases.propTypes = {
55 | newReleases: PropTypes.object.isRequired,
56 | loadNewReleases: PropTypes.func.isRequired,
57 | loadMore: PropTypes.func.isRequired,
58 | };
59 |
60 | export default connectNewReleases(NewReleases);
--------------------------------------------------------------------------------
/src/pages/Page404.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Link } from "react-router-dom";
3 |
4 | import EmptyPage from "components/Common/Empty";
5 | import { HOME } from "constants/RouteConstants";
6 |
7 | export default function Page404() {
8 | return (
9 |
16 | Back on HomePage
17 |
18 | }
19 | />
20 | );
21 | }
--------------------------------------------------------------------------------
/src/pages/SavedTracks.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 | import { Link } from "react-router-dom";
4 |
5 | import EmptyPage from "components/Common/Empty";
6 | import BlockHeader from "components/Common/BlockHeader";
7 | import Tracks from "components/Common/Tracks";
8 | import InfiniteScroll from "components/Common/InfiniteScroll";
9 | import SkeletonTracks from "components/Skeleton/SkeletonTracks";
10 | import { CHARTS } from "constants/RouteConstants";
11 | import { connectSavedTracks } from "containers/Tracks/SavedTracksContainer";
12 |
13 | export class SavedTracks extends Component {
14 | componentDidMount() {
15 | const {tracks, loadMySavedTracks} = this.props;
16 | window.scrollTo(0, 0);
17 | if (!tracks.loaded) {
18 | loadMySavedTracks();
19 | }
20 | }
21 |
22 | renderEmptyPage() {
23 | return (
24 |
31 | Popular Songs
32 |
33 | }
34 | />
35 | );
36 | }
37 |
38 | render() {
39 | const {pending, loadMorePending, total, items, error, loaded} = this.props.tracks;
40 | const loadMore = () => this.props.loadMore(items.length);
41 | const source = {name: "LikedTracks"};
42 | if (pending || (error && !loaded)) {
43 | return ;
44 | }
45 | if (!total && loaded) {
46 | return this.renderEmptyPage();
47 | }
48 | return (
49 |
50 |
51 |
52 |
58 |
59 |
60 |
61 |
62 | );
63 | }
64 | }
65 |
66 | SavedTracks.propTypes = {
67 | tracks: PropTypes.object.isRequired,
68 | loadMySavedTracks: PropTypes.func.isRequired,
69 | loadMore: PropTypes.func.isRequired,
70 | };
71 |
72 | export default connectSavedTracks(SavedTracks);
--------------------------------------------------------------------------------
/src/pages/Tracklist/TracklistEditableImage.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import alert from "components/Common/Alert/Alert";
5 | import cd from "images/cd.png";
6 |
7 | export default class EditableImage extends Component {
8 | shouldComponentUpdate(nextProps) {
9 | return nextProps.image !== this.props.image;
10 | }
11 |
12 | uploadCoverImage = el => {
13 | if (!el.target.files.length) {
14 | return;
15 | }
16 |
17 | const image = el.target.files[0];
18 | const imageSize = image.size / 1024;
19 | const reader = new FileReader();
20 | reader.readAsDataURL(image);
21 |
22 | if (imageSize > 256) {
23 | alert.show("File size must under 256 KB");
24 | return;
25 | }
26 |
27 | reader.onload = () => {
28 | this.props.uploadCoverImage(reader.result);
29 | };
30 | }
31 |
32 | render() {
33 | const {image} = this.props;
34 | return (
35 |
39 |
52 |
53 | );
54 | }
55 | }
56 |
57 | EditableImage.propTypes = {
58 | image: PropTypes.string,
59 | uploadCoverImage: PropTypes.func.isRequired,
60 | };
--------------------------------------------------------------------------------
/src/pages/Tracklist/TracklistEditableName.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | export default class EditableName extends Component {
5 | state = {
6 | name: this.props.name,
7 | }
8 |
9 | shouldComponentUpdate(nextProps, nextState) {
10 | return nextState.name !== this.state.name;
11 | }
12 |
13 | componentDidUpdate(prevProps) {
14 | if (this.props.name !== prevProps.name) {
15 | this.setState({
16 | name: this.props.name,
17 | });
18 | }
19 | }
20 |
21 | handleInputChange = event => {
22 | this.setState({
23 | name: event.target.value,
24 | });
25 | }
26 |
27 | handleInputBlur = () => {
28 | const {name, changeName} = this.props;
29 | if (!this.state.name.length) {
30 | this.setState({
31 | name: name,
32 | });
33 | return;
34 | } else if (this.state.name === name) {
35 | return;
36 | }
37 | changeName({
38 | name: this.state.name.trim(),
39 | });
40 | }
41 |
42 | render() {
43 | const {focus} = this.props;
44 | return (
45 | (input && focus && input.focus())}
47 | type="text"
48 | maxLength="40"
49 | className="tracklist__name tracklist__name_edit
50 | tracklist__editable-field"
51 | onChange={this.handleInputChange}
52 | value={this.state.name}
53 | onBlur={this.handleInputBlur}
54 | />
55 | );
56 | }
57 | }
58 |
59 | EditableName.propTypes = {
60 | changeName: PropTypes.func.isRequired,
61 | name: PropTypes.string.isRequired,
62 | focus: PropTypes.bool,
63 | };
--------------------------------------------------------------------------------
/src/pages/Tracklist/TracklistImage.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import cd from "images/cd.png";
5 | import EditableImage from "./TracklistEditableImage";
6 |
7 | export default class Image extends Component {
8 | shouldComponentUpdate(nextProps) {
9 | return nextProps.image !== this.props.image;
10 | }
11 |
12 | render() {
13 | const {image, uploadCoverImage, isEditableImage} = this.props;
14 | if (isEditableImage) {
15 | return (
16 |
20 | );
21 | }
22 | return (
23 |
27 | );
28 | }
29 | }
30 |
31 | Image.propTypes = {
32 | isEditableImage: PropTypes.bool,
33 | image: PropTypes.string,
34 | uploadCoverImage: PropTypes.func,
35 | };
--------------------------------------------------------------------------------
/src/pages/Tracklist/TracklistModal.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import { withRouter } from "react-router-dom";
3 | import "styles/Tracklist.scss";
4 |
5 | export class TracklistModal extends Component {
6 | componentDidMount() {
7 | if (!document.body.classList.contains("disable-scroll")) {
8 | document.body.classList.add("disable-scroll");
9 | }
10 | }
11 |
12 | componentWillUnmount() {
13 | document.body.classList.remove("disable-scroll");
14 | }
15 |
16 | goBack = e => {
17 | if (e.target.closest(".tracklist__modal")) {
18 | return;
19 | }
20 | this.props.history.goBack();
21 | }
22 |
23 | render() {
24 | return (
25 |
29 |
30 | {this.props.children}
31 |
32 |
33 | );
34 | }
35 | }
36 |
37 | export default withRouter(TracklistModal);
--------------------------------------------------------------------------------
/src/pages/Tracklist/TracklistName.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 | import EditableName from "./TracklistEditableName";
4 |
5 | export default class Name extends Component {
6 | shouldComponentUpdate(nextProps) {
7 | return nextProps.name !== this.props.name;
8 | }
9 |
10 | render() {
11 | const {name, changeName, isEditableName, focus} = this.props;
12 | if (isEditableName) {
13 | return (
14 |
19 | );
20 | }
21 | return {name}
;
22 | }
23 | }
24 |
25 | Name.propTypes = {
26 | name: PropTypes.string.isRequired,
27 | isEditableName: PropTypes.bool,
28 | changeName: PropTypes.func,
29 | focus: PropTypes.bool,
30 | };
--------------------------------------------------------------------------------
/src/pages/Tracklist/checkboxes/CollaborativeCheckbox.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | export default class CollaborativeCheckbox extends Component {
5 | handleOnChange = () => {
6 | const {
7 | collaborative,
8 | changePlaylistDetails,
9 | isPublic,
10 | } = this.props;
11 | if (isPublic) {
12 | return;
13 | }
14 | changePlaylistDetails({
15 | collaborative: !collaborative,
16 | });
17 | }
18 |
19 | handleOnClick = e => {
20 | if (this.props.isPublic) {
21 | e.target.checked = false;
22 | }
23 | }
24 |
25 | render() {
26 | const {collaborative, isPublic} = this.props;
27 | return (
28 |
29 |
36 |
37 |
38 | );
39 | }
40 | }
41 |
42 | CollaborativeCheckbox.propTypes = {
43 | isPublic: PropTypes.bool.isRequired,
44 | collaborative: PropTypes.bool.isRequired,
45 | changePlaylistDetails: PropTypes.func.isRequired,
46 | };
--------------------------------------------------------------------------------
/src/pages/Tracklist/checkboxes/PublicCheckbox.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import PropTypes from "prop-types";
3 |
4 | export default class PublicCheckbox extends Component {
5 | handleOnChange = () => {
6 | const {
7 | collaborative,
8 | changePlaylistDetails,
9 | isPublic,
10 | } = this.props;
11 | if (collaborative) {
12 | return;
13 | }
14 | changePlaylistDetails({
15 | public: !isPublic,
16 | });
17 | }
18 |
19 | handleOnClick = e => {
20 | if (this.props.collaborative) {
21 | e.target.checked = false;
22 | }
23 | }
24 |
25 | render() {
26 | const {collaborative, isPublic} = this.props;
27 | return (
28 |
29 |
36 |
37 |
38 | );
39 | }
40 | }
41 |
42 | PublicCheckbox.propTypes = {
43 | isPublic: PropTypes.bool.isRequired,
44 | collaborative: PropTypes.bool.isRequired,
45 | changePlaylistDetails: PropTypes.func.isRequired,
46 | };
--------------------------------------------------------------------------------
/src/reducers/CategoriesReducer.js:
--------------------------------------------------------------------------------
1 | import { CATEGORIES } from "constants/ActionConstants";
2 |
3 | const initialCategoriesState = {
4 | pending: false,
5 | loadMorePending: false,
6 | items: [],
7 | total: 0,
8 | error: false,
9 | };
10 |
11 | export default function categories(state = initialCategoriesState, action) {
12 | switch ( action.type ) {
13 | case CATEGORIES.PENDING:
14 | return {
15 | ...state,
16 | pending: true,
17 | };
18 | case CATEGORIES.SUCCESS:
19 | return {
20 | ...state,
21 | pending: false,
22 | items: action.payload.items,
23 | total: action.payload.total,
24 | };
25 | case CATEGORIES.ERROR:
26 | return {
27 | ...state,
28 | pending: false,
29 | loadMorePending: false,
30 | error: true,
31 | };
32 | case CATEGORIES.LOAD_MORE_PENDING:
33 | return {
34 | ...state,
35 | loadMorePending: true,
36 | };
37 | case CATEGORIES.LOAD_MORE_SUCCESS:
38 | return {
39 | ...state,
40 | loadMorePending: false,
41 | items: state.items.concat(action.payload.items),
42 | };
43 | case CATEGORIES.LOAD_MORE_ERROR:
44 | return {
45 | ...state,
46 | loadMorePending: false,
47 | };
48 | default:
49 | return state;
50 | }
51 | }
--------------------------------------------------------------------------------
/src/reducers/ChartsReducer.js:
--------------------------------------------------------------------------------
1 | import { FILTER_BY_COUNTRY, TOP_TRACKS } from "constants/ActionConstants";
2 | import { TOP_50 } from "constants/PlaylistIds";
3 |
4 | const initialChartsState = {
5 | country: "global",
6 | playlistId: TOP_50.global,
7 | pending: false,
8 | };
9 |
10 | export default function charts(state = initialChartsState, action) {
11 | switch (action.type) {
12 | case FILTER_BY_COUNTRY:
13 | return {
14 | ...state,
15 | country: action.payload.countryName,
16 | playlistId: action.payload.playlistId,
17 | };
18 | case TOP_TRACKS.PENDING:
19 | return {
20 | ...state,
21 | pending: true,
22 | };
23 | case TOP_TRACKS.ERROR:
24 | case TOP_TRACKS.SUCCESS:
25 | return {
26 | ...state,
27 | pending: false,
28 | };
29 | default:
30 | return state;
31 | }
32 | }
--------------------------------------------------------------------------------
/src/reducers/PlayerReducer.js:
--------------------------------------------------------------------------------
1 | import {
2 | PLAYER,
3 | ADD_TO_FAVORITE_TRACKS,
4 | REMOVE_FROM_FAVORITE_TRACKS,
5 | } from "constants/ActionConstants";
6 |
7 | export const initialPlayerState = {
8 | trackPlaying: false,
9 | trackPaused: false,
10 | playingTrackId: "",
11 | repeat: false,
12 | trackInfo: {},
13 | context: {
14 | name: "",
15 | tracks: [],
16 | },
17 | };
18 |
19 | const initialProgressBarState = {
20 | currentTime: 0,
21 | };
22 |
23 | export function player(state = initialPlayerState, action) {
24 | switch ( action.type ) {
25 | case PLAYER.PLAY:
26 | return {
27 | ...state,
28 | trackPlaying: true,
29 | trackPaused: false,
30 | playingTrackId: action.payload.id,
31 | trackInfo: {...action.payload},
32 | };
33 | case PLAYER.SET_CONTEXT:
34 | return {
35 | ...state,
36 | context: action.payload,
37 | };
38 | case PLAYER.PAUSE:
39 | return {
40 | ...state,
41 | trackPaused: true,
42 | };
43 | case PLAYER.RESUME:
44 | return {
45 | ...state,
46 | trackPaused: false,
47 | };
48 | case PLAYER.UPDATE_CONTEXT:
49 | return {
50 | ...state,
51 | context: {
52 | name: state.context.name,
53 | tracks: action.payload,
54 | },
55 | };
56 | case PLAYER.TOGGLE_REPEAT:
57 | return {
58 | ...state,
59 | repeat: !state.repeat,
60 | };
61 | case ADD_TO_FAVORITE_TRACKS:
62 | case REMOVE_FROM_FAVORITE_TRACKS:
63 | if (action.payload.id === state.playingTrackId) {
64 | return {
65 | ...state,
66 | trackInfo: {
67 | ...state.trackInfo,
68 | saved: !state.trackInfo.saved,
69 | },
70 | };
71 | }
72 | default:
73 | return state;
74 | }
75 | }
76 |
77 | export function progressBar(state = initialProgressBarState, action) {
78 | switch ( action.type ) {
79 | case PLAYER.CHANGE_CURRENT_TIME:
80 | return {
81 | currentTime: action.payload,
82 | };
83 | default:
84 | return state;
85 | }
86 | }
--------------------------------------------------------------------------------
/src/reducers/SearchReducer.js:
--------------------------------------------------------------------------------
1 | import {
2 | FILTER_BY_TYPE,
3 | SEARCH_RESULTS,
4 | RESET_SEARCH_RESULTS,
5 | TOGGLE_SEARCH,
6 | ADD_TO_FAVORITE_TRACKS,
7 | REMOVE_FROM_FAVORITE_TRACKS,
8 | } from "constants/ActionConstants";
9 | import toggleLike from "utils/toggleLike";
10 |
11 | export const initialSearchResultsState = {
12 | pending: false,
13 | items: [],
14 | type: "track",
15 | isOpen: false,
16 | error: false,
17 | };
18 |
19 | export default function searchResults(
20 | state = initialSearchResultsState, action
21 | ) {
22 | switch ( action.type ) {
23 | case FILTER_BY_TYPE:
24 | return {
25 | ...state,
26 | type: action.payload,
27 | };
28 | case SEARCH_RESULTS.PENDING:
29 | return {
30 | ...state,
31 | pending: true,
32 | };
33 | case SEARCH_RESULTS.SUCCESS:
34 | return {
35 | ...state,
36 | pending: false,
37 | items: action.payload,
38 | };
39 | case SEARCH_RESULTS.ERROR:
40 | return {
41 | ...state,
42 | pending: false,
43 | error: true,
44 | };
45 | case RESET_SEARCH_RESULTS:
46 | return {
47 | pending: false,
48 | items: [],
49 | type: "track",
50 | isOpen: state.isOpen,
51 | error: false,
52 | };
53 | case TOGGLE_SEARCH:
54 | return {
55 | ...state,
56 | isOpen: !state.isOpen,
57 | };
58 | case ADD_TO_FAVORITE_TRACKS:
59 | return {
60 | ...state,
61 | items: toggleLike(state.items, action.payload.id),
62 | };
63 | case REMOVE_FROM_FAVORITE_TRACKS:
64 | return {
65 | ...state,
66 | items: toggleLike(state.items, action.payload.id),
67 | };
68 | default:
69 | return state;
70 | }
71 | }
--------------------------------------------------------------------------------
/src/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from "redux";
2 |
3 | import {
4 | categoryPlaylists,
5 | featuredPlaylists,
6 | userPlaylists,
7 | listUserPlaylists,
8 | playlist,
9 | } from "./PlaylistReducer";
10 |
11 | import {
12 | newReleases,
13 | album,
14 | artistAlbums,
15 | artistSingles,
16 | } from "./AlbumReducer";
17 |
18 | import {
19 | followedArtists,
20 | artist,
21 | relatedArtists,
22 | } from "./ArtistReducer";
23 |
24 | import categories from "./CategoriesReducer";
25 | import charts from "./ChartsReducer";
26 | import searchResults from "./SearchReducer";
27 |
28 | import {
29 | player,
30 | progressBar,
31 | } from "./PlayerReducer";
32 |
33 | import {
34 | artistTopTracks,
35 | topTracks,
36 | savedTracks,
37 | } from "./TrackReducer";
38 |
39 | export const rootReducer = combineReducers({
40 | userPlaylists,
41 | featuredPlaylists,
42 | categoryPlaylists,
43 | playlist,
44 | listUserPlaylists,
45 |
46 | newReleases,
47 | album,
48 | artistAlbums,
49 | artistSingles,
50 |
51 | relatedArtists,
52 | followedArtists,
53 | artist,
54 |
55 | topTracks,
56 | savedTracks,
57 | artistTopTracks,
58 |
59 | categories,
60 | player,
61 | progressBar,
62 | charts,
63 | searchResults,
64 | });
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | import { configure } from 'enzyme';
2 | import Adapter from 'enzyme-adapter-react-16';
3 |
4 | configure({ adapter: new Adapter() });
--------------------------------------------------------------------------------
/src/store/configureStore.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware } from 'redux';
2 | import { rootReducer } from '../reducers';
3 | import thunk from 'redux-thunk';
4 |
5 | export const store = createStore(rootReducer, applyMiddleware(thunk))
--------------------------------------------------------------------------------
/src/styles/Artist.scss:
--------------------------------------------------------------------------------
1 | @import
2 | "variables",
3 | "modularscale";
4 |
5 | .artist {
6 | &__pic {
7 | border-radius: 100%;
8 | box-shadow: $shadows;
9 | width: 100%;
10 | margin-bottom: 10px;
11 | padding-bottom: 100%;
12 | }
13 | &__name {
14 | font-weight: 500;
15 | text-align: center;
16 | width: 100%;
17 | display: inline-block;
18 | color: $text-dark;
19 | }
20 | &-header {
21 | min-height: 350px;
22 | position: relative;
23 | text-align: center;
24 | margin-bottom: 40px;
25 | &:before {
26 | content: '';
27 | position: absolute;
28 | background: #000;
29 | left: 0;
30 | right: 0;
31 | width: 100%;
32 | height: 100%;
33 | opacity: .5;
34 | }
35 | &__info {
36 | z-index: 2;
37 | }
38 | &__name {
39 | margin-bottom: 20px;
40 | color: #fff;
41 | }
42 | &__followers {
43 | font-size: ms(-1);
44 | color: #fff;
45 | }
46 | &__btn.btn {
47 | width: 160px;
48 | margin: 0 auto;
49 | color: #fff;
50 | }
51 | }
52 | }
53 |
54 | .artists {
55 | display: flex;
56 | flex-wrap: wrap;
57 | margin-right: -30px;
58 | margin-bottom: -30px;
59 | .artist {
60 | margin-bottom: 30px;
61 | width: calc(100% / 5 - 30px);
62 | margin-right: 30px;
63 | @media (max-width: 900px) {
64 | width: calc(100% / 4 - 30px);
65 | }
66 | @media (max-width: 700px) {
67 | width: calc(100% / 3 - 30px);
68 | }
69 | @media (max-width: 550px) {
70 | width: calc(100% / 2 - 30px);
71 | }
72 | &__name {
73 | font-size: ms(-1);
74 | }
75 | }
76 | &_cols-6 {
77 | .artist {
78 | width: calc(100% / 6 - 30px);
79 | @media (max-width: 900px) {
80 | width: calc(100% / 4 - 30px);
81 | }
82 | @media (max-width: 550px) {
83 | width: calc(100% / 3 - 30px);
84 | }
85 | @media (max-width: 450px) {
86 | width: calc(100% / 2 - 30px);
87 | }
88 | }
89 | }
90 | }
--------------------------------------------------------------------------------
/src/styles/Block.scss:
--------------------------------------------------------------------------------
1 | @import
2 | "variables",
3 | "modularscale";
4 |
5 | .block {
6 | padding: 0 10px;
7 | &__media {
8 | margin-bottom: 12px;
9 | }
10 | &__img {
11 | border-radius: $border-radius;
12 | width: 100%;
13 | padding-bottom: 100%;
14 | }
15 | &__title {
16 | font-size: ms(-2);
17 | font-weight: 500;
18 | color: $text-dark;
19 | margin-bottom: 2px;
20 | word-break: break-word;
21 | }
22 | &__subtitle {
23 | font-size: ms(-3);
24 | color: $text-secondary;
25 | }
26 | &-header {
27 | display: flex;
28 | justify-content: space-between;
29 | align-items: flex-end;
30 | margin-bottom: 30px;
31 | flex-wrap: wrap;
32 | &__description {
33 | font-weight: 500;
34 | color: $text-secondary;
35 | }
36 | &__link {
37 | margin-bottom: 7px;
38 | svg {
39 | vertical-align: middle;
40 | line-height: 0;
41 | display: inline-block;
42 | max-height: 18px;
43 | width: 16px;
44 | margin-left: 5px;
45 | }
46 | }
47 | }
48 | }
49 |
50 | .blocks {
51 | &-container {
52 | display: flex;
53 | flex-wrap: wrap;
54 | margin: 0 -10px;
55 | &_cols-5 .block {
56 | width: calc(100% / 5);
57 | }
58 | .block {
59 | width: calc(100% / 4);
60 | margin-bottom: 40px;
61 | @media (max-width: 900px) {
62 | width: calc(100% / 3);
63 | }
64 | @media (max-width: 700px) {
65 | width: calc(100% / 2);
66 | }
67 | @media (max-width: 450px) {
68 | width: 100%;
69 | }
70 | &__img {
71 | box-shadow: $shadows;
72 | }
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/styles/Category.scss:
--------------------------------------------------------------------------------
1 | @import
2 | "variables",
3 | "modularscale";
4 |
5 | .category {
6 | width: calc(100% / 2 - 2%);
7 | margin-right: 2%;
8 | margin-bottom: 2%;
9 | cursor: pointer;
10 | position: relative;
11 | @media (max-width: 900px) {
12 | width: calc(100% / 4 - 2%);
13 | }
14 | @media (max-width: 700px) {
15 | width: calc(100% / 3 - 2%);
16 | }
17 | @media (max-width: 550px) {
18 | width: calc(100% / 2 - 2%);
19 | }
20 | &__cover-art {
21 | background-size: cover;
22 | background-position: 0 -16px;
23 | background-repeat: no-repeat;
24 | border-radius: $border-radius;
25 | transition: all .3s;
26 | padding-bottom: 64.5%;
27 | @media (max-width: 900px) {
28 | height: 0;
29 | padding-bottom: 100%;
30 | background-position: center;
31 | }
32 | }
33 | &__name {
34 | color: #fff;
35 | position: absolute;
36 | bottom: 6px;
37 | left: 0;
38 | right: 0;
39 | text-align: center;
40 | font-size: ms(-3);
41 | font-weight: 600;
42 | opacity: 0;
43 | transition: all .3s;
44 | @media (max-width: 900px) {
45 | opacity: 1;
46 | bottom: 25px;
47 | }
48 | }
49 | }
50 |
51 | .categories {
52 | &__container {
53 | display: flex;
54 | flex-wrap: wrap;
55 | margin-right: -2%;
56 | }
57 | &_preview {
58 | width: 40%;
59 | .category:hover {
60 | .category__cover-art {
61 | background-position: 0 -38px;
62 | }
63 | .category__name {
64 | opacity: 1;
65 | bottom: 16px;
66 | }
67 | }
68 | @media (max-width: 900px) {
69 | width: 100%;
70 | .category:hover {
71 | .category__cover-art {
72 | background-position: center;
73 | }
74 | .category__name {
75 | bottom: 25px;
76 | }
77 | }
78 | }
79 | }
80 | .category {
81 | width: calc(100% / 4 - 2%);
82 | @media (max-width: 900px) {
83 | width: calc(100% / 3 - 2%);
84 | }
85 | @media (max-width: 600px) {
86 | width: calc(100% / 2 - 2%);
87 | }
88 | &__cover-art {
89 | height: 0;
90 | background-position: center;
91 | padding-bottom: 100%;
92 | }
93 | &__name {
94 | opacity: 1;
95 | bottom: 30px;
96 | font-size: ms(-2);
97 | @media (max-width: 450px) {
98 | bottom: 15px;
99 | }
100 | }
101 | }
102 | }
--------------------------------------------------------------------------------
/src/styles/Navbar.scss:
--------------------------------------------------------------------------------
1 | @import
2 | "variables",
3 | "modularscale";
4 |
5 | .navbar {
6 | position: fixed;
7 | width: 14.6%;
8 | @media (max-width: 1024px) {
9 | position: relative;
10 | width: 100%;
11 | }
12 | &__logo {
13 | margin-bottom: 30px;
14 | display: flex;
15 | justify-content: space-between;
16 | align-items: center;
17 | }
18 | &__search {
19 | width: 20px;
20 | height: 20px;
21 | stroke-width: 3px;
22 | stroke: $text-secondary;
23 | cursor: pointer;
24 | vertical-align: middle;
25 | }
26 | &__group {
27 | margin-bottom: 40px;
28 | li:not(:last-child) .navbar__item {
29 | margin-bottom: 23px;
30 | }
31 | &-header {
32 | margin-bottom: 30px;
33 | font-size: ms(-3);
34 | letter-spacing: .9px;
35 | font-weight: 600;
36 | color: $text-secondary;
37 | }
38 | }
39 | &__item {
40 | display: inline-block;
41 | letter-spacing: .6px;
42 | font-size: ms(-1);
43 | color: $text-secondary;
44 | font-weight: 500;
45 | &.active {
46 | font-weight: 500;
47 | color: $text-dark;
48 | svg path {
49 | fill: $text-dark;
50 | }
51 | }
52 | }
53 | &__icon {
54 | margin-right: 15px;
55 | svg {
56 | width: 20px;
57 | height: 20px;
58 | vertical-align: middle;
59 | path {
60 | fill: #B7B7B7;
61 | }
62 | }
63 | }
64 | &__open-btn {
65 | position: fixed;
66 | width: 50px;
67 | height: 50px;
68 | cursor: pointer;
69 | z-index: 9999;
70 | border-radius: 50px;
71 | box-shadow: 0 6px 24px rgba(0, 0, 0, 0.2);
72 | background-color: $bg-primary;
73 | right: 30px;
74 | bottom: 150px;
75 | }
76 | }
--------------------------------------------------------------------------------
/src/styles/Search.scss:
--------------------------------------------------------------------------------
1 | @import
2 | "variables",
3 | "modularscale";
4 |
5 | .search {
6 | &__input {
7 | width: 100%;
8 | position: relative;
9 | display: flex;
10 | align-items: center;
11 | justify-content: space-between;
12 | &-container {
13 | position: fixed;
14 | z-index: 999;
15 | background: $bg-primary;
16 | top: 0;
17 | left: 0;
18 | width: 100%;
19 | height: 30vh;
20 | padding: 0 2em;
21 | box-shadow: $shadows;
22 | &-enter {
23 | transform: translateY(-200%);
24 | }
25 | &-enter-active {
26 | transform: translateY(0);
27 | transition: all 600ms ease-in-out;
28 | }
29 | &-exit {
30 | transform: translateY(0);
31 | }
32 | &-exit-active {
33 | transform: translateY(-200%);
34 | transition: all 600ms ease-in-out;
35 | }
36 | }
37 | &-wrapper {
38 | width: 100%;
39 | height: 95px;
40 | display: flex;
41 | flex-wrap: wrap;
42 | justify-content: space-between;
43 | align-items: center;
44 | color: $text-secondary;
45 | }
46 | &-field {
47 | font-size: ms(16);
48 | border: 0;
49 | width: 90%;
50 | }
51 | }
52 | &__close {
53 | cursor: pointer;
54 | svg {
55 | width: 40px;
56 | height: 40px;
57 | }
58 | }
59 | &__results {
60 | position: fixed;
61 | z-index: 999;
62 | background: $bg-secondary;
63 | bottom: 0;
64 | left: 0;
65 | width: 100%;
66 | height: 70vh;
67 | padding: 40px;
68 | overflow-y: scroll;
69 | overflow-x: hidden;
70 | @media (max-width: 450px) {
71 | padding: 40px 20px;
72 | }
73 | &-enter {
74 | transform: translateY(200%);
75 | }
76 | &-enter-active {
77 | transform: translateY(0);
78 | transition: all 600ms ease-in-out;
79 | }
80 | &-exit {
81 | transform: translateY(0);
82 | }
83 | &-exit-active {
84 | transform: translateY(200%);
85 | transition: all 600ms ease-in-out;
86 | }
87 | }
88 | &__tabs {
89 | margin-bottom: 40px;
90 | }
91 | &__tab {
92 | display: inline-block;
93 | margin-right: 20px;
94 | font-size: ms(1);
95 | cursor: pointer;
96 | text-transform: capitalize;
97 | &_active {
98 | border-bottom: 2px solid;
99 | }
100 | }
101 | }
--------------------------------------------------------------------------------
/src/styles/Select.scss:
--------------------------------------------------------------------------------
1 | @import
2 | "variables",
3 | "modularscale";
4 |
5 | .select {
6 | width: 250px;
7 | position: relative;
8 | @media (max-width: 600px) {
9 | width: 100%;
10 | margin: 10px 0;
11 | }
12 | &__label {
13 | cursor: pointer;
14 | padding: 10px 15px;
15 | font-size: ms(-1);
16 | z-index: 9;
17 | border: 1px solid $border-color;
18 | display: flex;
19 | align-items: center;
20 | justify-content: space-between;
21 | border-radius: 10px;
22 | svg {
23 | transition: all .3s;
24 | width: 24px;
25 | height: 24px;
26 | }
27 | }
28 | &__title {
29 | display: flex;
30 | align-items: center;
31 | padding: 14px 16px;
32 | cursor: pointer;
33 | font-size: ms(-1);
34 | font-weight: 600;
35 | padding-bottom: 15px;
36 | svg {
37 | margin-right: 6px;
38 | margin-left: -6px;
39 | }
40 | &_not-active {
41 | cursor: default;
42 | }
43 | }
44 | &.more-btn_active .chevron-up {
45 | transform: rotate(-180deg);
46 | }
47 | &__dropdown {
48 | width: 100%;
49 | overflow: hidden;
50 | position: absolute;
51 | font-size: ms(-1);
52 | background-color: $bg-primary;
53 | border-radius: 0 0 10px 10px;
54 | z-index: 2;
55 | }
56 | &__search {
57 | border-top: 2px solid $border-color;
58 | margin: 0 16px;
59 | &-input {
60 | padding: 14px 0;
61 | font-size: ms(-1);
62 | line-height: 20px;
63 | outline: none;
64 | border: 0;
65 | width: 100%;
66 | font-family: $font-family-base;
67 | }
68 | }
69 | &__inner {
70 | overflow-x: hidden;
71 | overflow-y: scroll;
72 | max-height: 200px;
73 | }
74 | &__country {
75 | margin-top: 10px;
76 | &.context-menu {
77 | @media (max-width: 600px) {
78 | width: 100%;
79 | }
80 | }
81 | }
82 | &__empty {
83 | cursor: default;
84 | padding: 10px 16px;
85 | font-size: ms(0);
86 | padding-top: 0;
87 | color: $text-secondary;
88 | }
89 | &__option {
90 | cursor: pointer;
91 | font-size: ms(-1);
92 | line-height: 18px;
93 | padding: 10px 16px;
94 | text-align: left;
95 | &.overflow-ellipsis {
96 | max-width: 100%;
97 | }
98 | svg {
99 | margin-right: 15px;
100 | stroke: $text-primary;
101 | vertical-align: middle;
102 | }
103 | &:hover {
104 | background: $bg-alt;
105 | }
106 | }
107 | }
--------------------------------------------------------------------------------
/src/styles/_modularscale.scss:
--------------------------------------------------------------------------------
1 | @import 'modularscale/vars';
2 |
3 | @import 'modularscale/settings';
4 | @import 'modularscale/pow';
5 | @import 'modularscale/strip-units';
6 | @import 'modularscale/sort';
7 | @import 'modularscale/target';
8 | @import 'modularscale/function';
9 | @import 'modularscale/round-px';
10 |
11 | @import 'modularscale/respond';
12 |
13 | @import 'modularscale/sugar';
14 |
15 | $modularscale: (
16 | base: 16px,
17 | ratio: 1.067
18 | );
--------------------------------------------------------------------------------
/src/styles/_normalize.css:
--------------------------------------------------------------------------------
1 | /* http://meyerweb.com/eric/tools/css/reset/
2 | v2.0 | 20110126
3 | License: none (public domain)
4 | */
5 |
6 | html, body, div, span, applet, object, iframe,
7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre,
8 | a, abbr, acronym, address, big, cite, code,
9 | del, dfn, em, img, ins, kbd, q, s, samp,
10 | small, strike, strong, sub, sup, tt, var,
11 | b, u, i, center,
12 | dl, dt, dd, ol, ul, li,
13 | fieldset, form, label, legend,
14 | table, caption, tbody, tfoot, thead, tr, th, td,
15 | article, aside, canvas, details, embed,
16 | figure, figcaption, footer, header, hgroup,
17 | menu, nav, output, ruby, section, summary,
18 | time, mark, audio, video {
19 | margin: 0;
20 | padding: 0;
21 | border: 0;
22 | font-size: 100%;
23 | font: inherit;
24 | vertical-align: baseline;
25 | }
26 | /* HTML5 display-role reset for older browsers */
27 | article, aside, details, figcaption, figure,
28 | footer, header, hgroup, menu, nav, section {
29 | display: block;
30 | }
31 | body {
32 | line-height: 1;
33 | font-family: sans-serif;
34 | }
35 | ol, ul {
36 | list-style: none;
37 | }
38 | blockquote, q {
39 | quotes: none;
40 | }
41 | blockquote:before, blockquote:after,
42 | q:before, q:after {
43 | content: '';
44 | content: none;
45 | }
46 | table {
47 | border-collapse: collapse;
48 | border-spacing: 0;
49 | }
50 |
51 | :focus,
52 | input:focus,
53 | textarea:focus {
54 | outline: 0;
55 | }
--------------------------------------------------------------------------------
/src/styles/_variables.scss:
--------------------------------------------------------------------------------
1 | $family: unquote("Poppins:300,400,500,600,700&display=swap");
2 | @import url('https://fonts.googleapis.com/css?family=#{$family}');
3 |
4 | $bg-primary: #FFFFFF;
5 | $bg-secondary: #FDFDFD;
6 | $bg-alt: #F8F8F8;
7 | $border-color: #efefef;
8 | $text-primary: #7d7d7d;
9 | $text-secondary: #949494;
10 | $text-dark: #1D1B1A;
11 |
12 | $font-family-base: 'Poppins', sans-serif;
13 |
14 | $border-radius: 5px;
15 | $shadows: 10px 8px 15px 0 rgba(168, 179, 211, 0.24);
--------------------------------------------------------------------------------
/src/styles/modularscale/_function.scss:
--------------------------------------------------------------------------------
1 | @function ms-function($v: 0, $base: false, $ratio: false, $thread: false, $settings: $modularscale) {
2 |
3 | // Parse settings
4 | $ms-settings: ms-settings($base,$ratio,$thread,$settings);
5 | $base: nth($ms-settings, 1);
6 | $ratio: nth($ms-settings, 2);
7 |
8 | // Render target values from settings.
9 | @if unit($ratio) != '' {
10 | $ratio: ms-target($ratio,$base)
11 | }
12 |
13 | // Fast calc if not multi stranded
14 | @if(length($base) == 1) {
15 | @return ms-pow($ratio, $v) * $base;
16 | }
17 |
18 | // Create new base array
19 | $ms-bases: nth($base,1);
20 |
21 | // Normalize base values
22 | @for $i from 2 through length($base) {
23 | // initial base value
24 | $ms-base: nth($base,$i);
25 | // If the base is bigger than the main base
26 | @if($ms-base > nth($base,1)) {
27 | // divide the value until it aligns with main base.
28 | @while($ms-base > nth($base,1)) {
29 | $ms-base: $ms-base / $ratio;
30 | }
31 | $ms-base: $ms-base * $ratio;
32 | }
33 | // If the base is smaller than the main base.
34 | @else if ($ms-base < nth($base,1)) {
35 | // pump up the value until it aligns with main base.
36 | @while $ms-base < nth($base,1) {
37 | $ms-base: $ms-base * $ratio;
38 | }
39 | }
40 | // Push into new array
41 | $ms-bases: append($ms-bases,$ms-base);
42 | }
43 |
44 | // Sort array from smallest to largest.
45 | $ms-bases: ms-sort($ms-bases);
46 |
47 | // Find step to use in calculation
48 | $vtep: floor($v / length($ms-bases));
49 | // Find base to use in calculation
50 | $ms-base: round(($v / length($ms-bases) - $vtep) * length($ms-bases)) + 1;
51 |
52 | @return ms-pow($ratio, $vtep) * nth($ms-bases,$ms-base);
53 | }
--------------------------------------------------------------------------------
/src/styles/modularscale/_pow.scss:
--------------------------------------------------------------------------------
1 | // Sass does not have native pow() support so this needs to be added.
2 | // Compass and other libs implement this more extensively.
3 | // In order to keep this simple, use those when they are avalible.
4 | // Issue for pow() support in Sass: https://github.com/sass/sass/issues/684
5 |
6 | @function ms-pow($b,$e) {
7 |
8 | // Return 1 if exponent is 0
9 | @if $e == 0 {
10 | @return 1;
11 | }
12 |
13 | // If pow() exists (compass or mathsass) use that.
14 | @if function-exists('pow') {
15 | @return pow($b,$e);
16 | }
17 |
18 | // This does not support non-integer exponents,
19 | // Check and return an error if a non-integer exponent is passed.
20 | @if (floor($e) != $e) {
21 | @error 'Non-integer values are not supported in modularscale by default. Try using mathsass in your project to add non-integer scale support. https://github.com/terkel/mathsass'
22 | }
23 |
24 | // Seed the return.
25 | $ms-return: $b;
26 |
27 | // Multiply or divide by the specified number of times.
28 | @if $e > 0 {
29 | @for $i from 1 to $e {
30 | $ms-return: $ms-return * $b;
31 | }
32 | }
33 | @if $e < 0 {
34 | @for $i from $e through 0 {
35 | $ms-return: $ms-return / $b;
36 | }
37 | }
38 | @return $ms-return;
39 | }
--------------------------------------------------------------------------------
/src/styles/modularscale/_respond.scss:
--------------------------------------------------------------------------------
1 | // Generate calc() function
2 | // based on Mike Riethmuller's Precise control over responsive typography
3 | // http://madebymike.com.au/writing/precise-control-responsive-typography/
4 | @function ms-fluid($val1: 1em, $val2: 1em, $break1: 0, $break2: 0) {
5 | $diff: ms-unitless($val2) - ms-unitless($val1);
6 |
7 | // v1 + (v2 - v1) * ( (100vw - b1) / b2 - b1 )
8 | @return calc( #{$val1} + #{ms-unitless($val2) - ms-unitless($val1)} * ( ( 100vw - #{$break1}) / #{ms-unitless($break2) - ms-unitless($break1)} ) );
9 | }
10 |
11 | // Main responsive mixin
12 | @mixin ms-respond($prop, $val, $map: $modularscale, $ms-important: false) {
13 | $base: $ms-base;
14 | $ratio: $ms-ratio;
15 |
16 | $first-write: true;
17 | $last-break: null;
18 |
19 | $important: '';
20 |
21 | @if $ms-important == true {
22 | $important: ' !important';
23 | }
24 |
25 | // loop through all settings with a breakpoint type value
26 | @each $v, $s in $map {
27 | @if type-of($v) == number {
28 | @if unit($v) != '' {
29 |
30 | // Write out the first value without a media query.
31 | @if $first-write {
32 | #{$prop}: unquote("#{ms-function($val, $thread: $v, $settings: $map)}#{$important}");
33 |
34 | // Not the first write anymore, reset to false to move on.
35 | $first-write: false;
36 | $last-break: $v;
37 | }
38 |
39 | // Write intermediate breakpoints.
40 | @else {
41 | @media (min-width: $last-break) and (max-width: $v) {
42 | $val1: ms-function($val, $thread: $last-break, $settings: $map);
43 | $val2: ms-function($val, $thread: $v, $settings: $map);
44 | #{$prop}: unquote("#{ms-fluid($val1,$val2,$last-break,$v)}#{$important}");
45 | }
46 | $last-break: $v;
47 | }
48 | }
49 | }
50 | }
51 |
52 | // Write the last breakpoint.
53 | @if $last-break {
54 | @media (min-width: $last-break) {
55 | #{$prop}: unquote("#{ms-function($val, $thread: $last-break, $settings: $map)}#{$important}");
56 | }
57 | }
58 | }
--------------------------------------------------------------------------------
/src/styles/modularscale/_round-px.scss:
--------------------------------------------------------------------------------
1 | @function ms-round-px($r) {
2 | @if unit($r) == 'px' {
3 | @return round($r);
4 | }
5 | @warn "ms-round-px is no longer used by modular scale and will be removed in the 3.1.0 release.";
6 | @return $r;
7 | }
--------------------------------------------------------------------------------
/src/styles/modularscale/_settings.scss:
--------------------------------------------------------------------------------
1 | // Parse settings starting with defaults.
2 | // Settings should cascade down like you would expect in CSS.
3 | // More specific overrides previous settings.
4 |
5 | @function ms-settings($b: false, $r: false, $t: false, $m: $modularscale) {
6 | $base: $ms-base;
7 | $ratio: $ms-ratio;
8 | $thread: map-get($m, $t);
9 |
10 | // Override with user settings
11 | @if map-get($m, base) {
12 | $base: map-get($m, base);
13 | }
14 | @if map-get($m, ratio) {
15 | $ratio: map-get($m, ratio);
16 | }
17 |
18 | // Override with thread settings
19 | @if $thread {
20 | @if map-get($thread, base) {
21 | $base: map-get($thread, base);
22 | }
23 | @if map-get($thread, ratio) {
24 | $ratio: map-get($thread, ratio);
25 | }
26 | }
27 |
28 | // Override with inline settings
29 | @if $b {
30 | $base: $b;
31 | }
32 | @if $r {
33 | $ratio: $r;
34 | }
35 |
36 | @return $base $ratio;
37 | }
--------------------------------------------------------------------------------
/src/styles/modularscale/_sort.scss:
--------------------------------------------------------------------------------
1 | // Basic list sorting
2 | // Would like to replace with http://sassmeister.com/gist/30e4863bd03ce0e1617c
3 | // Unfortunately libsass has a bug with passing arguments into the min() funciton.
4 |
5 | @function ms-sort($l) {
6 |
7 | // loop until the list is confirmed to be sorted
8 | $sorted: false;
9 | @while $sorted == false {
10 |
11 | // Start with the assumption that the lists are sorted.
12 | $sorted: true;
13 |
14 | // Loop through the list, checking each value with the one next to it.
15 | // Swap the values if they need to be swapped.
16 | // Not super fast but simple and modular scale doesn't lean hard on sorting.
17 | @for $i from 2 through length($l) {
18 | $n1: nth($l,$i - 1);
19 | $n2: nth($l,$i);
20 |
21 | // If the first value is greater than the 2nd, swap them.
22 | @if $n1 > $n2 {
23 | $l: set-nth($l, $i, $n1);
24 | $l: set-nth($l, $i - 1, $n2);
25 |
26 | // The list isn't sorted and needs to be looped through again.
27 | $sorted: false;
28 | }
29 | }
30 | }
31 |
32 | // Return the sorted list.
33 | @return $l;
34 | }
--------------------------------------------------------------------------------
/src/styles/modularscale/_strip-units.scss:
--------------------------------------------------------------------------------
1 | // Stripping units is not a best practice
2 | // This function should not be used elsewhere
3 | // It is used here because calc() doesn't do unit logic
4 | // AND target ratios use units as a hack to get a number.
5 | @function ms-unitless($val) {
6 | @return ($val / ($val - $val + 1));
7 | }
--------------------------------------------------------------------------------
/src/styles/modularscale/_sugar.scss:
--------------------------------------------------------------------------------
1 | // To attempt to avoid conflicts with other libraries
2 | // all funcitons are namespaced with `ms-`.
3 | // However, to increase usability, a shorthand function is included here.
4 |
5 | @function ms($v: 0, $base: false, $ratio: false, $thread: false, $settings: $modularscale) {
6 | @return ms-function($v, $base, $ratio, $thread, $settings);
7 | }
--------------------------------------------------------------------------------
/src/styles/modularscale/_target.scss:
--------------------------------------------------------------------------------
1 | // Convert number string to number
2 | @function ms-to-num($n) {
3 | $l: str-length($n);
4 | $r: 0;
5 | $m: str-index($n,'.');
6 | @if $m == null {
7 | $m: $l + 1;
8 | }
9 | // Loop through digits and convert to numbers
10 | @for $i from 1 through $l {
11 | $v: str-slice($n,$i,$i);
12 | @if $v == '1' { $v: 1; }
13 | @else if $v == '2' { $v: 2; }
14 | @else if $v == '3' { $v: 3; }
15 | @else if $v == '4' { $v: 4; }
16 | @else if $v == '5' { $v: 5; }
17 | @else if $v == '6' { $v: 6; }
18 | @else if $v == '7' { $v: 7; }
19 | @else if $v == '8' { $v: 8; }
20 | @else if $v == '9' { $v: 9; }
21 | @else if $v == '0' { $v: 0; }
22 | @else { $v: null; }
23 | @if $v != null {
24 | $m: $m - 1;
25 | $r: $r + ms-pow(10,$m - 1) * $v;
26 | } @else {
27 | $l: $l - 1;
28 | }
29 | }
30 | @return $r;
31 | }
32 |
33 | // Find a ratio based on a target value
34 | @function ms-target($t,$b) {
35 | // Convert to string
36 | $t: $t + '';
37 | // Remove base units to calulate ratio
38 | $b: ms-unitless(nth($b,1));
39 | // Find where 'at' is in the string
40 | $at: str-index($t,'at');
41 |
42 | // Slice the value and target out
43 | // and convert strings to numbers
44 | $v: ms-to-num(str-slice($t,0,$at - 1));
45 | $t: ms-to-num(str-slice($t,$at + 2));
46 |
47 | // Solve the modular scale function for the ratio.
48 | @return ms-pow(($v/$b),(1/$t));
49 | }
--------------------------------------------------------------------------------
/src/styles/modularscale/_vars.scss:
--------------------------------------------------------------------------------
1 | // Ratios
2 | $double-octave : 4 ;
3 | $pi : 3.14159265359 ;
4 | $major-twelfth : 3 ;
5 | $major-eleventh : 2.666666667 ;
6 | $major-tenth : 2.5 ;
7 | $octave : 2 ;
8 | $major-seventh : 1.875 ;
9 | $minor-seventh : 1.777777778 ;
10 | $major-sixth : 1.666666667 ;
11 | $phi : 1.618034 ;
12 | $golden : $phi ;
13 | $minor-sixth : 1.6 ;
14 | $fifth : 1.5 ;
15 | $augmented-fourth : 1.41421 ;
16 | $fourth : 1.333333333 ;
17 | $major-third : 1.25 ;
18 | $minor-third : 1.2 ;
19 | $major-second : 1.125 ;
20 | $minor-second : 1.066666667 ;
21 |
22 | // Base config
23 | $ms-base : 1em !default;
24 | $ms-ratio : $fifth !default;
25 | $modularscale : () !default;
--------------------------------------------------------------------------------
/src/utils/auth.js:
--------------------------------------------------------------------------------
1 | import {
2 | TOKEN_NAME,
3 | EXPIRATION_TIME,
4 | CLIENT_ID,
5 | REDIRECT_URL,
6 | USER_ID,
7 | SCOPE,
8 | } from "constants/AppConstants";
9 | import { SPOTIFY_API } from "constants/AppConstants";
10 | import hasTokenExpired from "./hasTokenExpired";
11 |
12 | export default class Auth {
13 | static _getToken() {
14 | return localStorage.getItem(TOKEN_NAME);
15 | }
16 |
17 | static _generateRandomString(length) {
18 | let text = "";
19 | const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
20 | for (let i = 0; i < length; i++) {
21 | text += possible.charAt(Math.floor(Math.random() * possible.length));
22 | }
23 | return text;
24 | }
25 |
26 | static _createLoginUrl() {
27 | var state = this._generateRandomString(16),
28 | AUTH_URL = "https://accounts.spotify.com/authorize";
29 | AUTH_URL += "?client_id=" + encodeURIComponent(CLIENT_ID);
30 | AUTH_URL += "&redirect_uri=" + encodeURIComponent(REDIRECT_URL);
31 | AUTH_URL += "&scope=" + encodeURIComponent(SCOPE);
32 | AUTH_URL += "&response_type=" + encodeURIComponent("token");
33 | AUTH_URL += "&state=" + encodeURIComponent(state);
34 | localStorage.setItem("spotify_auth_state", state);
35 | return AUTH_URL;
36 | }
37 |
38 | static setToken(token, expiration_time) {
39 | localStorage.setItem(TOKEN_NAME, token);
40 | localStorage.setItem(EXPIRATION_TIME, expiration_time);
41 | }
42 |
43 | static setTokenToSpotify() {
44 | if (!hasTokenExpired()) {
45 | SPOTIFY_API.setAccessToken(this._getToken());
46 | }
47 | }
48 |
49 | static setUserId() {
50 | SPOTIFY_API.getMe().then(
51 | response => localStorage.setItem(USER_ID, response.id),
52 | error => console.error(error)
53 | );
54 | }
55 |
56 | static redirectToLoginPage() {
57 | const loginUrl = this._createLoginUrl();
58 | window.location.href = loginUrl;
59 | }
60 | }
--------------------------------------------------------------------------------
/src/utils/changeNumFormat.js:
--------------------------------------------------------------------------------
1 | export default function changeNumFormat(num) {
2 | if ( num > 999999) {
3 | return (num / 1000000).toFixed(1).replace( /\.0$/, "" ) + "m";
4 | } else if (num > 999) {
5 | return (num / 1000).toFixed(1).replace( /\.0$/, "" ) + "k";
6 | }
7 | return num;
8 | }
--------------------------------------------------------------------------------
/src/utils/getCoords.js:
--------------------------------------------------------------------------------
1 | export default function getCoords(el) {
2 | const elCoords = el.getBoundingClientRect();
3 | const body = document.body;
4 | const docEl = document.documentElement;
5 |
6 | const scrollTop = window.pageYOffset || docEl.scrollTop || body.scrollTop;
7 |
8 | const clientLeft = docEl.clientLeft || body.clientLeft || 0;
9 | const clientTop = docEl.clientTop || body.clientTop || 0;
10 |
11 | return {
12 | left: elCoords.left - clientLeft,
13 | bottom: elCoords.bottom + scrollTop - clientTop,
14 | };
15 | }
--------------------------------------------------------------------------------
/src/utils/getCountryList.js:
--------------------------------------------------------------------------------
1 | import { TOP_50 } from "constants/PlaylistIds";
2 |
3 | const countries = [];
4 | for (let country in TOP_50) {
5 | countries.push({
6 | name: transformCountryName(country),
7 | });
8 | }
9 |
10 | function transformCountryName(string) {
11 | let splitStr = string.split("_");
12 | let res = [];
13 | for (let i of splitStr) {
14 | res.push(i.charAt(0).toUpperCase() + i.slice(1));
15 | }
16 | return res.join(" ");
17 | }
18 |
19 | export default countries;
--------------------------------------------------------------------------------
/src/utils/hasTokenExpired.js:
--------------------------------------------------------------------------------
1 | import { EXPIRATION_TIME, TOKEN_NAME } from "constants/AppConstants";
2 |
3 | export default function hasTokenExpired() {
4 | const current_time = new Date();
5 | const expiresIn = localStorage.getItem(EXPIRATION_TIME);
6 | const expirationTime = (expiresIn) ? new Date(Date.parse(expiresIn)) : false;
7 | if (!expirationTime || expirationTime < current_time) {
8 | localStorage.removeItem(EXPIRATION_TIME);
9 | localStorage.removeItem(TOKEN_NAME);
10 | return true;
11 | }
12 | return false;
13 | }
--------------------------------------------------------------------------------
/src/utils/intersectionObserver.js:
--------------------------------------------------------------------------------
1 | export default function isIntersectionObserverAvailable() {
2 | return (
3 | typeof window !== "undefined" &&
4 | "IntersectionObserver" in window &&
5 | "isIntersecting" in window.IntersectionObserverEntry.prototype
6 | );
7 | }
--------------------------------------------------------------------------------
/src/utils/makeActionCreator.js:
--------------------------------------------------------------------------------
1 | export default function makeActionCreator(type, ...argNames) {
2 | return function(...args) {
3 | const action = { type };
4 | argNames.forEach((arg, index) => {
5 | action[argNames[index]] = args[index];
6 | });
7 | return action;
8 | };
9 | }
--------------------------------------------------------------------------------
/src/utils/msToTime.js:
--------------------------------------------------------------------------------
1 | export default function msToTime(seconds) {
2 | let ms = seconds % 1000;
3 | let s = (seconds - ms) / 1000;
4 | let secs = s % 60;
5 | secs = (secs < 10) ? `0${secs}` : secs;
6 | s = (s - secs) / 60;
7 | let mins = s % 60;
8 | return mins + ":" + secs;
9 | }
--------------------------------------------------------------------------------
/src/utils/spotifyApi.js:
--------------------------------------------------------------------------------
1 | import { SPOTIFY_API, MESSAGES } from "constants/AppConstants";
2 | import hasTokenExpired from "./hasTokenExpired";
3 | import Auth from "./auth";
4 |
5 | export default function spotifyQuery(queryName, queryParams = []) {
6 | if (hasTokenExpired()) {
7 | Auth.redirectToLoginPage();
8 | return new Promise((resolve, reject) => {
9 | reject(MESSAGES.TOKEN_HAS_EXPIRED);
10 | });
11 | }
12 | return SPOTIFY_API[queryName](...queryParams);
13 | }
--------------------------------------------------------------------------------
/src/utils/toggleLike.js:
--------------------------------------------------------------------------------
1 | export default function toggleLike(trackList, trackId) {
2 | return trackList.map(track => {
3 | if (track.id === trackId) {
4 | return {
5 | ...track,
6 | saved: !track.saved,
7 | };
8 | }
9 | return track;
10 | });
11 | }
--------------------------------------------------------------------------------
/src/utils/transformResponse.js:
--------------------------------------------------------------------------------
1 | import msToTime from "./msToTime";
2 |
3 | const transformResponse = {
4 | artists: (data, {imageIndex = 0} = {}) => {
5 | return data.map(artist => {
6 | return {
7 | image: artist.images[imageIndex] ? artist.images[imageIndex].url : "",
8 | id: artist.id,
9 | name: artist.name,
10 | href: artist.href,
11 | };
12 | });
13 | },
14 | albums: data => {
15 | const albums = data.items ? data.items : data;
16 | return albums.map(album => {
17 | const meta = album.artists.map(artist => {
18 | return {
19 | name: artist.name,
20 | id: artist.id,
21 | };
22 | });
23 | return {
24 | image: album.images[0] ? album.images[0].url :"",
25 | id: album.id,
26 | name: album.name,
27 | meta,
28 | };
29 | });
30 | },
31 | playlists: (data, includeOwner = false) => {
32 | const playlists = data.items ? data.items : data;
33 | return playlists.map(playlist => {
34 | const total = playlist.tracks.total;
35 | const meta = total + ((total === 1) ? " track" : " tracks");
36 | const response = {
37 | image: playlist.images[0] ? playlist.images[0].url :"",
38 | id: playlist.id,
39 | name: playlist.name,
40 | meta,
41 | };
42 | if (includeOwner) {
43 | response.ownerId = playlist.owner.id;
44 | }
45 | return response;
46 | });
47 | },
48 | categories: data => {
49 | return {
50 | items: data.categories.items.map(category => {
51 | return {
52 | image: category.icons[0].url,
53 | id: category.id,
54 | name: category.name,
55 | href: category.href,
56 | };
57 | }),
58 | total: data.categories.total,
59 | };
60 | },
61 | tracks: (tracks, savedTracks) => {
62 | return tracks.map((item, index) => {
63 | const track = item.track || item;
64 | let trackNumber = "";
65 | if (track.track_number) {
66 | trackNumber = track.track_number < 10
67 | ? `0${track.track_number}`
68 | : track.track_number;
69 | }
70 | return {
71 | authors: track.artists.map(artist => {
72 | return {
73 | name: artist.name,
74 | id: artist.id,
75 | };
76 | }),
77 | image: track.images
78 | ? track.images[2].url
79 | : track.album.images[2].url,
80 | track_number: trackNumber,
81 | id: track.id,
82 | name: track.name,
83 | preview_url: track.preview_url,
84 | duration: msToTime(track.duration_ms),
85 | uri: track.uri,
86 | saved: savedTracks[index],
87 | };
88 | });
89 | },
90 | };
91 |
92 | export default transformResponse;
--------------------------------------------------------------------------------