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