├── .babelrc ├── .gitignore ├── .nvmrc ├── LICENSE ├── README.md ├── app ├── README.md ├── 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.html │ ├── 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 │ └── waveform.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 ├── dist ├── rollup │ ├── index.html │ └── waveform.js └── webpack │ ├── index.html │ └── waveform.js ├── package.json ├── rollup ├── development.js └── production.js ├── webpack-advanced ├── development.js └── production.js ├── webpack ├── development.js └── production.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/env", { "modules": false, "loose": true }], 4 | "@babel/preset-react" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .cache 3 | node_modules 4 | yarn-error.log 5 | stats.html 6 | 7 | dist/webpack/bundle* 8 | dist/parcel/* 9 | dist/rollup/bundle* 10 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 8.15.0 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 TehCookies 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Performance and usage comparison of Webpack 4, Parcel and Rollup bundlers. 4 | 5 | All bundlers were used for a big enough open-source [SoundCloud app](https://github.com/rwieruch/favesound-redux) implemented in React. The app was slightly tweaked so that it could work similarly with all the bundlers. 6 | 7 | This comparison doesn't pretend to be objective and was done for personal purpose only. I didn't try to setup the most optimized output results for each bundler. I used minimal setup for the comparison. If you feel that you can improve results please send PRs. 8 | 9 | ## Machine Specs 10 | 11 | | Attribute | Description | 12 | |-----------|-----------------------------------------| 13 | | Name | MacBook Pro (Retina, 15-inch, Mid 2015) | 14 | | Processor | 2,2 GHz Intel Core i7 | 15 | | Memory | 16 GB 1600 MHz DDR3 | 16 | | Graphics | Intel Iris Pro 1536 MB | 17 | | OS | macOS Mojave v10.14 | 18 | 19 | ## Bundle Size 20 | 21 | Here are the results for production JavaScript bundle size. 22 | 23 | | Bundler | Minified | Gzipped | 24 | |--------------------|----------|---------| 25 | | Webpack | 697 kB | 191 kB | 26 | | Webpack (advanced) | 697 kB | 191 kB | 27 | | Parcel | 687 kB | 180 kB | 28 | | Rollup | 461 kB | 138 kB | 29 | 30 | ## Development Build 31 | 32 | Here are the results for development build times. For Parcel there are two values for start since it has built-in cache. All the values is an average over 10 runs. 33 | 34 | | Bundler | Start | Reload | 35 | |--------------------|-------------------|---------| 36 | | Webpack | 4776 ms | 923 ms | 37 | | Webpack (advanced) | 3745 ms | 195 ms | 38 | | Parcel | 8409 ms (2459 ms) | 585 ms | 39 | | Rollup | 11570 ms | 3790 ms | 40 | 41 | ## Production Build 42 | 43 | Here are the results for production build times. For Parcel and Webpack there are two values since both have cache. Webpack has cache for [Terser Plugin](https://github.com/webpack-contrib/terser-webpack-plugin). All the values is an average over 10 runs. 44 | 45 | | Bundler | Time | 46 | |--------------------|--------------------| 47 | | Webpack | 15991 ms (3555 ms) | 48 | | Webpack (advanced) | 16089 ms (3617 ms) | 49 | | Parcel | 12098 ms (1301 ms) | 50 | | Rollup | 16200 ms | 51 | 52 | ## Usage Notes 53 | 54 | All of the bundlers require Babel to build JavaScript properly. Here are the common packages for all the bundlers: 55 | 56 | ``` 57 | @babel/core 58 | @babel/register 59 | @babel/preset-react 60 | @babel/preset-env 61 | node-sass 62 | ``` 63 | 64 | ### Webpack 65 | 66 | Webpack is probably the most solid option for app development. Especially when v4 is out. It cuts a good chunk of boilerplate from config comparing to v3. 67 | 68 | Webpack doesn't require much dependencies. You'll mostly need plugins and loaders installed additionally. Last time Webpack documentation was improved a lot, so it's easy to achieve what you need. 69 | 70 | While the configuration part is confusing at first, it's actually becomes a lot easier when you are familiar with the main concepts. 71 | 72 | There are plugins for literally everything you need. It's much more flexible than Parcel and it's less complicated to setup than Rollup. 73 | 74 | ##### Dependencies: 75 | 76 | ``` 77 | webpack 78 | webpack-cli 79 | webpack-dev-server 80 | babel-loader 81 | style-loader 82 | css-loader 83 | sass-loader 84 | ``` 85 | 86 | Advanced configuration also requires 87 | 88 | ``` 89 | cache-loader 90 | thread-loader 91 | ``` 92 | 93 | ### Parcel 94 | 95 | This is probably the easiest one to setup. Only one dependency was required to bundle the app. And zero config for both development and production. 96 | 97 | It also has a nice cache feature built-in. So for the subsequent runs it bundles faster than for the cold run. Though sometimes it's buggy and you need to clear cache in order to get proper build result. 98 | 99 | While it sounds very cool, in reality Parcel may be very limiting in some usage scenarios. For example, there is no control over hierarchy of output files. When you need to accomplish something specific, there may be no way to do so. It also seems to be less reliable than the others since it's quite new comparing to the others. 100 | 101 | ##### Dependencies: 102 | 103 | ``` 104 | parcel-bundler 105 | ``` 106 | 107 | ### Rollup 108 | 109 | It was really painful to setup Rollup for this particular app. It required tons of plugins to be installed in order to achieve the same result as the other bundlers. I didn't figure out how to bundle Soundcloud's libraries properly with Rollup, so I had to move them out of the bundling pipeline. 110 | 111 | Overall it's just too complicated. It requires a very careful setup. It may be a good thing for experienced developers though, since it's a very minimalistic tool, it doesn't bloat your bundle without a reason. 112 | 113 | There is a rule of thumb that Rollup should be used for libraries. I would agree, but it has a potential to become a good choice for apps too, if it would have sane defaults as Webpack has. It also should be more clear how to deal with CommonJS modules, because for the first-time users it's very confusing. 114 | 115 | ##### Dependencies: 116 | 117 | ``` 118 | rollup 119 | rollup-plugin-babel 120 | rollup-plugin-commonjs 121 | rollup-plugin-json 122 | rollup-plugin-livereload 123 | rollup-plugin-node-resolve 124 | rollup-plugin-progress 125 | rollup-plugin-re 126 | rollup-plugin-replace 127 | rollup-plugin-scss 128 | rollup-plugin-serve 129 | rollup-plugin-uglify 130 | rollup-plugin-visualizer 131 | ``` 132 | 133 | ## Conclusion 134 | 135 | - Use Webpack 4 by default. It's flexible and user-friendly enough for app development. There is a learning curve, but once you get it, it's not very complicated to use. The documentation became a lot better last time and the community is very big. After getting familiar with core concepts you can get a much smoother and snappier work flow than with other bundlers. 136 | - Use Parcel for simple scenarios. It's easy to setup and very fast. It's also a good option for beginners. Don't use it if you wish to do customizations and tweaks for your builds, in the long term it may cause problems. The documentation may lack some of the important details and it's a pain to fix little quirks. It's also still immature, so expect to face with bugs. 137 | - Use Rollup for library development and if bundle size is something very critical for you. The developer's experience is not the best here and you need to understand the tradeoffs for a small bundle size and minimalistic philosophy behind it. It's a solid tool though. The documentation would be better if it could provide more real-life examples of usage for common scenarios. 138 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | }); -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | }); -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | }); -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | }); -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | }); -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | }); -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | }); -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | }); -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | }); -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | }); -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | }); -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/src/components/WaveformSc/index.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import { normalizeSamples } from '../../services/track'; 4 | 5 | const WAVE_COLOR = '#61B25A'; 6 | 7 | class WaveformSc extends React.Component { 8 | 9 | componentDidMount() { 10 | const { activity } = this.props; 11 | 12 | if (!activity) { return; } 13 | 14 | const { waveform_url } = activity; 15 | 16 | if (!waveform_url) { return; } 17 | 18 | const waveformUrlJson = waveform_url.replace('.png', '.json'); 19 | 20 | this.fetchJsonWaveform(this.waveformCanvas, waveformUrlJson); 21 | 22 | // Png version will cause errors. 23 | // if (isPngWaveform(waveform_url)) { 24 | // this.fetchPngWaveform(elementId, activity); 25 | // } 26 | } 27 | 28 | fetchJsonWaveform(waveformCanvas, waveformUrl) { 29 | fetch(waveformUrl) 30 | .then(response => response.json()) 31 | .then((data) => { 32 | new Waveform({ 33 | container: waveformCanvas, 34 | innerColor: WAVE_COLOR, 35 | data: normalizeSamples(data.samples) 36 | }); 37 | }); 38 | } 39 | // Seems like SoundCloud has switched to json instead of png. 40 | // fetchPngWaveform(elementId, activity) { 41 | // const waveform = new Waveform({ 42 | // container: document.getElementById(elementId), 43 | // innerColor: WAVE_COLOR 44 | // }); 45 | // waveform.dataFromSoundCloudTrack(activity); 46 | // } 47 | 48 | render() { 49 | return
    { this.waveformCanvas = waveform; }} />; 50 | } 51 | 52 | } 53 | 54 | WaveformSc.propTypes = { 55 | activity: PropTypes.object, 56 | }; 57 | 58 | export default WaveformSc; 59 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/src/constants/genre.js: -------------------------------------------------------------------------------- 1 | export const GENRES = ['Tech House', 'Minimal', 'Deep House', 'Techno', 'Afterhour']; 2 | export const DEFAULT_GENRE = GENRES[0]; 3 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/src/constants/pathnames.js: -------------------------------------------------------------------------------- 1 | export const dashboard = '/dashboard'; 2 | export const browse = '/browse'; 3 | export const callback = '/callback'; 4 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | FaveSound 5 | 6 | 7 | 8 | 9 |
    10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | 5 | import configureStore from './stores/configureStore'; 6 | import App from './components/App'; 7 | 8 | import '../styles/index.scss'; 9 | 10 | const store = configureStore(); 11 | 12 | function render(Component) { 13 | ReactDOM.render( 14 | 15 | 16 | , 17 | document.getElementById('app'), 18 | ); 19 | } 20 | 21 | render(App); 22 | 23 | if (module.hot) { 24 | module.hot.accept('./components/App', () => { 25 | // eslint-disable-next-line 26 | const NextApp = require('./components/App').default; 27 | render(NextApp); 28 | }); 29 | } 30 | 31 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/src/schemas/user.js: -------------------------------------------------------------------------------- 1 | import { Schema } from 'normalizr'; 2 | 3 | const userSchema = new Schema('users'); 4 | 5 | export default userSchema; 6 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/src/services/map.js: -------------------------------------------------------------------------------- 1 | import map from 'lodash/fp/map'; 2 | 3 | export default map.convert({ cap: false }); 4 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/src/stores/configureStore.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | import rootReducer from '../reducers/index'; 4 | 5 | const createStoreWithMiddleware = applyMiddleware(thunk)(createStore); 6 | 7 | export default function configureStore(initialState) { 8 | const store = createStoreWithMiddleware( 9 | rootReducer, 10 | initialState, 11 | window.devToolsExtension && window.devToolsExtension() 12 | ); 13 | 14 | if (process.env.NODE_ENV !== 'production' && module.hot) { 15 | module.hot.accept('../reducers', () => { 16 | // eslint-disable-next-line 17 | const nextReducer = require('../reducers').default; 18 | store.replaceReducer(nextReducer); 19 | }); 20 | } 21 | return store; 22 | } 23 | -------------------------------------------------------------------------------- /app/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 | } -------------------------------------------------------------------------------- /app/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 | } -------------------------------------------------------------------------------- /app/styles/components/browse.scss: -------------------------------------------------------------------------------- 1 | .browse { 2 | 3 | } -------------------------------------------------------------------------------- /app/styles/components/buttonActive.scss: -------------------------------------------------------------------------------- 1 | .button-active { 2 | &-selected, &:hover { 3 | border-bottom: 1px solid $mainColor; 4 | } 5 | } -------------------------------------------------------------------------------- /app/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 | } -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/styles/components/buttonMore.scss: -------------------------------------------------------------------------------- 1 | .button-more { 2 | margin: ($padding / 2) auto; 3 | text-align: center; 4 | } -------------------------------------------------------------------------------- /app/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 | } -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | } -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | } -------------------------------------------------------------------------------- /app/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 | } -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | } -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /dist/rollup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | FaveSound 5 | 6 | 7 | 8 | 9 | 10 |
    11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /dist/webpack/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | FaveSound 5 | 6 | 7 | 8 | 9 |
    10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bundlers-comparison", 3 | "version": "1.0.0", 4 | "description": "Performance and usage comparison of Webpack 4, Parcel and Rollup bundlers.", 5 | "main": "app/src/index.js", 6 | "repository": "https://github.com/tehcookies/bundlers-comparison.git", 7 | "author": "TehCookies ", 8 | "license": "MIT", 9 | "scripts": { 10 | "webpack:serve": "webpack-dev-server --colors --config ./webpack/development.js", 11 | "webpack:build": "NODE_ENV=production webpack -p --colors --config ./webpack/production.js", 12 | "webpack-advanced:serve": "webpack-dev-server --colors --config ./webpack-advanced/development.js", 13 | "webpack-advanced:build": "NODE_ENV=production webpack -p --colors --config ./webpack-advanced/production.js", 14 | "parcel:serve": "parcel serve app/src/index.html --open --out-dir ./dist/parcel", 15 | "parcel:build": "NODE_ENV=production parcel build app/src/index.html --out-dir ./dist/parcel --public-url ./", 16 | "rollup:serve": "rollup --watch --config ./rollup/development.js", 17 | "rollup:build": "NODE_ENV=production rollup --config ./rollup/production.js", 18 | "clear": "rimraf .cache && rimraf node_modules/.cache && rimraf dist/**/bundle* && rimraf dist/parcel/*" 19 | }, 20 | "devDependencies": { 21 | "@babel/core": "7.2.2", 22 | "@babel/preset-env": "7.2.0", 23 | "@babel/preset-react": "7.0.0", 24 | "@babel/register": "7.0.0", 25 | "babel-loader": "8.0.4", 26 | "cache-loader": "^2.0.1", 27 | "css-loader": "2.0.1", 28 | "exports-loader": "0.7.0", 29 | "imports-loader": "0.8.0", 30 | "node-sass": "4.11.0", 31 | "parcel-bundler": "1.10.3", 32 | "rimraf": "2.6.2", 33 | "rollup": "0.67.4", 34 | "rollup-plugin-babel": "4.1.0", 35 | "rollup-plugin-commonjs": "9.2.0", 36 | "rollup-plugin-json": "3.1.0", 37 | "rollup-plugin-livereload": "0.6.0", 38 | "rollup-plugin-node-resolve": "4.0.0", 39 | "rollup-plugin-progress": "0.4.0", 40 | "rollup-plugin-re": "1.0.7", 41 | "rollup-plugin-replace": "2.1.0", 42 | "rollup-plugin-scss": "0.4.0", 43 | "rollup-plugin-serve": "0.6.0", 44 | "rollup-plugin-uglify": "6.0.0", 45 | "rollup-plugin-visualizer": "0.9.2", 46 | "sass-loader": "7.1.0", 47 | "style-loader": "0.23.1", 48 | "thread-loader": "^2.1.2", 49 | "webpack": "4.27.1", 50 | "webpack-cli": "3.1.2", 51 | "webpack-dev-server": "3.1.10" 52 | }, 53 | "dependencies": { 54 | "classnames": "2.2.5", 55 | "js-cookie": "2.1.0", 56 | "lodash": "4.17.4", 57 | "moment": "2.18.1", 58 | "normalizr": "2.0.0", 59 | "prop-types": "15.5.10", 60 | "react": "16.0.0", 61 | "react-clipboard.js": "1.1.2", 62 | "react-dom": "16.0.0", 63 | "react-rangeslider": "2.1.0", 64 | "react-redux": "5.0.6", 65 | "react-router": "4.1.1", 66 | "react-router-dom": "4.1.1", 67 | "react-tooltip": "3.3.0", 68 | "redux": "3.7.2", 69 | "redux-logger": "3.0.6", 70 | "redux-thunk": "2.2.0", 71 | "whatwg-fetch": "2.0.3" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /rollup/development.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import resolve from 'rollup-plugin-node-resolve'; 3 | import commonjs from 'rollup-plugin-commonjs'; 4 | import progress from 'rollup-plugin-progress'; 5 | import visualizer from 'rollup-plugin-visualizer'; 6 | import babel from 'rollup-plugin-babel'; 7 | import serve from 'rollup-plugin-serve'; 8 | import livereload from 'rollup-plugin-livereload'; 9 | import json from 'rollup-plugin-json'; 10 | import scss from 'rollup-plugin-scss'; 11 | import replace from 'rollup-plugin-replace'; 12 | import re from 'rollup-plugin-re'; 13 | 14 | const root = path.resolve(__dirname, '..'); 15 | 16 | export default { 17 | input: path.resolve(root, 'app', 'src', 'index.js'), 18 | output: { 19 | file: path.resolve(root, 'dist', 'rollup', 'bundle.js'), 20 | format: 'iife', 21 | sourcemap: true 22 | }, 23 | plugins: [ 24 | progress(), 25 | json(), 26 | scss({ 27 | output: path.resolve(root, 'dist', 'rollup', 'bundle.css'), 28 | }), 29 | resolve({ 30 | browser: true, 31 | preferBuiltins: true 32 | }), 33 | babel({ 34 | exclude: 'node_modules/**' 35 | }), 36 | // https://github.com/rollup/rollup-plugin-commonjs/issues/166#issuecomment-328853157 37 | re({ 38 | patterns: [ 39 | { 40 | match: /formidable(\/|\\)lib/, 41 | test: 'if (global.GENTLY) require = GENTLY.hijack(require);', 42 | replace: '', 43 | } 44 | ] 45 | }), 46 | replace({ 47 | 'process.env.NODE_ENV': JSON.stringify('development'), 48 | 'module.hot': false 49 | }), 50 | commonjs({ 51 | include: 'node_modules/**', 52 | namedExports: { 53 | 'node_modules/lodash/lodash.js': ['find', 'orderBy'], 54 | 'node_modules/react/index.js': ['Children', 'Component', 'PropTypes', 'createElement'], 55 | 'node_modules/react-dom/index.js': ['render'] 56 | } 57 | }), 58 | visualizer(), 59 | serve({ 60 | contentBase: path.resolve(root, 'dist', 'rollup'), 61 | historyApiFallback: true, 62 | open: true 63 | }), 64 | livereload() 65 | ] 66 | }; 67 | 68 | -------------------------------------------------------------------------------- /rollup/production.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { uglify } from 'rollup-plugin-uglify'; 3 | import resolve from 'rollup-plugin-node-resolve'; 4 | import commonjs from 'rollup-plugin-commonjs'; 5 | import progress from 'rollup-plugin-progress'; 6 | import visualizer from 'rollup-plugin-visualizer'; 7 | import babel from 'rollup-plugin-babel'; 8 | import json from 'rollup-plugin-json'; 9 | import scss from 'rollup-plugin-scss'; 10 | import replace from 'rollup-plugin-replace'; 11 | import re from 'rollup-plugin-re'; 12 | 13 | const root = path.resolve(__dirname, '..'); 14 | 15 | export default { 16 | input: path.resolve(root, 'app', 'src', 'index.js'), 17 | output: { 18 | file: path.resolve(root, 'dist', 'rollup', 'bundle.js'), 19 | format: 'iife', 20 | sourcemap: true 21 | }, 22 | plugins: [ 23 | progress(), 24 | json(), 25 | scss({ 26 | output: path.resolve(root, 'dist', 'rollup', 'bundle.css'), 27 | }), 28 | resolve({ 29 | browser: true, 30 | preferBuiltins: true 31 | }), 32 | babel({ 33 | exclude: 'node_modules/**' 34 | }), 35 | // https://github.com/rollup/rollup-plugin-commonjs/issues/166#issuecomment-328853157 36 | re({ 37 | patterns: [ 38 | { 39 | match: /formidable(\/|\\)lib/, 40 | test: 'if (global.GENTLY) require = GENTLY.hijack(require);', 41 | replace: '', 42 | } 43 | ] 44 | }), 45 | replace({ 46 | 'process.env.NODE_ENV': JSON.stringify('production'), 47 | 'module.hot': false 48 | }), 49 | commonjs({ 50 | include: 'node_modules/**', 51 | namedExports: { 52 | 'node_modules/lodash/lodash.js': ['find', 'orderBy'], 53 | 'node_modules/react/index.js': ['Children', 'Component', 'PropTypes', 'createElement'], 54 | 'node_modules/react-dom/index.js': ['render'] 55 | } 56 | }), 57 | uglify(), 58 | visualizer(), 59 | ] 60 | }; 61 | 62 | -------------------------------------------------------------------------------- /webpack-advanced/development.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const root = path.resolve(__dirname, '..'); 4 | 5 | const cacheDir = path.resolve(__dirname, '..', 'node_modules', '.cache'); 6 | 7 | const getThreadLoader = name => ({ 8 | loader: 'thread-loader', 9 | options: { 10 | workerParallelJobs: 50, 11 | poolRespawn: false, 12 | name 13 | } 14 | }); 15 | 16 | module.exports = { 17 | mode: 'development', 18 | context: root, 19 | entry: path.resolve(root, 'app', 'src', 'index.js'), 20 | output: { 21 | path: path.resolve(root, 'dist', 'webpack'), 22 | publicPath: '/', 23 | filename: 'bundle.js' 24 | }, 25 | module: { 26 | rules: [ 27 | { 28 | test: /\.(js|jsx)$/, 29 | exclude: /node_modules/, 30 | use: [ 31 | { 32 | loader: 'cache-loader', 33 | options: { 34 | cacheDirectory: path.resolve(cacheDir, 'js') 35 | } 36 | }, 37 | getThreadLoader('js'), 38 | { 39 | loader: 'babel-loader', 40 | options: { 41 | cacheDirectory: path.resolve(cacheDir, 'babel') 42 | } 43 | } 44 | ] 45 | }, 46 | { 47 | test: /\.scss$/, 48 | use: [ 49 | { 50 | loader: 'cache-loader', 51 | options: { 52 | cacheDirectory: path.resolve(cacheDir, 'css') 53 | } 54 | }, 55 | getThreadLoader('css'), 56 | 'style-loader', 57 | 'css-loader', 58 | 'sass-loader' 59 | ] 60 | } 61 | ] 62 | }, 63 | plugins: [ 64 | new webpack.HotModuleReplacementPlugin(), 65 | new webpack.ProvidePlugin({ 66 | fetch: 'imports-loader?this=>global!exports-loader?global.fetch!whatwg-fetch' 67 | }) 68 | ], 69 | devServer: { 70 | contentBase: path.resolve(root, 'dist', 'webpack'), 71 | publicPath: '/', 72 | compress: true, 73 | hot: true, 74 | historyApiFallback: true, 75 | open: true 76 | }, 77 | devtool: process.env.npm_config_sourcemaps ? 'inline-source-map' : 'inline-eval', 78 | }; 79 | -------------------------------------------------------------------------------- /webpack-advanced/production.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const root = path.resolve(__dirname, '..'); 4 | 5 | module.exports = { 6 | mode: 'production', 7 | context: root, 8 | entry: path.resolve(root, 'app', 'src', 'index.js'), 9 | output: { 10 | path: path.resolve(root, 'dist', 'webpack'), 11 | publicPath: '/', 12 | filename: 'bundle.js' 13 | }, 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.(js|jsx)$/, 18 | exclude: /node_modules/, 19 | use: ['thread-loader', 'babel-loader'] 20 | }, 21 | { 22 | test: /\.scss$/, 23 | use: ['thread-loader', 'style-loader', 'css-loader', 'sass-loader'] 24 | } 25 | ] 26 | }, 27 | plugins: [ 28 | new webpack.ProvidePlugin({ 29 | fetch: 'imports-loader?this=>global!exports-loader?global.fetch!whatwg-fetch' 30 | }) 31 | ] 32 | }; 33 | -------------------------------------------------------------------------------- /webpack/development.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const root = path.resolve(__dirname, '..'); 4 | 5 | module.exports = { 6 | mode: 'development', 7 | context: root, 8 | entry: path.resolve(root, 'app', 'src', 'index.js'), 9 | output: { 10 | path: path.resolve(root, 'dist', 'webpack'), 11 | publicPath: '/', 12 | filename: 'bundle.js' 13 | }, 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.(js|jsx)$/, 18 | exclude: /node_modules/, 19 | use: 'babel-loader' 20 | }, 21 | { 22 | test: /\.scss$/, 23 | use: ['style-loader', 'css-loader', 'sass-loader'] 24 | } 25 | ] 26 | }, 27 | plugins: [ 28 | new webpack.HotModuleReplacementPlugin(), 29 | new webpack.ProvidePlugin({ 30 | fetch: 'imports-loader?this=>global!exports-loader?global.fetch!whatwg-fetch' 31 | }) 32 | ], 33 | devServer: { 34 | contentBase: path.resolve(root, 'dist', 'webpack'), 35 | publicPath: '/', 36 | compress: true, 37 | hot: true, 38 | historyApiFallback: true, 39 | open: true 40 | }, 41 | devtool: 'source-map' 42 | }; 43 | -------------------------------------------------------------------------------- /webpack/production.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const root = path.resolve(__dirname, '..'); 4 | 5 | module.exports = { 6 | mode: 'production', 7 | context: root, 8 | entry: path.resolve(root, 'app', 'src', 'index.js'), 9 | output: { 10 | path: path.resolve(root, 'dist', 'webpack'), 11 | publicPath: '/', 12 | filename: 'bundle.js' 13 | }, 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.(js|jsx)$/, 18 | exclude: /node_modules/, 19 | use: 'babel-loader' 20 | }, 21 | { 22 | test: /\.scss$/, 23 | use: ['style-loader', 'css-loader', 'sass-loader'] 24 | } 25 | ] 26 | }, 27 | plugins: [ 28 | new webpack.ProvidePlugin({ 29 | fetch: 'imports-loader?this=>global!exports-loader?global.fetch!whatwg-fetch' 30 | }) 31 | ] 32 | }; 33 | --------------------------------------------------------------------------------