├── .babelrc ├── .eslintrc ├── .github └── FUNDING.yml ├── .gitignore ├── .nvmrc ├── .travis.yml ├── README.md ├── dist ├── favicon.ico └── index.html ├── package.json ├── src ├── actions │ ├── browse │ │ └── index.js │ ├── comments │ │ └── index.js │ ├── entities │ │ ├── index.js │ │ └── spec.js │ ├── filter │ │ └── index.js │ ├── following │ │ └── index.js │ ├── index.js │ ├── paginate │ │ ├── index.js │ │ └── spec.js │ ├── player │ │ ├── index.js │ │ └── spec.js │ ├── request │ │ ├── index.js │ │ └── spec.js │ ├── session │ │ └── index.js │ ├── sort │ │ └── index.js │ ├── toggle │ │ ├── index.js │ │ └── spec.js │ ├── track │ │ ├── index.js │ │ └── spec.js │ └── user │ │ ├── index.js │ │ └── spec.js ├── components │ ├── Activities │ │ └── index.js │ ├── App │ │ └── index.js │ ├── Artwork │ │ ├── index.js │ │ └── spec.js │ ├── ArtworkAction │ │ └── index.js │ ├── Browse │ │ └── index.js │ ├── ButtonActive │ │ └── index.js │ ├── ButtonGhost │ │ └── index.js │ ├── ButtonInline │ │ └── index.js │ ├── ButtonMore │ │ ├── index.js │ │ └── spec.js │ ├── Callback │ │ └── index.js │ ├── CommentExtension │ │ └── index.js │ ├── Dashboard │ │ └── index.js │ ├── DateSort │ │ └── index.js │ ├── FavoritesList │ │ ├── index.js │ │ └── spec.js │ ├── FilterDuration │ │ └── index.js │ ├── FilterName │ │ └── index.js │ ├── FollowersList │ │ ├── index.js │ │ └── spec.js │ ├── FollowingsList │ │ ├── index.js │ │ └── spec.js │ ├── Header │ │ └── index.js │ ├── HoverActions │ │ ├── index.js │ │ └── spec.js │ ├── InfoList │ │ ├── index.js │ │ └── spec.js │ ├── InputMenu │ │ └── index.js │ ├── List │ │ ├── index.js │ │ └── spec.js │ ├── LoadingSpinner │ │ ├── index.js │ │ └── spec.js │ ├── Permalink │ │ ├── index.js │ │ └── spec.js │ ├── Player │ │ └── index.js │ ├── Playlist │ │ └── index.js │ ├── Sort │ │ └── index.js │ ├── StreamActivities │ │ └── index.js │ ├── StreamInteractions │ │ └── index.js │ ├── Track │ │ ├── index.js │ │ ├── playlist.js │ │ ├── preview.js │ │ └── stream.js │ ├── TrackActions │ │ └── index.js │ ├── TrackExtension │ │ └── index.js │ ├── User │ │ ├── index.js │ │ └── preview.js │ ├── Volume │ │ └── index.js │ ├── WaveformSc │ │ └── index.js │ ├── withFetchOnScroll │ │ └── index.js │ └── withLoadingSpinner │ │ └── index.js ├── constants │ ├── actionTypes.js │ ├── artistFilter.js │ ├── authentication.js │ ├── dateSortTypes.js │ ├── durationFilter.js │ ├── filterTypes.js │ ├── genre.js │ ├── nameFilter.js │ ├── paginateLinkTypes.js │ ├── pathnames.js │ ├── requestTypes.js │ ├── schemas.js │ ├── sort.js │ ├── sortTypes.js │ ├── toggleTypes.js │ ├── trackAttributes.js │ └── trackTypes.js ├── index.js ├── reducers │ ├── browse │ │ ├── index.js │ │ └── spec.js │ ├── comment │ │ ├── index.js │ │ └── spec.js │ ├── entities │ │ ├── index.js │ │ └── spec.js │ ├── filter │ │ ├── index.js │ │ └── spec.js │ ├── index.js │ ├── paginate │ │ ├── index.js │ │ └── spec.js │ ├── player │ │ ├── index.js │ │ └── spec.js │ ├── request │ │ ├── index.js │ │ └── spec.js │ ├── session │ │ ├── index.js │ │ └── spec.js │ ├── sort │ │ ├── index.js │ │ └── spec.js │ ├── toggle │ │ ├── index.js │ │ └── spec.js │ └── user │ │ ├── index.js │ │ └── spec.js ├── schemas │ ├── comment.js │ ├── track.js │ └── user.js ├── services │ ├── api.js │ ├── filter.js │ ├── map.js │ ├── player.js │ ├── pluralize.js │ ├── string.js │ └── track.js └── stores │ ├── configureStore.js │ └── mixpanel.js ├── styles ├── components │ ├── actions.scss │ ├── artworkAction.scss │ ├── browse.scss │ ├── buttonActive.scss │ ├── buttonGhost.scss │ ├── buttonInline.scss │ ├── buttonMore.scss │ ├── comment.scss │ ├── dashboard.scss │ ├── header.scss │ ├── infoList.scss │ ├── inputMenu.scss │ ├── item.scss │ ├── list.scss │ ├── loadingSpinner.scss │ ├── player.scss │ ├── playlist.scss │ ├── playlistTrack.scss │ ├── streamInteractions.scss │ ├── track.scss │ ├── trackActions.scss │ └── volume.scss ├── custom │ ├── main.scss │ └── variable.scss └── index.scss ├── test └── setup.js ├── webpack.config.js └── webpack.prod.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "react", 5 | "stage-2" 6 | ], 7 | "plugins": [ 8 | "react-hot-loader/babel" 9 | ] 10 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | parser: "babel-eslint", 3 | "extends": "airbnb", 4 | "rules": { 5 | "react/prop-types": 0, 6 | "react/jsx-no-bind": 0, 7 | "react/jsx-filename-extension": 0, 8 | "react/forbid-prop-types": 0, 9 | "react/jsx-curly-spacing": 0, 10 | "react/no-unused-prop-types": 0, 11 | "react/jsx-space-before-closing": 0, 12 | "react/no-string-refs": 0, 13 | "react/self-closing-comp": 0, 14 | "react/no-find-dom-node": 0, 15 | "react/jsx-indent": 0, 16 | "react/prefer-stateless-function": 0, 17 | "jsx-a11y/no-static-element-interactions": 0, 18 | "import/no-named-as-default": 0, 19 | "import/prefer-default-export": 0, 20 | "no-plusplus": 0, 21 | "import/first": 0, 22 | "arrow-parens": 0, 23 | "class-methods-use-this": 0, 24 | "no-undef": 0, 25 | "camelcase": 0, 26 | "default-case": 0, 27 | "comma-dangle": 0, 28 | "consistent-return": 0, 29 | "no-new": 0, 30 | "prefer-template": 0, 31 | "quotes": 0, 32 | "new-cap": 0, 33 | "no-else-return": 0, 34 | "arrow-body-style": 0, 35 | "no-use-before-define": 0, 36 | "space-before-function-paren": [2, { "anonymous": "never", "named": "never" }], 37 | "max-len": [1, 120, 2, {ignoreComments: true}] 38 | } 39 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: rwieruch 4 | patreon: # rwieruch 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with a single custom sponsorship URL 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/bundle.js 3 | dist/server 4 | npm-debug.log 5 | yarn.lock 6 | package-lock.json -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 7.4 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - stable 5 | 6 | install: 7 | - npm install 8 | 9 | script: 10 | - npm test -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # favesound-redux 2 | 3 | [![Build Status](https://travis-ci.org/rwieruch/favesound-redux.svg?branch=master)](https://travis-ci.org/rwieruch/favesound-redux) [![Slack](https://slack-the-road-to-learn-react.wieruch.com/badge.svg)](https://slack-the-road-to-learn-react.wieruch.com/) 4 | 5 | The SoundCloud Client in React + Redux made with passion. [Demo](http://www.favesound.de/), [Sibling Project: favesound-mobx](https://github.com/rwieruch/favesound-mobx) 6 | 7 | ## Get started on your own! 8 | 9 | * [Comprehensive Guide: The SoundCloud Client in React + Redux](http://www.robinwieruch.de/the-soundcloud-client-in-react-redux/) 10 | * [Boilerplate Project](https://github.com/rwieruch/react-redux-soundcloud) 11 | 12 | ## Includes 13 | 14 | * react v. 16 15 | * react-router v. 4 16 | * redux 17 | * react-redux 18 | * redux-thunk 19 | * normalizr 20 | * lodash-fp 21 | * airbnb-extended eslint 22 | * enzyme v. 3 23 | * Soundcloud API. 24 | 25 | ## Features 26 | 27 | * login to SoundCloud 28 | * show your personal stream 29 | * show favorite tracks, followers and followings 30 | * infinite scroll + paginated fetching 31 | * follow people 32 | * like tracks 33 | * player play/stop/forward/backward track 34 | * player with shuffle tracks, share link and volume 35 | * player with duration bar for tracks and navigation 36 | * playlist 37 | * sort tracks by plays, likes, comments, reposts, downloads 38 | * filter tracks by duration 39 | * search tracks by name and artist 40 | 41 | ## Run on your Machine 42 | 43 | 1. Clone Repository: `git clone git@github.com:rwieruch/favesound-redux.git` 44 | 2. `npm install` 45 | 3. `npm start` 46 | 4. visit http://localhost:8080/ 47 | 4. `npm test` 48 | 49 | ## Contribute 50 | 51 | I am looking actively for contributors to make this project awesome! 52 | 53 | I wouldn't want to extend the project with new routes like: that's my profile page and that's my favorite track page. Rather I see more value in improving the status quo of the app: Improving the player, the playlist or the interaction overall. I would love to see a GitHub issue to see where you want to work on. Moreover I will try to find the time to raise some more issues where people can contribute. At the end it is a perfect project to get started in open source! 54 | 55 | ## Improve 56 | 57 | Feedback is more than appreciated via [GitHub](https://github.com/rwieruch) or [Twitter](https://twitter.com/rwieruch) 58 | -------------------------------------------------------------------------------- /dist/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rwieruch/favesound-redux/4a8cddf720e2d6ef8413f3b65190e6a4ed190706/dist/favicon.ico -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | FaveSound 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "favesound-redux", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "NODE_ENV=production webpack -p --progress --colors --config ./webpack.prod.config.js", 8 | "start": "webpack-dev-server --progress --colors --config ./webpack.config.js", 9 | "test": "mocha --compilers js:babel-core/register --require ./test/setup.js 'src/**/spec.js'", 10 | "test:watch": "npm run test -- --watch" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "devDependencies": { 16 | "babel-core": "6.3.15", 17 | "babel-eslint": "7.1.1", 18 | "babel-loader": "6.2.0", 19 | "babel-preset-es2015": "6.3.13", 20 | "babel-preset-react": "6.3.13", 21 | "babel-preset-stage-2": "6.5.0", 22 | "babel-register": "6.7.2", 23 | "chai": "4.1.2", 24 | "chai-as-promised": "7.1.1", 25 | "css-loader": "0.28.7", 26 | "enzyme": "3.0.0", 27 | "enzyme-adapter-react-16": "1.0.0", 28 | "eslint": "3.10.2", 29 | "eslint-config-airbnb": "13.0.0", 30 | "eslint-loader": "1.9.0", 31 | "eslint-plugin-import": "2.2.0", 32 | "eslint-plugin-jsx-a11y": "2.2.3", 33 | "eslint-plugin-react": "6.7.1", 34 | "exports-loader": "^0.6.4", 35 | "imports-loader": "^0.6.5", 36 | "jsdom": "9.8.3", 37 | "mocha": "3.5.3", 38 | "node-sass": "4.5.3", 39 | "react-addons-test-utils": "15.6.2", 40 | "react-hot-loader": "3.1.3", 41 | "react-test-renderer": "16.0.0", 42 | "sass-loader": "4.0.2", 43 | "sinon": "4.0.0", 44 | "sinon-chai": "2.14.0", 45 | "style-loader": "0.19.0", 46 | "webpack": "3.9.1", 47 | "webpack-dev-server": "2.9.5" 48 | }, 49 | "dependencies": { 50 | "classnames": "2.2.5", 51 | "js-cookie": "2.1.0", 52 | "lodash": "4.17.4", 53 | "moment": "2.18.1", 54 | "normalizr": "2.0.0", 55 | "prop-types": "15.5.10", 56 | "react": "16.0.0", 57 | "react-clipboard.js": "1.1.2", 58 | "react-dom": "16.0.0", 59 | "react-rangeslider": "2.1.0", 60 | "react-redux": "5.0.6", 61 | "react-router": "4.1.1", 62 | "react-router-dom": "4.1.1", 63 | "react-tooltip": "3.3.0", 64 | "redux": "3.7.2", 65 | "redux-logger": "3.0.6", 66 | "redux-thunk": "2.2.0", 67 | "rn-redux-mixpanel": "1.1.9", 68 | "soundcloud": "3.0.1", 69 | "waveform.js": "1.0.0", 70 | "whatwg-fetch": "2.0.3" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/actions/browse/index.js: -------------------------------------------------------------------------------- 1 | import { arrayOf, normalize } from 'normalizr'; 2 | import trackSchema from '../../schemas/track'; 3 | import * as actionTypes from '../../constants/actionTypes'; 4 | import * as requestTypes from '../../constants/requestTypes'; 5 | import { unauthApiUrl } from '../../services/api'; 6 | import { setRequestInProcess } from '../../actions/request'; 7 | import { setPaginateLink } from '../../actions/paginate'; 8 | import { mergeEntities } from '../../actions/entities'; 9 | 10 | export function setSelectedGenre(genre) { 11 | return { 12 | type: actionTypes.SET_SELECTED_GENRE, 13 | genre 14 | }; 15 | } 16 | 17 | function mergeActivitiesByGenre(activities, genre) { 18 | return { 19 | type: actionTypes.MERGE_GENRE_ACTIVITIES, 20 | activities, 21 | genre 22 | }; 23 | } 24 | 25 | export const fetchActivitiesByGenre = (nextHref, genre) => (dispatch, getState) => { 26 | const requestType = requestTypes.GENRES; 27 | const initHref = unauthApiUrl(`tracks?linked_partitioning=1&limit=20&offset=0&tags=${genre}`, '&'); 28 | const url = nextHref || initHref; 29 | const requestInProcess = getState().request[requestType]; 30 | 31 | if (requestInProcess) { return; } 32 | 33 | dispatch(setRequestInProcess(true, requestType)); 34 | 35 | return fetch(url) 36 | .then(response => response.json()) 37 | .then(data => { 38 | const normalized = normalize(data.collection, arrayOf(trackSchema)); 39 | dispatch(mergeEntities(normalized.entities)); 40 | dispatch(mergeActivitiesByGenre(normalized.result, genre)); 41 | dispatch(setPaginateLink(data.next_href, genre)); 42 | dispatch(setRequestInProcess(false, requestType)); 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /src/actions/comments/index.js: -------------------------------------------------------------------------------- 1 | import { arrayOf, normalize } from 'normalizr'; 2 | import commentSchema from '../../schemas/comment'; 3 | import * as actionTypes from '../../constants/actionTypes'; 4 | import { mergeEntities } from '../../actions/entities'; 5 | import { setRequestInProcess } from '../../actions/request'; 6 | import { setPaginateLink } from '../../actions/paginate'; 7 | import { getLazyLoadingCommentsUrl } from '../../services/api'; 8 | import { getCommentProperty } from '../../services/string'; 9 | 10 | export function setOpenComments(trackId) { 11 | return { 12 | type: actionTypes.OPEN_COMMENTS, 13 | trackId 14 | }; 15 | } 16 | 17 | export function mergeComments(comments, trackId) { 18 | return { 19 | type: actionTypes.MERGE_COMMENTS, 20 | comments, 21 | trackId 22 | }; 23 | } 24 | 25 | export const fetchComments = (trackId, nextHref) => (dispatch, getState) => { 26 | const requestProperty = getCommentProperty(trackId); 27 | const initUrl = 'tracks/' + trackId + '/comments?linked_partitioning=1&limit=20&offset=0'; 28 | const url = getLazyLoadingCommentsUrl(nextHref, initUrl); 29 | const requestInProcess = getState().request[requestProperty]; 30 | 31 | if (requestInProcess) { return; } 32 | 33 | dispatch(setRequestInProcess(true, requestProperty)); 34 | 35 | return fetch(url) 36 | .then(response => response.json()) 37 | .then(data => { 38 | const normalized = normalize(data.collection, arrayOf(commentSchema)); 39 | dispatch(mergeEntities(normalized.entities)); 40 | dispatch(mergeComments(normalized.result, trackId)); 41 | dispatch(setPaginateLink(data.next_href, requestProperty)); 42 | dispatch(setRequestInProcess(false, requestProperty)); 43 | }); 44 | }; 45 | 46 | export const openComments = (trackId) => (dispatch, getState) => { 47 | const comments = getState().comment.comments[trackId]; 48 | 49 | dispatch(setOpenComments(trackId)); 50 | 51 | if (!comments) { 52 | dispatch(fetchComments(trackId)); 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /src/actions/entities/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | 3 | export function mergeEntities(entities) { 4 | return { 5 | type: actionTypes.MERGE_ENTITIES, 6 | entities 7 | }; 8 | } 9 | 10 | export function syncEntities(entity, key) { 11 | return { 12 | type: actionTypes.SYNC_ENTITIES, 13 | entity, 14 | key 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /src/actions/entities/spec.js: -------------------------------------------------------------------------------- 1 | import * as actions from './index'; 2 | import * as actionTypes from '../../constants/actionTypes'; 3 | 4 | describe('mergeEntities()', () => { 5 | 6 | it('creates an action to merge entities', () => { 7 | const entities = [{ name: 'x', name: 'y' }]; 8 | const expectedAction = { 9 | type: actionTypes.MERGE_ENTITIES, 10 | entities 11 | }; 12 | 13 | expect(actions.mergeEntities(entities)).to.eql(expectedAction); 14 | }); 15 | 16 | }); 17 | 18 | describe('syncEntities()', () => { 19 | 20 | it('creates an action to sync an entity', () => { 21 | const key = 'users'; 22 | const entity = { name: 'x' }; 23 | const expectedAction = { 24 | type: actionTypes.SYNC_ENTITIES, 25 | entity, 26 | key 27 | }; 28 | 29 | expect(actions.syncEntities(entity, key)).to.eql(expectedAction); 30 | }); 31 | 32 | }); -------------------------------------------------------------------------------- /src/actions/filter/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | 3 | export function filterDuration(filterType) { 4 | return { 5 | type: actionTypes.FILTER_DURATION, 6 | filterType 7 | }; 8 | } 9 | 10 | export function filterName(filterNameQuery) { 11 | return { 12 | type: actionTypes.FILTER_NAME, 13 | filterNameQuery 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /src/actions/following/index.js: -------------------------------------------------------------------------------- 1 | import { find } from 'lodash'; 2 | import * as actionTypes from '../../constants/actionTypes'; 3 | import { apiUrl } from '../../services/api'; 4 | import { mergeFollowings } from '../../actions/user'; 5 | 6 | export function removeFromFollowings(userId) { 7 | return { 8 | type: actionTypes.REMOVE_FROM_FOLLOWINGS, 9 | userId 10 | }; 11 | } 12 | 13 | export const follow = (user) => (dispatch, getState) => { 14 | const followings = getState().user.followings; 15 | const isFollowing = find(followings, (following) => following === user.id); 16 | 17 | fetch(apiUrl(`me/followings/${user.id}`, '?'), { method: isFollowing ? 'delete' : 'put' }) 18 | .then(response => response.json()) 19 | .then(() => { 20 | if (isFollowing) { 21 | dispatch(removeFromFollowings(user.id)); 22 | } else { 23 | dispatch(mergeFollowings([user.id])); 24 | } 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /src/actions/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | import { login, logout } from './session'; 3 | import { fetchActivities, fetchFollowers, fetchFavorites, fetchFollowings } from './user'; 4 | import { fetchActivitiesByGenre, setSelectedGenre } from './browse'; 5 | import { like } from './track'; 6 | import { follow } from './following'; 7 | import { setToggle } from './toggle'; 8 | import { activateTrack, activateIteratedPlaylistTrack, activateIteratedStreamTrack, addTrackToPlaylist, removeTrackFromPlaylist, clearPlaylist, togglePlayTrack, toggleShuffleMode, toggleRepeatMode, changeVolume } from './player'; 9 | import { openComments, fetchComments } from './comments'; 10 | import { filterDuration, filterName } from './filter'; 11 | import { sortStream, dateSortStream } from './sort'; 12 | /* eslint-enable max-len */ 13 | 14 | export { 15 | login, 16 | logout, 17 | fetchActivities, 18 | fetchFollowings, 19 | fetchFollowers, 20 | fetchFavorites, 21 | activateTrack, 22 | togglePlayTrack, 23 | addTrackToPlaylist, 24 | removeTrackFromPlaylist, 25 | clearPlaylist, 26 | activateIteratedPlaylistTrack, 27 | activateIteratedStreamTrack, 28 | like, 29 | follow, 30 | setToggle, 31 | setSelectedGenre, 32 | fetchActivitiesByGenre, 33 | openComments, 34 | fetchComments, 35 | filterDuration, 36 | filterName, 37 | sortStream, 38 | dateSortStream, 39 | toggleShuffleMode, 40 | toggleRepeatMode, 41 | changeVolume, 42 | }; 43 | -------------------------------------------------------------------------------- /src/actions/paginate/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | 3 | export function setPaginateLink(nextHref, paginateType) { 4 | return { 5 | type: actionTypes.SET_PAGINATE_LINK, 6 | paginateType, 7 | nextHref 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /src/actions/paginate/spec.js: -------------------------------------------------------------------------------- 1 | import * as actions from './index'; 2 | import * as actionTypes from '../../constants/actionTypes'; 3 | 4 | describe('setPaginateLink()', () => { 5 | 6 | it('creates an action to set a paginate link', () => { 7 | const nextHref = '/foo'; 8 | const paginateType = 'FOO_PAGINATE'; 9 | const expectedAction = { 10 | type: actionTypes.SET_PAGINATE_LINK, 11 | nextHref, 12 | paginateType 13 | }; 14 | 15 | expect(actions.setPaginateLink(nextHref, paginateType)).to.eql(expectedAction); 16 | }); 17 | 18 | }); -------------------------------------------------------------------------------- /src/actions/player/index.js: -------------------------------------------------------------------------------- 1 | import find from 'lodash/fp/find'; 2 | import findIndex from 'lodash/fp/findIndex'; 3 | import * as actionTypes from '../../constants/actionTypes'; 4 | import * as toggleTypes from '../../constants/toggleTypes'; 5 | import { resetToggle } from '../../actions/toggle'; 6 | import { isSameTrackAndPlaying, isSameTrack } from '../../services/player'; 7 | 8 | export function setActiveTrack(activeTrackId) { 9 | return { 10 | type: actionTypes.SET_ACTIVE_TRACK, 11 | activeTrackId 12 | }; 13 | } 14 | 15 | export function setIsPlaying(isPlaying) { 16 | return { 17 | type: actionTypes.SET_IS_PLAYING, 18 | isPlaying 19 | }; 20 | } 21 | 22 | export function setTrackInPlaylist(trackId) { 23 | return { 24 | type: actionTypes.SET_TRACK_IN_PLAYLIST, 25 | trackId 26 | }; 27 | } 28 | 29 | export function removeFromPlaylist(trackId) { 30 | return { 31 | type: actionTypes.REMOVE_TRACK_FROM_PLAYLIST, 32 | trackId 33 | }; 34 | } 35 | 36 | export function deactivateTrack() { 37 | return { 38 | type: actionTypes.RESET_ACTIVE_TRACK, 39 | }; 40 | } 41 | 42 | export function emptyPlaylist() { 43 | return { 44 | type: actionTypes.RESET_PLAYLIST, 45 | }; 46 | } 47 | 48 | export function setIsInShuffleMode() { 49 | return { 50 | type: actionTypes.SET_SHUFFLE_MODE, 51 | }; 52 | } 53 | 54 | export function setIsInRepeatMode() { 55 | return { 56 | type: actionTypes.SET_REPEAT_MODE, 57 | }; 58 | } 59 | 60 | export function setTrackVolume(volume) { 61 | return { 62 | type: actionTypes.SET_VOLUME, 63 | volume 64 | }; 65 | } 66 | 67 | export const clearPlaylist = () => (dispatch) => { 68 | dispatch(emptyPlaylist()); 69 | dispatch(deactivateTrack()); 70 | dispatch(togglePlayTrack(false)); 71 | dispatch(resetToggle(toggleTypes.PLAYLIST)); 72 | dispatch(resetToggle(toggleTypes.VOLUME)); 73 | }; 74 | 75 | function isInPlaylist(playlist, trackId) { 76 | return find(isSameTrack(trackId), playlist); 77 | } 78 | 79 | export const togglePlayTrack = (isPlaying) => (dispatch) => { 80 | dispatch(setIsPlaying(isPlaying)); 81 | }; 82 | 83 | export const activateTrack = (trackId) => (dispatch, getState) => { 84 | const playlist = getState().player.playlist; 85 | const previousActiveTrackId = getState().player.activeTrackId; 86 | const isCurrentlyPlaying = getState().player.isPlaying; 87 | const isPlaying = !isSameTrackAndPlaying(previousActiveTrackId, trackId, isCurrentlyPlaying); 88 | 89 | dispatch(togglePlayTrack(isPlaying)); 90 | dispatch(setActiveTrack(trackId)); 91 | 92 | if (!isInPlaylist(playlist, trackId)) { 93 | dispatch(setTrackInPlaylist(trackId)); 94 | } 95 | }; 96 | 97 | export const addTrackToPlaylist = (track) => (dispatch, getState) => { 98 | const playlist = getState().player.playlist; 99 | 100 | if (!isInPlaylist(playlist, track.id)) { 101 | dispatch(setTrackInPlaylist(track.id)); 102 | } 103 | 104 | if (!playlist.length) { 105 | dispatch(activateTrack(track.id)); 106 | } 107 | }; 108 | 109 | function getIteratedTrack(playlist, currentActiveTrackId, iterate) { 110 | const index = findIndex(isSameTrack(currentActiveTrackId), playlist); 111 | const nextIndex = (index + iterate) % playlist.length; 112 | return playlist[nextIndex]; 113 | } 114 | 115 | function getRandomTrack(playlist, currentActiveTrackId) { 116 | const index = findIndex(isSameTrack(currentActiveTrackId), playlist); 117 | 118 | function getRandomIndex() { 119 | const randNum = Math.floor(Math.random() * playlist.length); 120 | if (randNum === index && playlist.length > 1) { 121 | return getRandomIndex(); 122 | } else { 123 | return randNum; 124 | } 125 | } 126 | return playlist[getRandomIndex()]; 127 | } 128 | 129 | export const activateIteratedPlaylistTrack = (currentActiveTrackId, iterate) => (dispatch, getState) => { 130 | const playlist = getState().player.playlist; 131 | const nextActiveTrackId = getIteratedTrack(playlist, currentActiveTrackId, iterate); 132 | const isInShuffleMode = getState().player.isInShuffleMode; 133 | 134 | if (nextActiveTrackId && isInShuffleMode === false) { 135 | dispatch(activateTrack(nextActiveTrackId)); 136 | } else if (isInShuffleMode) { 137 | dispatchRandomTrack(playlist, currentActiveTrackId, dispatch); 138 | } 139 | }; 140 | 141 | function dispatchRandomTrack(playlist, currentActiveTrackId, dispatch) { 142 | const randomActiveTrackId = getRandomTrack(playlist, currentActiveTrackId); 143 | dispatch(activateTrack(randomActiveTrackId)); 144 | } 145 | 146 | export const activateIteratedStreamTrack = (currentActiveTrackId, iterate) => (dispatch, getState) => { 147 | const isInShuffleMode = getState().player.isInShuffleMode; 148 | const playlist = getState().player.playlist; 149 | 150 | const streamList = getStreamList(getState); 151 | 152 | if (isInShuffleMode) { 153 | dispatchRandomTrack(streamList, currentActiveTrackId, dispatch); 154 | } else { 155 | const nextStreamTrackId = findNextStreamTrackId(streamList, playlist, currentActiveTrackId, iterate); 156 | if (nextStreamTrackId) { 157 | dispatch(activateTrack(nextStreamTrackId)); 158 | } else { 159 | dispatch(togglePlayTrack(false)); 160 | } 161 | } 162 | }; 163 | 164 | function getStreamList(getState) { 165 | const selectedGenre = getState().browse.selectedGenre; 166 | if (selectedGenre) { 167 | return getState().browse[selectedGenre]; 168 | } 169 | return getState().user.activities; 170 | } 171 | 172 | function findNextStreamTrackId(streamList, playlist, currentActiveTrackId, iterate) { 173 | let nextStreamTrackId = getIteratedTrack(streamList, currentActiveTrackId, iterate); 174 | while (playlist.includes(nextStreamTrackId)) { 175 | nextStreamTrackId = getIteratedTrack(streamList, nextStreamTrackId, iterate); 176 | } 177 | return nextStreamTrackId; 178 | } 179 | 180 | export const removeTrackFromPlaylist = (track) => (dispatch, getState) => { 181 | const activeTrackId = getState().player.activeTrackId; 182 | const isPlaying = getState().player.isPlaying; 183 | const isRelevantTrack = isSameTrackAndPlaying(activeTrackId, track.id, isPlaying); 184 | 185 | if (isRelevantTrack) { 186 | dispatch(activateIteratedPlaylistTrack(activeTrackId, 1)); 187 | } 188 | 189 | const playlistSize = getState().player.playlist.length; 190 | if (playlistSize < 2) { 191 | dispatch(deactivateTrack()); 192 | dispatch(togglePlayTrack(false)); 193 | dispatch(resetToggle(toggleTypes.PLAYLIST)); 194 | dispatch(resetToggle(toggleTypes.VOLUME)); 195 | } 196 | 197 | dispatch(removeFromPlaylist(track.id)); 198 | }; 199 | 200 | export const toggleShuffleMode = (isInShuffleMode) => (dispatch) => { 201 | dispatch(setIsInShuffleMode(isInShuffleMode)); 202 | }; 203 | 204 | export const toggleRepeatMode = (isInRepeatMode) => (dispatch) => { 205 | dispatch(setIsInRepeatMode(isInRepeatMode)); 206 | }; 207 | 208 | 209 | export const changeVolume = (volume) => (dispatch) => { 210 | dispatch(setTrackVolume(volume)); 211 | }; 212 | -------------------------------------------------------------------------------- /src/actions/player/spec.js: -------------------------------------------------------------------------------- 1 | import * as actions from './index'; 2 | import * as actionTypes from '../../constants/actionTypes'; 3 | 4 | describe('setActiveTrack()', () => { 5 | 6 | it('creates an action to set an active track', () => { 7 | const activeTrackId = 4; 8 | const expectedAction = { 9 | type: actionTypes.SET_ACTIVE_TRACK, 10 | activeTrackId 11 | }; 12 | 13 | expect(actions.setActiveTrack(activeTrackId)).to.eql(expectedAction); 14 | }); 15 | 16 | }); -------------------------------------------------------------------------------- /src/actions/request/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | 3 | export function setRequestInProcess(inProcess, requestType) { 4 | return { 5 | type: actionTypes.SET_REQUEST_IN_PROCESS, 6 | requestType, 7 | inProcess 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /src/actions/request/spec.js: -------------------------------------------------------------------------------- 1 | import * as actions from './index'; 2 | import * as actionTypes from '../../constants/actionTypes'; 3 | 4 | describe('setRequestInProcess()', () => { 5 | 6 | it('creates an action to set a toggle', () => { 7 | const requestType = 'FOO_REQUEST'; 8 | const inProcess = true; 9 | const expectedAction = { 10 | type: actionTypes.SET_REQUEST_IN_PROCESS, 11 | inProcess, 12 | requestType 13 | }; 14 | 15 | expect(actions.setRequestInProcess(inProcess, requestType)).to.eql(expectedAction); 16 | }); 17 | 18 | }); -------------------------------------------------------------------------------- /src/actions/session/index.js: -------------------------------------------------------------------------------- 1 | import Cookies from 'js-cookie'; 2 | import { CLIENT_ID, OAUTH_TOKEN, REDIRECT_URI } from '../../constants/authentication'; 3 | import * as actionTypes from '../../constants/actionTypes'; 4 | import { apiUrl } from '../../services/api'; 5 | import { fetchFollowings, fetchActivities, fetchFollowers, fetchFavorites } from '../../actions/user'; 6 | import { setRequestInProcess } from '../../actions/request'; 7 | import * as requestTypes from '../../constants/requestTypes'; 8 | 9 | function setSession(session) { 10 | return { 11 | type: actionTypes.SET_SESSION, 12 | session 13 | }; 14 | } 15 | 16 | function setUser(user) { 17 | return { 18 | type: actionTypes.SET_USER, 19 | user 20 | }; 21 | } 22 | 23 | export function resetSession() { 24 | return { 25 | type: actionTypes.RESET_SESSION 26 | }; 27 | } 28 | 29 | const fetchUser = () => (dispatch) => { 30 | fetch(apiUrl(`me`, '?')) 31 | .then(response => response.json()) 32 | .then(me => { 33 | dispatch(setUser(me)); 34 | dispatch(fetchActivities()); 35 | dispatch(fetchFavorites(me)); 36 | dispatch(fetchFollowings(me)); 37 | dispatch(fetchFollowers(me)); 38 | }); 39 | }; 40 | 41 | export const login = () => (dispatch) => { 42 | const client_id = CLIENT_ID; 43 | const redirect_uri = REDIRECT_URI; 44 | dispatch(setRequestInProcess(true, requestTypes.AUTH)); 45 | SC.initialize({ client_id, redirect_uri }); 46 | SC.connect().then((session) => { 47 | Cookies.set(OAUTH_TOKEN, session.oauth_token); 48 | dispatch(setSession(session)); 49 | dispatch(fetchUser()); 50 | dispatch(setRequestInProcess(false, requestTypes.AUTH)); 51 | }).catch(() => { 52 | dispatch(setRequestInProcess(false, requestTypes.AUTH)); 53 | }); 54 | }; 55 | 56 | export const logout = () => (dispatch) => { 57 | Cookies.remove(OAUTH_TOKEN); 58 | dispatch(resetSession()); 59 | }; 60 | -------------------------------------------------------------------------------- /src/actions/sort/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | 3 | export function sortStream(sortType) { 4 | return { 5 | type: actionTypes.SORT_STREAM, 6 | sortType 7 | }; 8 | } 9 | export function dateSortStream(dateSortType) { 10 | return { 11 | type: actionTypes.DATE_SORT_STREAM, 12 | dateSortType 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /src/actions/toggle/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | 3 | export function setToggle(toggleType) { 4 | return { 5 | type: actionTypes.SET_TOGGLED, 6 | toggleType 7 | }; 8 | } 9 | 10 | export function resetToggle(toggleType) { 11 | return { 12 | type: actionTypes.RESET_TOGGLED, 13 | toggleType 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /src/actions/toggle/spec.js: -------------------------------------------------------------------------------- 1 | import * as actions from './index'; 2 | import * as actionTypes from '../../constants/actionTypes'; 3 | 4 | describe('setToggle()', () => { 5 | 6 | it('creates an action to set a toggle', () => { 7 | const toggleType = 'FOO_TOGGLE'; 8 | const expectedAction = { 9 | type: actionTypes.SET_TOGGLED, 10 | toggleType 11 | }; 12 | 13 | expect(actions.setToggle(toggleType)).to.eql(expectedAction); 14 | }); 15 | 16 | }); 17 | 18 | describe('resetToggle()', () => { 19 | 20 | it('creates an action to set a toggle', () => { 21 | const toggleType = 'FOO_TOGGLE'; 22 | const expectedAction = { 23 | type: actionTypes.RESET_TOGGLED, 24 | toggleType 25 | }; 26 | 27 | expect(actions.resetToggle(toggleType)).to.eql(expectedAction); 28 | }); 29 | 30 | }); -------------------------------------------------------------------------------- /src/actions/track/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | import { apiUrl } from '../../services/api'; 3 | import { mergeFavorites } from '../../actions/user'; 4 | import { syncEntities } from '../../actions/entities'; 5 | 6 | export function removeFromFavorites(trackId) { 7 | return { 8 | type: actionTypes.REMOVE_FROM_FAVORITES, 9 | trackId 10 | }; 11 | } 12 | 13 | export const like = (track) => (dispatch) => { 14 | fetch(apiUrl(`me/favorites/${track.id}`, '?'), { method: track.user_favorite ? 'delete' : 'put' }) 15 | .then(response => response.json()) 16 | .then(() => { 17 | if (track.user_favorite) { 18 | dispatch(removeFromFavorites(track.id)); 19 | } else { 20 | dispatch(mergeFavorites([track.id])); 21 | } 22 | 23 | const updateEntity = Object.assign({}, track, { user_favorite: !track.user_favorite }); 24 | dispatch(syncEntities(updateEntity, 'tracks')); 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /src/actions/track/spec.js: -------------------------------------------------------------------------------- 1 | import * as actions from './index'; 2 | import * as actionTypes from '../../constants/actionTypes'; 3 | 4 | describe('removeFromFavorites()', () => { 5 | 6 | it('creates an action to remove a track from favorites', () => { 7 | const trackId = 7; 8 | const expectedAction = { 9 | type: actionTypes.REMOVE_FROM_FAVORITES, 10 | trackId 11 | }; 12 | 13 | expect(actions.removeFromFavorites(trackId)).to.eql(expectedAction); 14 | }); 15 | 16 | }); -------------------------------------------------------------------------------- /src/actions/user/index.js: -------------------------------------------------------------------------------- 1 | import flow from 'lodash/fp/flow'; 2 | import map from 'lodash/fp/map'; 3 | import filter from 'lodash/fp/filter'; 4 | import { arrayOf, normalize } from 'normalizr'; 5 | import userSchema from '../../schemas/user'; 6 | import trackSchema from '../../schemas/track'; 7 | import * as trackTypes from '../../constants/trackTypes'; 8 | import * as actionTypes from '../../constants/actionTypes'; 9 | import * as requestTypes from '../../constants/requestTypes'; 10 | import * as paginateLinkTypes from '../../constants/paginateLinkTypes'; 11 | import { setRequestInProcess } from '../../actions/request'; 12 | import { setPaginateLink } from '../../actions/paginate'; 13 | import { mergeEntities } from '../../actions/entities'; 14 | import { isTrack, toIdAndType } from '../../services/track'; 15 | import { getLazyLoadingUsersUrl } from '../../services/api'; 16 | 17 | export function mergeFollowings(followings) { 18 | return { 19 | type: actionTypes.MERGE_FOLLOWINGS, 20 | followings 21 | }; 22 | } 23 | 24 | export const fetchFollowings = (user, nextHref) => (dispatch, getState) => { 25 | const requestType = requestTypes.FOLLOWINGS; 26 | const url = getLazyLoadingUsersUrl(user, nextHref, 'followings?limit=20&offset=0'); 27 | const requestInProcess = getState().request[requestType]; 28 | 29 | if (requestInProcess) { return; } 30 | 31 | dispatch(setRequestInProcess(true, requestType)); 32 | 33 | return fetch(url) 34 | .then(response => response.json()) 35 | .then(data => { 36 | const normalized = normalize(data.collection, arrayOf(userSchema)); 37 | dispatch(mergeEntities(normalized.entities)); 38 | dispatch(mergeFollowings(normalized.result)); 39 | dispatch(setPaginateLink(data.next_href, paginateLinkTypes.FOLLOWINGS)); 40 | dispatch(setRequestInProcess(false, requestType)); 41 | }); 42 | }; 43 | 44 | export function mergeActivities(activities) { 45 | return { 46 | type: actionTypes.MERGE_ACTIVITIES, 47 | activities 48 | }; 49 | } 50 | 51 | function mergeTrackTypesTrack(tracks) { 52 | return { 53 | type: actionTypes.MERGE_TRACK_TYPES_TRACK, 54 | tracks 55 | }; 56 | } 57 | 58 | function mergeTrackTypesRepost(reposts) { 59 | return { 60 | type: actionTypes.MERGE_TRACK_TYPES_REPOST, 61 | reposts 62 | }; 63 | } 64 | 65 | export const fetchActivities = (user, nextHref) => (dispatch, getState) => { 66 | const requestType = requestTypes.ACTIVITIES; 67 | const url = getLazyLoadingUsersUrl(user, nextHref, 'activities?limit=20&offset=0'); 68 | const requestInProcess = getState().request[requestType]; 69 | 70 | if (requestInProcess) { return; } 71 | 72 | dispatch(setRequestInProcess(true, requestType)); 73 | 74 | return fetch(url) 75 | .then(response => response.json()) 76 | .then(data => { 77 | const typeMap = flow( 78 | filter(isTrack), 79 | map(toIdAndType) 80 | )(data.collection); 81 | 82 | dispatch(mergeTrackTypesTrack(filter((value) => value.type === trackTypes.TRACK, typeMap))); 83 | dispatch(mergeTrackTypesRepost(filter((value) => value.type === trackTypes.TRACK_REPOST, typeMap))); 84 | 85 | const activitiesMap = flow( 86 | filter(isTrack), 87 | map('origin') 88 | )(data.collection); 89 | 90 | const normalized = normalize(activitiesMap, arrayOf(trackSchema)); 91 | dispatch(mergeEntities(normalized.entities)); 92 | dispatch(mergeActivities(normalized.result)); 93 | 94 | dispatch(setPaginateLink(data.next_href, paginateLinkTypes.ACTIVITIES)); 95 | dispatch(setRequestInProcess(false, requestType)); 96 | }); 97 | }; 98 | 99 | export function mergeFollowers(followers) { 100 | return { 101 | type: actionTypes.MERGE_FOLLOWERS, 102 | followers 103 | }; 104 | } 105 | 106 | export const fetchFollowers = (user, nextHref) => (dispatch, getState) => { 107 | const requestType = requestTypes.FOLLOWERS; 108 | const url = getLazyLoadingUsersUrl(user, nextHref, 'followers?limit=20&offset=0'); 109 | const requestInProcess = getState().request[requestType]; 110 | 111 | if (requestInProcess) { return; } 112 | 113 | dispatch(setRequestInProcess(true, requestType)); 114 | 115 | return fetch(url) 116 | .then(response => response.json()) 117 | .then(data => { 118 | const normalized = normalize(data.collection, arrayOf(userSchema)); 119 | dispatch(mergeEntities(normalized.entities)); 120 | dispatch(mergeFollowers(normalized.result)); 121 | dispatch(setPaginateLink(data.next_href, paginateLinkTypes.FOLLOWERS)); 122 | dispatch(setRequestInProcess(false, requestType)); 123 | }); 124 | }; 125 | 126 | export function mergeFavorites(favorites) { 127 | return { 128 | type: actionTypes.MERGE_FAVORITES, 129 | favorites 130 | }; 131 | } 132 | 133 | export const fetchFavorites = (user, nextHref) => (dispatch, getState) => { 134 | const requestType = requestTypes.FAVORITES; 135 | const url = getLazyLoadingUsersUrl(user, nextHref, 'favorites?linked_partitioning=1&limit=20&offset=0'); 136 | const requestInProcess = getState().request[requestType]; 137 | 138 | if (requestInProcess) { return; } 139 | 140 | dispatch(setRequestInProcess(true, requestType)); 141 | 142 | return fetch(url) 143 | .then(response => response.json()) 144 | .then(data => { 145 | const normalized = normalize(data.collection, arrayOf(trackSchema)); 146 | dispatch(mergeEntities(normalized.entities)); 147 | dispatch(mergeFavorites(normalized.result)); 148 | dispatch(setPaginateLink(data.next_href, paginateLinkTypes.FAVORITES)); 149 | dispatch(setRequestInProcess(false, requestType)); 150 | }); 151 | }; 152 | -------------------------------------------------------------------------------- /src/actions/user/spec.js: -------------------------------------------------------------------------------- 1 | import * as actions from './index'; 2 | import * as actionTypes from '../../constants/actionTypes'; 3 | 4 | describe('mergeFollowings()', () => { 5 | 6 | it('creates an action to merge followings', () => { 7 | const followings = ['x', 'y']; 8 | const expectedAction = { 9 | type: actionTypes.MERGE_FOLLOWINGS, 10 | followings 11 | }; 12 | 13 | expect(actions.mergeFollowings(followings)).to.eql(expectedAction); 14 | }); 15 | 16 | }); 17 | 18 | describe('mergeFavorites()', () => { 19 | 20 | it('creates an action to merge favorites', () => { 21 | const favorites = ['x', 'y']; 22 | const expectedAction = { 23 | type: actionTypes.MERGE_FAVORITES, 24 | favorites 25 | }; 26 | 27 | expect(actions.mergeFavorites(favorites)).to.eql(expectedAction); 28 | }); 29 | 30 | }); 31 | 32 | describe('mergeFollowers()', () => { 33 | 34 | it('creates an action to merge followers', () => { 35 | const followers = ['x', 'y']; 36 | const expectedAction = { 37 | type: actionTypes.MERGE_FOLLOWERS, 38 | followers 39 | }; 40 | 41 | expect(actions.mergeFollowers(followers)).to.eql(expectedAction); 42 | }); 43 | 44 | }); 45 | 46 | describe('mergeActivities()', () => { 47 | 48 | it('creates an action to merge activities', () => { 49 | const activities = ['x', 'y']; 50 | const expectedAction = { 51 | type: actionTypes.MERGE_ACTIVITIES, 52 | activities 53 | }; 54 | 55 | expect(actions.mergeActivities(activities)).to.eql(expectedAction); 56 | }); 57 | 58 | }); 59 | -------------------------------------------------------------------------------- /src/components/Activities/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import map from '../../services/map'; 4 | import withFetchOnScroll from '../../components/withFetchOnScroll'; 5 | import withLoadingSpinner from '../../components/withLoadingSpinner'; 6 | import TrackExtension from '../../components/TrackExtension'; 7 | import { TrackStreamContainer } from '../../components/Track'; 8 | 9 | function Activity({ 10 | activity, 11 | idx 12 | }) { 13 | return ( 14 |
  • 15 | 16 | 17 |
  • 18 | ); 19 | } 20 | 21 | function getMatchedEntities(ids, entities) { 22 | return map((id) => entities[id], ids); 23 | } 24 | 25 | function Activities({ 26 | ids, 27 | entities, 28 | activeFilter, 29 | activeSort, 30 | activeDateSort 31 | }) { 32 | const matchedEntities = getMatchedEntities(ids, entities); 33 | const filteredEntities = matchedEntities.filter(activeFilter); 34 | const sortedDateEntities = activeDateSort(filteredEntities); 35 | const sortedEntities = activeSort(sortedDateEntities); 36 | return ( 37 |
    38 |
    39 | 45 |
    46 |
    47 | ); 48 | } 49 | 50 | Activities.propTypes = { 51 | ids: PropTypes.array, 52 | entities: PropTypes.object, 53 | activeFilter: PropTypes.func, 54 | activeSort: PropTypes.func, 55 | activeDateSort: PropTypes.func, 56 | }; 57 | 58 | export default withLoadingSpinner(withFetchOnScroll(Activities)); 59 | -------------------------------------------------------------------------------- /src/components/App/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter, Route, Switch, Redirect } from 'react-router-dom'; 3 | import { OAUTH_TOKEN } from '../../constants/authentication'; 4 | import Cookies from 'js-cookie'; 5 | import { DEFAULT_GENRE } from '../../constants/genre'; 6 | import Browse from '../../components/Browse'; 7 | import Callback from '../../components/Callback'; 8 | import Dashboard from '../../components/Dashboard'; 9 | import Header from '../../components/Header'; 10 | import Player from '../../components/Player'; 11 | import Playlist from '../../components/Playlist'; 12 | import Volume from '../../components/Volume'; 13 | import { browse, dashboard, callback } from '../../constants/pathnames'; 14 | 15 | export default class App extends React.Component { 16 | constructor(props) { 17 | super(props); 18 | this.onAppClose = this.onAppClose.bind(this); 19 | } 20 | 21 | componentDidMount() { 22 | window.addEventListener('beforeunload', this.onAppClose); 23 | } 24 | 25 | componentWillUnmount() { 26 | window.removeEventListener('beforeunload', this.onAppClose); 27 | } 28 | 29 | onAppClose() { 30 | Cookies.remove(OAUTH_TOKEN); 31 | } 32 | 33 | render() { 34 | return ( 35 | 36 |
    37 |
    38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
    48 |
    49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/components/Artwork/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | 4 | function Artwork({ image, title, optionalImage, size }) { 5 | return {title}; 6 | } 7 | 8 | Artwork.propTypes = { 9 | image: PropTypes.string, 10 | title: PropTypes.string, 11 | optionalImage: PropTypes.string, 12 | size: PropTypes.number 13 | }; 14 | 15 | export default Artwork; 16 | -------------------------------------------------------------------------------- /src/components/Artwork/spec.js: -------------------------------------------------------------------------------- 1 | import Artwork from './index'; 2 | import { shallow } from 'enzyme'; 3 | 4 | describe('Artwork', () => { 5 | 6 | const props = { 7 | image: '/foo', 8 | title: 'Foo', 9 | optionalImage: '/bar', 10 | size: 20 11 | }; 12 | 13 | it('renders', () => { 14 | const element = shallow(); 15 | 16 | expect(element.find('img')).to.have.length(1); 17 | expect(element.find('img').prop('src')).to.equal(props.image); 18 | expect(element.find('img').prop('alt')).to.equal(props.title); 19 | expect(element.find('img').prop('height')).to.equal(props.size); 20 | expect(element.find('img').prop('width')).to.equal(props.size); 21 | }); 22 | 23 | it('takes an optional image into account', () => { 24 | props.image = null; 25 | const element = shallow(); 26 | expect(element.find('img').prop('src')).to.equal(props.optionalImage); 27 | }); 28 | 29 | }); 30 | -------------------------------------------------------------------------------- /src/components/ArtworkAction/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import classNames from 'classnames'; 4 | 5 | function ArtworkAction({ action, isVisible, className, children }) { 6 | const overlayClass = classNames( 7 | 'artwork-action-overlay', 8 | { 9 | 'artwork-action-overlay-visible': isVisible 10 | } 11 | ); 12 | 13 | return ( 14 |
    15 |
    {children}
    16 |
    17 | 18 |
    19 |
    20 | ); 21 | } 22 | 23 | ArtworkAction.propTypes = { 24 | action: PropTypes.func, 25 | isVisible: PropTypes.bool, 26 | className: PropTypes.string, 27 | children: PropTypes.object, 28 | }; 29 | 30 | export default ArtworkAction; 31 | -------------------------------------------------------------------------------- /src/components/Browse/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { bindActionCreators } from 'redux'; 5 | import { SORT_FUNCTIONS, DATE_SORT_FUNCTIONS } from '../../constants/sort'; 6 | import { DURATION_FILTER_FUNCTIONS } from '../../constants/durationFilter'; 7 | import * as actions from '../../actions/index'; 8 | import * as requestTypes from '../../constants/requestTypes'; 9 | import StreamInteractions from '../../components/StreamInteractions'; 10 | import Activities from '../../components/Activities'; 11 | import LoadingSpinner from '../../components/LoadingSpinner'; 12 | import { getTracknameFilter } from '../../constants/nameFilter'; 13 | import { getAndCombined, getOrCombined } from '../../services/filter'; 14 | import { getArtistFilter } from "../../constants/artistFilter"; 15 | 16 | class Browse extends React.Component { 17 | constructor(props) { 18 | super(props); 19 | this.fetchActivitiesByGenre = this.fetchActivitiesByGenre.bind(this); 20 | } 21 | 22 | componentDidMount() { 23 | const { setSelectedGenre, match } = this.props; 24 | setSelectedGenre(match.params.genre); 25 | 26 | if (!this.needToFetchActivities()) { 27 | return; 28 | } 29 | this.fetchActivitiesByGenre(); 30 | } 31 | 32 | componentWillReceiveProps(nextProps) { 33 | const curGenre = this.props.match.params.genre; 34 | const nextGenre = nextProps.match.params.genre; 35 | const { setSelectedGenre } = this.props; 36 | if (curGenre !== nextGenre) { 37 | setSelectedGenre(nextGenre); 38 | } 39 | } 40 | 41 | componentDidUpdate() { 42 | if (!this.needToFetchActivities()) { return; } 43 | this.fetchActivitiesByGenre(); 44 | } 45 | 46 | componentWillUnmount() { 47 | const { setSelectedGenre } = this.props; 48 | setSelectedGenre(null); 49 | } 50 | 51 | fetchActivitiesByGenre() { 52 | const { match, paginateLinks } = this.props; 53 | const genre = match.params.genre; 54 | const nextHref = paginateLinks[genre]; 55 | this.props.fetchActivitiesByGenre(nextHref, genre); 56 | } 57 | 58 | needToFetchActivities() { 59 | const { match, browseActivities } = this.props; 60 | const genre = match.params.genre; 61 | return !browseActivities[genre] || browseActivities[genre].length < 20; 62 | } 63 | 64 | render() { 65 | const { browseActivities, match, requestsInProcess, trackEntities, 66 | activeFilter, activeSort, activeDateSort } = this.props; 67 | const genre = match.params.genre; 68 | return ( 69 |
    70 | 71 | 80 | 81 |
    82 | ); 83 | } 84 | 85 | } 86 | 87 | function mapStateToProps(state) { 88 | const queryFilters = [getTracknameFilter(state.filter.filterNameQuery), 89 | getArtistFilter(state.filter.filterNameQuery, state.entities.users)]; 90 | 91 | const filters = [ 92 | DURATION_FILTER_FUNCTIONS[state.filter.durationFilterType], 93 | getOrCombined(queryFilters) 94 | ]; 95 | 96 | return { 97 | browseActivities: state.browse, 98 | requestsInProcess: state.request, 99 | paginateLinks: state.paginate, 100 | trackEntities: state.entities.tracks, 101 | userEntities: state.entities.users, 102 | activeFilter: getAndCombined(filters), 103 | activeSort: SORT_FUNCTIONS[state.sort.sortType], 104 | activeDateSort: DATE_SORT_FUNCTIONS[state.sort.dateSortType], 105 | }; 106 | } 107 | 108 | function mapDispatchToProps(dispatch) { 109 | return { 110 | setSelectedGenre: bindActionCreators(actions.setSelectedGenre, dispatch), 111 | fetchActivitiesByGenre: bindActionCreators(actions.fetchActivitiesByGenre, dispatch) 112 | }; 113 | } 114 | 115 | Browse.propTypes = { 116 | browseActivities: PropTypes.object, 117 | requestsInProcess: PropTypes.object, 118 | paginateLinks: PropTypes.object, 119 | trackEntities: PropTypes.object, 120 | userEntities: PropTypes.object, 121 | fetchActivitiesByGenre: PropTypes.func 122 | }; 123 | 124 | export default connect(mapStateToProps, mapDispatchToProps)(Browse); 125 | -------------------------------------------------------------------------------- /src/components/ButtonActive/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import ButtonInline from '../../components/ButtonInline'; 4 | 5 | function ButtonActive({ onClick, isActive, children }) { 6 | const buttonActiveClass = classNames( 7 | 'button-active', 8 | { 9 | 'button-active-selected': isActive 10 | } 11 | ); 12 | 13 | return ( 14 |
    15 | 16 | {children} 17 | 18 |
    19 | ); 20 | } 21 | 22 | export default ButtonActive; 23 | -------------------------------------------------------------------------------- /src/components/ButtonGhost/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | 4 | function ButtonGhost({ onClick, isSmall, children }) { 5 | const buttonGhostClass = classNames( 6 | 'button-ghost', 7 | { 8 | 'button-ghost-small': isSmall 9 | } 10 | ); 11 | 12 | return ( 13 | 16 | ); 17 | } 18 | 19 | export default ButtonGhost; 20 | -------------------------------------------------------------------------------- /src/components/ButtonInline/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function ButtonInline({ onClick, children }) { 4 | return ( 5 | 8 | ); 9 | } 10 | 11 | export default ButtonInline; 12 | -------------------------------------------------------------------------------- /src/components/ButtonMore/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import withLoadingSpinner from '../../components/withLoadingSpinner'; 3 | import ButtonGhost from '../../components/ButtonGhost'; 4 | 5 | function ButtonMore({ onClick, nextHref, isHidden }) { 6 | return ( 7 |
    8 | { 9 | !nextHref || isHidden ? 10 | null : 11 | More 12 | } 13 |
    14 | ); 15 | } 16 | 17 | export default withLoadingSpinner(ButtonMore); 18 | -------------------------------------------------------------------------------- /src/components/ButtonMore/spec.js: -------------------------------------------------------------------------------- 1 | import ButtonMore from './index'; 2 | import { mount } from 'enzyme'; 3 | 4 | describe('ButtonMore', () => { 5 | 6 | let props; 7 | 8 | beforeEach(() => { 9 | props = { 10 | nextHref: '/foo', 11 | onClick: () => {}, 12 | isLoading: false, 13 | isHidden: false 14 | }; 15 | }); 16 | 17 | it('renders', () => { 18 | const element = mount(); 19 | expect(element.find('ButtonGhost')).to.have.length(1); 20 | }); 21 | 22 | it('does not render, when it is set to hidden', () => { 23 | props.isHidden = true; 24 | const element = mount(); 25 | expect(element.find('ButtonGhost')).to.have.length(0); 26 | }); 27 | 28 | it('does not render, when there is no next link', () => { 29 | props.nextHref = null; 30 | const element = mount(); 31 | expect(element.find('ButtonGhost')).to.have.length(0); 32 | }); 33 | 34 | it('renders a loading spinner instead, when request is in process', () => { 35 | props.isLoading = true; 36 | const element = mount(); 37 | expect(element.find('LoadingSpinner')).to.have.length(1); 38 | expect(element.find('ButtonGhost')).to.have.length(0); 39 | }); 40 | 41 | }); -------------------------------------------------------------------------------- /src/components/Callback/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default class Callback extends React.Component { 4 | 5 | componentDidMount() { 6 | window.setTimeout(opener.SC.connectCallback, 1); 7 | } 8 | 9 | render() { 10 | return ( 11 |
    12 |

    13 | This page should close soon. 14 |

    15 |
    16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/components/CommentExtension/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { bindActionCreators } from 'redux'; 5 | import * as actions from '../../actions/index'; 6 | import map from '../../services/map'; 7 | import { getCommentProperty } from '../../services/string'; 8 | import ButtonMore from '../../components/ButtonMore'; 9 | import Artwork from '../../components/Artwork'; 10 | import { fromNow } from '../../services/track'; 11 | 12 | function CommentExtension({ 13 | activity, 14 | commentIds, 15 | commentEntities, 16 | userEntities, 17 | requestInProcess, 18 | nextHref, 19 | onFetchComments 20 | }) { 21 | const moreButtonProps = { 22 | onClick: () => onFetchComments(activity.id, nextHref), 23 | isLoading: requestInProcess || !commentIds, 24 | nextHref, 25 | }; 26 | 27 | return ( 28 |
    29 | {map((commentId, key) => { 30 | const comment = commentEntities[commentId]; 31 | const user = userEntities[comment.user]; 32 | return ( 33 |
    34 | 35 |
    36 |
    37 | {user.username} 38 | {fromNow(comment.created_at)} 39 |
    40 |
    41 | {comment.body} 42 |
    43 |
    44 |
    45 | ); 46 | }, commentIds)} 47 | 48 |
    49 | ); 50 | } 51 | 52 | function mapStateToProps(state, props) { 53 | const { activity } = props; 54 | const requestInProcess = state.request[getCommentProperty(activity.id)]; 55 | const nextHref = state.paginate[getCommentProperty(activity.id)]; 56 | 57 | return { 58 | activity, 59 | commentIds: state.comment.comments[activity.id], 60 | commentEntities: state.entities.comments, 61 | userEntities: state.entities.users, 62 | requestInProcess, 63 | nextHref, 64 | }; 65 | } 66 | 67 | function mapDispatchToProps(dispatch) { 68 | return { 69 | onFetchComments: bindActionCreators(actions.fetchComments, dispatch), 70 | }; 71 | } 72 | 73 | CommentExtension.propTypes = { 74 | onFetchComments: PropTypes.func, 75 | activity: PropTypes.object, 76 | commentIds: PropTypes.array, 77 | commentEntities: PropTypes.object, 78 | userEntities: PropTypes.object, 79 | requestInProcess: PropTypes.bool, 80 | nextHref: PropTypes.string, 81 | }; 82 | 83 | export default connect(mapStateToProps, mapDispatchToProps)(CommentExtension); 84 | -------------------------------------------------------------------------------- /src/components/Dashboard/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Redirect } from 'react-router-dom'; 3 | import { connect } from 'react-redux'; 4 | import * as requestTypes from '../../constants/requestTypes'; 5 | import StreamActivities from '../../components/StreamActivities'; 6 | import FollowersList from '../../components/FollowersList'; 7 | import FollowingsList from '../../components/FollowingsList'; 8 | import FavoritesList from '../../components/FavoritesList'; 9 | 10 | class Dashboard extends React.Component { 11 | render() { 12 | const { isAuthInProgress, isAuthed } = this.props; 13 | 14 | if (isAuthInProgress) { 15 | return null; 16 | } 17 | 18 | if (!isAuthed) { 19 | return ; 20 | } 21 | 22 | return ( 23 |
    24 |
    25 |
    26 | 27 |
    28 |
    29 |
    30 | 31 | 32 | 33 |
    34 |
    35 | ); 36 | } 37 | } 38 | 39 | const mapStateToProps = state => ({ 40 | isAuthed: Boolean(state.session.session), 41 | isAuthInProgress: state.request[requestTypes.AUTH], 42 | }); 43 | 44 | export default connect(mapStateToProps)(Dashboard); 45 | -------------------------------------------------------------------------------- /src/components/DateSort/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import map from '../../services/map'; 4 | import classNames from 'classnames'; 5 | import { connect } from 'react-redux'; 6 | import { bindActionCreators } from 'redux'; 7 | import * as actions from '../../actions/index'; 8 | import * as dateSortTypes from '../../constants/dateSortTypes'; 9 | import { DATE_SORT_NAMES } from '../../constants/sort'; 10 | import ButtonActive from '../../components/ButtonActive'; 11 | import ButtonInline from '../../components/ButtonInline'; 12 | 13 | function hasActiveSort(activeDateSort) { 14 | return activeDateSort !== dateSortTypes.NONE; 15 | } 16 | 17 | function DateSort({ 18 | activeDateSort, 19 | onSort, 20 | }) { 21 | const sortIconClass = classNames( 22 | 'stream-interaction-icon', 23 | { 24 | 'stream-interaction-icon-active': hasActiveSort(activeDateSort) 25 | } 26 | ); 27 | 28 | return ( 29 |
    30 |
    31 | onSort(dateSortTypes.NONE)}> 32 | 33 | 34 |
    35 |
    36 | { 37 | map((value, key) => { 38 | return ( 39 | 40 | onSort(value)} isActive={value === activeDateSort}> 41 | {DATE_SORT_NAMES[value]} 42 | 43 | 44 | ); 45 | }, dateSortTypes) 46 | } 47 |
    48 |
    49 | ); 50 | } 51 | 52 | function mapStateToProps(state) { 53 | return { 54 | activeDateSort: state.sort.dateSortType 55 | }; 56 | } 57 | 58 | function mapDispatchToProps(dispatch) { 59 | return { 60 | onSort: (dateSortType) => bindActionCreators(actions.dateSortStream, dispatch)(dateSortType) 61 | }; 62 | } 63 | 64 | DateSort.propTypes = { 65 | activeDateSort: PropTypes.string, 66 | onSort: PropTypes.func 67 | }; 68 | 69 | export default connect(mapStateToProps, mapDispatchToProps)(DateSort); 70 | -------------------------------------------------------------------------------- /src/components/FavoritesList/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { bindActionCreators } from 'redux'; 5 | import * as actions from '../../actions/index'; 6 | import * as toggleTypes from '../../constants/toggleTypes'; 7 | import * as requestTypes from '../../constants/requestTypes'; 8 | import * as paginateLinkTypes from '../../constants/paginateLinkTypes'; 9 | import List from '../../components/List'; 10 | 11 | function FavoritesList({ 12 | currentUser, 13 | trackEntities, 14 | favorites, 15 | nextHref, 16 | requestInProcess, 17 | isExpanded, 18 | onSetToggle, 19 | onFetchFavorites 20 | }) { 21 | return ( 22 | onSetToggle(toggleTypes.FAVORITES)} 31 | onFetchMore={() => onFetchFavorites(currentUser, nextHref)} 32 | kind="TRACK" 33 | /> 34 | ); 35 | } 36 | 37 | function mapStateToProps(state) { 38 | const nextHref = state.paginate[paginateLinkTypes.FAVORITES]; 39 | const requestInProcess = state.request[requestTypes.FAVORITES]; 40 | const isExpanded = state.toggle[toggleTypes.FAVORITES]; 41 | 42 | return { 43 | currentUser: state.session.user, 44 | trackEntities: state.entities.tracks, 45 | favorites: state.user.favorites, 46 | nextHref, 47 | requestInProcess, 48 | isExpanded 49 | }; 50 | } 51 | 52 | function mapDispatchToProps(dispatch) { 53 | return { 54 | onSetToggle: bindActionCreators(actions.setToggle, dispatch), 55 | onFetchFavorites: bindActionCreators(actions.fetchFavorites, dispatch) 56 | }; 57 | } 58 | 59 | FavoritesList.propTypes = { 60 | currentUser: PropTypes.object, 61 | trackEntities: PropTypes.object, 62 | favorites: PropTypes.array, 63 | requestsInProcess: PropTypes.object, 64 | paginateLinks: PropTypes.object, 65 | toggle: PropTypes.object, 66 | onSetToggle: PropTypes.func, 67 | onFetchFavorites: PropTypes.func 68 | }; 69 | 70 | export default connect(mapStateToProps, mapDispatchToProps)(FavoritesList); 71 | export { FavoritesList }; 72 | -------------------------------------------------------------------------------- /src/components/FavoritesList/spec.js: -------------------------------------------------------------------------------- 1 | import { FavoritesList } from './index'; 2 | import { shallow } from 'enzyme'; 3 | 4 | describe('FavoritesList', () => { 5 | 6 | const props = { 7 | currentUser: { name: 'x' }, 8 | trackEntities: { 1: { name: 'x' }, 2: { name: 'y' } }, 9 | favorites: [1], 10 | nextHref: '/foo', 11 | requestInProcess: false, 12 | isExpanded: false, 13 | setToggle: () => {}, 14 | fetchFavorites: () => {} 15 | }; 16 | 17 | it('renders', () => { 18 | const element = shallow(); 19 | expect(element.find('List')).to.have.length(1); 20 | }); 21 | 22 | }); -------------------------------------------------------------------------------- /src/components/FilterDuration/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import map from '../../services/map'; 4 | import classNames from 'classnames'; 5 | import { connect } from 'react-redux'; 6 | import { bindActionCreators } from 'redux'; 7 | import * as actions from '../../actions/index'; 8 | import * as filterTypes from '../../constants/filterTypes'; 9 | import { DURATION_FILTER_NAMES } from '../../constants/durationFilter'; 10 | import ButtonActive from '../../components/ButtonActive'; 11 | import ButtonInline from '../../components/ButtonInline'; 12 | 13 | function hasActiveFilter(activeDurationFilter) { 14 | const { FILTER_DURATION_TRACK, FILTER_DURATION_MIX } = filterTypes; 15 | return activeDurationFilter === FILTER_DURATION_TRACK || activeDurationFilter === FILTER_DURATION_MIX; 16 | } 17 | 18 | function FilterDuration({ 19 | activeDurationFilter, 20 | onDurationFilter, 21 | }) { 22 | const filterDurationIconClass = classNames( 23 | 'stream-interaction-icon', 24 | { 25 | 'stream-interaction-icon-active': hasActiveFilter(activeDurationFilter) 26 | } 27 | ); 28 | 29 | return ( 30 |
    31 |
    32 | onDurationFilter(filterTypes.ALL)}> 33 | 34 | 35 |
    36 |
    37 | { 38 | map((value, key) => { 39 | return ( 40 | 41 | onDurationFilter(value)} isActive={value === activeDurationFilter}> 42 | {DURATION_FILTER_NAMES[value]} 43 | 44 | 45 | ); 46 | }, filterTypes) 47 | } 48 |
    49 |
    50 | ); 51 | } 52 | 53 | function mapStateToProps(state) { 54 | return { 55 | activeDurationFilter: state.filter.durationFilterType 56 | }; 57 | } 58 | 59 | function mapDispatchToProps(dispatch) { 60 | return { 61 | onDurationFilter: (filterType) => bindActionCreators(actions.filterDuration, dispatch)(filterType) 62 | }; 63 | } 64 | 65 | FilterDuration.propTypes = { 66 | activeDurationFilter: PropTypes.string, 67 | onDurationFilter: PropTypes.func 68 | }; 69 | 70 | export default connect(mapStateToProps, mapDispatchToProps)(FilterDuration); 71 | -------------------------------------------------------------------------------- /src/components/FilterName/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import classNames from 'classnames'; 4 | import { connect } from 'react-redux'; 5 | import { bindActionCreators } from 'redux'; 6 | import * as actions from '../../actions/index'; 7 | import ButtonInline from '../../components/ButtonInline'; 8 | import InputMenu from '../../components/InputMenu'; 9 | 10 | function FilterName({ 11 | filterNameQuery, 12 | onNameFilter, 13 | }) { 14 | const filterNameIconClass = classNames( 15 | 'stream-interaction-icon', 16 | { 17 | 'stream-interaction-icon-active': filterNameQuery 18 | } 19 | ); 20 | 21 | return ( 22 |
    23 |
    24 | onNameFilter('')}> 25 | 26 | 27 |
    28 |
    29 | onNameFilter(event.target.value)} 32 | value={filterNameQuery} 33 | /> 34 |
    35 |
    36 | ); 37 | } 38 | 39 | function mapStateToProps(state) { 40 | return { 41 | filterNameQuery: state.filter.filterNameQuery 42 | }; 43 | } 44 | 45 | function mapDispatchToProps(dispatch) { 46 | return { 47 | onNameFilter: bindActionCreators(actions.filterName, dispatch) 48 | }; 49 | } 50 | 51 | FilterName.propTypes = { 52 | filterNameQuery: PropTypes.string, 53 | onNameFilter: PropTypes.func 54 | }; 55 | 56 | export default connect(mapStateToProps, mapDispatchToProps)(FilterName); 57 | -------------------------------------------------------------------------------- /src/components/FollowersList/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { bindActionCreators } from 'redux'; 5 | import * as actions from '../../actions/index'; 6 | import * as toggleTypes from '../../constants/toggleTypes'; 7 | import * as requestTypes from '../../constants/requestTypes'; 8 | import * as paginateLinkTypes from '../../constants/paginateLinkTypes'; 9 | import List from '../../components/List'; 10 | 11 | function FollowersList({ 12 | currentUser, 13 | userEntities, 14 | followers, 15 | nextHref, 16 | requestInProcess, 17 | isExpanded, 18 | onSetToggle, 19 | onFetchFollowers 20 | }) { 21 | return ( 22 | onSetToggle(toggleTypes.FOLLOWERS)} 31 | onFetchMore={() => onFetchFollowers(currentUser, nextHref)} 32 | kind="USER" 33 | /> 34 | ); 35 | } 36 | 37 | function mapStateToProps(state) { 38 | const nextHref = state.paginate[paginateLinkTypes.FOLLOWERS]; 39 | const requestInProcess = state.request[requestTypes.FOLLOWERS]; 40 | const isExpanded = state.toggle[toggleTypes.FOLLOWERS]; 41 | 42 | return { 43 | currentUser: state.session.user, 44 | userEntities: state.entities.users, 45 | followers: state.user.followers, 46 | nextHref, 47 | requestInProcess, 48 | isExpanded 49 | }; 50 | } 51 | 52 | function mapDispatchToProps(dispatch) { 53 | return { 54 | onSetToggle: bindActionCreators(actions.setToggle, dispatch), 55 | onFetchFollowers: bindActionCreators(actions.fetchFollowers, dispatch) 56 | }; 57 | } 58 | 59 | FollowersList.propTypes = { 60 | currentUser: PropTypes.object, 61 | userEntities: PropTypes.object, 62 | followers: PropTypes.array, 63 | requestsInProcess: PropTypes.object, 64 | paginateLinks: PropTypes.object, 65 | toggle: PropTypes.object, 66 | onSetToggle: PropTypes.func, 67 | onFetchFollowers: PropTypes.func 68 | }; 69 | 70 | export default connect(mapStateToProps, mapDispatchToProps)(FollowersList); 71 | export { FollowersList }; 72 | -------------------------------------------------------------------------------- /src/components/FollowersList/spec.js: -------------------------------------------------------------------------------- 1 | import { FollowersList } from './index'; 2 | import { shallow } from 'enzyme'; 3 | 4 | describe('FollowersList', () => { 5 | 6 | const props = { 7 | currentUser: { name: 'x' }, 8 | userEntities: { 1: { name: 'x' }, 2: { name: 'y' } }, 9 | followers: [1], 10 | nextHref: '/foo', 11 | requestInProcess: false, 12 | isExpanded: false, 13 | setToggle: () => {}, 14 | fetchFollowers: () => {} 15 | }; 16 | 17 | it('renders', () => { 18 | const element = shallow(); 19 | expect(element.find('List')).to.have.length(1); 20 | }); 21 | 22 | }); 23 | -------------------------------------------------------------------------------- /src/components/FollowingsList/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { bindActionCreators } from 'redux'; 5 | import * as actions from '../../actions/index'; 6 | import * as toggleTypes from '../../constants/toggleTypes'; 7 | import * as requestTypes from '../../constants/requestTypes'; 8 | import * as paginateLinkTypes from '../../constants/paginateLinkTypes'; 9 | import List from '../../components/List'; 10 | 11 | function FollowingsList({ 12 | currentUser, 13 | userEntities, 14 | followings, 15 | nextHref, 16 | requestInProcess, 17 | isExpanded, 18 | onSetToggle, 19 | onFetchFollowings 20 | }) { 21 | return ( 22 | onSetToggle(toggleTypes.FOLLOWINGS)} 31 | onFetchMore={() => onFetchFollowings(currentUser, nextHref)} 32 | kind="USER" 33 | /> 34 | ); 35 | } 36 | 37 | function mapStateToProps(state) { 38 | const nextHref = state.paginate[paginateLinkTypes.FOLLOWINGS]; 39 | const requestInProcess = state.request[requestTypes.FOLLOWINGS]; 40 | const isExpanded = state.toggle[toggleTypes.FOLLOWINGS]; 41 | 42 | return { 43 | currentUser: state.session.user, 44 | userEntities: state.entities.users, 45 | followings: state.user.followings, 46 | nextHref, 47 | requestInProcess, 48 | isExpanded 49 | }; 50 | } 51 | 52 | function mapDispatchToProps(dispatch) { 53 | return { 54 | onSetToggle: bindActionCreators(actions.setToggle, dispatch), 55 | onFetchFollowings: bindActionCreators(actions.fetchFollowings, dispatch) 56 | }; 57 | } 58 | 59 | FollowingsList.propTypes = { 60 | currentUser: PropTypes.object, 61 | userEntities: PropTypes.object, 62 | followings: PropTypes.array, 63 | requestsInProcess: PropTypes.object, 64 | paginateLinks: PropTypes.object, 65 | toggle: PropTypes.object, 66 | onSetToggle: PropTypes.func, 67 | onFetchFollowings: PropTypes.func 68 | }; 69 | 70 | export default connect(mapStateToProps, mapDispatchToProps)(FollowingsList); 71 | export { FollowingsList }; 72 | -------------------------------------------------------------------------------- /src/components/FollowingsList/spec.js: -------------------------------------------------------------------------------- 1 | import { FollowingsList } from './index'; 2 | import { shallow } from 'enzyme'; 3 | 4 | describe('FollowingsList', () => { 5 | 6 | const props = { 7 | currentUser: { name: 'x' }, 8 | userEntities: { 1: { name: 'x' }, 2: { name: 'y' } }, 9 | followings: [1], 10 | nextHref: '/foo', 11 | requestInProcess: false, 12 | isExpanded: false, 13 | setToggle: () => {}, 14 | fetchFollowings: () => {} 15 | }; 16 | 17 | it('renders', () => { 18 | const element = shallow(); 19 | expect(element.find('List')).to.have.length(1); 20 | }); 21 | 22 | }); 23 | -------------------------------------------------------------------------------- /src/components/Header/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import map from '../../services/map'; 4 | import classNames from 'classnames'; 5 | import { Link } from 'react-router-dom'; 6 | import { connect } from 'react-redux'; 7 | import { bindActionCreators } from 'redux'; 8 | 9 | import * as actions from '../../actions/index'; 10 | import { GENRES, DEFAULT_GENRE } from '../../constants/genre'; 11 | import { browse, dashboard } from '../../constants/pathnames'; 12 | 13 | function getGenreLink(genre) { 14 | return `${browse}/${genre || DEFAULT_GENRE}`; 15 | } 16 | 17 | function Logo() { 18 | return ( 19 |
    20 |
    21 | 22 |

    Favesound

    23 | 24 |
    25 |
    26 | 27 |

    Fork Me on Github

    28 | 29 |
    30 |
    31 | ); 32 | } 33 | 34 | function MenuItem({ genre, selectedGenre }) { 35 | const linkClass = classNames('menu-item', { 36 | 'menu-item-selected': genre === selectedGenre, 37 | }); 38 | 39 | return ( 40 | 41 | {genre} 42 | 43 | ); 44 | } 45 | 46 | function Login({ onLogin }) { 47 | return ( 48 | 49 | Login 50 | 51 | ); 52 | } 53 | 54 | function Logout({ onLogout }) { 55 | return ( 56 | 57 | Logout 58 | 59 | ); 60 | } 61 | 62 | function Dashboard() { 63 | return ( 64 | 65 | Dashboard 66 | 67 | ); 68 | } 69 | 70 | function SessionAction({ currentUser, onLogin, onLogout }) { 71 | return ( 72 |
    73 |
    74 | { currentUser ? : ' ' } 75 |
    76 |
    77 | { currentUser ? : } 78 |
    79 |
    80 | ); 81 | } 82 | 83 | function MenuList({ selectedGenre }) { 84 | if (!selectedGenre) return null; 85 | return ( 86 |
    87 | {map((genre, key) => { 88 | const menuItemProps = { genre, selectedGenre, key }; 89 | return ; 90 | }, GENRES)} 91 |
    92 | ); 93 | } 94 | 95 | function Header({ currentUser, selectedGenre, onLogin, onLogout }) { 96 | return ( 97 |
    98 |
    99 | 100 | 101 | 106 |
    107 |
    108 | ); 109 | } 110 | 111 | function mapStateToProps(state) { 112 | return { 113 | currentUser: state.session.user, 114 | selectedGenre: state.browse.selectedGenre 115 | }; 116 | } 117 | 118 | function mapDispatchToProps(dispatch) { 119 | return { 120 | onLogin: bindActionCreators(actions.login, dispatch), 121 | onLogout: bindActionCreators(actions.logout, dispatch), 122 | }; 123 | } 124 | 125 | Header.propTypes = { 126 | currentUser: PropTypes.object, 127 | onLogin: PropTypes.func, 128 | onLogout: PropTypes.func, 129 | }; 130 | 131 | export default connect(mapStateToProps, mapDispatchToProps)(Header); 132 | -------------------------------------------------------------------------------- /src/components/HoverActions/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import map from '../../services/map'; 4 | import classNames from 'classnames'; 5 | import ButtonInline from '../../components/ButtonInline'; 6 | 7 | function Action({ actionItem }) { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | } 16 | 17 | function Actions({ configuration, isVisible }) { 18 | const actionsClass = classNames( 19 | 'action', 20 | { 21 | 'action-visible': isVisible 22 | } 23 | ); 24 | 25 | return ( 26 |
    27 | {map((actionItem, key) => { 28 | return ; 29 | }, configuration)} 30 |
    31 | ); 32 | } 33 | 34 | Actions.propTypes = { 35 | configuration: PropTypes.array, 36 | isVisible: PropTypes.bool 37 | }; 38 | 39 | export default Actions; 40 | export { Action }; 41 | -------------------------------------------------------------------------------- /src/components/HoverActions/spec.js: -------------------------------------------------------------------------------- 1 | import Actions, { Action } from './index'; 2 | import { shallow } from 'enzyme'; 3 | 4 | describe('Actions', () => { 5 | 6 | const props = { 7 | configuration: [ 8 | { fn: () => {}, className: 'foo' }, 9 | { fn: () => {}, className: 'bar' }, 10 | ], 11 | isVisible: true 12 | }; 13 | 14 | it('renders', () => { 15 | const element = shallow(); 16 | expect(element.find('.action')).to.have.length(1); 17 | }); 18 | 19 | it('renders action item according to configuration length', () => { 20 | const element = shallow(); 21 | expect(element.find('Action')).to.have.length(2); 22 | }); 23 | 24 | it('is visible, when it is set to visible ', () => { 25 | props.isVisible = true; 26 | const element = shallow(); 27 | expect(element.find('.action').prop('className')).to.equal('action action-visible'); 28 | }); 29 | 30 | it('is invisible, when it is set to invisible ', () => { 31 | props.isVisible = false; 32 | const element = shallow(); 33 | expect(element.find('.action').prop('className')).to.equal('action'); 34 | }); 35 | 36 | }); 37 | 38 | describe('Action', () => { 39 | 40 | const props = { 41 | actionItem: { fn: () => {}, className: 'foo' } 42 | }; 43 | 44 | it('renders', () => { 45 | const element = shallow(); 46 | expect(element.find('.action-item')).to.have.length(1); 47 | }); 48 | 49 | it('shows proper className', () => { 50 | const element = shallow(); 51 | expect(element.find('i').prop('className')).to.equal(props.actionItem.className); 52 | }); 53 | 54 | }); -------------------------------------------------------------------------------- /src/components/InfoList/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import classNames from 'classnames'; 4 | import map from '../../services/map'; 5 | 6 | function InfoItem({ infoItem }) { 7 | const infoItemClass = classNames( 8 | 'info-list-item', 9 | { 10 | 'info-list-item-active': infoItem.activeSort 11 | } 12 | ); 13 | 14 | return ( 15 |
    16 | {infoItem.count} 17 |
    18 | ); 19 | } 20 | 21 | function InfoList({ information }) { 22 | return ( 23 |
    24 | {map((infoItem, key) => { 25 | return ; 26 | }, information)} 27 |
    28 | ); 29 | } 30 | 31 | InfoList.propTypes = { 32 | information: PropTypes.array 33 | }; 34 | 35 | export default InfoList; 36 | export { InfoItem }; 37 | -------------------------------------------------------------------------------- /src/components/InfoList/spec.js: -------------------------------------------------------------------------------- 1 | import InfoList, { InfoItem } from './index'; 2 | import { shallow } from 'enzyme'; 3 | 4 | describe('InfoList', () => { 5 | 6 | const props = { 7 | information: [ 8 | { count: 1, className: 'foo' }, 9 | { count: 2, className: 'bar' }, 10 | ] 11 | }; 12 | 13 | it('renders', () => { 14 | const element = shallow(); 15 | expect(element.find('.info-list')).to.have.length(1); 16 | }); 17 | 18 | it('renders info item according to information length', () => { 19 | const element = shallow(); 20 | expect(element.find('InfoItem')).to.have.length(2); 21 | }); 22 | 23 | }); 24 | 25 | describe('InfoItem', () => { 26 | 27 | const props = { 28 | infoItem: { count: 1, className: 'foo' } 29 | }; 30 | 31 | it('renders', () => { 32 | const element = shallow(); 33 | expect(element.find('.info-list-item')).to.have.length(1); 34 | }); 35 | 36 | it('shows proper className and count', () => { 37 | const element = shallow(); 38 | expect(element.find('i').prop('className')).to.equal(props.infoItem.className); 39 | expect(element.find('.info-list-item').text()).to.contain(props.infoItem.count); 40 | }); 41 | 42 | }); -------------------------------------------------------------------------------- /src/components/InputMenu/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function InputMenu({ value, onChange, placeholder }) { 4 | return ( 5 | 12 | ); 13 | } 14 | 15 | export default InputMenu; 16 | -------------------------------------------------------------------------------- /src/components/List/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import map from '../../services/map'; 4 | import classNames from 'classnames'; 5 | import { TrackPreviewContainer } from '../../components/Track'; 6 | import { UserPreviewContainer } from '../../components/User'; 7 | import ButtonMore from '../../components/ButtonMore'; 8 | import ButtonInline from '../../components/ButtonInline'; 9 | 10 | function Chevron({ ids, isExpanded }) { 11 | const chevronClass = classNames( 12 | 'fa', 13 | { 14 | 'fa-chevron-up': isExpanded, 15 | 'fa-chevron-down': !isExpanded 16 | } 17 | ); 18 | 19 | return ids.length > 4 ? : null; 20 | } 21 | 22 | function SpecificItemTrack({ entities, trackId }) { 23 | return ( 24 |
  • 25 | 26 |
  • 27 | ); 28 | } 29 | 30 | function SpecificItemUser({ entities, userId }) { 31 | return ( 32 |
  • 33 | 34 |
  • 35 | ); 36 | } 37 | 38 | function SpecificList({ ids, kind, entities }) { 39 | if (kind === 'USER') { 40 | return ( 41 |
    42 |
      43 | {map((id, key) => { 44 | const userProps = { userId: id, entities }; 45 | return ; 46 | }, ids)} 47 |
    48 |
    49 | ); 50 | } 51 | 52 | if (kind === 'TRACK') { 53 | return ( 54 |
    55 |
      56 | {map((id, key) => { 57 | const trackProps = { trackId: id, entities }; 58 | return ; 59 | }, ids)} 60 |
    61 |
    62 | ); 63 | } 64 | } 65 | 66 | function List({ 67 | ids, 68 | isExpanded, 69 | title, 70 | kind, 71 | requestInProcess, 72 | entities, 73 | onToggleMore, 74 | nextHref, 75 | onFetchMore 76 | }) { 77 | const listClass = classNames({ 78 | 'more-visible': isExpanded 79 | }); 80 | 81 | return ( 82 |
    83 |

    84 | 85 | {title} 86 | 87 |

    88 |
    89 | 94 | 100 |
    101 |
    102 | ); 103 | } 104 | 105 | List.propTypes = { 106 | ids: PropTypes.array, 107 | isExpanded: PropTypes.bool, 108 | title: PropTypes.string, 109 | kind: PropTypes.string, 110 | requestInProcess: PropTypes.bool, 111 | entities: PropTypes.object, 112 | nextHref: PropTypes.string, 113 | onToggleMore: PropTypes.func, 114 | onFetchMore: PropTypes.func 115 | }; 116 | 117 | export default List; 118 | export { 119 | SpecificList, 120 | Chevron, 121 | }; 122 | -------------------------------------------------------------------------------- /src/components/List/spec.js: -------------------------------------------------------------------------------- 1 | import List, { NextButton, Chevron, SpecificList } from './index'; 2 | import { shallow } from 'enzyme'; 3 | 4 | describe('List', () => { 5 | 6 | let props; 7 | 8 | beforeEach(() => { 9 | props = { 10 | ids: [1, 2, 3, 4, 7], 11 | isExpanded: false, 12 | title: 'Foo', 13 | kind: 'Any', 14 | requestInProcess: false, 15 | entities: { 1: { name: 'x' }, 2: { name: 'y' } }, 16 | toggleMore: () => {}, 17 | nextHref: '/foo', 18 | fetchMore: () => {} 19 | }; 20 | }); 21 | 22 | it('renders', () => { 23 | const element = shallow(); 24 | expect(element.find('.list')).to.have.length(1); 25 | 26 | expect(element.find('.more-visible')).to.have.length(0); 27 | }); 28 | 29 | it('shows expanded content', () => { 30 | props.isExpanded = true; 31 | const element = shallow(); 32 | expect(element.find('.more-visible')).to.have.length(1); 33 | }); 34 | 35 | }); 36 | 37 | describe('Chevron', () => { 38 | 39 | let props; 40 | 41 | beforeEach(() => { 42 | props = { 43 | ids: [1, 2, 4, 6, 7], 44 | isExpanded: false 45 | }; 46 | }); 47 | 48 | it('renders', () => { 49 | const element = shallow(); 50 | expect(element.find('i')).to.have.length(1); 51 | }); 52 | 53 | it('does not render, when there are less ids', () => { 54 | props.ids = [1, 2, 3]; 55 | const element = shallow(); 56 | expect(element.find('i')).to.have.length(0); 57 | }); 58 | 59 | }); 60 | 61 | describe('SpecificList', () => { 62 | 63 | let props; 64 | 65 | beforeEach(() => { 66 | props = { 67 | ids: [1, 2, 4, 6, 7], 68 | kind: 'USER', 69 | entities: { 1: { name: 'x' }, 2: { name: 'y' } }, 70 | }; 71 | }); 72 | 73 | it('renders specific item user according to length of ids', () => { 74 | props.kind = 'USER'; 75 | const element = shallow(); 76 | expect(element.find('SpecificItemUser')).to.have.length(5); 77 | }); 78 | 79 | it('renders specific item track according to length of ids', () => { 80 | props.kind = 'TRACK'; 81 | const element = shallow(); 82 | expect(element.find('SpecificItemTrack')).to.have.length(5); 83 | }); 84 | 85 | }); 86 | -------------------------------------------------------------------------------- /src/components/LoadingSpinner/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | 4 | function LoadingSpinner({ isLoading }) { 5 | if (!isLoading) { return null; } 6 | 7 | return ( 8 |
    9 | 10 |
    11 | ); 12 | } 13 | 14 | LoadingSpinner.propTypes = { 15 | isLoading: PropTypes.bool 16 | }; 17 | 18 | export default LoadingSpinner; 19 | -------------------------------------------------------------------------------- /src/components/LoadingSpinner/spec.js: -------------------------------------------------------------------------------- 1 | import LoadingSpinner from './index'; 2 | import { shallow } from 'enzyme'; 3 | 4 | describe('InfoList', () => { 5 | 6 | const props = { 7 | isLoading: true 8 | }; 9 | 10 | it('renders', () => { 11 | const element = shallow(); 12 | expect(element.find('i')).to.have.length(1); 13 | }); 14 | 15 | it('does not render when not loading', () => { 16 | props.isLoading = false; 17 | const element = shallow(); 18 | expect(element.find('i')).to.have.length(0); 19 | }); 20 | 21 | }); -------------------------------------------------------------------------------- /src/components/Permalink/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | 4 | function Permalink({ link, text, title, openInNewTab }) { 5 | const additionalAttributes = (openInNewTab) ? { target: '_blank', rel: 'noopener' } : {}; 6 | 7 | return ( 8 | 9 | {text} 10 | 11 | ); 12 | } 13 | 14 | Permalink.propTypes = { 15 | link: PropTypes.string, 16 | text: PropTypes.string, 17 | title: PropTypes.string, 18 | openInNewTab: PropTypes.bool 19 | }; 20 | 21 | export default Permalink; 22 | -------------------------------------------------------------------------------- /src/components/Permalink/spec.js: -------------------------------------------------------------------------------- 1 | import Permalink from './index'; 2 | import { shallow } from 'enzyme'; 3 | 4 | describe('Permalink', () => { 5 | 6 | it('renders', () => { 7 | const props = { text: 'Foo', link: '/bar' }; 8 | const element = shallow(); 9 | 10 | expect(element.find('a')).to.have.length(1); 11 | expect(element.find('a').prop('href')).to.equal(props.link); 12 | expect(element.find('a').text()).to.contain(props.text); 13 | }); 14 | 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/Player/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import classNames from 'classnames'; 4 | import { connect } from 'react-redux'; 5 | import { bindActionCreators } from 'redux'; 6 | import * as actions from '../../actions/index'; 7 | import * as toggleTypes from '../../constants/toggleTypes'; 8 | import { addTempClientIdWith } from '../../services/api'; 9 | import { formatSeconds } from '../../services/player'; 10 | import ButtonInline from '../../components/ButtonInline'; 11 | import ReactTooltip from 'react-tooltip'; 12 | import Clipboard from 'react-clipboard.js'; 13 | 14 | class Player extends React.Component { 15 | constructor(props) { 16 | super(props); 17 | this.updateProgress = this.updateProgress.bind(this); 18 | this.setAudioPosition = this.setAudioPosition.bind(this); 19 | this.handleTimeUpdate = this.handleTimeUpdate.bind(this); 20 | this.handleIteratedTrack = this.handleIteratedTrack.bind(this); 21 | } 22 | 23 | componentDidUpdate() { 24 | const { audioElement } = this; 25 | 26 | if (!audioElement) { return; } 27 | 28 | const { isPlaying, volume } = this.props; 29 | if (isPlaying) { 30 | audioElement.play(); 31 | audioElement.addEventListener('timeupdate', this.updateProgress, false); 32 | audioElement.addEventListener('timeupdate', this.handleTimeUpdate, false); 33 | } else { 34 | audioElement.pause(); 35 | } 36 | audioElement.volume = volume / 100; 37 | } 38 | 39 | setAudioPosition(ev) { 40 | const { audioElement } = this; 41 | if (!audioElement) { return; } 42 | const songPercentage = ev.clientX / window.innerWidth; 43 | const duration = audioElement.duration; 44 | audioElement.currentTime = duration * songPercentage; 45 | } 46 | 47 | updateProgress(event) { 48 | const statusbar = document.getElementById('player-status-bar'); 49 | if (!statusbar) return; 50 | let val = 0; 51 | if (event.target.currentTime > 0) { 52 | val = ((100 / event.target.duration) * event.target.currentTime).toFixed(2); 53 | } 54 | statusbar.style.width = val + "%"; 55 | 56 | if (event.target.duration <= event.target.currentTime) { 57 | const iterate = this.props.isInRepeatMode ? 0 : 1; 58 | this.handleIteratedTrack(iterate); 59 | } 60 | } 61 | 62 | handleTimeUpdate(event) { 63 | const timeElapsedElement = document.getElementById('player-status-time'); 64 | const { audioElement } = this; 65 | if (!timeElapsedElement || !audioElement) return; 66 | if (event.target.currentTime > 0) { 67 | const timeInSeconds = Math.floor(event.target.currentTime); 68 | const duration = isNaN(Math.trunc(audioElement.duration)) ? 'Loading' : Math.trunc(audioElement.duration); 69 | 70 | timeElapsedElement.textContent = `${formatSeconds(timeInSeconds)}/${formatSeconds(duration)}`; 71 | } else { 72 | timeElapsedElement.textContent = 'Loading...'; 73 | } 74 | } 75 | 76 | handleIteratedTrack(iterate) { 77 | const { activeTrackId, playlist } = this.props; 78 | const shouldStream = (activeTrackId === playlist[playlist.length - 1] && iterate > 0); 79 | if (!shouldStream) { 80 | this.props.onActivateIteratedPlaylistTrack(activeTrackId, iterate); 81 | } else { 82 | this.props.onActivateIteratedStreamTrack(activeTrackId, 1); 83 | } 84 | } 85 | 86 | renderNav() { 87 | const { 88 | currentUser, 89 | activeTrackId, 90 | isPlaying, 91 | entities, 92 | playlist, 93 | isInShuffleMode, 94 | isInRepeatMode, 95 | onSetToggle, 96 | onLike, 97 | onTogglePlayTrack, 98 | onSetShuffleMode, 99 | onSetRepeatMode, 100 | volume 101 | } = this.props; 102 | 103 | if (!activeTrackId) { return null; } 104 | 105 | const track = entities.tracks[activeTrackId]; 106 | const { user, title, stream_url } = track; 107 | const { username } = entities.users[user]; 108 | 109 | const isMuted = !volume; 110 | 111 | const muteClass = classNames( 112 | 'fa', 113 | { 114 | 'fa-volume-up': !isMuted, 115 | 'fa-volume-off': isMuted, 116 | } 117 | ); 118 | 119 | const playClass = classNames( 120 | 'fa', 121 | { 122 | 'fa-pause': isPlaying, 123 | 'fa-play': !isPlaying 124 | } 125 | ); 126 | 127 | const likeClass = classNames( 128 | 'fa fa-heart', 129 | { 130 | 'is-favorite': track.user_favorite 131 | } 132 | ); 133 | 134 | const shuffleClass = classNames( 135 | 'fa fa-random', 136 | { 137 | randomSelected: isInShuffleMode 138 | } 139 | ); 140 | 141 | const repeatClass = classNames( 142 | 'fa fa-repeat', 143 | { 144 | repeatSelected: isInRepeatMode 145 | } 146 | ); 147 | 148 | return ( 149 |
    150 |
    151 |
    152 | 153 |
    154 |
    155 |
    156 |
    157 | this.handleIteratedTrack(-1)}> 158 | 159 | 160 | 161 | 162 |
    163 |
    164 | onTogglePlayTrack(!isPlaying)}> 165 | 166 | 167 | 168 | 169 |
    170 |
    171 | this.handleIteratedTrack(1)}> 172 | 173 | 174 | 175 | 176 |
    177 |
    178 | {username} - {title} 179 |
    180 |
    181 | onSetToggle(toggleTypes.PLAYLIST)}> 182 | 183 | {playlist.length} 184 | 185 | 186 |
    187 |
    188 | 189 | 190 | 191 | 192 | 193 |
    194 |
    195 | 196 | 197 | 198 | 199 | 200 |
    201 |
    202 | onSetToggle(toggleTypes.VOLUME)}> 203 | 204 | 205 | 206 | 207 |
    208 |
    209 | 210 |
    211 |
    212 | { 213 | currentUser ? 214 | onLike(track)}> 215 | 216 | : null 217 | } 218 |
    219 |
    220 | 221 |
    222 | 232 | 233 | 234 |
    235 |
    236 |
    237 | 242 | 243 |
    244 |
    245 | ); 246 | } 247 | 248 | render() { 249 | const playerClass = classNames( 250 | 'player', 251 | { 252 | 'player-visible': this.props.activeTrackId 253 | } 254 | ); 255 | 256 | return
    {this.renderNav()}
    ; 257 | } 258 | 259 | } 260 | 261 | function mapStateToProps(state) { 262 | return { 263 | currentUser: state.session.user, 264 | activeTrackId: state.player.activeTrackId, 265 | isPlaying: state.player.isPlaying, 266 | entities: state.entities, 267 | playlist: state.player.playlist, 268 | isInShuffleMode: state.player.isInShuffleMode, 269 | isInRepeatMode: state.player.isInRepeatMode, 270 | volume: state.player.volume 271 | }; 272 | } 273 | 274 | function mapDispatchToProps(dispatch) { 275 | return { 276 | onTogglePlayTrack: bindActionCreators(actions.togglePlayTrack, dispatch), 277 | onSetToggle: bindActionCreators(actions.setToggle, dispatch), 278 | onActivateIteratedPlaylistTrack: bindActionCreators(actions.activateIteratedPlaylistTrack, dispatch), 279 | onActivateIteratedStreamTrack: bindActionCreators(actions.activateIteratedStreamTrack, dispatch), 280 | onActivateCurrentTrack: bindActionCreators(actions.activateTrack, dispatch), 281 | onLike: bindActionCreators(actions.like, dispatch), 282 | onSetShuffleMode: bindActionCreators(actions.toggleShuffleMode, dispatch), 283 | onSetRepeatMode: bindActionCreators(actions.toggleRepeatMode, dispatch) 284 | }; 285 | } 286 | 287 | Player.propTypes = { 288 | currentUser: PropTypes.object, 289 | activeTrackId: PropTypes.number, 290 | isPlaying: PropTypes.bool, 291 | entities: PropTypes.object, 292 | playlist: PropTypes.array, 293 | onTogglePlayTrack: PropTypes.func, 294 | onSetToggle: PropTypes.func, 295 | onActivateIteratedPlaylistTrack: PropTypes.func, 296 | onActivateIteratedStreamTrack: PropTypes.func, 297 | onLike: PropTypes.func, 298 | onSetShuffleMode: PropTypes.func, 299 | onSetRepeatMode: PropTypes.func, 300 | isInShuffleMode: PropTypes.bool, 301 | isInRepeatMode: PropTypes.bool, 302 | handleTimeUpdate: PropTypes.func, 303 | handleIteratedTrack: PropTypes.func 304 | }; 305 | 306 | export default connect(mapStateToProps, mapDispatchToProps)(Player); 307 | -------------------------------------------------------------------------------- /src/components/Playlist/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import map from '../../services/map'; 4 | import classNames from 'classnames'; 5 | import { connect } from 'react-redux'; 6 | import { bindActionCreators } from 'redux'; 7 | import * as actions from '../../actions/index'; 8 | import * as toggleTypes from '../../constants/toggleTypes'; 9 | import { TrackPlaylistContainer } from '../../components/Track'; 10 | import ButtonInline from '../../components/ButtonInline'; 11 | 12 | function PlaylistItem({ activity }) { 13 | return ( 14 |
  • 15 | 16 |
  • 17 | ); 18 | } 19 | 20 | function PlaylistMenu({ onClearPlaylist }) { 21 | return ( 22 |
    23 |
    Playlist
    24 |
    25 | 26 | Clear playlist 27 | 28 |
    29 |
    30 | ); 31 | } 32 | 33 | function Playlist({ toggle, playlist, trackEntities, onClearPlaylist }) { 34 | const playlistClass = classNames( 35 | 'playlist', 36 | { 37 | 'playlist-visible': toggle[toggleTypes.PLAYLIST] 38 | } 39 | ); 40 | 41 | return ( 42 |
    43 | 44 |
      45 | {map((id, key) => { 46 | return ; 47 | }, playlist)} 48 |
    49 |
    50 | ); 51 | } 52 | 53 | function mapStateToProps(state) { 54 | return { 55 | toggle: state.toggle, 56 | playlist: state.player.playlist, 57 | trackEntities: state.entities.tracks 58 | }; 59 | } 60 | 61 | function mapDispatchToProps(dispatch) { 62 | return { 63 | onClearPlaylist: bindActionCreators(actions.clearPlaylist, dispatch), 64 | }; 65 | } 66 | 67 | Playlist.propTypes = { 68 | toggle: PropTypes.object, 69 | playlist: PropTypes.array, 70 | trackEntities: PropTypes.object, 71 | onClearPlaylist: PropTypes.func 72 | }; 73 | 74 | export default connect(mapStateToProps, mapDispatchToProps)(Playlist); 75 | -------------------------------------------------------------------------------- /src/components/Sort/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import map from '../../services/map'; 4 | import classNames from 'classnames'; 5 | import { connect } from 'react-redux'; 6 | import { bindActionCreators } from 'redux'; 7 | import * as actions from '../../actions/index'; 8 | import * as sortTypes from '../../constants/sortTypes'; 9 | import { SORT_NAMES } from '../../constants/sort'; 10 | import ButtonActive from '../../components/ButtonActive'; 11 | import ButtonInline from '../../components/ButtonInline'; 12 | 13 | function hasActiveSort(activeSort) { 14 | return activeSort !== sortTypes.NONE; 15 | } 16 | 17 | function Sort({ 18 | activeSort, 19 | onSort, 20 | }) { 21 | const sortIconClass = classNames( 22 | 'stream-interaction-icon', 23 | { 24 | 'stream-interaction-icon-active': hasActiveSort(activeSort) 25 | } 26 | ); 27 | 28 | return ( 29 |
    30 |
    31 | onSort(sortTypes.NONE)}> 32 | 33 | 34 |
    35 |
    36 | { 37 | map((value, key) => { 38 | return ( 39 | 40 | onSort(value)} isActive={value === activeSort}> 41 | {SORT_NAMES[value]} 42 | 43 | 44 | ); 45 | }, sortTypes) 46 | } 47 |
    48 |
    49 | ); 50 | } 51 | 52 | function mapStateToProps(state) { 53 | return { 54 | activeSort: state.sort.sortType 55 | }; 56 | } 57 | 58 | function mapDispatchToProps(dispatch) { 59 | return { 60 | onSort: (sortType) => bindActionCreators(actions.sortStream, dispatch)(sortType) 61 | }; 62 | } 63 | 64 | Sort.propTypes = { 65 | activeSort: PropTypes.string, 66 | onSort: PropTypes.func 67 | }; 68 | 69 | export default connect(mapStateToProps, mapDispatchToProps)(Sort); 70 | -------------------------------------------------------------------------------- /src/components/StreamActivities/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { bindActionCreators } from 'redux'; 5 | import * as actions from '../../actions/index'; 6 | import * as requestTypes from '../../constants/requestTypes'; 7 | import * as paginateLinkTypes from '../../constants/paginateLinkTypes'; 8 | import { getAndCombined, getOrCombined } from '../../services/filter'; 9 | import Activities from '../../components/Activities'; 10 | import LoadingSpinner from '../../components/LoadingSpinner'; 11 | import StreamInteractions from '../../components/StreamInteractions'; 12 | import { DURATION_FILTER_FUNCTIONS } from '../../constants/durationFilter'; 13 | import { getTracknameFilter } from '../../constants/nameFilter'; 14 | import { SORT_FUNCTIONS, DATE_SORT_FUNCTIONS } from '../../constants/sort'; 15 | import { getArtistFilter } from '../../constants/artistFilter'; 16 | 17 | function StreamActivities({ 18 | activities, 19 | requestInProcess, 20 | nextHref, 21 | trackEntities, 22 | activeFilter, 23 | activeSort, 24 | activeDateSort, 25 | onFetchActivities, 26 | }) { 27 | return ( 28 |
    29 | 30 | onFetchActivities(null, nextHref)} 38 | /> 39 | 40 |
    41 | ); 42 | } 43 | 44 | function mapStateToProps(state) { 45 | const queryFilters = [getTracknameFilter(state.filter.filterNameQuery), 46 | getArtistFilter(state.filter.filterNameQuery, state.entities.users)]; 47 | 48 | const filters = [ 49 | DURATION_FILTER_FUNCTIONS[state.filter.durationFilterType], 50 | getOrCombined(queryFilters) 51 | ]; 52 | 53 | return { 54 | trackEntities: state.entities.tracks, 55 | activities: state.user.activities, 56 | requestInProcess: state.request[requestTypes.ACTIVITIES], 57 | nextHref: state.paginate[paginateLinkTypes.ACTIVITIES], 58 | activeFilter: getAndCombined(filters), 59 | activeSort: SORT_FUNCTIONS[state.sort.sortType], 60 | activeDateSort: DATE_SORT_FUNCTIONS[state.sort.dateSortType], 61 | }; 62 | } 63 | 64 | function mapDispatchToProps(dispatch) { 65 | return { 66 | onFetchActivities: bindActionCreators(actions.fetchActivities, dispatch) 67 | }; 68 | } 69 | 70 | StreamActivities.propTypes = { 71 | trackEntities: PropTypes.object, 72 | activities: PropTypes.array, 73 | requestInProcess: PropTypes.bool, 74 | nextHref: PropTypes.string, 75 | activeFilter: PropTypes.func, 76 | activeSort: PropTypes.func, 77 | activeDateSort: PropTypes.func, 78 | onFetchActivities: PropTypes.func, 79 | }; 80 | 81 | export default connect(mapStateToProps, mapDispatchToProps)(StreamActivities); 82 | -------------------------------------------------------------------------------- /src/components/StreamInteractions/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import FilterDuration from '../../components/FilterDuration'; 3 | import FilterName from '../../components/FilterName'; 4 | import Sort from '../../components/Sort'; 5 | import DateSort from '../../components/DateSort'; 6 | 7 | function StreamInteractions() { 8 | return ( 9 |
    10 |
    11 | 12 |
    13 |
    14 | 15 |
    16 |
    17 | 18 |
    19 |
    20 | 21 |
    22 |
    23 | ); 24 | } 25 | 26 | export default StreamInteractions; 27 | -------------------------------------------------------------------------------- /src/components/Track/index.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { bindActionCreators } from 'redux'; 3 | import * as actions from '../../actions/index'; 4 | import { TrackPlaylist } from './playlist'; 5 | import { TrackPreview } from './preview'; 6 | import { TrackStream } from './stream'; 7 | 8 | function mapStateToProps(state, props) { 9 | const { idx, activity } = props; 10 | 11 | return { 12 | idx, 13 | activity, 14 | typeReposts: state.user.typeReposts, 15 | typeTracks: state.user.typeTracks, 16 | userEntities: state.entities.users, 17 | isPlaying: state.player.isPlaying, 18 | activeTrackId: state.player.activeTrackId, 19 | activeSortType: state.sort.sortType, 20 | activeDateSortType: state.sort.dateSortType, 21 | activeDurationFilterType: state.filter.durationFilterType, 22 | }; 23 | } 24 | 25 | function mapDispatchToProps(dispatch) { 26 | return { 27 | onActivateTrack: bindActionCreators(actions.activateTrack, dispatch), 28 | onAddTrackToPlaylist: bindActionCreators(actions.addTrackToPlaylist, dispatch), 29 | onRemoveTrackFromPlaylist: bindActionCreators(actions.removeTrackFromPlaylist, dispatch), 30 | }; 31 | } 32 | 33 | const TrackPlaylistContainer = connect(mapStateToProps, mapDispatchToProps)(TrackPlaylist); 34 | const TrackPreviewContainer = connect(mapStateToProps, mapDispatchToProps)(TrackPreview); 35 | const TrackStreamContainer = connect(mapStateToProps, mapDispatchToProps)(TrackStream); 36 | 37 | export { 38 | TrackPlaylistContainer, 39 | TrackPreviewContainer, 40 | TrackStreamContainer, 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/Track/playlist.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import Artwork from '../../components/Artwork'; 4 | import Permalink from '../../components/Permalink'; 5 | import Actions from '../../components/HoverActions'; 6 | import { isSameTrackAndPlaying, isSameTrack } from '../../services/player'; 7 | 8 | function TrackPlaylist({ 9 | activity, 10 | userEntities, 11 | activeTrackId, 12 | isPlaying, 13 | onActivateTrack, 14 | onRemoveTrackFromPlaylist 15 | }) { 16 | if (!activity) { return null; } 17 | 18 | const { user, title, permalink_url, artwork_url } = activity; 19 | const { avatar_url, username } = userEntities[user]; 20 | const userPermalinkUrl = userEntities[user].permalink_url; 21 | 22 | const trackIsPlaying = isSameTrackAndPlaying(activeTrackId, activity.id, isPlaying); 23 | const isVisible = isSameTrack(activeTrackId)(activity.id); 24 | 25 | const configuration = [ 26 | { 27 | className: trackIsPlaying ? 'fa fa-pause' : 'fa fa-play', 28 | fn: () => onActivateTrack(activity.id), 29 | }, 30 | { 31 | className: 'fa fa-times', 32 | fn: () => onRemoveTrackFromPlaylist(activity) 33 | } 34 | ]; 35 | 36 | return ( 37 |
    38 |
    39 | 40 |
    41 |
    42 | 43 | 44 | 45 |
    46 |
    47 | ); 48 | } 49 | 50 | TrackPlaylist.propTypes = { 51 | activity: PropTypes.object, 52 | userEntities: PropTypes.object, 53 | isPlaying: PropTypes.bool, 54 | activeTrackId: PropTypes.number, 55 | onActivateTrack: PropTypes.func, 56 | onRemoveTrackFromPlaylist: PropTypes.func 57 | }; 58 | 59 | export { 60 | TrackPlaylist 61 | }; 62 | -------------------------------------------------------------------------------- /src/components/Track/preview.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import Artwork from '../../components/Artwork'; 4 | import Permalink from '../../components/Permalink'; 5 | import InfoList from '../../components/InfoList'; 6 | import Actions from '../../components/HoverActions'; 7 | import { isSameTrackAndPlaying, isSameTrack } from '../../services/player'; 8 | import { COMMENTS, LIKES, PLAYBACK } from "../../constants/trackAttributes"; 9 | 10 | function TrackPreview({ 11 | activity, 12 | isPlaying, 13 | activeTrackId, 14 | userEntities, 15 | onActivateTrack, 16 | onAddTrackToPlaylist 17 | }) { 18 | const { avatar_url, username } = userEntities[activity.user]; 19 | const { playback_count, favoritings_count, comment_count, permalink_url, artwork_url } = activity; 20 | 21 | const isVisible = isSameTrack(activeTrackId)(activity.id); 22 | const trackIsPlaying = isSameTrackAndPlaying(activeTrackId, activity.id, isPlaying); 23 | 24 | const configuration = [ 25 | { 26 | className: trackIsPlaying ? 'fa fa-pause' : 'fa fa-play', 27 | fn: () => onActivateTrack(activity.id), 28 | }, 29 | { 30 | className: 'fa fa-th-list', 31 | fn: () => onAddTrackToPlaylist(activity) 32 | } 33 | ]; 34 | 35 | const information = [ 36 | { 37 | className: 'fa fa-play', 38 | count: playback_count, 39 | title: PLAYBACK 40 | }, 41 | { 42 | className: 'fa fa-heart', 43 | count: favoritings_count, 44 | title: LIKES 45 | }, 46 | { 47 | className: 'fa fa-comment', 48 | count: comment_count, 49 | title: COMMENTS 50 | } 51 | ]; 52 | 53 | return ( 54 |
    55 |
    56 | 57 |
    58 |
    59 | 60 | 61 | 62 |
    63 |
    64 | ); 65 | } 66 | 67 | TrackPreview.propTypes = { 68 | userEntities: PropTypes.object, 69 | activity: PropTypes.object, 70 | isPlaying: PropTypes.bool, 71 | activeTrackId: PropTypes.number, 72 | onActivateTrack: PropTypes.func, 73 | onAddTrackToPlaylist: PropTypes.func 74 | }; 75 | 76 | export { 77 | TrackPreview 78 | }; 79 | -------------------------------------------------------------------------------- /src/components/Track/stream.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import classNames from 'classnames'; 4 | import * as sortTypes from '../../constants/sortTypes'; 5 | import * as dateSortTypes from '../../constants/dateSortTypes'; 6 | import * as filterTypes from '../../constants/filterTypes'; 7 | import WaveformSc from '../../components/WaveformSc'; 8 | import TrackActions from '../../components/TrackActions'; 9 | import Artwork from '../../components/Artwork'; 10 | import ArtworkAction from '../../components/ArtworkAction'; 11 | import Permalink from '../../components/Permalink'; 12 | import InfoList from '../../components/InfoList'; 13 | import { durationFormat, fromNow } from '../../services/track'; 14 | import { getPluralizedWithCount } from '../../services/pluralize'; 15 | import { isSameTrackAndPlaying, isSameTrack } from '../../services/player'; 16 | import * as attributes from "../../constants/trackAttributes"; 17 | 18 | function Duration({ duration, isActive }) { 19 | const durationClass = classNames({ 20 | 'active-duration-filter': isActive 21 | }); 22 | 23 | return ( 24 | 25 | {durationFormat(duration)} 26 | 27 | ); 28 | } 29 | 30 | function Created({ created_at, isActive }) { 31 | const durationClass = classNames({ 32 | 'active-duration-filter': isActive 33 | }); 34 | 35 | return ( 36 | 37 | {fromNow(created_at)} 38 | 39 | ); 40 | } 41 | 42 | function TrackStream({ 43 | activity, 44 | activeTrackId, 45 | isPlaying, 46 | userEntities, 47 | typeReposts, 48 | typeTracks, 49 | activeSortType, 50 | activeDateSortType, 51 | activeDurationFilterType, 52 | onActivateTrack, 53 | }) { 54 | const { 55 | user, 56 | title, 57 | duration, 58 | reposts_count, 59 | playback_count, 60 | comment_count, 61 | download_count, 62 | likes_count, 63 | artwork_url, 64 | permalink_url, 65 | created_at 66 | } = activity; 67 | const userEntity = userEntities[user]; 68 | const { avatar_url, username } = userEntity; 69 | 70 | const isVisible = isSameTrack(activeTrackId)(activity.id); 71 | const isSameAndPlaying = isSameTrackAndPlaying(activeTrackId, activity.id, isPlaying); 72 | 73 | const trackClass = classNames( 74 | 'track', 75 | { 76 | 'track-visible': isVisible 77 | } 78 | ); 79 | 80 | const playClass = classNames( 81 | 'fa', 82 | { 83 | 'fa-pause': isSameAndPlaying, 84 | 'fa-play': !isSameAndPlaying 85 | } 86 | ); 87 | 88 | const information = [ 89 | { 90 | className: 'fa fa-play', 91 | count: playback_count, 92 | activeSort: activeSortType === sortTypes.SORT_PLAYS, 93 | title: attributes.PLAYBACK 94 | }, 95 | { 96 | className: 'fa fa-heart', 97 | count: likes_count, 98 | activeSort: activeSortType === sortTypes.SORT_FAVORITES, 99 | title: attributes.LIKES 100 | }, 101 | { 102 | className: 'fa fa-retweet', 103 | count: reposts_count, 104 | activeSort: activeSortType === sortTypes.SORT_REPOSTS, 105 | title: attributes.REPOST 106 | }, 107 | { 108 | className: 'fa fa-comment', 109 | count: comment_count, 110 | title: attributes.COMMENTS 111 | }, 112 | { 113 | className: 'fa fa-download', 114 | count: download_count, 115 | title: attributes.DOWNLOADS 116 | } 117 | ]; 118 | 119 | return ( 120 |
    121 |
    122 | onActivateTrack(activity.id)} className={playClass} isVisible={isVisible}> 123 | 124 | 125 |
    126 |
    127 |
    128 |
    129 | 130 | 131 | 132 |  ‑  133 | 134 |
    135 |
    136 | / 140 | 144 |
    145 |
    146 |
    147 |
    148 | 149 |
    150 |
    151 | 152 |
    153 |
    154 |
    155 |
    156 | 157 |
    158 |
    159 | ); 160 | } 161 | 162 | function TrackIcon({ trackCount }) { 163 | const title = 'Released by ' + getPluralizedWithCount(trackCount, 'guy') + '.'; 164 | return trackCount ? : null; 165 | } 166 | 167 | function RepostIcon({ repostCount }) { 168 | const title = 'Reposted by ' + getPluralizedWithCount(repostCount, 'guy') + '.'; 169 | return repostCount ? : null; 170 | } 171 | 172 | TrackStream.propTypes = { 173 | userEntities: PropTypes.object, 174 | typeReposts: PropTypes.object, 175 | typeTracks: PropTypes.object, 176 | activity: PropTypes.object, 177 | isPlaying: PropTypes.bool, 178 | activeTrackId: PropTypes.number, 179 | activeSortType: PropTypes.string, 180 | activeDateSortType: PropTypes.string, 181 | activeDurationFilterType: PropTypes.string, 182 | onActivateTrack: PropTypes.func, 183 | }; 184 | 185 | export { 186 | TrackStream 187 | }; 188 | -------------------------------------------------------------------------------- /src/components/TrackActions/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { bindActionCreators } from 'redux'; 5 | import * as actions from '../../actions/index'; 6 | import ButtonGhost from '../../components/ButtonGhost'; 7 | 8 | function TrackActions({ onOpenComments, onAddTrackToPlaylist }) { 9 | const isSmall = true; 10 | return ( 11 |
    12 |
    13 | 14 | Add to Playlist 15 | 16 |
    17 |
    18 | 19 | Comment 20 | 21 |
    22 |
    23 | ); 24 | } 25 | 26 | function mapStateToProps(state, props) { 27 | return { 28 | activity: props.activity 29 | }; 30 | } 31 | 32 | function mapDispatchToProps(dispatch, props) { 33 | const { activity } = props; 34 | 35 | return { 36 | onOpenComments: () => bindActionCreators(actions.openComments, dispatch)(activity.id), 37 | onAddTrackToPlaylist: () => bindActionCreators(actions.addTrackToPlaylist, dispatch)(activity), 38 | }; 39 | } 40 | 41 | TrackActions.propTypes = { 42 | onOpenComments: PropTypes.func, 43 | onAddTrackToPlaylist: PropTypes.func, 44 | }; 45 | 46 | export default connect(mapStateToProps, mapDispatchToProps)(TrackActions); 47 | -------------------------------------------------------------------------------- /src/components/TrackExtension/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { bindActionCreators } from 'redux'; 5 | import * as actions from '../../actions/index'; 6 | import CommentExtension from '../../components/CommentExtension'; 7 | 8 | function TrackExtension({ activity, isOpenComment }) { 9 | return isOpenComment ? : null; 10 | } 11 | 12 | function mapStateToProps(state, props) { 13 | const { activity } = props; 14 | return { 15 | activity, 16 | isOpenComment: state.comment.openComments[activity.id] 17 | }; 18 | } 19 | 20 | function mapDispatchToProps(dispatch) { 21 | return { 22 | openComments: bindActionCreators(actions.openComments, dispatch), 23 | }; 24 | } 25 | 26 | TrackExtension.propTypes = { 27 | activity: PropTypes.object, 28 | openComments: PropTypes.func, 29 | }; 30 | 31 | export default connect(mapStateToProps, mapDispatchToProps)(TrackExtension); 32 | -------------------------------------------------------------------------------- /src/components/User/index.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { bindActionCreators } from 'redux'; 3 | import * as actions from '../../actions/index'; 4 | import { UserPreview } from './preview'; 5 | 6 | function mapStateToProps(state, props) { 7 | return { 8 | followings: state.user.followings, 9 | user: props.user 10 | }; 11 | } 12 | 13 | function mapDispatchToProps(dispatch) { 14 | return { 15 | onFollow: bindActionCreators(actions.follow, dispatch) 16 | }; 17 | } 18 | 19 | const UserPreviewContainer = connect(mapStateToProps, mapDispatchToProps)(UserPreview); 20 | 21 | export { 22 | UserPreviewContainer 23 | }; 24 | -------------------------------------------------------------------------------- /src/components/User/preview.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import find from 'lodash/fp/find'; 4 | import InfoList from '../../components/InfoList'; 5 | import Actions from '../../components/HoverActions'; 6 | import Artwork from '../../components/Artwork'; 7 | import Permalink from '../../components/Permalink'; 8 | 9 | function UserPreview({ user, followings, onFollow }) { 10 | const { followings_count, followers_count, track_count, avatar_url, username, permalink_url } = user; 11 | 12 | const configuration = [ 13 | { 14 | className: find((following) => following === user.id, followings) ? 'fa fa-group is-active' : 'fa fa-group', 15 | fn: () => onFollow(user) 16 | } 17 | ]; 18 | 19 | const information = [ 20 | { 21 | className: 'fa fa-plus', 22 | count: followings_count 23 | }, 24 | { 25 | className: 'fa fa-group', 26 | count: followers_count 27 | }, 28 | { 29 | className: 'fa fa-music', 30 | count: track_count 31 | } 32 | ]; 33 | 34 | return ( 35 |
    36 |
    37 | 38 |
    39 |
    40 | 41 | 42 | 43 |
    44 |
    45 | ); 46 | } 47 | 48 | UserPreview.propTypes = { 49 | followings: PropTypes.array, 50 | user: PropTypes.object, 51 | onFollow: PropTypes.func 52 | }; 53 | 54 | export { 55 | UserPreview 56 | }; 57 | -------------------------------------------------------------------------------- /src/components/Volume/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import classNames from 'classnames'; 4 | import { connect } from 'react-redux'; 5 | import { bindActionCreators } from 'redux'; 6 | import * as actions from '../../actions/index'; 7 | import * as toggleTypes from '../../constants/toggleTypes'; 8 | import Slider from 'react-rangeslider'; 9 | import ButtonInline from '../../components/ButtonInline'; 10 | 11 | function VolumeSlider({ volume, onChangeVolume }) { 12 | return ( 13 | 21 | ); 22 | } 23 | 24 | function Volume({ toggle, volume, onChangeVolume }) { 25 | const volumeClass = classNames( 26 | 'volume', 27 | { 28 | 'volume-visible': toggle[toggleTypes.VOLUME] 29 | } 30 | ); 31 | 32 | const isMuted = !volume; 33 | 34 | const onMute = isMuted ? 35 | () => onChangeVolume(70) : 36 | () => onChangeVolume(0); 37 | 38 | const muteClass = classNames( 39 | 'fa', 40 | { 41 | 'fa-volume-up': !isMuted, 42 | 'fa-volume-off': isMuted, 43 | } 44 | ); 45 | 46 | return ( 47 |
    48 |
    49 |

    {volume}

    50 | 51 |
    52 | 53 | 54 | 55 |
    56 |
    57 |
    58 | ); 59 | } 60 | 61 | function mapStateToProps(state) { 62 | return { 63 | toggle: state.toggle, 64 | volume: state.player.volume, 65 | }; 66 | } 67 | 68 | function mapDispatchToProps(dispatch) { 69 | return { 70 | onChangeVolume: bindActionCreators(actions.changeVolume, dispatch), 71 | }; 72 | } 73 | 74 | Volume.propTypes = { 75 | onChangeVolume: PropTypes.func, 76 | volume: PropTypes.number, 77 | toggle: PropTypes.object 78 | }; 79 | 80 | export default connect(mapStateToProps, mapDispatchToProps)(Volume); 81 | -------------------------------------------------------------------------------- /src/components/WaveformSc/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import Waveform from 'waveform.js'; 4 | import { normalizeSamples } from '../../services/track'; 5 | 6 | const WAVE_COLOR = '#61B25A'; 7 | 8 | class WaveformSc extends React.Component { 9 | 10 | componentDidMount() { 11 | const { activity } = this.props; 12 | 13 | if (!activity) { return; } 14 | 15 | const { waveform_url } = activity; 16 | 17 | if (!waveform_url) { return; } 18 | 19 | const waveformUrlJson = waveform_url.replace('.png', '.json'); 20 | 21 | this.fetchJsonWaveform(this.waveformCanvas, waveformUrlJson); 22 | 23 | // Png version will cause errors. 24 | // if (isPngWaveform(waveform_url)) { 25 | // this.fetchPngWaveform(elementId, activity); 26 | // } 27 | } 28 | 29 | fetchJsonWaveform(waveformCanvas, waveformUrl) { 30 | fetch(waveformUrl) 31 | .then(response => response.json()) 32 | .then((data) => { 33 | new Waveform({ 34 | container: waveformCanvas, 35 | innerColor: WAVE_COLOR, 36 | data: normalizeSamples(data.samples) 37 | }); 38 | }); 39 | } 40 | // Seems like SoundCloud has switched to json instead of png. 41 | // fetchPngWaveform(elementId, activity) { 42 | // const waveform = new Waveform({ 43 | // container: document.getElementById(elementId), 44 | // innerColor: WAVE_COLOR 45 | // }); 46 | // waveform.dataFromSoundCloudTrack(activity); 47 | // } 48 | 49 | render() { 50 | return
    { this.waveformCanvas = waveform; }} />; 51 | } 52 | 53 | } 54 | 55 | WaveformSc.propTypes = { 56 | activity: PropTypes.object, 57 | }; 58 | 59 | export default WaveformSc; 60 | -------------------------------------------------------------------------------- /src/components/withFetchOnScroll/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | 4 | function withFetchOnScroll(Component) { 5 | class FetchOnScroll extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | this.onScroll = this.onScroll.bind(this); 9 | } 10 | 11 | componentDidMount() { 12 | window.addEventListener('scroll', this.onScroll, false); 13 | } 14 | 15 | componentWillUnmount() { 16 | window.removeEventListener('scroll', this.onScroll, false); 17 | } 18 | 19 | onScroll() { 20 | if ((window.innerHeight + window.scrollY) >= (document.body.offsetHeight - 500)) { 21 | this.props.scrollFunction(); 22 | } 23 | } 24 | 25 | render() { 26 | return ; 27 | } 28 | } 29 | 30 | FetchOnScroll.propTypes = { 31 | scrollFunction: PropTypes.func.isRequired, 32 | }; 33 | 34 | return FetchOnScroll; 35 | } 36 | 37 | export default withFetchOnScroll; 38 | -------------------------------------------------------------------------------- /src/components/withLoadingSpinner/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import LoadingSpinner from '../../components/LoadingSpinner'; 3 | 4 | function withLoadingSpinner(Component) { 5 | return function composedComponent({ isLoading, ...props }) { 6 | if (!isLoading) { 7 | return ; 8 | } 9 | 10 | return ; 11 | }; 12 | } 13 | 14 | export default withLoadingSpinner; 15 | -------------------------------------------------------------------------------- /src/constants/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const SET_SESSION = 'SET_SESSION'; 2 | export const SET_USER = 'SET_USER'; 3 | export const RESET_SESSION = 'RESET_SESSION'; 4 | 5 | export const MERGE_ENTITIES = 'MERGE_ENTITIES'; 6 | export const SYNC_ENTITIES = 'SYNC_ENTITIES'; 7 | 8 | export const MERGE_FOLLOWINGS = 'MERGE_FOLLOWINGS'; 9 | export const REMOVE_FROM_FOLLOWINGS = 'REMOVE_FROM_FOLLOWINGS'; 10 | 11 | export const MERGE_ACTIVITIES = 'MERGE_ACTIVITIES'; 12 | export const MERGE_TRACK_TYPES_TRACK = 'MERGE_TRACK_TYPES_TRACK'; 13 | export const MERGE_TRACK_TYPES_REPOST = 'MERGE_TRACK_TYPES_REPOST'; 14 | 15 | export const MERGE_FOLLOWERS = 'MERGE_FOLLOWERS'; 16 | 17 | export const MERGE_FAVORITES = 'MERGE_FAVORITES'; 18 | export const REMOVE_FROM_FAVORITES = 'REMOVE_FROM_FAVORITES'; 19 | 20 | export const SET_ACTIVE_TRACK = 'SET_ACTIVE_TRACK'; 21 | export const RESET_ACTIVE_TRACK = 'RESET_ACTIVE_TRACK'; 22 | 23 | export const SET_IS_PLAYING = 'SET_IS_PLAYING'; 24 | 25 | export const SET_TRACK_IN_PLAYLIST = 'SET_TRACK_IN_PLAYLIST'; 26 | export const REMOVE_TRACK_FROM_PLAYLIST = 'REMOVE_TRACK_FROM_PLAYLIST'; 27 | export const RESET_PLAYLIST = 'RESET_PLAYLIST'; 28 | export const SET_SHUFFLE_MODE = 'SET_SHUFFLE_MODE'; 29 | export const SET_REPEAT_MODE = 'SET_REPEAT_MODE'; 30 | export const SET_VOLUME = 'SET_VOLUME'; 31 | 32 | export const SET_IS_OPEN_PLAYLIST = 'SET_IS_OPEN_PLAYLIST'; 33 | 34 | export const MERGE_GENRE_ACTIVITIES = 'MERGE_GENRE_ACTIVITIES'; 35 | 36 | export const SET_REQUEST_IN_PROCESS = 'SET_REQUEST_IN_PROCESS'; 37 | export const SET_PAGINATE_LINK = 'SET_PAGINATE_LINK'; 38 | 39 | export const SET_TOGGLED = 'SET_TOGGLED'; 40 | export const RESET_TOGGLED = 'RESET_TOGGLED'; 41 | 42 | export const OPEN_COMMENTS = 'OPEN_COMMENTS'; 43 | export const MERGE_COMMENTS = 'MERGE_COMMENTS'; 44 | 45 | export const FILTER_DURATION = 'FILTER_DURATION'; 46 | export const FILTER_NAME = 'FILTER_NAME'; 47 | 48 | export const SORT_STREAM = 'SORT_STREAM'; 49 | export const DATE_SORT_STREAM = 'DATE_SORT_STREAM'; 50 | 51 | export const SET_SELECTED_GENRE = 'SET_SELECTED_GENRE'; 52 | -------------------------------------------------------------------------------- /src/constants/artistFilter.js: -------------------------------------------------------------------------------- 1 | export function getArtistFilter(query, users) { 2 | const filteredUserIds = Object.values(users) 3 | .filter(user => { 4 | return user.username.toLowerCase() 5 | .indexOf(query.toLowerCase()) !== -1; 6 | }).map(user => user.id); 7 | 8 | return (activity) => { 9 | return filteredUserIds.indexOf(activity.user) !== -1; 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/constants/authentication.js: -------------------------------------------------------------------------------- 1 | const isDev = process.env.NODE_ENV === 'development'; 2 | 3 | export const REDIRECT_URI = isDev ? 4 | `${window.location.protocol}//${window.location.host}/callback` : 5 | 'http://www.favesound.de/callback'; 6 | 7 | export const CLIENT_ID = isDev ? 8 | 'a281614d7f34dc30b665dfcaa3ed7505' : 9 | 'a281614d7f34dc30b665dfcaa3ed7505'; 10 | 11 | // This client_id is a temporary fix for the Request Limit Reached issue of the old one. 12 | // This only apply to streaming. 13 | export const TEMP_CLIENT_ID = 'f9e1e2232182a46705c880554a1011af'; 14 | 15 | export const OAUTH_TOKEN = 'accessToken'; 16 | -------------------------------------------------------------------------------- /src/constants/dateSortTypes.js: -------------------------------------------------------------------------------- 1 | export const NONE = 'NONE'; 2 | 3 | export const PAST_6MONTH = 'PAST_6MONTH'; 4 | export const PAST_YEAR = 'PAST_YEAR'; 5 | export const OLDER = 'OLDER'; 6 | -------------------------------------------------------------------------------- /src/constants/durationFilter.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | import * as filterTypes from '../constants/filterTypes'; 3 | 4 | const DURATION_FILTER_NAMES = { 5 | [filterTypes.ALL]: 'ALL', 6 | [filterTypes.FILTER_DURATION_TRACK]: 'TRACK', 7 | [filterTypes.FILTER_DURATION_MIX]: 'MIX', 8 | }; 9 | 10 | const DURATION_FILTER_FUNCTIONS = { 11 | [filterTypes.ALL]: () => true, 12 | [filterTypes.FILTER_DURATION_TRACK]: (activity) => !isMixDuration(activity), 13 | [filterTypes.FILTER_DURATION_MIX]: (activity) => isMixDuration(activity), 14 | }; 15 | 16 | function isMixDuration(activity) { 17 | return moment.duration(activity.duration).asMinutes() > 15; 18 | } 19 | 20 | export { 21 | DURATION_FILTER_NAMES, 22 | DURATION_FILTER_FUNCTIONS, 23 | }; 24 | -------------------------------------------------------------------------------- /src/constants/filterTypes.js: -------------------------------------------------------------------------------- 1 | export const ALL = 'ALL'; 2 | 3 | export const FILTER_DURATION_MIX = 'FILTER_DURATION_MIX'; 4 | export const FILTER_DURATION_TRACK = 'FILTER_DURATION_TRACK'; 5 | -------------------------------------------------------------------------------- /src/constants/genre.js: -------------------------------------------------------------------------------- 1 | export const GENRES = ['Tech House', 'Minimal', 'Deep House', 'Techno', 'Afterhour']; 2 | export const DEFAULT_GENRE = GENRES[0]; 3 | -------------------------------------------------------------------------------- /src/constants/nameFilter.js: -------------------------------------------------------------------------------- 1 | export function getTracknameFilter(query) { 2 | return (activity) => { 3 | const title = activity.title.toLowerCase(); 4 | return title.indexOf(query.toLowerCase()) !== -1; 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /src/constants/paginateLinkTypes.js: -------------------------------------------------------------------------------- 1 | export const ACTIVITIES = 'ACTIVITIES'; 2 | export const FOLLOWINGS = 'FOLLOWINGS'; 3 | export const FOLLOWERS = 'FOLLOWERS'; 4 | export const FAVORITES = 'FAVORITES'; 5 | export const COMMENTS = 'COMMENTS'; 6 | -------------------------------------------------------------------------------- /src/constants/pathnames.js: -------------------------------------------------------------------------------- 1 | export const dashboard = '/dashboard'; 2 | export const browse = '/browse'; 3 | export const callback = '/callback'; 4 | -------------------------------------------------------------------------------- /src/constants/requestTypes.js: -------------------------------------------------------------------------------- 1 | export const ACTIVITIES = 'ACTIVITIES'; 2 | export const FOLLOWINGS = 'FOLLOWINGS'; 3 | export const FOLLOWERS = 'FOLLOWERS'; 4 | export const FAVORITES = 'FAVORITES'; 5 | export const GENRES = 'GENRES'; 6 | export const COMMENTS = 'COMMENTS'; 7 | export const AUTH = 'AUTH'; 8 | -------------------------------------------------------------------------------- /src/constants/schemas.js: -------------------------------------------------------------------------------- 1 | import { normalize, Schema, arrayOf } from 'normalizr'; 2 | 3 | let track = new Schema('tracks'); 4 | let user = new Schema('users'); 5 | let comment = new Schema('comments'); 6 | 7 | track.define({ 8 | user 9 | }); 10 | 11 | comment.define({ 12 | user 13 | }); 14 | 15 | export const trackSchema = track; 16 | export const userSchema = user; 17 | export const commentSchema = comment; 18 | -------------------------------------------------------------------------------- /src/constants/sort.js: -------------------------------------------------------------------------------- 1 | import { orderBy } from 'lodash'; 2 | import * as sortTypes from '../constants/sortTypes'; 3 | import * as dateSortTypes from './dateSortTypes'; 4 | import moment from 'moment'; 5 | 6 | const SORT_NAMES = { 7 | [sortTypes.NONE]: 'NONE', 8 | [sortTypes.SORT_PLAYS]: 'PLAYS', 9 | [sortTypes.SORT_FAVORITES]: 'FAVORITES', 10 | [sortTypes.SORT_REPOSTS]: 'REPOSTS', 11 | }; 12 | const DATE_SORT_NAMES = { 13 | [dateSortTypes.NONE]: 'NONE', 14 | [dateSortTypes.PAST_6MONTH]: 'PAST 6 MONTHS', 15 | [dateSortTypes.PAST_YEAR]: 'PAST YEAR', 16 | [dateSortTypes.OLDER]: 'OLDER' 17 | }; 18 | const DATE_SORT_FUNCTIONS = { 19 | [dateSortTypes.NONE]: (objs) => objs, 20 | [dateSortTypes.PAST_6MONTH]: (activities) => sortByMonth(activities), 21 | [dateSortTypes.PAST_YEAR]: (activities) => sortByYear(activities), 22 | [dateSortTypes.OLDER]: (activities) => sortByOld(activities), 23 | }; 24 | 25 | const SORT_FUNCTIONS = { 26 | [sortTypes.NONE]: (objs) => objs, 27 | [sortTypes.SORT_PLAYS]: (activities) => sortByPlays(activities), 28 | [sortTypes.SORT_FAVORITES]: (activities) => sortByFavorites(activities), 29 | [sortTypes.SORT_REPOSTS]: (activities) => sortByReposts(activities), 30 | }; 31 | 32 | function sortDates(dt1, dt2) { 33 | const dateA = new Date(dt1.created_at); 34 | const dateB = new Date(dt2.created_at); 35 | return dateA - dateB; 36 | } 37 | 38 | function sortByMonth(activities) { 39 | const sortDt = new moment().subtract(6, 'months').date(1); 40 | const act = activities.filter(obj => { 41 | return moment(obj.created_at) >= sortDt; 42 | }); 43 | return act.sort((a, b) => { 44 | return sortDates(a, b); 45 | }); 46 | } 47 | 48 | function sortByYear(activities) { 49 | const sortDt = new moment().subtract(1, 'year').date(1); 50 | const act = activities.filter(obj => { 51 | return moment(obj.created_at) >= sortDt; 52 | }); 53 | return act.sort((a, b) => { 54 | return sortDates(a, b); 55 | }); 56 | } 57 | 58 | function sortByOld(activities) { 59 | const sortDt = new moment().subtract(1, 'year').date(1); 60 | const act = activities.filter(obj => { 61 | return moment(obj.created_at) < sortDt; 62 | }); 63 | return act.sort((a, b) => { 64 | return sortDates(a, b); 65 | }); 66 | } 67 | 68 | function sortByPlays(activities) { 69 | return orderBy(activities, (activity) => activity.playback_count, 'desc'); 70 | } 71 | 72 | function sortByFavorites(activities) { 73 | return orderBy(activities, (activity) => activity.likes_count, 'desc'); 74 | } 75 | 76 | function sortByReposts(activities) { 77 | return orderBy(activities, (activity) => activity.reposts_count, 'desc'); 78 | } 79 | 80 | export { 81 | SORT_NAMES, 82 | SORT_FUNCTIONS, 83 | DATE_SORT_NAMES, 84 | DATE_SORT_FUNCTIONS 85 | }; 86 | -------------------------------------------------------------------------------- /src/constants/sortTypes.js: -------------------------------------------------------------------------------- 1 | export const NONE = 'NONE'; 2 | 3 | export const SORT_PLAYS = 'SORT_PLAYS'; 4 | export const SORT_FAVORITES = 'SORT_FAVORITES'; 5 | export const SORT_REPOSTS = 'SORT_REPOSTS'; 6 | -------------------------------------------------------------------------------- /src/constants/toggleTypes.js: -------------------------------------------------------------------------------- 1 | export const PLAYLIST = 'PLAYLIST'; 2 | export const FOLLOWINGS = 'FOLLOWINGS'; 3 | export const FOLLOWERS = 'FOLLOWERS'; 4 | export const FAVORITES = 'FAVORITES'; 5 | export const VOLUME = 'VOLUME'; 6 | -------------------------------------------------------------------------------- /src/constants/trackAttributes.js: -------------------------------------------------------------------------------- 1 | export const PLAYBACK = 'Playbacks'; 2 | export const REPOST = 'Reposts'; 3 | export const LIKES = 'Likes'; 4 | export const COMMENTS = 'Comments'; 5 | export const DOWNLOADS = 'Downloads'; 6 | -------------------------------------------------------------------------------- /src/constants/trackTypes.js: -------------------------------------------------------------------------------- 1 | export const TRACK = 'track'; 2 | export const TRACK_REPOST = 'track-repost'; 3 | export const PLAYLIST = 'playlist'; 4 | export const PLAYLIST_REPOST = 'playlist-repost'; 5 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import SC from 'soundcloud'; 3 | import { AppContainer } from 'react-hot-loader'; 4 | /* eslint-enable */ 5 | 6 | import React from 'react'; 7 | import ReactDOM from 'react-dom'; 8 | import { Provider } from 'react-redux'; 9 | 10 | import configureStore from './stores/configureStore'; 11 | import App from './components/App'; 12 | 13 | require('../styles/index.scss'); 14 | 15 | const store = configureStore(); 16 | 17 | function render(Component) { 18 | ReactDOM.render( 19 | 20 | 21 | 22 | 23 | , 24 | document.getElementById('app'), 25 | ); 26 | } 27 | 28 | render(App); 29 | 30 | if (module.hot) { 31 | module.hot.accept('./components/App', () => { 32 | // eslint-disable-next-line 33 | const NextApp = require('./components/App').default; 34 | render(NextApp); 35 | }); 36 | } 37 | 38 | -------------------------------------------------------------------------------- /src/reducers/browse/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | 3 | const initialState = { 4 | selectedGenre: null 5 | }; 6 | 7 | export default function(state = initialState, action) { 8 | switch (action.type) { 9 | case actionTypes.MERGE_GENRE_ACTIVITIES: 10 | return mergeActivities(state, action.activities, action.genre); 11 | 12 | case actionTypes.SET_SELECTED_GENRE: 13 | return setSelectedGenre(state, action.genre); 14 | } 15 | return state; 16 | } 17 | 18 | function mergeActivities(state, list, genre) { 19 | const oldList = state[genre] || []; 20 | 21 | const newList = [ 22 | ...oldList, 23 | ...list 24 | ]; 25 | 26 | const obj = {}; 27 | obj[genre] = newList; 28 | 29 | return Object.assign({}, state, obj); 30 | } 31 | 32 | function setSelectedGenre(state, genre) { 33 | const obj = { 34 | selectedGenre: genre 35 | }; 36 | 37 | return Object.assign({}, state, obj); 38 | } 39 | -------------------------------------------------------------------------------- /src/reducers/browse/spec.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | import browse from './index'; 3 | 4 | describe('browse reducer', () => { 5 | 6 | describe('MERGE_GENRE_ACTIVITIES', () => { 7 | 8 | it('initiates activities by genre, when there are no activities yet', () => { 9 | const GENRE = 'FOO_GENRE'; 10 | const activities = [{ name: 'x' }, { name: 'y' }]; 11 | 12 | const action = { 13 | type: actionTypes.MERGE_GENRE_ACTIVITIES, 14 | activities: activities, 15 | genre: GENRE 16 | }; 17 | 18 | const expectedState = { 19 | [GENRE]: activities, 20 | selectedGenre: null 21 | }; 22 | 23 | expect(browse(undefined, action)).to.eql(expectedState); 24 | }); 25 | 26 | it('merges activities by genre, when there are already activities', () => { 27 | const GENRE = 'FOO_GENRE'; 28 | const activities = [{ name: 'x' }, { name: 'y' }]; 29 | 30 | const action = { 31 | type: actionTypes.MERGE_GENRE_ACTIVITIES, 32 | activities: activities, 33 | genre: GENRE 34 | }; 35 | 36 | const expectedState = { 37 | [GENRE]: [{ name: 'f' }, { name: 'g' }, { name: 'x' }, { name: 'y' }] 38 | }; 39 | 40 | const previousActivities = [{ name: 'f' }, { name: 'g' }]; 41 | 42 | const previousState = { 43 | [GENRE]: previousActivities 44 | } 45 | 46 | expect(browse(previousState, action)).to.eql(expectedState); 47 | }); 48 | 49 | it('merges activities by genre side by side', () => { 50 | const GENRE = 'FOO_GENRE'; 51 | const activities = [{ name: 'x' }, { name: 'y' }]; 52 | 53 | const action = { 54 | type: actionTypes.MERGE_GENRE_ACTIVITIES, 55 | activities: activities, 56 | genre: GENRE 57 | }; 58 | 59 | const expectedState = { 60 | [GENRE]: [{ name: 'x' }, { name: 'y' }], 61 | 'BAR_GENRE': [{ name: 'f' }, { name: 'g' }] 62 | }; 63 | 64 | const previousActivities = [{ name: 'f' }, { name: 'g' }]; 65 | 66 | const previousState = { 67 | 'BAR_GENRE': previousActivities 68 | } 69 | 70 | expect(browse(previousState, action)).to.eql(expectedState); 71 | }); 72 | 73 | }); 74 | 75 | }); 76 | -------------------------------------------------------------------------------- /src/reducers/comment/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | 3 | const initialState = { 4 | comments: {}, 5 | openComments: {}, 6 | }; 7 | 8 | export default function(state = initialState, action) { 9 | switch (action.type) { 10 | case actionTypes.OPEN_COMMENTS: 11 | return openComments(state, action); 12 | case actionTypes.MERGE_COMMENTS: 13 | return mergeComments(state, action); 14 | } 15 | return state; 16 | } 17 | 18 | function openComments(state, action) { 19 | const { trackId } = action; 20 | return { 21 | ...state, openComments: { ...state.openComments || [], [trackId]: !state.openComments[trackId] } 22 | }; 23 | } 24 | 25 | function mergeComments(state, action) { 26 | const { comments, trackId } = action; 27 | return { 28 | ...state, 29 | comments: { ...state.comments || [], [trackId]: [...state.comments[trackId] || [], ...comments] } 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/reducers/comment/spec.js: -------------------------------------------------------------------------------- 1 | import * as actionCreators from '../../actions/comments'; 2 | import comment from './index'; 3 | 4 | describe('comment reducer', () => { 5 | describe('OPEN_COMMENTS', () => { 6 | it('returns the open comments if they exist', () => { 7 | const trackId = 15; 8 | const action = actionCreators.setOpenComments(trackId); 9 | const previousState = { 10 | trackId: 15, 11 | openComments: { 0: 'Good Song!', 1: 'I agree!' }, 12 | }; 13 | const expectedState = { 14 | trackId: 15, 15 | openComments: { 0: 'Good Song!', 1: 'I agree!', 15: true }, 16 | }; 17 | expect(comment(previousState, action)).to.eql(expectedState); 18 | }); 19 | }); 20 | describe('MERGE_COMMENTS', () => { 21 | it('merges the comments', () => { 22 | const comments = ['Third Comment', 'Fourth']; 23 | const trackId = 15; 24 | const action = actionCreators.mergeComments(comments, trackId); 25 | 26 | const previousState = { 27 | trackId: 15, 28 | comments: { 0: 'First', 1: 'Second comment!' }, 29 | }; 30 | const expectedState = { 31 | trackId: 15, 32 | comments: { 0: 'First', 1: 'Second comment!', 15: ['Third Comment', 'Fourth'] }, 33 | }; 34 | expect(comment(previousState, action)).to.eql(expectedState); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/reducers/entities/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | import merge from 'lodash/fp/merge'; 3 | 4 | const initialState = { 5 | users: {}, 6 | tracks: {} 7 | }; 8 | 9 | export default function(state = initialState, action) { 10 | switch (action.type) { 11 | case actionTypes.MERGE_ENTITIES: 12 | return mergeEntities(state, action.entities); 13 | case actionTypes.SYNC_ENTITIES: 14 | return syncEntities(state, action.entity, action.key); 15 | } 16 | return state; 17 | } 18 | 19 | function mergeEntities(state, entities) { 20 | return merge(entities, state, {}); 21 | } 22 | 23 | function syncEntities(state, entity, key) { 24 | return { ...state, [key]: { ...state[key], [entity.id]: entity } }; 25 | } 26 | -------------------------------------------------------------------------------- /src/reducers/entities/spec.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | import entities from './index'; 3 | 4 | describe('entities reducer', () => { 5 | 6 | describe('SYNC_ENTITIES', () => { 7 | 8 | it('updates an entity in a list of entities', () => { 9 | const users = { 10 | 1: { name: 'x' }, 11 | 2: { name: 'y' } 12 | }; 13 | 14 | const entity = { id: 1, name: 'foo' }; 15 | 16 | const action = { 17 | type: actionTypes.SYNC_ENTITIES, 18 | entity, 19 | key: 'users' 20 | }; 21 | 22 | const expectedState = { 23 | users: { 24 | 1: { id: 1, name: 'foo' }, 25 | 2: { name: 'y' } 26 | }, 27 | tracks: {} 28 | }; 29 | 30 | const previousState = { 31 | users, 32 | tracks: {} 33 | }; 34 | 35 | expect(entities(previousState, action)).to.eql(expectedState); 36 | }); 37 | 38 | }); 39 | 40 | describe('MERGE_ENTITIES', () => { 41 | 42 | it('initiates entities, when there are no entities yet', () => { 43 | const users = { 44 | 1: { name: 'x' }, 45 | 2: { name: 'y' } 46 | }; 47 | const myEntities = { users }; 48 | 49 | const action = { 50 | type: actionTypes.MERGE_ENTITIES, 51 | entities: myEntities 52 | }; 53 | 54 | const expectedState = { 55 | users, 56 | tracks: {} 57 | }; 58 | 59 | expect(entities(undefined, action)).to.eql(expectedState); 60 | }); 61 | 62 | it('merges entities, when there are already entities', () => { 63 | const users = { 64 | 1: { name: 'x' }, 65 | 2: { name: 'y' } 66 | }; 67 | const myEntities = { users }; 68 | 69 | const action = { 70 | type: actionTypes.MERGE_ENTITIES, 71 | entities: myEntities 72 | }; 73 | 74 | const expectedState = { 75 | users: { 76 | 3: { name: 'f' }, 77 | 4: { name: 'g' }, 78 | 1: { name: 'x' }, 79 | 2: { name: 'y' } 80 | }, 81 | tracks: {} 82 | }; 83 | 84 | const previousUsers = { 85 | 3: { name: 'f' }, 86 | 4: { name: 'g' } 87 | }; 88 | 89 | const previousState = { 90 | users: previousUsers, 91 | tracks: {} 92 | } 93 | 94 | expect(entities(previousState, action)).to.eql(expectedState); 95 | }); 96 | 97 | it('merges entities side by side', () => { 98 | const users = { 99 | 1: { name: 'x' }, 100 | 2: { name: 'y' } 101 | }; 102 | const myEntities = { users }; 103 | 104 | const action = { 105 | type: actionTypes.MERGE_ENTITIES, 106 | entities: myEntities 107 | }; 108 | 109 | const expectedState = { 110 | users: { 111 | 3: { name: 'f' }, 112 | 4: { name: 'g' }, 113 | 1: { name: 'x' }, 114 | 2: { name: 'y' } 115 | }, 116 | tracks: { 117 | 5: { title: 'g' }, 118 | 6: { title: 'h' } 119 | } 120 | }; 121 | 122 | const previousUsers = { 123 | 3: { name: 'f' }, 124 | 4: { name: 'g' } 125 | }; 126 | 127 | const previousTracks = { 128 | 5: { title: 'g' }, 129 | 6: { title: 'h' } 130 | }; 131 | 132 | const previousState = { 133 | users: previousUsers, 134 | tracks: previousTracks 135 | }; 136 | 137 | expect(entities(previousState, action)).to.eql(expectedState); 138 | }); 139 | 140 | }); 141 | 142 | }); 143 | -------------------------------------------------------------------------------- /src/reducers/filter/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | import * as filterTypes from '../../constants/filterTypes'; 3 | 4 | const initialState = { 5 | durationFilterType: filterTypes.ALL, 6 | filterNameQuery: '', 7 | }; 8 | 9 | export default function(state = initialState, action) { 10 | switch (action.type) { 11 | case actionTypes.FILTER_DURATION: 12 | return setDurationFilter(state, action.filterType); 13 | case actionTypes.FILTER_NAME: 14 | return setNameFilter(state, action.filterNameQuery); 15 | } 16 | return state; 17 | } 18 | 19 | function setDurationFilter(state, filterType) { 20 | return { ...state, durationFilterType: filterType }; 21 | } 22 | 23 | function setNameFilter(state, filterNameQuery) { 24 | return { ...state, filterNameQuery }; 25 | } 26 | -------------------------------------------------------------------------------- /src/reducers/filter/spec.js: -------------------------------------------------------------------------------- 1 | import * as actionCreators from '../../actions/filter'; 2 | import filter from './index'; 3 | 4 | describe('filter reducer', () => { 5 | describe('FILTER_DURATION', () => { 6 | it('sets the filter by duration', () => { 7 | const filterType = 'FILTER_DURATION_MIX'; 8 | const action = actionCreators.filterDuration(filterType); 9 | const previousState = { 10 | isPlaying: true, 11 | volume: 11, 12 | durationFilterType: 'FILTER_DURATION_TRACK', 13 | }; 14 | const expectedState = { 15 | isPlaying: true, 16 | volume: 11, 17 | durationFilterType: 'FILTER_DURATION_MIX', 18 | }; 19 | expect(filter(previousState, action)).to.eql(expectedState); 20 | }); 21 | }); 22 | describe('FILTER_NAME', () => { 23 | it('sets the filter by name', () => { 24 | const filterNameQuery = 'Dani Masi'; 25 | const action = actionCreators.filterName(filterNameQuery); 26 | const previousState = { 27 | isPlaying: true, 28 | volume: 11, 29 | filterNameQuery: 'Kanye West', 30 | }; 31 | const expectedState = { 32 | isPlaying: true, 33 | volume: 11, 34 | filterNameQuery: 'Dani Masi', 35 | }; 36 | expect(filter(previousState, action)).to.eql(expectedState); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import session from './session'; 3 | import user from './user'; 4 | import player from './player'; 5 | import browse from './browse'; 6 | import request from './request'; 7 | import paginate from './paginate'; 8 | import entities from './entities'; 9 | import toggle from './toggle'; 10 | import comment from './comment'; 11 | import filter from './filter'; 12 | import sort from './sort'; 13 | 14 | export default combineReducers({ 15 | session, 16 | user, 17 | player, 18 | browse, 19 | request, 20 | paginate, 21 | entities, 22 | toggle, 23 | comment, 24 | filter, 25 | sort 26 | }); 27 | -------------------------------------------------------------------------------- /src/reducers/paginate/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | 3 | const initialState = {}; 4 | 5 | export default function(state = initialState, action) { 6 | switch (action.type) { 7 | case actionTypes.SET_PAGINATE_LINK: 8 | return setPaginateLink(state, action.nextHref, action.paginateType); 9 | } 10 | return state; 11 | } 12 | 13 | function setPaginateLink(state, nextHref, paginateType) { 14 | const paginateObject = {}; 15 | paginateObject[paginateType] = nextHref; 16 | return Object.assign({}, state, paginateObject); 17 | } 18 | -------------------------------------------------------------------------------- /src/reducers/paginate/spec.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | import paginate from './index'; 3 | 4 | describe('paginate reducer', () => { 5 | 6 | describe('SET_PAGINATE_LINK', () => { 7 | 8 | it('sets a paginate link', () => { 9 | 10 | const PAGINATE_TYPE = 'FOO_PAGINATE'; 11 | 12 | const action = { 13 | type: actionTypes.SET_PAGINATE_LINK, 14 | nextHref: '/foo', 15 | paginateType: PAGINATE_TYPE 16 | } 17 | 18 | const expectedState = { 19 | [PAGINATE_TYPE]: '/foo' 20 | }; 21 | 22 | expect(paginate(undefined, action)).to.eql(expectedState); 23 | }); 24 | 25 | }); 26 | 27 | }); 28 | -------------------------------------------------------------------------------- /src/reducers/player/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | 3 | const initialState = { 4 | volume: 70, 5 | isInShuffleMode: false, 6 | isInRepeatMode: false, 7 | activeTrackId: null, 8 | isPlaying: false, 9 | playlist: [] 10 | }; 11 | 12 | export default function(state = initialState, action) { 13 | switch (action.type) { 14 | case actionTypes.SET_ACTIVE_TRACK: 15 | return setActiveTrack(state, action.activeTrackId); 16 | case actionTypes.RESET_ACTIVE_TRACK: 17 | return resetActiveTrack(state); 18 | case actionTypes.SET_IS_PLAYING: 19 | return setIsPlaying(state, action.isPlaying); 20 | case actionTypes.SET_TRACK_IN_PLAYLIST: 21 | return setTrackInPlaylist(state, action.trackId); 22 | case actionTypes.REMOVE_TRACK_FROM_PLAYLIST: 23 | return removeTrackFromPlaylist(state, action.trackId); 24 | case actionTypes.RESET_PLAYLIST: 25 | return emptyPlaylist(state); 26 | case actionTypes.SET_SHUFFLE_MODE: 27 | return setShuffleMode(state); 28 | case actionTypes.SET_REPEAT_MODE: 29 | return setRepeatMode(state); 30 | case actionTypes.SET_VOLUME: 31 | return setVolume(state, action.volume); 32 | } 33 | return state; 34 | } 35 | 36 | function setActiveTrack(state, activeTrackId) { 37 | return { ...state, activeTrackId }; 38 | } 39 | 40 | function resetActiveTrack(state) { 41 | return { ...state, activeTrackId: null }; 42 | } 43 | 44 | function setIsPlaying(state, isPlaying) { 45 | return { ...state, isPlaying }; 46 | } 47 | 48 | function setTrackInPlaylist(state, trackId) { 49 | if (state.playlist.indexOf(trackId) !== -1) { 50 | return state; 51 | } else { 52 | return { ...state, playlist: [...state.playlist, trackId] }; 53 | } 54 | } 55 | 56 | function removeTrackFromPlaylist(state, trackId) { 57 | const index = state.playlist.indexOf(trackId); 58 | const playlist = [ 59 | ...state.playlist.slice(0, index), 60 | ...state.playlist.slice(index + 1) 61 | ]; 62 | return { ...state, playlist }; 63 | } 64 | 65 | function emptyPlaylist(state) { 66 | return { ...state, playlist: [] }; 67 | } 68 | 69 | function setShuffleMode(state) { 70 | return { ...state, isInShuffleMode: !state.isInShuffleMode }; 71 | } 72 | 73 | function setRepeatMode(state) { 74 | return { ...state, isInRepeatMode: !state.isInRepeatMode }; 75 | } 76 | 77 | function setVolume(state, volume) { 78 | return { ...state, volume }; 79 | } 80 | -------------------------------------------------------------------------------- /src/reducers/player/spec.js: -------------------------------------------------------------------------------- 1 | import * as actionCreators from '../../actions/player'; 2 | import player from './index'; 3 | 4 | describe('player reducer', () => { 5 | describe('SET_SHUFFLE_MODE', () => { 6 | it('toggles shuffle mode', () => { 7 | const action = actionCreators.setIsInShuffleMode(); 8 | const previousState = { 9 | activeTrackId: 1, 10 | isPlaying: true, 11 | playlist: [1, 2, 3], 12 | isInShuffleMode: false, 13 | }; 14 | const expectedState = { 15 | activeTrackId: 1, 16 | isPlaying: true, 17 | playlist: [1, 2, 3], 18 | isInShuffleMode: true, 19 | }; 20 | expect(player(previousState, action)).to.eql(expectedState); 21 | }); 22 | }); 23 | 24 | describe('SET_REPEAT_MODE', () => { 25 | it('toggles repeat mode', () => { 26 | const action = actionCreators.setIsInRepeatMode(); 27 | const previousState = { 28 | activeTrackId: 1, 29 | isPlaying: true, 30 | playlist: [1, 2, 3], 31 | isInShuffleMode: false, 32 | isInRepeatMode: false, 33 | }; 34 | const expectedState = { 35 | activeTrackId: 1, 36 | isPlaying: true, 37 | playlist: [1, 2, 3], 38 | isInShuffleMode: false, 39 | isInRepeatMode: true, 40 | }; 41 | expect(player(previousState, action)).to.eql(expectedState); 42 | }); 43 | }); 44 | 45 | 46 | describe('RESET_PLAYLIST', () => { 47 | it('resets a player', () => { 48 | const action = actionCreators.emptyPlaylist(); 49 | const previousState = { 50 | activeTrackId: 1, 51 | isPlaying: true, 52 | playlist: [1, 2, 3] 53 | }; 54 | 55 | const expectedState = { 56 | activeTrackId: 1, 57 | isPlaying: true, 58 | playlist: [] 59 | }; 60 | 61 | expect(player(previousState, action)).to.eql(expectedState); 62 | }); 63 | }); 64 | 65 | describe('SET_ACTIVE_TRACK', () => { 66 | it('sets an active track', () => { 67 | const activeTrackId = 1; 68 | const action = actionCreators.setActiveTrack(activeTrackId); 69 | const previousState = { 70 | activeTrackId: null, 71 | isPlaying: false, 72 | playlist: [] 73 | }; 74 | 75 | const expectedState = { 76 | activeTrackId: 1, 77 | isPlaying: false, 78 | playlist: [] 79 | }; 80 | 81 | expect(player(previousState, action)).to.eql(expectedState); 82 | }); 83 | 84 | it('sets an new active track', () => { 85 | const activeTrackId = 3; 86 | const action = actionCreators.setActiveTrack(activeTrackId); 87 | const previousState = { 88 | activeTrackId: 2, 89 | isPlaying: false, 90 | playlist: [] 91 | }; 92 | 93 | const expectedState = { 94 | activeTrackId: 3, 95 | isPlaying: false, 96 | playlist: [] 97 | }; 98 | 99 | expect(player(previousState, action)).to.eql(expectedState); 100 | }); 101 | }); 102 | describe('RESET_ACTIVE_TRACK', () => { 103 | it('resets an active track', () => { 104 | const action = actionCreators.deactivateTrack(); 105 | const previousState = { 106 | activeTrackId: 1, 107 | isPlaying: true, 108 | playlist: [1, 2, 3] 109 | }; 110 | 111 | const expectedState = { 112 | activeTrackId: null, 113 | isPlaying: true, 114 | playlist: [1, 2, 3] 115 | }; 116 | 117 | expect(player(previousState, action)).to.eql(expectedState); 118 | }); 119 | }); 120 | describe('SET_IS_PLAYING', () => { 121 | it('sets state as playing', () => { 122 | const isPlaying = 'Ibiza'; 123 | const action = actionCreators.setIsPlaying(isPlaying); 124 | 125 | const previousState = { 126 | activeTrackId: 1, 127 | isPlaying: null, 128 | playlist: [1, 2, 3] 129 | }; 130 | 131 | const expectedState = { 132 | activeTrackId: 1, 133 | isPlaying: 'Ibiza', 134 | playlist: [1, 2, 3] 135 | }; 136 | 137 | expect(player(previousState, action)).to.eql(expectedState); 138 | }); 139 | }); 140 | describe('SET_TRACK_IN_PLAYLIST', () => { 141 | it('sets a track in playlist', () => { 142 | const trackId = 3; 143 | const action = actionCreators.setTrackInPlaylist(trackId); 144 | const previousState = { 145 | activeTrackId: 1, 146 | isPlaying: false, 147 | playlist: [1, 2] 148 | }; 149 | 150 | const expectedState = { 151 | activeTrackId: 1, 152 | isPlaying: false, 153 | playlist: [1, 2, 3] 154 | }; 155 | 156 | expect(player(previousState, action)).to.eql(expectedState); 157 | }); 158 | }); 159 | describe('REMOVE_TRACK_FROM_PLAYLIST', () => { 160 | it('sets a track in playlist', () => { 161 | const trackId = 2; 162 | const action = actionCreators.removeFromPlaylist(trackId); 163 | 164 | const previousState = { 165 | activeTrackId: 1, 166 | isPlaying: false, 167 | playlist: [1, 2, 3] 168 | }; 169 | 170 | const expectedState = { 171 | activeTrackId: 1, 172 | isPlaying: false, 173 | playlist: [1, 3] 174 | }; 175 | 176 | expect(player(previousState, action)).to.eql(expectedState); 177 | }); 178 | }); 179 | describe('SET_VOLUME', () => { 180 | it('sets the volume of the active track', () => { 181 | const newVolume = 20; 182 | const action = actionCreators.setTrackVolume(newVolume); 183 | const previousState = { 184 | activeTrackId: 1, 185 | volume: 70 186 | }; 187 | 188 | const expectedState = { 189 | activeTrackId: 1, 190 | volume: 20 191 | }; 192 | expect(player(previousState, action)).to.eql(expectedState); 193 | }); 194 | }); 195 | }); 196 | -------------------------------------------------------------------------------- /src/reducers/request/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | 3 | const initialState = {}; 4 | 5 | export default function(state = initialState, action) { 6 | switch (action.type) { 7 | case actionTypes.SET_REQUEST_IN_PROCESS: 8 | return setRequestInProcess(state, action); 9 | } 10 | return state; 11 | } 12 | 13 | function setRequestInProcess(state, action) { 14 | const { inProcess, requestType } = action; 15 | const requestObject = {}; 16 | requestObject[requestType] = inProcess; 17 | return Object.assign({}, state, requestObject); 18 | } 19 | -------------------------------------------------------------------------------- /src/reducers/request/spec.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | import request from './index'; 3 | 4 | describe('request reducer', () => { 5 | 6 | describe('SET_REQUEST_IN_PROCESS', () => { 7 | 8 | it('add a request as in process', () => { 9 | const REQUEST_TYPE = 'FOO_REQUEST'; 10 | 11 | const action = { 12 | type: actionTypes.SET_REQUEST_IN_PROCESS, 13 | inProcess: true, 14 | requestType: REQUEST_TYPE 15 | } 16 | 17 | const expectedState = { 18 | [REQUEST_TYPE]: true 19 | }; 20 | 21 | expect(request(undefined, action)).to.eql(expectedState); 22 | }); 23 | 24 | it('add a request as not in process', () => { 25 | const REQUEST_TYPE = 'FOO_REQUEST'; 26 | 27 | const action = { 28 | type: actionTypes.SET_REQUEST_IN_PROCESS, 29 | inProcess: false, 30 | requestType: REQUEST_TYPE 31 | } 32 | 33 | const expectedState = { 34 | [REQUEST_TYPE]: false 35 | }; 36 | 37 | expect(request(undefined, action)).to.eql(expectedState); 38 | }); 39 | 40 | }); 41 | 42 | }); 43 | -------------------------------------------------------------------------------- /src/reducers/session/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | 3 | const initialState = { 4 | session: null, 5 | user: null, 6 | loginError: null, 7 | }; 8 | 9 | export default function(state = initialState, action) { 10 | switch (action.type) { 11 | case actionTypes.SET_SESSION: 12 | return setSession(state, action.session); 13 | case actionTypes.SET_USER: 14 | return setUser(state, action.user); 15 | case actionTypes.RESET_SESSION: 16 | return initialState; 17 | } 18 | return state; 19 | } 20 | 21 | function setSession(state, session) { 22 | return { ...state, session }; 23 | } 24 | 25 | function setUser(state, user) { 26 | return { ...state, user }; 27 | } 28 | -------------------------------------------------------------------------------- /src/reducers/session/spec.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | import session from './index'; 3 | 4 | describe('session reducer', () => { 5 | 6 | describe('RESET_SESSION', () => { 7 | 8 | it('resets a session', () => { 9 | const action = { 10 | type: actionTypes.RESET_SESSION 11 | }; 12 | 13 | const previousState = { 14 | user: 'foo', 15 | session: 'bar', 16 | loginError: 'access_denied' 17 | }; 18 | 19 | const expectedState = { 20 | loginError: null, 21 | user: null, 22 | session: null 23 | }; 24 | 25 | expect(session(previousState, action)).to.eql(expectedState); 26 | }); 27 | 28 | }); 29 | 30 | describe('SET_SESSION', () => { 31 | 32 | it('sets a session', () => { 33 | const action = { 34 | type: actionTypes.SET_SESSION, 35 | session: 'koar' 36 | }; 37 | 38 | const previousState = { 39 | user: 'foo', 40 | session: 'bar' 41 | }; 42 | 43 | const expectedState = { 44 | user: 'foo', 45 | session: 'koar' 46 | }; 47 | 48 | expect(session(previousState, action)).to.eql(expectedState); 49 | }); 50 | 51 | }); 52 | 53 | describe('SET_USER', () => { 54 | 55 | it('sets an user', () => { 56 | const action = { 57 | type: actionTypes.SET_USER, 58 | user: 'shuar' 59 | }; 60 | 61 | const previousState = { 62 | user: 'foo', 63 | session: 'bar' 64 | }; 65 | 66 | const expectedState = { 67 | user: 'shuar', 68 | session: 'bar' 69 | }; 70 | 71 | expect(session(previousState, action)).to.eql(expectedState); 72 | }); 73 | 74 | }); 75 | 76 | }); 77 | -------------------------------------------------------------------------------- /src/reducers/sort/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | import * as sortTypes from '../../constants/sortTypes'; 3 | import * as dateSortTypes from '../../constants/dateSortTypes'; 4 | 5 | const initialState = { 6 | sortType: sortTypes.NONE, 7 | dateSortType: dateSortTypes.NONE, 8 | }; 9 | 10 | export default function(state = initialState, action) { 11 | switch (action.type) { 12 | case actionTypes.SORT_STREAM: 13 | return setSortStream(state, action.sortType); 14 | case actionTypes.DATE_SORT_STREAM: 15 | return setDateSortStream(state, action.dateSortType); 16 | } 17 | return state; 18 | } 19 | 20 | function setSortStream(state, sortType) { 21 | return { ...state, sortType }; 22 | } 23 | function setDateSortStream(state, dateSortType) { 24 | return { ...state, dateSortType }; 25 | } 26 | -------------------------------------------------------------------------------- /src/reducers/sort/spec.js: -------------------------------------------------------------------------------- 1 | import * as actionCreators from '../../actions/sort'; 2 | import sort from './index'; 3 | 4 | describe('sort', () => { 5 | describe('SORT_STREAM', () => { 6 | it('sets the sort stream if previously null', () => { 7 | const sortType = 'SORT_FAVORITES'; 8 | const action = actionCreators.sortStream(sortType); 9 | const previousState = { 10 | isPlaying: true, 11 | volume: 11, 12 | sortType: null 13 | }; 14 | const expectedState = { 15 | isPlaying: true, 16 | volume: 11, 17 | sortType: 'SORT_FAVORITES' 18 | }; 19 | expect(sort(previousState, action)).to.eql(expectedState); 20 | }); 21 | it('sets the sort stream if already set', () => { 22 | const sortType = 'SORT_FAVORITES'; 23 | const action = actionCreators.sortStream(sortType); 24 | const previousState = { 25 | isPlaying: true, 26 | volume: 11, 27 | sortType: 'SORT_PLAYS' 28 | }; 29 | const expectedState = { 30 | isPlaying: true, 31 | volume: 11, 32 | sortType: 'SORT_FAVORITES' 33 | }; 34 | expect(sort(previousState, action)).to.eql(expectedState); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/reducers/toggle/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | 3 | const initialState = {}; 4 | 5 | export default function(state = initialState, action) { 6 | switch (action.type) { 7 | case actionTypes.SET_TOGGLED: 8 | return setToggled(state, action.toggleType); 9 | case actionTypes.RESET_TOGGLED: 10 | return resetToggled(state, action.toggleType); 11 | } 12 | return state; 13 | } 14 | 15 | function setToggled(state, toggleType) { 16 | const toggleObject = {}; 17 | toggleObject[toggleType] = !state[toggleType]; 18 | return Object.assign({}, state, toggleObject); 19 | } 20 | 21 | function resetToggled(state, toggleType) { 22 | const toggleObject = {}; 23 | toggleObject[toggleType] = false; 24 | return Object.assign({}, state, toggleObject); 25 | } 26 | -------------------------------------------------------------------------------- /src/reducers/toggle/spec.js: -------------------------------------------------------------------------------- 1 | import * as actionCreators from '../../actions/toggle'; 2 | import toggle from './index'; 3 | 4 | describe('toggle reducer', () => { 5 | describe('SET_TOGGLED', () => { 6 | it('sets something to toggled', () => { 7 | const TOGGLE_TYPE = 'FOO_TOGGLE'; 8 | const action = actionCreators.setToggle(TOGGLE_TYPE); 9 | const expectedState = { 10 | [TOGGLE_TYPE]: true 11 | }; 12 | expect(toggle(undefined, action)).to.eql(expectedState); 13 | }); 14 | 15 | it('sets something to untoggled, when it was toggled before', () => { 16 | const TOGGLE_TYPE = 'FOO_TOGGLE'; 17 | const action = actionCreators.setToggle(TOGGLE_TYPE); 18 | const previousState = { 19 | [TOGGLE_TYPE]: true 20 | }; 21 | const expectedState = { 22 | [TOGGLE_TYPE]: false 23 | }; 24 | expect(toggle(previousState, action)).to.eql(expectedState); 25 | }); 26 | }); 27 | describe('RESET_TOGGLED', () => { 28 | it('resets the toggle to false after it has been previously toggled', () => { 29 | const TOGGLE_TYPE = 'FOO_TOGGLE'; 30 | const action = actionCreators.setToggle(TOGGLE_TYPE); 31 | const previousState = { 32 | [TOGGLE_TYPE]: true 33 | }; 34 | const expectedState = { 35 | [TOGGLE_TYPE]: false 36 | }; 37 | expect(toggle(previousState, action)).to.eql(expectedState); 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/reducers/user/index.js: -------------------------------------------------------------------------------- 1 | import reduce from 'lodash/fp/reduce'; 2 | import * as actionTypes from '../../constants/actionTypes'; 3 | 4 | const initialState = { 5 | followings: [], 6 | activities: [], 7 | typeReposts: {}, 8 | typeTracks: {}, 9 | followers: [], 10 | favorites: [], 11 | }; 12 | 13 | export default function(state = initialState, action) { 14 | switch (action.type) { 15 | case actionTypes.MERGE_FOLLOWINGS: 16 | return mergeFollowings(state, action.followings); 17 | case actionTypes.REMOVE_FROM_FOLLOWINGS: 18 | return removeFromFollowings(state, action.userId); 19 | case actionTypes.MERGE_ACTIVITIES: 20 | return mergeActivities(state, action.activities); 21 | case actionTypes.MERGE_TRACK_TYPES_TRACK: 22 | return mergeTrackTypesTrack(state, action.tracks); 23 | case actionTypes.MERGE_TRACK_TYPES_REPOST: 24 | return mergeTrackTypesRepost(state, action.reposts); 25 | case actionTypes.MERGE_FOLLOWERS: 26 | return mergeFollowers(state, action.followers); 27 | case actionTypes.MERGE_FAVORITES: 28 | return mergeFavorites(state, action.favorites); 29 | case actionTypes.REMOVE_FROM_FAVORITES: 30 | return removeFromFavorites(state, action.trackId); 31 | case actionTypes.RESET_SESSION: 32 | return initialState; 33 | } 34 | return state; 35 | } 36 | 37 | function mergeFollowings(state, list) { 38 | return { ...state, followings: getConcatList(state.followings, list) }; 39 | } 40 | 41 | function mergeActivities(state, list) { 42 | return { ...state, activities: getConcatList(state.activities, list) }; 43 | } 44 | 45 | function mergeTrackTypesTrack(state, list) { 46 | const { typeTracks } = state; 47 | const mergeTypes = reduce(countByType, typeTracks); 48 | return { ...state, typeTracks: mergeTypes(list) }; 49 | } 50 | 51 | function mergeTrackTypesRepost(state, list) { 52 | const { typeReposts } = state; 53 | const mergeTypes = reduce(countByType, typeReposts); 54 | return { ...state, typeReposts: mergeTypes(list) }; 55 | } 56 | 57 | function countByType(result, value) { 58 | /* eslint-disable no-param-reassign */ 59 | result[value.id] = result[value.id] ? result[value.id] + 1 : 1; 60 | /* eslint-enable no-param-reassign */ 61 | return result; 62 | } 63 | 64 | function mergeFollowers(state, list) { 65 | return { ...state, followers: getConcatList(state.followers, list) }; 66 | } 67 | 68 | function mergeFavorites(state, list) { 69 | return { ...state, favorites: getConcatList(state.favorites, list) }; 70 | } 71 | 72 | function removeFromFollowings(state, userId) { 73 | const index = state.followings.indexOf(userId); 74 | return { ...state, followings: removeWithIndex(state.followings, index) }; 75 | } 76 | 77 | function removeFromFavorites(state, trackId) { 78 | const index = state.favorites.indexOf(trackId); 79 | return { ...state, favorites: removeWithIndex(state.favorites, index) }; 80 | } 81 | 82 | function removeWithIndex(list, index) { 83 | return [ 84 | ...list.slice(0, index), 85 | ...list.slice(index + 1) 86 | ]; 87 | } 88 | 89 | function getConcatList(currentList, concatList) { 90 | return [...currentList, ...concatList]; 91 | } 92 | -------------------------------------------------------------------------------- /src/reducers/user/spec.js: -------------------------------------------------------------------------------- 1 | import * as actionCreators from '../../actions/user'; 2 | import * as sessionActionCreators from '../../actions/session'; 3 | import * as followingActionCreators from '../../actions/following'; 4 | import user from './index'; 5 | 6 | describe('user reducer', () => { 7 | describe('MERGE_FOLLOWINGS', () => { 8 | it('merges followings to list', () => { 9 | const followings = [1, 2, 3]; 10 | const action = actionCreators.mergeFollowings(followings); 11 | 12 | const expectedState = { 13 | followings: [4, 1, 2, 3], 14 | activities: [], 15 | followers: [], 16 | favorites: [] 17 | }; 18 | 19 | const previousState = { 20 | followings: [4], 21 | activities: [], 22 | followers: [], 23 | favorites: [] 24 | }; 25 | 26 | expect(user(previousState, action)).to.eql(expectedState); 27 | }); 28 | }); 29 | 30 | describe('REMOVE_FROM_FOLLOWINGS', () => { 31 | it('removes a following from list of followings', () => { 32 | const userId = 2; 33 | const action = followingActionCreators.removeFromFollowings(userId); 34 | const expectedState = { 35 | followings: [1, 3], 36 | activities: [], 37 | followers: [], 38 | favorites: [] 39 | }; 40 | 41 | const previousState = { 42 | followings: [1, 2, 3], 43 | activities: [], 44 | followers: [], 45 | favorites: [] 46 | }; 47 | 48 | expect(user(previousState, action)).to.eql(expectedState); 49 | }); 50 | }); 51 | describe('MERGE_ACTIVITIES', () => { 52 | it('merges activities from a list', () => { 53 | const activities = ['foo', 'bar']; 54 | const action = actionCreators.mergeActivities(activities); 55 | const expectedState = { 56 | followings: [], 57 | activities: ['baz', 'foo', 'bar'], 58 | followers: [], 59 | favorites: [] 60 | }; 61 | 62 | const previousState = { 63 | followings: [], 64 | activities: ['baz'], 65 | followers: [], 66 | favorites: [] 67 | }; 68 | expect(user(previousState, action)).to.eql(expectedState); 69 | }); 70 | }); 71 | describe('MERGE_FOLLOWERS', () => { 72 | it('merges followers from a list', () => { 73 | const followers = ['Jack', 'Jill', 'Lebron']; 74 | const action = actionCreators.mergeFollowers(followers); 75 | const expectedState = { 76 | followings: [], 77 | activities: [], 78 | followers: ['Kyrie', 'Jack', 'Jill', 'Lebron'], 79 | favorites: [] 80 | }; 81 | 82 | const previousState = { 83 | followings: [], 84 | activities: [], 85 | followers: ['Kyrie'], 86 | favorites: [] 87 | }; 88 | expect(user(previousState, action)).to.eql(expectedState); 89 | }); 90 | }); 91 | describe('MERGE_FAVORITES', () => { 92 | it('merges favorites from a list', () => { 93 | const favorites = ['Jack', 'Jill', 'Lebron']; 94 | const action = actionCreators.mergeFavorites(favorites); 95 | const expectedState = { 96 | followings: [], 97 | activities: [], 98 | followers: [], 99 | favorites: ['Kyrie', 'Jack', 'Jill', 'Lebron'] 100 | }; 101 | 102 | const previousState = { 103 | followings: [], 104 | activities: [], 105 | followers: [], 106 | favorites: ['Kyrie'] 107 | }; 108 | expect(user(previousState, action)).to.eql(expectedState); 109 | }); 110 | }); 111 | describe('RESET_SESSION', () => { 112 | it('resets the session to initialState', () => { 113 | const action = sessionActionCreators.resetSession(); 114 | const previousState = { 115 | followings: [], 116 | activities: [], 117 | followers: [], 118 | favorites: ['FOO'], 119 | typeReposts: {}, 120 | typeTracks: { 0: 'Bar' } 121 | }; 122 | const expectedState = { 123 | followings: [], 124 | activities: [], 125 | followers: [], 126 | favorites: [], 127 | typeReposts: {}, 128 | typeTracks: {} 129 | }; 130 | expect(user(previousState, action)).to.eql(expectedState); 131 | }); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /src/schemas/comment.js: -------------------------------------------------------------------------------- 1 | import { Schema } from 'normalizr'; 2 | import userSchema from './user'; 3 | 4 | const commentSchema = new Schema('comments'); 5 | 6 | commentSchema.define({ 7 | user: userSchema 8 | }); 9 | 10 | export default commentSchema; 11 | -------------------------------------------------------------------------------- /src/schemas/track.js: -------------------------------------------------------------------------------- 1 | import { Schema } from 'normalizr'; 2 | import userSchema from './user'; 3 | 4 | const trackSchema = new Schema('tracks'); 5 | 6 | trackSchema.define({ 7 | user: userSchema 8 | }); 9 | 10 | export default trackSchema; 11 | -------------------------------------------------------------------------------- /src/schemas/user.js: -------------------------------------------------------------------------------- 1 | import { Schema } from 'normalizr'; 2 | 3 | const userSchema = new Schema('users'); 4 | 5 | export default userSchema; 6 | -------------------------------------------------------------------------------- /src/services/api.js: -------------------------------------------------------------------------------- 1 | import Cookies from 'js-cookie'; 2 | import { CLIENT_ID, TEMP_CLIENT_ID } from '../constants/authentication'; 3 | 4 | export function unauthApiUrl(url, symbol) { 5 | return `//api.soundcloud.com/${url}${symbol}client_id=${CLIENT_ID}`; 6 | } 7 | 8 | export function apiUrl(url, symbol) { 9 | const accessToken = Cookies.get('accessToken'); 10 | 11 | if (!accessToken) { // Fallback 12 | return unauthApiUrl(url, symbol); 13 | } 14 | 15 | return `//api.soundcloud.com/${url}${symbol}oauth_token=${accessToken}`; 16 | } 17 | 18 | export function addTempClientIdWith(url, symbol) { 19 | return `${url}${symbol}client_id=${TEMP_CLIENT_ID}`; 20 | } 21 | 22 | export function addAccessTokenWith(url, symbol) { 23 | const accessToken = Cookies.get('accessToken'); 24 | if (accessToken) { 25 | return `${url}${symbol}oauth_token=${accessToken}`; 26 | } else { 27 | return `${url}${symbol}client_id=${CLIENT_ID}`; 28 | } 29 | } 30 | 31 | export function getLazyLoadingUsersUrl(user, nextHref, initHref) { 32 | function getUrlPrefix(u) { 33 | return u ? `users/${u.id}` : `me`; 34 | } 35 | 36 | if (nextHref) { 37 | return addAccessTokenWith(nextHref, '&'); 38 | } else { 39 | return apiUrl(`${getUrlPrefix(user)}/${initHref}`, '&'); 40 | } 41 | } 42 | 43 | export function getLazyLoadingCommentsUrl(nextHref, initHref) { 44 | if (nextHref) { 45 | return addAccessTokenWith(nextHref, '&'); 46 | } else { 47 | return apiUrl(initHref, '&'); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/services/filter.js: -------------------------------------------------------------------------------- 1 | import every from 'lodash/fp/every'; 2 | import some from 'lodash/fp/some'; 3 | 4 | export function getOrCombined(filters) { 5 | return (obj) => some((fn) => fn(obj), filters); 6 | } 7 | 8 | export function getAndCombined(filters) { 9 | return (obj) => every((fn) => fn(obj), filters); 10 | } 11 | -------------------------------------------------------------------------------- /src/services/map.js: -------------------------------------------------------------------------------- 1 | import map from 'lodash/fp/map'; 2 | 3 | export default map.convert({ cap: false }); 4 | -------------------------------------------------------------------------------- /src/services/player.js: -------------------------------------------------------------------------------- 1 | export function isSameTrackAndPlaying(activeTrackId, trackId, isPlaying) { 2 | return activeTrackId && isPlaying && activeTrackId === trackId; 3 | } 4 | 5 | export function isSameTrack(trackId) { 6 | return function is(id) { 7 | return trackId && id && trackId === id; 8 | }; 9 | } 10 | 11 | export function formatSeconds(num) { 12 | if (num < 3600) { 13 | const minutes = padZero(Math.floor(num / 60), 2); 14 | const seconds = padZero(num % 60, 2); 15 | return `${minutes}:${seconds}`; 16 | } else { 17 | const hours = padZero(Math.floor(num / 3600), 2); 18 | const minutes = padZero(Math.floor((num - (hours * 3600)) / 60), 2); 19 | const seconds = padZero(num % 60, 2); 20 | return `${hours}:${minutes}:${seconds}`; 21 | } 22 | } 23 | 24 | function padZero(num, size) { 25 | let s = String(num); 26 | while (s.length < size) { 27 | s = `0${s}`; 28 | } 29 | return s; 30 | } 31 | -------------------------------------------------------------------------------- /src/services/pluralize.js: -------------------------------------------------------------------------------- 1 | export function getPluralized(count, string) { 2 | return count > 1 ? string + 's' : string; 3 | } 4 | 5 | export function getPluralizedWithCount(count, string) { 6 | return count > 1 ? count + ' ' + string + 's' : count + ' ' + string; 7 | } 8 | -------------------------------------------------------------------------------- /src/services/string.js: -------------------------------------------------------------------------------- 1 | import * as requestTypes from '../constants/requestTypes'; 2 | 3 | function getCommentProperty(commentId) { 4 | return `${commentId}/${requestTypes.COMMENTS}`; 5 | } 6 | 7 | export { 8 | getCommentProperty 9 | }; 10 | -------------------------------------------------------------------------------- /src/services/track.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | import * as trackTypes from '../constants/trackTypes'; 3 | 4 | export function isTrack(track) { 5 | const { origin, type } = track; 6 | return origin && type && type !== trackTypes.PLAYLIST && type !== trackTypes.PLAYLIST_REPOST; 7 | } 8 | 9 | export function toIdAndType(o) { 10 | return { type: o.type, id: o.origin.id }; 11 | } 12 | 13 | export function normalizeSamples(samples) { 14 | let highestValue = 0; 15 | for (let i = 0; i <= samples.length; i++) { 16 | if (samples[i] > highestValue) { 17 | highestValue = samples[i]; 18 | } 19 | } 20 | 21 | const newSamples = []; 22 | for (let j = 0; j <= samples.length; j++) { 23 | const newValue = samples[j] / highestValue; 24 | newSamples.push(newValue); 25 | } 26 | return newSamples; 27 | } 28 | 29 | export function isJsonWaveform(waveformUrl) { 30 | return waveformUrl.indexOf('.json') !== -1; 31 | } 32 | 33 | export function isPngWaveform(waveformUrl) { 34 | return waveformUrl.indexOf('.png') !== -1; 35 | } 36 | 37 | export function durationFormat(ms) { 38 | const duration = moment.duration(ms); 39 | if (duration.asHours() > 1) { 40 | return Math.floor(duration.asHours()) + moment.utc(duration.asMilliseconds()).format(":mm:ss"); 41 | } else { 42 | return moment.utc(duration.asMilliseconds()).format("mm:ss"); 43 | } 44 | } 45 | 46 | export function fromNow(createdAt) { 47 | return moment(new Date(createdAt)).from(moment()); 48 | } 49 | -------------------------------------------------------------------------------- /src/stores/configureStore.js: -------------------------------------------------------------------------------- 1 | import mixpanel from './mixpanel'; 2 | import { createStore, applyMiddleware } from 'redux'; 3 | import thunk from 'redux-thunk'; 4 | import rootReducer from '../reducers/index'; 5 | 6 | const createStoreWithMiddleware = applyMiddleware(thunk, mixpanel)(createStore); 7 | 8 | export default function configureStore(initialState) { 9 | const store = createStoreWithMiddleware( 10 | rootReducer, 11 | initialState, 12 | window.devToolsExtension && window.devToolsExtension() 13 | ); 14 | 15 | if (process.env.NODE_ENV !== 'production' && module.hot) { 16 | module.hot.accept('../reducers', () => { 17 | // eslint-disable-next-line 18 | const nextReducer = require('../reducers').default; 19 | store.replaceReducer(nextReducer); 20 | }); 21 | } 22 | return store; 23 | } 24 | -------------------------------------------------------------------------------- /src/stores/mixpanel.js: -------------------------------------------------------------------------------- 1 | import mixpanel from 'rn-redux-mixpanel'; 2 | 3 | import { 4 | SET_USER, 5 | MERGE_ACTIVITIES, 6 | MERGE_FOLLOWINGS, 7 | MERGE_FOLLOWERS, 8 | MERGE_FAVORITES, 9 | MERGE_GENRE_ACTIVITIES, 10 | MERGE_ENTITIES, 11 | SET_PAGINATE_LINK, 12 | SET_TOGGLED, 13 | RESET_TOGGLED, 14 | SET_REQUEST_IN_PROCESS, 15 | SYNC_ENTITIES 16 | } from '../constants/actionTypes'; 17 | 18 | const blacklist = [ 19 | MERGE_ACTIVITIES, 20 | MERGE_FOLLOWINGS, 21 | MERGE_FOLLOWERS, 22 | MERGE_FAVORITES, 23 | MERGE_GENRE_ACTIVITIES, 24 | MERGE_ENTITIES, 25 | SET_PAGINATE_LINK, 26 | SET_TOGGLED, 27 | RESET_TOGGLED, 28 | SET_REQUEST_IN_PROCESS, 29 | SYNC_ENTITIES 30 | ]; 31 | 32 | export default mixpanel({ 33 | 34 | ignoreAction: (action) => { 35 | return blacklist.indexOf(action.type) > -1; 36 | }, 37 | 38 | token: 'b36e27047a8724f0977edc36dbf8477d', 39 | 40 | selectEventName: (action) => action.type, 41 | 42 | selectDistinctId: (action, state) => { 43 | if (state.session && state.session.user && state.session.user.permalink) { 44 | return state.session.user.permalink; 45 | } else { 46 | return 'NO_USER'; 47 | } 48 | }, 49 | 50 | selectUserProfileData: (action, state) => { 51 | let user; 52 | 53 | if (state.session.user) { 54 | user = state.session.user; 55 | } 56 | 57 | if (action.type === SET_USER) { 58 | user = action.user; 59 | } 60 | 61 | if (user) { 62 | return generateUserData(user); 63 | } 64 | } 65 | }); 66 | 67 | function generateUserData(user) { 68 | return { 69 | $permalink: user.permalink, 70 | $permalink_url: user.permalink_url, 71 | $username: user.username 72 | }; 73 | } 74 | -------------------------------------------------------------------------------- /styles/components/actions.scss: -------------------------------------------------------------------------------- 1 | .action { 2 | position: absolute; 3 | right: 0; 4 | top: 0; 5 | height: 100%; 6 | display: flex; 7 | align-items: center; 8 | 9 | opacity: 0; 10 | transition: all 0.2s; 11 | -webkit-transition: all 0.2s; 12 | transition-timing-function: ease; 13 | -webkit-transition-timing-function: ease; 14 | 15 | &-visible { 16 | margin: auto ($padding / 2); 17 | opacity: 1; 18 | } 19 | 20 | i { 21 | margin: 0 ($padding / 4); 22 | cursor: pointer; 23 | font-size: $mediumIcon; 24 | 25 | &:hover { 26 | color: $mainColor; 27 | } 28 | } 29 | 30 | } -------------------------------------------------------------------------------- /styles/components/artworkAction.scss: -------------------------------------------------------------------------------- 1 | .artwork-action { 2 | 3 | position: relative; 4 | 5 | &-overlay { 6 | position: absolute; 7 | top: 0; 8 | left: 0; 9 | display: flex; 10 | justify-content: center; 11 | color: $mainColor; 12 | font-size: 34px; 13 | width: 100%; 14 | height: 100%; 15 | background: rgba(89, 89, 89, 0); 16 | opacity: 0; 17 | cursor: pointer; 18 | transition: opacity 0.2s; 19 | 20 | &:hover, &-visible { 21 | background: rgba(255, 255, 255, .7); 22 | opacity: 1; 23 | } 24 | 25 | i { 26 | margin-top: ($padding - ($padding / 4)); 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /styles/components/browse.scss: -------------------------------------------------------------------------------- 1 | .browse { 2 | 3 | } -------------------------------------------------------------------------------- /styles/components/buttonActive.scss: -------------------------------------------------------------------------------- 1 | .button-active { 2 | &-selected, &:hover { 3 | border-bottom: 1px solid $mainColor; 4 | } 5 | } -------------------------------------------------------------------------------- /styles/components/buttonGhost.scss: -------------------------------------------------------------------------------- 1 | .button-ghost { 2 | display: inline-block; 3 | border: 1px solid $mainColor; 4 | height: $padding; 5 | line-height: $padding; 6 | color: $mainColor; 7 | -webkit-border-radius: 5px; 8 | -webkit-background-clip: padding-box; 9 | -moz-border-radius: 5px; 10 | -moz-background-clip: padding; 11 | border-radius: 5px; 12 | background-clip: padding-box; 13 | padding: .5em 1.5em; 14 | font-weight: bold; 15 | -webkit-transition: all 0.2s ease-out; 16 | -moz-transition: all 0.2s ease-out; 17 | -o-transition: all 0.2s ease-out; 18 | transition: all 0.2s ease-out; 19 | background: transparent; 20 | -webkit-box-sizing: content-box; 21 | -moz-box-sizing: content-box; 22 | box-sizing: content-box; 23 | cursor: pointer; 24 | -webkit-backface-visibility: hidden; 25 | 26 | &-small { 27 | height: $padding / 2; 28 | line-height: $padding / 2; 29 | } 30 | 31 | &:hover { 32 | -webkit-transition: 0.2s ease; 33 | -moz-transition: 0.2s ease; 34 | -o-transition: 0.2s ease; 35 | transition: 0.2s ease; 36 | background-color: $mainColor; 37 | color: #FFFFFF !important; 38 | } 39 | 40 | &:focus { 41 | outline: none; 42 | } 43 | 44 | } -------------------------------------------------------------------------------- /styles/components/buttonInline.scss: -------------------------------------------------------------------------------- 1 | .button-inline { 2 | border-width: 0; 3 | background: transparent; 4 | color: inherit; 5 | text-align: inherit; 6 | -webkit-font-smoothing: inherit; 7 | padding: 0; 8 | font-size: inherit; 9 | cursor: pointer; 10 | white-space:nowrap; 11 | 12 | &:focus { 13 | background: transparent; 14 | outline: none; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /styles/components/buttonMore.scss: -------------------------------------------------------------------------------- 1 | .button-more { 2 | margin: ($padding / 2) auto; 3 | text-align: center; 4 | } -------------------------------------------------------------------------------- /styles/components/comment.scss: -------------------------------------------------------------------------------- 1 | .comment-extension { 2 | 3 | margin: $padding; 4 | 5 | &-item { 6 | display: flex; 7 | border: $border; 8 | background-color: $backgroundSecondary; 9 | 10 | &-body { 11 | padding: $padding / 4; 12 | flex: 1; 13 | 14 | &-header { 15 | display: flex; 16 | justify-content: space-between; 17 | } 18 | } 19 | } 20 | 21 | } -------------------------------------------------------------------------------- /styles/components/dashboard.scss: -------------------------------------------------------------------------------- 1 | .dashboard { 2 | 3 | display: flex; 4 | flex-direction: row; 5 | 6 | &-main { 7 | margin-right: $padding; 8 | flex: 3; 9 | } 10 | 11 | &-side { 12 | flex: 1; 13 | float:right; 14 | width:25%; 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /styles/components/header.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | 3 | background: $nav; 4 | position: fixed; 5 | right: 0; 6 | top: 0; 7 | width: 100%; 8 | height: $padding * 3; 9 | border-bottom: $darkBorder; 10 | z-index: 1; 11 | 12 | &-content { 13 | position: relative; 14 | display: flex; 15 | height: 100%; 16 | justify-content: space-between; 17 | 18 | .menu-item { 19 | font-size: $menuSize; 20 | text-transform: uppercase; 21 | margin: 0 ($padding / 2); 22 | padding: ($padding / 8) 0; 23 | 24 | &-selected, &:hover { 25 | border-bottom: 1px solid $mainColor; 26 | } 27 | } 28 | 29 | .dashboard-link , .logo { 30 | display: inline-block; 31 | margin-right: 10px; 32 | } 33 | 34 | .session-link, .github-link { 35 | display: inline-block; 36 | margin-left: 0px; 37 | } 38 | 39 | a.menu-item:visited { 40 | color: $fontColor; 41 | } 42 | 43 | div { 44 | margin: auto ($padding * 2); 45 | 46 | i { 47 | cursor: pointer; 48 | font-size: 20px; 49 | width: 20px; 50 | 51 | &:hover { 52 | color: $mainColor; 53 | } 54 | 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /styles/components/infoList.scss: -------------------------------------------------------------------------------- 1 | .info-list { 2 | display: flex; 3 | justify-content: space-between; 4 | width: 100%; 5 | 6 | &-item { 7 | min-width: 85px; 8 | 9 | &-active { 10 | color: $mainColor; 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /styles/components/inputMenu.scss: -------------------------------------------------------------------------------- 1 | .input-menu { 2 | font-size: $menuSize; 3 | color: $fontColor; 4 | border: 0; 5 | background-color: $backgroundSecondary; 6 | 7 | &:focus { 8 | outline: none; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /styles/components/item.scss: -------------------------------------------------------------------------------- 1 | .item { 2 | 3 | height: 40px; 4 | display: flex; 5 | 6 | &:hover .action { 7 | opacity: 1; 8 | margin: auto ($padding / 2); 9 | } 10 | 11 | &-content { 12 | position: relative; 13 | width: 100%; 14 | padding: $padding / 4; 15 | display: flex; 16 | flex-direction: column; 17 | justify-content: space-between; 18 | 19 | a { 20 | overflow: hidden; 21 | height: 14px; 22 | span { 23 | line-height: 14px; 24 | } 25 | } 26 | 27 | } 28 | 29 | .is-active { 30 | color: $mainColor; 31 | } 32 | } -------------------------------------------------------------------------------- /styles/components/list.scss: -------------------------------------------------------------------------------- 1 | .list { 2 | 3 | h2 { 4 | margin-top: 0; 5 | } 6 | 7 | i.fa-chevron-up, i.fa-chevron-down { 8 | opacity: 0; 9 | transition: opacity 0.2s; 10 | -webkit-transition: opacity 0.2s; 11 | transition-timing-function: ease; 12 | -webkit-transition-timing-function: ease; 13 | } 14 | 15 | &:hover i { 16 | opacity: 1; 17 | } 18 | 19 | &-content { 20 | height: $padding * 8; 21 | overflow: hidden; 22 | border: $border; 23 | background: $backgroundSecondary; 24 | 25 | display: flex; 26 | flex-direction: column; 27 | 28 | li { 29 | border-bottom: $border; 30 | 31 | &:last-child { 32 | border-bottom: none; 33 | } 34 | } 35 | 36 | } 37 | 38 | .more-visible &-content { 39 | height: 100%; 40 | } 41 | 42 | } -------------------------------------------------------------------------------- /styles/components/loadingSpinner.scss: -------------------------------------------------------------------------------- 1 | .loading-spinner { 2 | height: 75px; 3 | width: 100%; 4 | text-align: center; 5 | 6 | i.fa-spinner { 7 | margin: 15px 0; 8 | font-size: 25px; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /styles/components/player.scss: -------------------------------------------------------------------------------- 1 | .player { 2 | 3 | background: $nav; 4 | position: fixed; 5 | right: 0; 6 | bottom: 0; 7 | width: 100%; 8 | height: 0; 9 | border-top: $darkBorder; 10 | z-index: 1; 11 | 12 | transition: height 0.2s; 13 | -webkit-transition: height 0.2s; 14 | transition-timing-function: ease; 15 | -webkit-transition-timing-function: ease; 16 | 17 | &-visible { 18 | height: $padding * 3 + 10; 19 | } 20 | 21 | &-content { 22 | position: relative; 23 | display: flex; 24 | justify-content: start; 25 | height: 100%; 26 | margin-left: 20%; 27 | flex-grow: 1; 28 | 29 | &-name { 30 | width: 50%; 31 | 32 | } 33 | &-link { 34 | font-size: 15px; 35 | white-space: nowrap; 36 | } 37 | 38 | &-action { 39 | font-size: $bigIcon; 40 | cursor: pointer; 41 | 42 | &:hover { 43 | color: $mainColor; 44 | } 45 | 46 | i { 47 | width: $bigIcon; 48 | 49 | &.is-favorite { 50 | color: $mainColor; 51 | } 52 | } 53 | } 54 | 55 | div { 56 | margin: auto $padding; 57 | min-width: $padding * 2; 58 | } 59 | 60 | } 61 | 62 | .randomSelected{ 63 | color: $mainColor; 64 | } 65 | 66 | .repeatSelected{ 67 | color: $mainColor; 68 | } 69 | 70 | .player-container { 71 | display: flex; 72 | flex-direction: column; 73 | height: 100%; 74 | } 75 | 76 | .player-status { 77 | height: 10px; 78 | border-bottom: $darkBorder; 79 | cursor: pointer; 80 | 81 | .player-status-bar { 82 | width: 0; 83 | transition: width 0.3s linear; 84 | background-color: #61B25A; 85 | height: 100%; 86 | position: relative; 87 | 88 | .player-status-bar-dragger { 89 | width: 20px; 90 | height: 10px; 91 | position: absolute; 92 | right: -8px; 93 | background-color: #f5f5f5; 94 | border: 1px solid #8dc572; 95 | top: -1px; 96 | cursor: pointer; 97 | border-radius: 3px; 98 | } 99 | } 100 | } 101 | 102 | } 103 | -------------------------------------------------------------------------------- /styles/components/playlist.scss: -------------------------------------------------------------------------------- 1 | .playlist { 2 | 3 | background: $nav; 4 | position: fixed; 5 | right: 15vw; 6 | bottom: $padding * 3.5; 7 | width: $padding * 18; 8 | height: $padding * 15; 9 | max-height: $padding * 15; 10 | border: $darkBorder; 11 | overflow: auto; 12 | opacity: 0; 13 | pointer-events: none; 14 | 15 | transition: height 0.2s; 16 | -webkit-transition: opacity 0.2s; 17 | transition-timing-function: ease; 18 | -webkit-transition-timing-function: ease; 19 | 20 | &-visible { 21 | opacity: 1; 22 | pointer-events: all; 23 | } 24 | 25 | &-menu { 26 | display: flex; 27 | justify-content: space-between; 28 | height: $padding; 29 | padding: $padding / 2; 30 | border-bottom: $darkBorder; 31 | font-size: $menuSize; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /styles/components/playlistTrack.scss: -------------------------------------------------------------------------------- 1 | .playlist-track { 2 | 3 | height: 40px; 4 | display: flex; 5 | overflow: hidden; 6 | border-bottom: $darkBorder; 7 | 8 | &:hover .action { 9 | opacity: 1; 10 | margin: auto ($padding / 2); 11 | } 12 | 13 | &-content { 14 | position: relative; 15 | padding: $padding / 4; 16 | background: $nav; 17 | position: relative; 18 | width: 100%; 19 | 20 | a { 21 | float: left; 22 | width: $padding * 13; 23 | display: inline-block; 24 | overflow: hidden; 25 | text-overflow: ellipsis; 26 | white-space: nowrap; 27 | } 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /styles/components/streamInteractions.scss: -------------------------------------------------------------------------------- 1 | .stream-interactions { 2 | display: flex; 3 | padding-bottom: ($padding / 2); 4 | 5 | &-item { 6 | background: $backgroundSecondary; 7 | border: $border; 8 | padding: ($padding / 2); 9 | margin-right: ($padding / 2); 10 | } 11 | } 12 | 13 | .stream-interaction { 14 | display: flex; 15 | align-items: flex-end; 16 | 17 | &-icon { 18 | font-size: $mediumIcon; 19 | margin-right: ($padding / 4); 20 | 21 | &-active { 22 | color: $mainColor; 23 | } 24 | } 25 | 26 | &-content { 27 | display: flex; 28 | font-size: $menuSize; 29 | 30 | div { 31 | margin: 0 ($padding / 4); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /styles/components/track.scss: -------------------------------------------------------------------------------- 1 | .track { 2 | display: flex; 3 | height: $padding * 3; 4 | margin: ($padding / 2) 0; 5 | background: $backgroundSecondary; 6 | border: $border; 7 | position: relative; 8 | transition: all 0.2s; 9 | 10 | &-waveform { 11 | position: absolute; 12 | display: flex; 13 | top: 0; 14 | left: 120px; // artwork size 15 | width: calc(100% - 120px); // artwork size 16 | height: 100%; 17 | opacity: 0; 18 | pointer-events: none; 19 | 20 | &-json { 21 | flex: 1; 22 | } 23 | } 24 | 25 | &-artwork { 26 | overflow: hidden; 27 | } 28 | 29 | &-content { 30 | padding: ($padding / 4); 31 | display: flex; 32 | flex: 1; 33 | flex-direction: column; 34 | justify-content: space-between; 35 | 36 | &-header, &-footer { 37 | display: inline-flex; 38 | align-items: flex-end; 39 | justify-content: space-between; 40 | width: 100%; 41 | } 42 | 43 | &-footer-actions { 44 | .button-ghost { 45 | color: $fontColor; 46 | border: 1px solid $fontColor; 47 | } 48 | } 49 | } 50 | 51 | &:hover, &-visible { 52 | 53 | .track-waveform { 54 | opacity: 0.2; 55 | } 56 | 57 | .track-content-footer-actions { 58 | .button-ghost { 59 | color: $mainColor; 60 | border: 1px solid $mainColor; 61 | } 62 | } 63 | 64 | .artwork-action-overlay { 65 | background: rgba(255, 255, 255, .7); 66 | opacity: 1; 67 | } 68 | } 69 | 70 | .active-duration-filter { 71 | color: $mainColor; 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /styles/components/trackActions.scss: -------------------------------------------------------------------------------- 1 | .track-actions { 2 | &-list { 3 | display: flex; 4 | 5 | &-item { 6 | white-space: nowrap; 7 | margin: 0 0 0 ($padding / 2); 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /styles/components/volume.scss: -------------------------------------------------------------------------------- 1 | .volume { 2 | 3 | background: $nav; 4 | position: fixed; 5 | right: 5em; 6 | bottom: $padding * 3; 7 | width: $padding * 5; 8 | height: $padding * 15; 9 | max-height: $padding * 15; 10 | border: $darkBorder; 11 | overflow: auto; 12 | opacity: 0; 13 | pointer-events: none; 14 | 15 | transition: height 0.2s; 16 | -webkit-transition: opacity 0.2s; 17 | transition-timing-function: ease; 18 | -webkit-transition-timing-function: ease; 19 | 20 | &-visible { 21 | opacity: 1; 22 | pointer-events: all; 23 | } 24 | 25 | &-menu { 26 | display: flex; 27 | justify-content: space-between; 28 | height: $padding; 29 | padding: $padding / 2; 30 | border-bottom: $darkBorder; 31 | 32 | font-size: $menuSize; 33 | } 34 | 35 | &-number { 36 | text-align: center; 37 | color: $mainColor; 38 | } 39 | 40 | &-muter { 41 | text-align: center; 42 | font-size: $bigIcon; 43 | } 44 | } 45 | 46 | .rangeslider { 47 | margin: 20px 0; 48 | position: relative; 49 | background: $fontColor; 50 | .rangeslider__fill, .rangeslider__handle { 51 | position: absolute; 52 | } 53 | &, .rangeslider__fill { 54 | display: block; 55 | box-shadow: inset 0px 1px 3px rgba(0, 0, 0, 0.3); 56 | } 57 | .rangeslider__handle { 58 | background: #fff; 59 | border: 1px solid #ccc; 60 | cursor: pointer; 61 | display: inline-block; 62 | position: absolute; 63 | &:active { 64 | background: #999; 65 | } 66 | } 67 | } 68 | 69 | .rangeslider-vertical { 70 | margin: 20px auto; 71 | height: 150px; 72 | max-width: 10px; 73 | .rangeslider__fill { 74 | width: 100%; 75 | background: $mainColor; 76 | box-shadow: none; 77 | bottom: 0; 78 | } 79 | .rangeslider__handle { 80 | width: 30px; 81 | height: 10px; 82 | left: -10px; 83 | &:active { 84 | box-shadow: none; 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /styles/custom/main.scss: -------------------------------------------------------------------------------- 1 | body { 2 | background: $background; 3 | font: 400 13px "CoreSans", Arial,sans-serif; 4 | -moz-osx-font-smoothing: grayscale; 5 | -webkit-font-smoothing: antialiased; 6 | color: $fontColor; 7 | letter-spacing: .02em; 8 | position: relative; 9 | margin: ($padding * 4) ($padding * 2); 10 | } 11 | 12 | ul, li { 13 | list-style: none; 14 | padding: 0; 15 | margin: 0; 16 | } 17 | 18 | a { 19 | color: $fontColor; 20 | text-decoration: none; 21 | 22 | &:hover { 23 | color: $hyperlinkHover; 24 | } 25 | 26 | &:active { 27 | color: $hyperlinkActive; 28 | } 29 | 30 | &:visited { 31 | color: $hyperlinkVisited; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /styles/custom/variable.scss: -------------------------------------------------------------------------------- 1 | $padding: 20px; 2 | 3 | $fontColor: #A0A0A0; 4 | 5 | $menuSize: 14px; 6 | 7 | $bigIcon: 20px; 8 | $mediumIcon: 16px; 9 | 10 | $mainColor: #61B25A; 11 | $hyperlinkHover: rgba(255, 255, 255, .8); 12 | $hyperlinkActive: rgba(255, 255, 255, .4); 13 | $hyperlinkVisited: rgba(200, 200, 200, .4); 14 | 15 | $nav: #141414; 16 | $background: #1C1C1C; 17 | $backgroundSecondary: #262626; 18 | $backgroundSecondaryHover: rgba(89, 89, 89, 1); 19 | 20 | $border: 1px solid #3A3A3A; 21 | $darkBorder: 1px solid #3A3A3A; 22 | -------------------------------------------------------------------------------- /styles/index.scss: -------------------------------------------------------------------------------- 1 | @import 'custom/variable'; 2 | @import 'custom/main'; 3 | @import 'components/header'; 4 | @import 'components/dashboard'; 5 | @import 'components/browse'; 6 | @import 'components/list'; 7 | @import 'components/item'; 8 | @import 'components/infoList'; 9 | @import 'components/actions'; 10 | @import 'components/track'; 11 | @import 'components/playlistTrack'; 12 | @import 'components/player'; 13 | @import 'components/playlist'; 14 | @import 'components/loadingSpinner'; 15 | @import 'components/comment'; 16 | @import 'components/artworkAction'; 17 | @import 'components/trackActions'; 18 | @import 'components/buttonMore'; 19 | @import 'components/buttonInline'; 20 | @import 'components/buttonGhost'; 21 | @import 'components/buttonActive'; 22 | @import 'components/streamInteractions'; 23 | @import 'components/inputMenu'; 24 | @import 'components/volume'; 25 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; // eslint-disable-line no-unused-vars 2 | import chai, { expect } from 'chai'; 3 | import sinonChai from 'sinon-chai'; 4 | import chaiAsPromised from 'chai-as-promised'; 5 | import Enzyme from 'enzyme'; 6 | import Adapter from 'enzyme-adapter-react-16'; 7 | import { jsdom } from 'jsdom'; 8 | 9 | Enzyme.configure({ adapter: new Adapter() }); 10 | 11 | chai.use(sinonChai); 12 | chai.use(chaiAsPromised); 13 | 14 | global.document = jsdom(''); 15 | global.window = document.defaultView; 16 | global.navigator = { userAgent: 'browser' }; 17 | 18 | global.React = React; 19 | global.expect = expect; 20 | 21 | global.fdescribe = (...args) => describe.only(...args); 22 | global.fit = (...args) => it.only(...args); 23 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | 4 | module.exports = { 5 | entry: [ 6 | 'react-hot-loader/patch', 7 | 'webpack-dev-server/client?http://localhost:8080', 8 | 'webpack/hot/only-dev-server', 9 | path.resolve(__dirname, 'src', 'index.js') 10 | ], 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.(js|jsx)$/, 15 | exclude: /node_modules/, 16 | use: ['babel-loader', 'eslint-loader'] 17 | }, 18 | { 19 | test: /\.scss$/, 20 | use: ['style-loader', 'css-loader', 'sass-loader'] 21 | }, 22 | { 23 | test: /\.(jpe?g|png|gif|ico)$/, loader: 'file-loader?name=[name].[ext]' 24 | } 25 | ] 26 | }, 27 | resolve: { 28 | modules: [path.resolve(__dirname, 'node_modules')], 29 | extensions: ['*', '.js', '.jsx'] 30 | }, 31 | output: { 32 | path: path.resolve(__dirname, 'dist'), 33 | publicPath: '/', 34 | filename: 'bundle.js' 35 | }, 36 | devServer: { 37 | contentBase: path.resolve(__dirname, 'dist'), 38 | publicPath: '/', 39 | compress: true, 40 | hot: true, 41 | historyApiFallback: true 42 | }, 43 | plugins: [ 44 | new webpack.NamedModulesPlugin(), 45 | new webpack.HotModuleReplacementPlugin(), 46 | new webpack.ProvidePlugin({ 47 | fetch: 'imports-loader?this=>global!exports-loader?global.fetch!whatwg-fetch' 48 | }), 49 | new webpack.DefinePlugin({ 50 | 'process.env': { 51 | NODE_ENV: '"development"' 52 | } 53 | }), 54 | ], 55 | devtool: 'source-map' 56 | }; 57 | -------------------------------------------------------------------------------- /webpack.prod.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var path = require('path'); 3 | 4 | module.exports = { 5 | entry: { 6 | main: path.resolve(__dirname, 'src', 'index.js') 7 | }, 8 | resolve: { 9 | extensions: ['*', '.js', '.jsx'] 10 | }, 11 | output: { 12 | path: path.resolve(__dirname, 'dist'), 13 | publicPath: '/', 14 | filename: 'bundle.js' 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.(js|jsx)$/, 20 | exclude: /node_modules/, 21 | use: ['babel-loader', 'eslint-loader'] 22 | }, 23 | { 24 | test: /\.scss$/, 25 | use: ['style-loader', 'css-loader', 'sass-loader'] 26 | } 27 | ] 28 | }, 29 | plugins: [ 30 | new webpack.ProvidePlugin({ 31 | fetch: 'imports-loader?this=>global!exports-loader?global.fetch!whatwg-fetch' 32 | }), 33 | new webpack.optimize.UglifyJsPlugin({ 34 | compress: { 35 | warnings: false 36 | } 37 | }), 38 | new webpack.DefinePlugin({ 39 | 'process.env': { 40 | NODE_ENV: '"production"' 41 | } 42 | }) 43 | ] 44 | }; 45 | --------------------------------------------------------------------------------