├── .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 | ![](https://raw.githubusercontent.com/andrepv/spotify-react/master/screenshots/1.gif) 21 |
22 | 
23 | 
24 | 
25 | 
26 | ### №2 27 | ![](https://raw.githubusercontent.com/andrepv/spotify-react/master/screenshots/2.gif) 28 |
29 | 
30 | 
31 | 
32 | 
33 | ### №3 34 | ![](https://raw.githubusercontent.com/andrepv/spotify-react/master/screenshots/3.gif) 35 |
36 | 
37 | 
38 | 
39 | 
40 | ### №4 41 | ![](https://raw.githubusercontent.com/andrepv/spotify-react/master/screenshots/4.gif) 42 |
43 | 
44 | 
45 | 
46 | 
47 | ### №5 48 | ![](https://raw.githubusercontent.com/andrepv/spotify-react/master/screenshots/5.gif) 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 |
47 | 52 |
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 |
25 | 26 |
32 |
33 |
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 |
26 |

{name}

27 |
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 |
62 | 63 |
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 |
20 |
21 |
22 |
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 |
23 |
24 |
25 |
26 |
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 |
21 |
22 |
23 |
24 |
25 |
26 |
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 |
7 | 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 |
7 |
10 |
18 |
19 |
22 | 26 | 30 | test 31 | 32 | 33 |

36 | 0 tracks 37 |

38 |
39 |
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 | 39 | 45 | 51 | 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 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/images/Navbar/Home.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/images/Navbar/Label.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/images/Navbar/Layers.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/images/Navbar/LightBulb.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 11 | 12 | 14 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/images/Navbar/Lightning.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/images/Navbar/User.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/Player/pause.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/images/Player/play-next.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/images/Player/play-previous.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/images/Player/play.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/images/addPlaylist.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 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 | 2 | 3 | 10 | 11 | -------------------------------------------------------------------------------- /src/images/music-note.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 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 |
35 | 36 |
37 | 42 |
43 |
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 |
21 | 22 | 23 |
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 |
35 | 39 |
43 |
44 | 51 | 55 | 56 |
57 |
58 |
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; --------------------------------------------------------------------------------