├── .gitignore ├── client ├── styles │ ├── main.scss │ └── custom │ │ ├── components │ │ ├── a.scss │ │ ├── container.scss │ │ ├── songs-body.scss │ │ ├── reset.scss │ │ ├── song.scss │ │ ├── user.scss │ │ ├── row.scss │ │ ├── sidebar.scss │ │ ├── nav-search.scss │ │ ├── nav-user.scss │ │ ├── stats.scss │ │ ├── heart.scss │ │ ├── switch.scss │ │ ├── slider.scss │ │ ├── artwork-play.scss │ │ ├── user-following.scss │ │ ├── popover.scss │ │ ├── loader.scss │ │ ├── nav.scss │ │ ├── toggle-play-button.scss │ │ ├── song-comment.scss │ │ ├── button.scss │ │ ├── nav-playlists.scss │ │ ├── nav-session.scss │ │ ├── song-main.scss │ │ ├── user-main.scss │ │ ├── song-list.scss │ │ ├── waveform.scss │ │ ├── history.scss │ │ ├── songs-body-card.scss │ │ ├── player.scss │ │ └── songs-header.scss │ │ ├── mixins.scss │ │ ├── variables.scss │ │ └── custom.scss ├── public │ ├── favicon.ico │ ├── fonts │ │ ├── ionicons.eot │ │ ├── ionicons.ttf │ │ └── ionicons.woff │ ├── api │ │ └── callback │ │ │ └── index.html │ └── index.html ├── src │ ├── constants │ │ ├── EnvironmentConstants.js │ │ ├── ImageConstants.js │ │ ├── RouterConstants.js │ │ ├── Schemas.js │ │ ├── SongConstants.js │ │ ├── ApiConstants.js │ │ ├── PlaylistConstants.js │ │ └── ActionTypes.js │ ├── .babelrc │ ├── .eslintrc │ ├── actions │ │ ├── HistoryActions.js │ │ ├── EnvironmentActions.js │ │ ├── RouterActions.js │ │ ├── SongActions.js │ │ ├── UserActions.js │ │ ├── PlayerActions.js │ │ └── PlaylistActions.js │ ├── utils │ │ ├── SongUtils.js │ │ ├── PlayerUtils.js │ │ ├── DomUtils.js │ │ ├── ImageUtils.js │ │ ├── NumberUtils.js │ │ ├── ApiUtils.js │ │ ├── UserUtils.js │ │ ├── RouterUtils.js │ │ ├── ScrollUtils.js │ │ └── PlaylistUtils.js │ ├── components │ │ ├── SessionPopoverPanel.jsx │ │ ├── LoginPopoverPanel.jsx │ │ ├── Router.jsx │ │ ├── HeartCount.jsx │ │ ├── SidebarBody.jsx │ │ ├── WaveformEvents.jsx │ │ ├── Switch.jsx │ │ ├── Loader.jsx │ │ ├── UserFollowButton.jsx │ │ ├── UserFollowings.jsx │ │ ├── SongsBodyCardMobileEvents.jsx │ │ ├── SongComment.jsx │ │ ├── stickyOnScroll.jsx │ │ ├── SongsHeaderTimes.jsx │ │ ├── InfiniteScroll.jsx │ │ ├── Root.jsx │ │ ├── NavPlaylists.jsx │ │ ├── PopoverPanel.jsx │ │ ├── Popover.jsx │ │ ├── NavPlaylistsItem.jsx │ │ ├── Link.jsx │ │ ├── ArtworkPlay.jsx │ │ ├── NavUser.jsx │ │ ├── SongComments.jsx │ │ ├── Stats.jsx │ │ ├── UserFollowing.jsx │ │ ├── NavSearch.jsx │ │ ├── SongList.jsx │ │ ├── SongsHeader.jsx │ │ ├── Heart.jsx │ │ ├── SongsHeaderGenres.jsx │ │ ├── NavStream.jsx │ │ ├── HistorySong.jsx │ │ ├── History.jsx │ │ ├── NavSession.jsx │ │ ├── SongsBodyRendered.jsx │ │ ├── UserMain.jsx │ │ ├── Waveform.jsx │ │ ├── Slider.jsx │ │ ├── Nav.jsx │ │ ├── SongsBody.jsx │ │ ├── SongMain.jsx │ │ ├── SongsBodyCard.jsx │ │ ├── SongListItem.jsx │ │ ├── User.jsx │ │ ├── Song.jsx │ │ ├── Songs.jsx │ │ ├── audio.jsx │ │ └── Player.jsx │ ├── reducers │ │ ├── environment.js │ │ ├── router.js │ │ ├── entities.js │ │ ├── index.js │ │ ├── history.js │ │ ├── session.js │ │ ├── player.js │ │ └── playlists.js │ ├── store │ │ └── configureStore.js │ ├── selectors │ │ ├── HistorySelectors.js │ │ ├── SongsSelectors.js │ │ ├── NavSelectors.js │ │ ├── PlayerSelectors.js │ │ ├── SongSelectors.js │ │ ├── UserSelectors.js │ │ └── CommonSelectors.js │ ├── index.jsx │ └── containers │ │ ├── HistoryContainer.jsx │ │ ├── NavContainer.jsx │ │ ├── RootContainer.jsx │ │ ├── SongContainer.jsx │ │ ├── UserContainer.jsx │ │ ├── SongsContainer.jsx │ │ └── PlayerContainer.jsx ├── webpack.dev.config.js └── webpack.prod.config.js ├── README.md └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist 3 | npm-debug.log 4 | node_modules 5 | -------------------------------------------------------------------------------- /client/styles/main.scss: -------------------------------------------------------------------------------- 1 | @import 'custom/custom'; 2 | @import 'ionicons/ionicons'; 3 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewngu/sound-redux/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /client/src/constants/EnvironmentConstants.js: -------------------------------------------------------------------------------- 1 | export const MOBILE_WIDTH = 320; 2 | export const TABLET_WIDTH = 736; 3 | -------------------------------------------------------------------------------- /client/public/fonts/ionicons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewngu/sound-redux/HEAD/client/public/fonts/ionicons.eot -------------------------------------------------------------------------------- /client/public/fonts/ionicons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewngu/sound-redux/HEAD/client/public/fonts/ionicons.ttf -------------------------------------------------------------------------------- /client/public/fonts/ionicons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewngu/sound-redux/HEAD/client/public/fonts/ionicons.woff -------------------------------------------------------------------------------- /client/src/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["es2015", { "modules": false }], "react", "stage-2"], 3 | "plugins": ["react-hot-loader/babel"] 4 | } 5 | -------------------------------------------------------------------------------- /client/src/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "rules": { 4 | "jsx-a11y/media-has-caption": 0, 5 | "react/no-danger": 0 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /client/src/constants/ImageConstants.js: -------------------------------------------------------------------------------- 1 | const IMAGE_SIZES = { 2 | LARGE: 't300x300', 3 | XLARGE: 't500x500', 4 | }; 5 | 6 | export default IMAGE_SIZES; 7 | -------------------------------------------------------------------------------- /client/styles/custom/components/a.scss: -------------------------------------------------------------------------------- 1 | a { 2 | color: $blue; 3 | text-decoration: none; 4 | 5 | &:hover { 6 | text-decoration: underline; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /client/styles/custom/components/container.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 1170px; 3 | margin: 0 auto; 4 | 5 | @include mobile { 6 | width: 100%; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /client/src/actions/HistoryActions.js: -------------------------------------------------------------------------------- 1 | import * as types from '../constants/ActionTypes'; 2 | 3 | const toggleShowHistory = () => ({ 4 | type: types.TOGGLE_SHOW_HISTORY, 5 | }); 6 | 7 | export default toggleShowHistory; 8 | -------------------------------------------------------------------------------- /client/styles/custom/components/songs-body.scss: -------------------------------------------------------------------------------- 1 | .songs-body { 2 | margin: 40px 0 80px 0; 3 | 4 | @include mobile { 5 | margin: 0; 6 | } 7 | } 8 | 9 | .songs-body__padder { 10 | visibility: hidden; 11 | } 12 | -------------------------------------------------------------------------------- /client/styles/custom/mixins.scss: -------------------------------------------------------------------------------- 1 | $mobileWidth: 320px; 2 | $tabletWidth: 736px; 3 | 4 | @mixin mobile { 5 | @media only screen and (min-width: #{$mobileWidth}) and (max-width: #{$tabletWidth - 1px}) { 6 | @content; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /client/src/utils/SongUtils.js: -------------------------------------------------------------------------------- 1 | const formatSongTitle = (s) => { 2 | if (!s) { 3 | return ''; 4 | } 5 | 6 | const arr = s.replace('–', '-').split(' - '); 7 | 8 | return arr[arr.length - 1].split(' (')[0]; 9 | }; 10 | 11 | export default formatSongTitle; 12 | -------------------------------------------------------------------------------- /client/src/utils/PlayerUtils.js: -------------------------------------------------------------------------------- 1 | const volumeClassName = (volume) => { 2 | if (volume > 0.8) { 3 | return 'ion-android-volume-up'; 4 | } else if (volume > 0) { 5 | return 'ion-android-volume-down'; 6 | } 7 | 8 | return ''; 9 | }; 10 | 11 | export default volumeClassName; 12 | -------------------------------------------------------------------------------- /client/styles/custom/components/reset.scss: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | body { 7 | width: 100%; 8 | height: 100%; 9 | background-color: #f4f4f4; 10 | font-family: $fontFamily; 11 | } 12 | 13 | *, *:after, *:before { 14 | box-sizing: border-box; 15 | } 16 | -------------------------------------------------------------------------------- /client/src/utils/DomUtils.js: -------------------------------------------------------------------------------- 1 | const offsetLeft = (element) => { 2 | let el = element; 3 | let x = el.offsetLeft; 4 | 5 | while (el.offsetParent) { 6 | x += el.offsetParent.offsetLeft; 7 | el = el.offsetParent; 8 | } 9 | 10 | return x; 11 | }; 12 | 13 | export default offsetLeft; 14 | -------------------------------------------------------------------------------- /client/public/api/callback/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SoundRedux 6 | 7 | 8 |

9 | This page should close soon 10 |

11 | 12 | 13 | -------------------------------------------------------------------------------- /client/styles/custom/components/song.scss: -------------------------------------------------------------------------------- 1 | .song { 2 | display: flex; 3 | flex-direction: row; 4 | margin: 40px 0 80px 0; 5 | } 6 | 7 | .song__main { 8 | width: 800px; 9 | } 10 | 11 | .song__song-list { 12 | margin-top: 20px; 13 | } 14 | 15 | .song__sidebar { 16 | width: 350px; 17 | margin-left: 20px; 18 | } 19 | -------------------------------------------------------------------------------- /client/styles/custom/components/user.scss: -------------------------------------------------------------------------------- 1 | .user { 2 | display: flex; 3 | flex-direction: row; 4 | margin: 40px 0 80px 0; 5 | } 6 | 7 | .user__main { 8 | width: 800px; 9 | } 10 | 11 | .user__song-list { 12 | margin-top: 20px; 13 | } 14 | 15 | .user__sidebar { 16 | width: 350px; 17 | margin-left: 20px; 18 | } 19 | -------------------------------------------------------------------------------- /client/src/constants/RouterConstants.js: -------------------------------------------------------------------------------- 1 | export const INDEX_PATH = ''; 2 | export const LIKES_PATH = 'me/likes'; 3 | export const PLAYLIST_PATH = 'playlists/:id'; 4 | export const SONG_PATH = 'songs/:id'; 5 | export const SONGS_PATH = 'songs'; 6 | export const STREAM_PATH = 'me/stream'; 7 | export const USER_PATH = 'users/:id'; 8 | 9 | export const INITIAL_ROUTE = { 10 | keys: {}, 11 | options: {}, 12 | path: '', 13 | }; 14 | -------------------------------------------------------------------------------- /client/src/constants/Schemas.js: -------------------------------------------------------------------------------- 1 | import { schema } from 'normalizr'; 2 | 3 | const song = new schema.Entity('songs'); 4 | const user = new schema.Entity('users'); 5 | const playlist = new schema.Entity('playlists'); 6 | 7 | song.define({ 8 | user, 9 | }); 10 | 11 | playlist.define({ 12 | tracks: [song], 13 | }); 14 | 15 | export const songSchema = song; 16 | export const playlistSchema = playlist; 17 | export const userSchema = user; 18 | -------------------------------------------------------------------------------- /client/styles/custom/components/row.scss: -------------------------------------------------------------------------------- 1 | .row { 2 | display: flex; 3 | flex-direction: row; 4 | 5 | @include mobile { 6 | display: block; 7 | }; 8 | 9 | & + .row { 10 | margin-top: 20px; 11 | 12 | @include mobile { 13 | margin-top: 0; 14 | } 15 | } 16 | } 17 | 18 | .row__cell { 19 | & + .row__cell { 20 | margin-left: 20px; 21 | 22 | @include mobile { 23 | margin: 0; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /client/src/actions/EnvironmentActions.js: -------------------------------------------------------------------------------- 1 | /* global window */ 2 | import * as types from '../constants/ActionTypes'; 3 | 4 | export const windowResize = (height, width) => ({ 5 | type: types.WINDOW_RESIZE, 6 | height, 7 | width, 8 | }); 9 | 10 | export const initEnvironment = () => (dispatch) => { 11 | dispatch(windowResize(window.innerHeight, window.innerWidth)); 12 | 13 | window.onresize = () => { 14 | dispatch(windowResize(window.innerHeight, window.innerWidth)); 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /client/src/components/SessionPopoverPanel.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | 4 | const propTypes = { 5 | logout: PropTypes.func.isRequired, 6 | }; 7 | 8 | const SessionPopoverPanel = ({ logout }) => ( 9 |
15 | Logout 16 |
17 | ); 18 | 19 | SessionPopoverPanel.propTypes = propTypes; 20 | 21 | export default SessionPopoverPanel; 22 | -------------------------------------------------------------------------------- /client/src/reducers/environment.js: -------------------------------------------------------------------------------- 1 | import * as types from '../constants/ActionTypes'; 2 | 3 | const initialState = { 4 | height: 0, 5 | width: 0, 6 | }; 7 | 8 | const environment = (state = initialState, action) => { 9 | switch (action.type) { 10 | case types.WINDOW_RESIZE: 11 | return { 12 | ...state, 13 | height: action.height, 14 | width: action.width, 15 | }; 16 | 17 | default: 18 | return state; 19 | } 20 | }; 21 | 22 | export default environment; 23 | -------------------------------------------------------------------------------- /client/src/reducers/router.js: -------------------------------------------------------------------------------- 1 | import * as types from '../constants/ActionTypes'; 2 | import { INITIAL_ROUTE } from '../constants/RouterConstants'; 3 | 4 | const initialState = { 5 | route: { ...INITIAL_ROUTE }, 6 | }; 7 | 8 | const router = (state = initialState, action) => { 9 | switch (action.type) { 10 | case types.CHANGE_ROUTE: 11 | return { 12 | ...state, 13 | route: action.route, 14 | }; 15 | 16 | default: 17 | return state; 18 | } 19 | }; 20 | 21 | export default router; 22 | -------------------------------------------------------------------------------- /client/src/utils/ImageUtils.js: -------------------------------------------------------------------------------- 1 | import IMAGE_SIZES from '../constants/ImageConstants'; 2 | 3 | const getImageUrl = (s, size = null) => { 4 | if (!s) { 5 | return ''; 6 | } 7 | 8 | const url = s.replace('http:', ''); 9 | 10 | switch (size) { 11 | case IMAGE_SIZES.LARGE: 12 | return url.replace('large', IMAGE_SIZES.LARGE); 13 | case IMAGE_SIZES.XLARGE: 14 | return url.replace('large', IMAGE_SIZES.XLARGE); 15 | default: 16 | return url; 17 | } 18 | }; 19 | 20 | export default getImageUrl; 21 | -------------------------------------------------------------------------------- /client/src/components/LoginPopoverPanel.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | 4 | const propTypes = { 5 | login: PropTypes.func.isRequired, 6 | }; 7 | 8 | const LoginPopoverPanel = ({ login }) => ( 9 |
15 | Sign into SoundCloud 16 |
17 | ); 18 | 19 | LoginPopoverPanel.propTypes = propTypes; 20 | 21 | export default LoginPopoverPanel; 22 | -------------------------------------------------------------------------------- /client/src/utils/NumberUtils.js: -------------------------------------------------------------------------------- 1 | export const addCommas = (i) => { 2 | if (i === null || i === undefined) { 3 | return ''; 4 | } 5 | 6 | return i.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); 7 | }; 8 | 9 | const padZero = (num, size) => { 10 | let s = String(num); 11 | while (s.length < size) { 12 | s = `0${s}`; 13 | } 14 | return s; 15 | }; 16 | 17 | export const formatSeconds = (num) => { 18 | const minutes = padZero(Math.floor(num / 60), 2); 19 | const seconds = padZero(num % 60, 2); 20 | return `${minutes}:${seconds}`; 21 | }; 22 | -------------------------------------------------------------------------------- /client/src/constants/SongConstants.js: -------------------------------------------------------------------------------- 1 | export const CHANGE_TYPES = { 2 | NEXT: 'next', 3 | PLAY: 'play', 4 | PREV: 'prev', 5 | SHUFFLE: 'shuffle', 6 | }; 7 | 8 | export const GENRES = [ 9 | 'chill', 10 | 'deep', 11 | 'dubstep', 12 | 'house', 13 | 'progressive', 14 | 'tech', 15 | 'trance', 16 | 'tropical', 17 | ]; 18 | 19 | export const GENRES_MAP = GENRES.reduce((obj, genre) => 20 | Object.assign({}, obj, { 21 | [genre]: 1, 22 | }), {}); 23 | 24 | export const IMAGE_SIZES = { 25 | LARGE: 't300x300', 26 | XLARGE: 't500x500', 27 | }; 28 | -------------------------------------------------------------------------------- /client/src/reducers/entities.js: -------------------------------------------------------------------------------- 1 | import merge from 'lodash.merge'; 2 | import * as types from '../constants/ActionTypes'; 3 | 4 | const initialState = { 5 | playlists: {}, 6 | songs: {}, 7 | users: {}, 8 | }; 9 | 10 | export default function entities(state = initialState, action) { 11 | if (action.entities) { 12 | return merge({}, state, action.entities); 13 | } 14 | 15 | switch (action.type) { 16 | case types.LOGOUT: 17 | return { 18 | ...state, 19 | playlists: {}, 20 | }; 21 | 22 | default: 23 | return state; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /client/src/store/configureStore.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import thunkMiddleware from 'redux-thunk'; 3 | import rootReducer from '../reducers/index'; 4 | 5 | const createStoreWithMiddleware = applyMiddleware(thunkMiddleware)(createStore); 6 | 7 | export default function configureStore(initialState) { 8 | const store = createStoreWithMiddleware(rootReducer, initialState); 9 | 10 | if (module.hot) { 11 | module.hot.accept('../reducers', () => { 12 | store.replaceReducer(rootReducer); 13 | }); 14 | } 15 | 16 | return store; 17 | } 18 | -------------------------------------------------------------------------------- /client/styles/custom/components/sidebar.scss: -------------------------------------------------------------------------------- 1 | .sidebar { 2 | display: flex; 3 | flex-direction: column; 4 | width: 100%; 5 | } 6 | 7 | .sidebar--sticky { 8 | position: fixed; 9 | top: 40px; 10 | width: 350px; 11 | } 12 | 13 | .sidebar__header { 14 | display: flex; 15 | flex-direction: row; 16 | align-items: center; 17 | height: 60px; 18 | padding: 0 20px; 19 | background-color: $white; 20 | border: 1px solid $lighterGray; 21 | } 22 | 23 | .sidebar__header__left { 24 | flex: 1; 25 | } 26 | 27 | .sidebar__body { 28 | flex: 1; 29 | width: 100%; 30 | overflow: scroll; 31 | } 32 | -------------------------------------------------------------------------------- /client/src/components/Router.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | 4 | const propTypes = { 5 | router: PropTypes.shape({ 6 | route: PropTypes.shape({ 7 | path: PropTypes.string, 8 | }), 9 | }).isRequired, 10 | routes: PropTypes.shape({}).isRequired, 11 | }; 12 | 13 | const Router = ({ router, routes }) => { 14 | const { path } = router.route; 15 | if (path in routes) { 16 | const Component = routes[path]; 17 | return ; 18 | } 19 | 20 | return null; 21 | }; 22 | 23 | Router.propTypes = propTypes; 24 | 25 | export default Router; 26 | -------------------------------------------------------------------------------- /client/src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import entities from '../reducers/entities'; 3 | import environment from '../reducers/environment'; 4 | import history from '../reducers/history'; 5 | import router from '../reducers/router'; 6 | import player from '../reducers/player'; 7 | import playlists from '../reducers/playlists'; 8 | import session from '../reducers/session'; 9 | 10 | const rootReducer = combineReducers({ 11 | entities, 12 | environment, 13 | history, 14 | player, 15 | playlists, 16 | router, 17 | session, 18 | }); 19 | 20 | export default rootReducer; 21 | -------------------------------------------------------------------------------- /client/styles/custom/components/nav-search.scss: -------------------------------------------------------------------------------- 1 | .nav-search { 2 | position: relative; 3 | background-color: $darkestGray; 4 | border-radius: 3px; 5 | } 6 | 7 | .nav-search__icon { 8 | position: absolute; 9 | top: 2px; 10 | left: 10px; 11 | color: #808080; 12 | pointer-events: none; 13 | } 14 | 15 | .nav-search__input { 16 | width: 290px; 17 | padding: 6px 10px 6px 30px; 18 | color: $lighterGray; 19 | font-size: 12px; 20 | font-weight: 300; 21 | background-color: transparent; 22 | border: none; 23 | outline: 0; 24 | } 25 | 26 | .nav-search__input::placeholder { 27 | font-family: $fontFamily; 28 | } 29 | -------------------------------------------------------------------------------- /client/styles/custom/components/nav-user.scss: -------------------------------------------------------------------------------- 1 | .nav-user { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | height: 100%; 6 | } 7 | 8 | .nav-user__trigger { 9 | display: flex; 10 | flex-direction: row; 11 | align-items: center; 12 | } 13 | 14 | .nav-user__icon { 15 | color: $white; 16 | font-size: 20px; 17 | } 18 | 19 | .nav-user__chevron { 20 | margin-left: 6px; 21 | color: $lightBlack; 22 | font-size: 10px; 23 | } 24 | 25 | .nav-user__avatar { 26 | width: 29px; 27 | height: 29px; 28 | background: no-repeat center center; 29 | background-size: cover; 30 | border-radius: 50%; 31 | } 32 | -------------------------------------------------------------------------------- /client/src/selectors/HistorySelectors.js: -------------------------------------------------------------------------------- 1 | import { denormalize } from 'normalizr'; 2 | import { createSelector } from 'reselect'; 3 | import { HISTORY_PLAYLIST } from '../constants/PlaylistConstants'; 4 | import { songSchema } from '../constants/Schemas'; 5 | import { getEntities, getPlaylists } from '../selectors/CommonSelectors'; 6 | 7 | export const getSongs = createSelector( 8 | getPlaylists, 9 | getEntities, 10 | (playlists, entities) => (HISTORY_PLAYLIST in playlists 11 | ? denormalize(playlists[HISTORY_PLAYLIST].items, [songSchema], entities) 12 | : [] 13 | ), 14 | ); 15 | 16 | export const getShowHistory = state => state.history.showHistory; 17 | -------------------------------------------------------------------------------- /client/styles/custom/components/stats.scss: -------------------------------------------------------------------------------- 1 | .stats { 2 | display: flex; 3 | flex-direction: row; 4 | } 5 | 6 | .stats__stat { 7 | color: $gray; 8 | font-size: 11px; 9 | 10 | & + .stats__stat { 11 | margin-left: 20px; 12 | } 13 | } 14 | 15 | .stats__stat__text { 16 | margin-left: 10px; 17 | } 18 | 19 | .stats__stat--heart { 20 | .heart__inner { 21 | display: flex; 22 | flex-direction: row; 23 | 24 | &:hover { 25 | cursor: pointer; 26 | text-decoration: underline; 27 | } 28 | } 29 | 30 | .heart__icon { 31 | font-size: 11px; 32 | } 33 | 34 | .heart__count { 35 | margin-left: 10px; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /client/src/components/HeartCount.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import { addCommas } from '../utils/NumberUtils'; 4 | 5 | const defaultProps = { 6 | favoritingsCount: null, 7 | }; 8 | 9 | const propTypes = { 10 | favoritingsCount: PropTypes.number, 11 | }; 12 | 13 | const HeartCount = ({ favoritingsCount }) => { 14 | if (favoritingsCount) { 15 | return ( 16 |
17 | {addCommas(favoritingsCount)} 18 |
19 | ); 20 | } 21 | 22 | return null; 23 | }; 24 | 25 | HeartCount.defaultProps = defaultProps; 26 | HeartCount.propTypes = propTypes; 27 | 28 | export default HeartCount; 29 | -------------------------------------------------------------------------------- /client/src/selectors/SongsSelectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | import { 3 | getEntities, 4 | getGenre, 5 | getId, 6 | getOauthToken, 7 | getPlaylists, 8 | getSearch, 9 | getShowLikes, 10 | getShowPlaylist, 11 | getShowStream, 12 | getTime, 13 | } from '../selectors/CommonSelectors'; 14 | import { playlistData } from '../utils/PlaylistUtils'; 15 | 16 | const getPlaylistData = createSelector( 17 | getGenre, 18 | getSearch, 19 | getShowLikes, 20 | getShowPlaylist, 21 | getShowStream, 22 | getTime, 23 | getEntities, 24 | getId, 25 | getOauthToken, 26 | getPlaylists, 27 | playlistData, 28 | ); 29 | 30 | export default getPlaylistData; 31 | -------------------------------------------------------------------------------- /client/styles/custom/components/heart.scss: -------------------------------------------------------------------------------- 1 | .heart__inner { 2 | outline: 0; 3 | 4 | &:hover { 5 | cursor: pointer; 6 | 7 | .heart__icon { 8 | color: $lighterRed; 9 | } 10 | } 11 | 12 | &:active { 13 | .heart__icon { 14 | color: $lightRed; 15 | } 16 | } 17 | } 18 | 19 | .heart__icon { 20 | color: $gray; 21 | 22 | transition: color 0.2s ease-in-out; 23 | } 24 | 25 | .heart--liked { 26 | .heart__icon { 27 | color: $red; 28 | } 29 | 30 | .heart__inner { 31 | &:hover { 32 | .heart__icon { 33 | color: $darkRed; 34 | } 35 | } 36 | 37 | &:active { 38 | .heart__icon { 39 | color: $darkerRed; 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /client/styles/custom/components/switch.scss: -------------------------------------------------------------------------------- 1 | .switch { 2 | position: relative; 3 | display: inline-block; 4 | width: 20px; 5 | height: 6px; 6 | border-radius: 3px; 7 | border: 1px solid $lighterGray; 8 | outline: 0; 9 | 10 | &:hover { 11 | cursor: pointer; 12 | } 13 | } 14 | 15 | .switch__button { 16 | position: absolute; 17 | top: -4px; 18 | left: -2px; 19 | width: 12px; 20 | height: 12px; 21 | border-radius: 50%; 22 | background-color: $white; 23 | border: 1px solid $lighterGray; 24 | transition: transform 110ms ease-in-out; 25 | } 26 | 27 | .switch--on { 28 | border-color: $blue; 29 | background-color: $blue; 30 | 31 | .switch__button { 32 | transform: translateX(10px); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /client/styles/custom/components/slider.scss: -------------------------------------------------------------------------------- 1 | .slider { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | width: 100%; 6 | padding: 8px 0; 7 | outline: 0; 8 | 9 | &:hover { 10 | cursor: pointer; 11 | } 12 | } 13 | 14 | .slider__bar { 15 | width: 100%; 16 | height: 2px; 17 | background-color: $lightGray; 18 | outline: 0; 19 | } 20 | 21 | .slider__bar__fill { 22 | position: relative; 23 | height: 100%; 24 | background-color: $green; 25 | } 26 | 27 | .slider__handle { 28 | position: absolute; 29 | top: -5px; 30 | right: -6px; 31 | width: 12px; 32 | height: 12px; 33 | background-color: $white; 34 | border-radius: 50%; 35 | border: 1px solid $lighterGray; 36 | outline: 0; 37 | } 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SoundRedux 2 | 3 | **NOTE** It seems that SoundCloud has revoked my api client keys without any explanation or warning. Running the app locally no longer works unless you have a working SoundCloud API client id (SoundCloud has disabled registration of new apps for quite some time now). The live demo is also not working at the moment. 4 | 5 | In an effort to learn es6 and [redux](https://github.com/reactjs/redux), this is SoundRedux, a simple [Soundcloud](http://soundcloud.com) client 6 | 7 | See it in action at https://soundredux.io 8 | 9 | Uses [normalizr](https://github.com/gaearon/normalizr) 10 | 11 | 1. `npm install` 12 | 2. `npm run start` 13 | 3. visit `http://localhost:8080` 14 | 15 | Feedback, issues, etc. are more than welcome! 16 | -------------------------------------------------------------------------------- /client/styles/custom/components/artwork-play.scss: -------------------------------------------------------------------------------- 1 | .artwork-play { 2 | position: relative; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | width: 100%; 7 | height: 100%; 8 | background-color: transparent; 9 | outline: 0; 10 | transition: background-color 0.2s ease-in-out; 11 | 12 | &:hover { 13 | background-color: rgba(0, 0, 0, 0.8); 14 | cursor: pointer; 15 | 16 | .artwork-play__icon { 17 | opacity: 1; 18 | } 19 | } 20 | } 21 | 22 | .artwork-play--active { 23 | background-color: rgba(0, 0, 0, 0.8); 24 | 25 | .artwork-play__icon { 26 | opacity: 1; 27 | } 28 | } 29 | 30 | .artwork-play__icon { 31 | color: $green; 32 | font-size: 28px; 33 | opacity: 0; 34 | transition: opacity 0.2s ease-in-out; 35 | } 36 | -------------------------------------------------------------------------------- /client/src/components/SidebarBody.jsx: -------------------------------------------------------------------------------- 1 | /* global document */ 2 | import PropTypes from 'prop-types'; 3 | import React, { Component } from 'react'; 4 | 5 | const propTypes = { 6 | children: PropTypes.node.isRequired, 7 | }; 8 | 9 | const onScroll = () => { document.body.style.overflow = 'hidden'; }; 10 | const onMouseLeave = () => { document.body.style.overflow = 'auto'; }; 11 | 12 | class SidebarBody extends Component { 13 | componentWillUnmount() { 14 | onMouseLeave(); 15 | } 16 | 17 | render() { 18 | const { children } = this.props; 19 | 20 | return ( 21 |
22 |
23 | {children} 24 |
25 |
26 | ); 27 | } 28 | } 29 | 30 | SidebarBody.propTypes = propTypes; 31 | 32 | export default SidebarBody; 33 | -------------------------------------------------------------------------------- /client/styles/custom/variables.scss: -------------------------------------------------------------------------------- 1 | $fontFamily: 'Open Sans','Helvetica Neue', Helvetica, Arial, sans-serif; 2 | 3 | $black: #222; 4 | $lightBlack: #6d6d6d; 5 | $lighterBlack: #999; 6 | 7 | $blue: #3381b7; 8 | $lightBlue: lighten($blue, 5); 9 | $darkBlue: darken($blue, 5); 10 | 11 | $lightestGray: #fafafa; 12 | $lighterGray: #e3e3e3; 13 | $lightGray: #ddd; 14 | $gray: #ccc; 15 | $mediumGray: #c8c9cb; 16 | $darkGray: #adadad; 17 | $darkerGray: #3a3f41; 18 | $darkestGray: #2B2B2B; 19 | 20 | $orange: #f85d0f; 21 | $darkOrange: darken($orange, 5); 22 | $darkerOrange: darken($orange, 10); 23 | 24 | $lightGreen: #a6d2a5; 25 | $green: #7ec57c; 26 | $darkGreen: darken($green, 5); 27 | $darkerGreen: darken($green, 10); 28 | 29 | $lighterRed: #F9C5C5; 30 | $lightRed: darken($lighterRed, 5); 31 | $red: #e17d74; 32 | $darkRed: darken($red, 5); 33 | $darkerRed: darken($red, 10); 34 | 35 | $white: #fff; 36 | -------------------------------------------------------------------------------- /client/src/components/WaveformEvents.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | 4 | const propTypes = { 5 | isActive: PropTypes.bool.isRequired, 6 | onMouseMove: PropTypes.func.isRequired, 7 | playSong: PropTypes.func.isRequired, 8 | seek: PropTypes.func.isRequired, 9 | }; 10 | 11 | const WaveformEvents = ({ isActive, onMouseMove, playSong, seek }) => { 12 | if (isActive) { 13 | return ( 14 |
21 | ); 22 | } 23 | 24 | return ( 25 |
31 | ); 32 | }; 33 | 34 | WaveformEvents.propTypes = propTypes; 35 | 36 | export default WaveformEvents; 37 | -------------------------------------------------------------------------------- /client/src/utils/ApiUtils.js: -------------------------------------------------------------------------------- 1 | /* global fetch */ 2 | /* global window */ 3 | import camelize from 'camelize'; 4 | import SC from 'soundcloud'; 5 | 6 | export const callApi = (url, options) => 7 | fetch(url, options) 8 | .then( 9 | response => (response.ok 10 | ? response.json() 11 | : Promise.reject(response.text()) 12 | ), 13 | error => Promise.reject(error)) 14 | .then( 15 | json => ({ json: camelize(json) }), 16 | error => ({ error })) 17 | .catch(error => ({ error })); 18 | 19 | export const loginToSoundCloud = (clientId) => { 20 | SC.initialize({ 21 | client_id: clientId, 22 | redirect_uri: `${window.location.protocol}//${window.location.host}/api/callback`, 23 | }); 24 | 25 | return SC.connect() 26 | .then( 27 | json => ({ json: camelize(json) }), 28 | error => ({ error }), 29 | ) 30 | .catch(error => ({ error })); 31 | }; 32 | -------------------------------------------------------------------------------- /client/src/index.jsx: -------------------------------------------------------------------------------- 1 | /* global document */ 2 | 3 | import 'babel-polyfill'; 4 | import 'isomorphic-fetch'; 5 | import OfflinePluginRuntime from 'offline-plugin/runtime'; 6 | import React from 'react'; 7 | import ReactDOM from 'react-dom'; 8 | import { AppContainer } from 'react-hot-loader'; 9 | import { Provider } from 'react-redux'; 10 | 11 | import '../styles/main.scss'; 12 | import RootContainer from './containers/RootContainer'; 13 | import configureStore from './store/configureStore'; 14 | 15 | OfflinePluginRuntime.install(); 16 | 17 | const render = (Component) => { 18 | ReactDOM.render( 19 | 20 | 21 | 22 | 23 | , 24 | document.getElementById('root'), 25 | ); 26 | }; 27 | 28 | render(RootContainer); 29 | 30 | if (module.hot) { 31 | module.hot.accept('./containers/RootContainer', () => { 32 | render(RootContainer); 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /client/styles/custom/components/user-following.scss: -------------------------------------------------------------------------------- 1 | .user-following { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | padding: 10px; 6 | font-size: 11px; 7 | background-color: #fff; 8 | border-bottom: 1px solid $lighterGray; 9 | border-left: 1px solid $lighterGray; 10 | border-right: 1px solid $lighterGray; 11 | } 12 | 13 | .user-following__avatar { 14 | width: 30px; 15 | height: 30px; 16 | margin-right: 10px; 17 | background: no-repeat center center; 18 | background-size: cover; 19 | border-radius: 50%; 20 | } 21 | 22 | .user-following__main { 23 | flex: 1; 24 | overflow: hidden; 25 | } 26 | 27 | .user-following__location { 28 | display: flex; 29 | flex-direction: row; 30 | color: $gray; 31 | } 32 | 33 | .user-following__location__icon { 34 | margin-right: 4px; 35 | } 36 | 37 | .user-following__followers { 38 | text-align: right; 39 | } 40 | 41 | .user-following__followers__text { 42 | color: $gray; 43 | } 44 | -------------------------------------------------------------------------------- /client/styles/custom/components/popover.scss: -------------------------------------------------------------------------------- 1 | .popover { 2 | position: relative; 3 | } 4 | 5 | .popover__trigger { 6 | outline: 0; 7 | 8 | &:hover { 9 | cursor: pointer; 10 | text-decoration: none; 11 | } 12 | } 13 | 14 | .popover__panel { 15 | position: absolute; 16 | left: 0; 17 | top: 100%; 18 | min-width: 200px; 19 | margin-top: 4px; 20 | background-color: $white; 21 | border: solid 1px $lighterBlack; 22 | box-shadow: 0 0 2px 1px rgba(0,0,0,.2); 23 | z-index: 501; 24 | } 25 | 26 | .popover__panel__item { 27 | display: block; 28 | padding: 12px 16px; 29 | font-size: 12px; 30 | transition: background-color 200ms ease-in-out; 31 | 32 | &[role="button"] { 33 | &:hover { 34 | background-color: $lightestGray; 35 | cursor: pointer; 36 | } 37 | 38 | &:active { 39 | background-color: $lighterGray; 40 | } 41 | } 42 | } 43 | 44 | .popover--right { 45 | .popover__panel { 46 | left: auto; 47 | right: 0; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /client/src/containers/HistoryContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import toggleShowHistory from '../actions/HistoryActions'; 4 | import { playSong } from '../actions/PlayerActions'; 5 | import { navigateTo } from '../actions/RouterActions'; 6 | import History from '../components/History'; 7 | import { HISTORY_PLAYLIST } from '../constants/PlaylistConstants'; 8 | import { getIsPlaying, getPlayingSongId } from '../selectors/CommonSelectors'; 9 | import { getShowHistory, getSongs } from '../selectors/HistorySelectors'; 10 | 11 | const HistoryContainer = props => ; 12 | 13 | const mapStateToProps = state => ({ 14 | isPlaying: getIsPlaying(state), 15 | playingSongId: getPlayingSongId(state), 16 | playlist: HISTORY_PLAYLIST, 17 | showHistory: getShowHistory(state), 18 | songs: getSongs(state), 19 | }); 20 | 21 | export default connect(mapStateToProps, { 22 | navigateTo, 23 | playSong, 24 | toggleShowHistory, 25 | })(HistoryContainer); 26 | -------------------------------------------------------------------------------- /client/src/components/Switch.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React, { Component } from 'react'; 3 | 4 | const defaultProps = { 5 | args: [], 6 | }; 7 | 8 | const propTypes = { 9 | args: PropTypes.arrayOf(PropTypes.any), 10 | on: PropTypes.bool.isRequired, 11 | onClick: PropTypes.func.isRequired, 12 | }; 13 | 14 | class Switch extends Component { 15 | constructor() { 16 | super(); 17 | this.onClick = this.onClick.bind(this); 18 | } 19 | 20 | onClick() { 21 | const { args, onClick } = this.props; 22 | onClick(...args); 23 | } 24 | 25 | render() { 26 | const { on } = this.props; 27 | return ( 28 |
34 |
35 |
36 | ); 37 | } 38 | } 39 | 40 | Switch.defaultProps = defaultProps; 41 | Switch.propTypes = propTypes; 42 | 43 | export default Switch; 44 | -------------------------------------------------------------------------------- /client/styles/custom/components/loader.scss: -------------------------------------------------------------------------------- 1 | .loader--full { 2 | position: relative; 3 | width: 100%; 4 | 5 | .loader__rects { 6 | position: absolute; 7 | top: 20px; 8 | left: 0; 9 | width: 100%; 10 | } 11 | } 12 | 13 | .loader__rects { 14 | display: flex; 15 | flex-direction: row; 16 | align-items: center; 17 | justify-content: center; 18 | } 19 | 20 | .loader__rect { 21 | width: 7px; 22 | height: 30px; 23 | background-color: rgba(166, 210, 165, 0.9); 24 | animation: stretch 1.2s infinite ease-in-out; 25 | 26 | & + .loader__rect { 27 | margin-left: 3px; 28 | } 29 | } 30 | 31 | .loader__rect--2 { 32 | animation-delay: -1.1s; 33 | } 34 | 35 | .loader__rect--3 { 36 | animation-delay: -1.0s; 37 | } 38 | 39 | .loader__rect--4 { 40 | animation-delay: -0.9s; 41 | } 42 | 43 | .loader__rect--5 { 44 | animation-delay: -0.8s; 45 | } 46 | 47 | @keyframes stretch { 48 | 0%, 40%, 100% { 49 | transform: scaleY(0.4); 50 | } 20% { 51 | transform: scaleY(1.0); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /client/styles/custom/components/nav.scss: -------------------------------------------------------------------------------- 1 | .nav { 2 | height: 50px; 3 | background-color: $darkerGray; 4 | 5 | @include mobile { 6 | position: relative; 7 | padding: 0 10px; 8 | z-index: 5; 9 | }; 10 | }; 11 | 12 | .nav__inner { 13 | display: flex; 14 | flex-direction: row; 15 | height: 100%; 16 | } 17 | 18 | .nav__section { 19 | display: flex; 20 | flex-direction: row; 21 | align-items: center; 22 | height: 100%; 23 | 24 | & + .nav__section { 25 | margin-left: 30px; 26 | } 27 | } 28 | 29 | .nav__section--session { 30 | flex: 1; 31 | } 32 | 33 | .nav__section--search, 34 | .nav__section--session, 35 | .nav__section--user { 36 | @include mobile { 37 | display: none; 38 | }; 39 | } 40 | 41 | .nav__logo__icon { 42 | color: $white; 43 | font-size: 20px; 44 | } 45 | 46 | .nav__logo__text { 47 | margin-left: 30px; 48 | color: $white; 49 | font-size: 14px; 50 | letter-spacing: 2px; 51 | text-transform: uppercase; 52 | 53 | &:hover { 54 | text-decoration: none; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /client/src/components/Loader.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | 4 | const defaultProps = { 5 | children: null, 6 | className: '', 7 | }; 8 | 9 | const propTypes = { 10 | children: PropTypes.node, 11 | className: PropTypes.string, 12 | isLoading: PropTypes.bool.isRequired, 13 | }; 14 | 15 | const Loader = ({ children, className, isLoading }) => { 16 | if (isLoading) { 17 | return ( 18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | ); 28 | } 29 | 30 | return children; 31 | }; 32 | 33 | Loader.defaultProps = defaultProps; 34 | Loader.propTypes = propTypes; 35 | 36 | export default Loader; 37 | -------------------------------------------------------------------------------- /client/src/utils/UserUtils.js: -------------------------------------------------------------------------------- 1 | export const getLocation = (user) => { 2 | const { city, country } = user; 3 | 4 | if (city && country) { 5 | return `${city}, ${country}`; 6 | } 7 | 8 | if (city) { 9 | return city; 10 | } 11 | 12 | if (country) { 13 | return country; 14 | } 15 | 16 | return 'Earth'; 17 | }; 18 | 19 | export const getSocialIcon = (service) => { 20 | switch (service) { 21 | case 'facebook': 22 | return 'ion-social-facebook'; 23 | case 'twitter': 24 | return 'ion-social-twitter'; 25 | case 'instagram': 26 | return 'ion-social-instagram'; 27 | case 'youtube': 28 | return 'ion-social-youtube'; 29 | case 'hypem': 30 | return 'ion-heart'; 31 | case 'google_plus': 32 | return 'ion-social-googleplus'; 33 | case 'spotify': 34 | return 'ion-music-note'; 35 | case 'songkick': 36 | return 'ion-music-note'; 37 | case 'soundcloud': 38 | return 'ion-music-note'; 39 | default: 40 | return 'ion-ios-world-outline'; 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /client/src/components/UserFollowButton.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React, { Component } from 'react'; 3 | 4 | const propTypes = { 5 | id: PropTypes.number.isRequired, 6 | isFollowing: PropTypes.bool.isRequired, 7 | toggleFollow: PropTypes.func.isRequired, 8 | }; 9 | 10 | class UserFollowButton extends Component { 11 | constructor() { 12 | super(); 13 | this.onClick = this.onClick.bind(this); 14 | } 15 | 16 | onClick() { 17 | const { id, isFollowing, toggleFollow } = this.props; 18 | toggleFollow(id, !isFollowing); 19 | } 20 | 21 | render() { 22 | const { isFollowing } = this.props; 23 | 24 | return ( 25 |
31 | {isFollowing ? 'Following' : 'Follow'} 32 |
33 | ); 34 | } 35 | } 36 | 37 | UserFollowButton.propTypes = propTypes; 38 | 39 | export default UserFollowButton; 40 | -------------------------------------------------------------------------------- /client/src/actions/RouterActions.js: -------------------------------------------------------------------------------- 1 | /* global history */ 2 | /* global location */ 3 | /* global window */ 4 | import { CHANGE_ROUTE } from '../constants/ActionTypes'; 5 | import { compileHash, parseRoute } from '../utils/RouterUtils'; 6 | 7 | const pushState = (route) => { 8 | const hash = compileHash(route); 9 | if (location.hash !== hash) { 10 | history.pushState({ route }, '', hash); 11 | } 12 | }; 13 | 14 | export const navigateTo = (route, shouldPushState = true) => { 15 | if (shouldPushState) { 16 | pushState(route); 17 | } 18 | 19 | return { 20 | type: CHANGE_ROUTE, 21 | route, 22 | }; 23 | }; 24 | 25 | export const navigateBack = e => (dispatch) => { 26 | const { state } = e; 27 | if (state) { 28 | const { route } = state; 29 | dispatch(navigateTo(route, false)); 30 | } 31 | }; 32 | 33 | export const initRouter = paths => (dispatch) => { 34 | window.onpopstate = (e) => { 35 | dispatch(navigateBack(e)); 36 | }; 37 | 38 | const hash = location.hash ? location.hash.slice(2) : ''; 39 | const route = parseRoute(hash, paths); 40 | return dispatch(navigateTo(route)); 41 | }; 42 | -------------------------------------------------------------------------------- /client/styles/custom/components/toggle-play-button.scss: -------------------------------------------------------------------------------- 1 | .toggle-play-button { 2 | position: relative; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | width: 100%; 7 | height: 100%; 8 | background-color: transparent; 9 | transition: background-color 0.2s ease-in-out; 10 | 11 | &.active { 12 | background-color: rgba(0, 0, 0, 0.8); 13 | 14 | .toggle-play-button-icon { 15 | position: relative; 16 | opacity: 1; 17 | z-index: 2; 18 | } 19 | } 20 | 21 | &.is-playing { 22 | .ion-ios-play { 23 | display: none; 24 | } 25 | 26 | .ion-radio-waves { 27 | display: inline-block; 28 | } 29 | } 30 | 31 | &:hover { 32 | background-color: rgba(0, 0, 0, 0.8); 33 | 34 | .toggle-play-button-icon { 35 | opacity: 1; 36 | } 37 | } 38 | 39 | .ion-radio-waves { 40 | display: none; 41 | } 42 | } 43 | 44 | .toggle-play-button-icon { 45 | color: #A6D2A5; 46 | font-size: 28px; 47 | opacity: 0; 48 | transition: opacity 0.2s ease-in-out; 49 | } 50 | -------------------------------------------------------------------------------- /client/styles/custom/components/song-comment.scss: -------------------------------------------------------------------------------- 1 | .song-comment { 2 | display: flex; 3 | flex-direction: row; 4 | padding: 10px; 5 | background-color: #fff; 6 | border-bottom: 1px solid $lighterGray; 7 | border-left: 1px solid $lighterGray; 8 | border-right: 1px solid $lighterGray; 9 | font-size: 11px; 10 | overflow: hidden; 11 | 12 | &:nth-child(-n + 10) { 13 | animation-fill-mode: forwards; 14 | animation-name: slideIn; 15 | animation-duration: 360ms; 16 | animation-timing-function: ease; 17 | opacity: 0; 18 | transform: translateX(200px); 19 | } 20 | } 21 | 22 | .song-comment__image { 23 | width: 24px; 24 | height: 24px; 25 | margin-right: 20px; 26 | border-radius: 50%; 27 | background: no-repeat center center; 28 | background-size: cover; 29 | }; 30 | 31 | .song-comment__main { 32 | flex: 1; 33 | margin-right: 20px; 34 | overflow: hidden; 35 | } 36 | 37 | .song-comment__username { 38 | color: $gray; 39 | } 40 | 41 | .song-comment__body { 42 | word-break: break-word; 43 | word-wrap: break-word; 44 | } 45 | 46 | @keyframes slideIn { 47 | to { 48 | transform: translateX(0px); 49 | opacity: 1; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /client/src/selectors/NavSelectors.js: -------------------------------------------------------------------------------- 1 | import { denormalize } from 'normalizr'; 2 | import { createSelector } from 'reselect'; 3 | import { SESSION_STREAM_PLAYLIST } from '../constants/PlaylistConstants'; 4 | import { PLAYLIST_PATH } from '../constants/RouterConstants'; 5 | import { playlistSchema } from '../constants/Schemas'; 6 | import { getEntities, getId, getPlaylists, getOauthToken, getPath } from '../selectors/CommonSelectors'; 7 | 8 | export const getNavPlaylists = createSelector( 9 | getEntities, 10 | entities => denormalize(Object.keys(entities.playlists), [playlistSchema], entities), 11 | ); 12 | 13 | export const getNavPlaylist = createSelector( 14 | getPath, 15 | getId, 16 | getEntities, 17 | (path, id, entities) => (path === PLAYLIST_PATH && id 18 | ? denormalize(id, playlistSchema, entities) 19 | : null 20 | ), 21 | ); 22 | 23 | export const getStreamFutureUrl = createSelector( 24 | getOauthToken, 25 | getPlaylists, 26 | (oauthToken, playlists) => ( 27 | SESSION_STREAM_PLAYLIST in playlists && playlists[SESSION_STREAM_PLAYLIST].futureUrl 28 | ? `${playlists[SESSION_STREAM_PLAYLIST].futureUrl}&oauth_token=${oauthToken}` 29 | : '' 30 | ), 31 | ); 32 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | SoundRedux 12 | 13 | 14 |
15 | 16 | 17 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /client/styles/custom/custom.scss: -------------------------------------------------------------------------------- 1 | @import 'mixins'; 2 | @import 'variables'; 3 | 4 | @import 'components/a'; 5 | @import 'components/artwork-play'; 6 | @import 'components/button'; 7 | @import 'components/container'; 8 | @import 'components/heart'; 9 | @import 'components/history'; 10 | @import 'components/loader'; 11 | @import 'components/nav'; 12 | @import 'components/nav-playlists'; 13 | @import 'components/nav-search'; 14 | @import 'components/nav-session'; 15 | @import 'components/nav-user'; 16 | @import 'components/player'; 17 | @import 'components/popover'; 18 | @import 'components/reset'; 19 | @import 'components/row'; 20 | @import 'components/sidebar'; 21 | @import 'components/slider'; 22 | @import 'components/song'; 23 | @import 'components/song-comment'; 24 | @import 'components/song-main'; 25 | @import 'components/song-list'; 26 | @import 'components/songs-header'; 27 | @import 'components/songs-body'; 28 | @import 'components/songs-body-card'; 29 | @import 'components/stats'; 30 | @import 'components/switch'; 31 | @import 'components/toggle-play-button'; 32 | @import 'components/user'; 33 | @import 'components/user-following'; 34 | @import 'components/user-main'; 35 | @import 'components/waveform'; 36 | -------------------------------------------------------------------------------- /client/src/containers/NavContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { navigateTo } from '../actions/RouterActions'; 4 | import { fetchNewStreamSongs, loadNewStreamSongs, login, logout } from '../actions/SessionActions'; 5 | import Nav from '../components/Nav'; 6 | import { getIsAuthenticated, getNewStreamSongs, getSessionUser, getShowLikes, getShowPlaylist, getShowStream } from '../selectors/CommonSelectors'; 7 | import { getNavPlaylist, getNavPlaylists, getStreamFutureUrl } from '../selectors/NavSelectors'; 8 | 9 | const NavContainer = props =>