├── .babelrc ├── favicon.ico ├── .eslintignore ├── public ├── favicon.ico ├── style.css.map ├── img │ └── ampache-blue.png ├── 1dc35d25e61d819a9c357074014867ab.ttf ├── 25a32416abee198dd821b0b17a198a8f.eot ├── c8ddf1e5e5bf3682bc7bebf30f394148.woff ├── e18bbf611f2a2e43afc071aa2f4e1512.ttf ├── f4769f9bdb7466be65088239c12046d1.eot ├── fa2772327f55d8198301fdb8bcfc8158.woff ├── 448c34a56d699c29117adc64c43affeb.woff2 ├── e6cf7c6ec7c2d6f670ae9d762604cb0b.woff2 ├── ie10-viewport-bug-workaround │ ├── ie10-viewport-bug-workaround.css │ └── ie10-viewport-bug-workaround.js ├── index.html └── fix.ie9.js ├── app ├── assets │ └── img │ │ └── ampache-blue.png ├── common │ ├── styles │ │ ├── index.js │ │ ├── common.scss │ │ └── hacks.scss │ └── utils │ │ ├── index.js │ │ ├── string.js │ │ └── jquery.js ├── locales │ ├── index.js │ ├── messagesDescriptors │ │ ├── Settings.js │ │ ├── grid.js │ │ ├── elements │ │ │ ├── FilterBar.js │ │ │ ├── Pagination.js │ │ │ └── WebPlayer.js │ │ ├── Songs.js │ │ ├── api.js │ │ ├── Playlist.js │ │ ├── layouts │ │ │ └── Sidebar.js │ │ ├── common.js │ │ └── Login.js │ ├── en-US │ │ └── index.js │ └── fr-FR │ │ └── index.js ├── styles │ ├── elements │ │ ├── Pagination.scss │ │ ├── FilterBar.scss │ │ ├── Grid.scss │ │ └── WebPlayer.scss │ ├── Login.scss │ ├── variables.scss │ ├── Songs.scss │ ├── Artist.scss │ ├── Discover.scss │ ├── Album.scss │ └── layouts │ │ └── Sidebar.scss ├── store │ ├── configureStore.js │ ├── configureStore.production.js │ └── configureStore.development.js ├── utils │ ├── index.js │ ├── immutable.js │ ├── reducers.js │ ├── misc.js │ ├── ampache.js │ ├── url.js │ ├── pagination.js │ └── locale.js ├── views │ ├── DiscoverPage.jsx │ ├── HomePage.jsx │ ├── SettingsPage.jsx │ ├── BrowsePage.jsx │ ├── LogoutPage.jsx │ ├── PlaylistPage.jsx │ ├── ArtistsPage.jsx │ ├── LoginPage.jsx │ ├── AlbumsPage.jsx │ ├── ArtistPage.jsx │ ├── SongsPage.jsx │ └── WebPlayer.jsx ├── actions │ ├── store.js │ ├── pagination.js │ ├── paginated.js │ ├── index.js │ ├── entities.js │ ├── auth.js │ └── APIActions.js ├── components │ ├── layouts │ │ └── Simple.jsx │ ├── elements │ │ ├── DismissibleAlert.jsx │ │ ├── FilterBar.jsx │ │ └── Pagination.jsx │ ├── Settings.jsx │ ├── Artists.jsx │ ├── Albums.jsx │ ├── Playlist.jsx │ ├── Artist.jsx │ ├── Album.jsx │ ├── Discover.jsx │ └── Songs.jsx ├── models │ ├── i18n.js │ ├── paginated.js │ ├── webplayer.js │ ├── api.js │ ├── entities.js │ └── auth.js ├── containers │ ├── App.jsx │ ├── Root.jsx │ └── RequireAuthentication.js ├── vendor │ └── ie10-viewport-bug-workaround │ │ ├── ie10-viewport-bug-workaround.css │ │ └── ie10-viewport-bug-workaround.js ├── reducers │ ├── index.js │ ├── paginated.js │ ├── auth.js │ ├── webplayer.js │ └── entities.js └── routes.js ├── fix.ie9.js ├── webpack.config.js ├── .gitignore ├── .travis.yml ├── index.production.js ├── index.js ├── .stylelintrc ├── webpack.config.development.js ├── hooks └── pre-commit ├── webpack.config.production.js ├── LICENSE ├── index.html ├── index.development.js ├── .eslintrc.js ├── scripts └── extractTranslations.js ├── CONTRIBUTING.md ├── README.md ├── index.all.js ├── .bootstraprc ├── package.json └── webpack.config.base.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"], 3 | } 4 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/ampache_react/HEAD/favicon.ico -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | public/* 2 | node_modules/* 3 | app/vendor/* 4 | webpack.config.* 5 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/ampache_react/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/style.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":[],"names":[],"mappings":"","file":"style.css","sourceRoot":""} -------------------------------------------------------------------------------- /public/img/ampache-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/ampache_react/HEAD/public/img/ampache-blue.png -------------------------------------------------------------------------------- /app/assets/img/ampache-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/ampache_react/HEAD/app/assets/img/ampache-blue.png -------------------------------------------------------------------------------- /fix.ie9.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Special JS entry point to add IE9-dedicated fixes. 3 | */ 4 | export * from "html5shiv"; 5 | -------------------------------------------------------------------------------- /public/1dc35d25e61d819a9c357074014867ab.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/ampache_react/HEAD/public/1dc35d25e61d819a9c357074014867ab.ttf -------------------------------------------------------------------------------- /public/25a32416abee198dd821b0b17a198a8f.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/ampache_react/HEAD/public/25a32416abee198dd821b0b17a198a8f.eot -------------------------------------------------------------------------------- /public/c8ddf1e5e5bf3682bc7bebf30f394148.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/ampache_react/HEAD/public/c8ddf1e5e5bf3682bc7bebf30f394148.woff -------------------------------------------------------------------------------- /public/e18bbf611f2a2e43afc071aa2f4e1512.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/ampache_react/HEAD/public/e18bbf611f2a2e43afc071aa2f4e1512.ttf -------------------------------------------------------------------------------- /public/f4769f9bdb7466be65088239c12046d1.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/ampache_react/HEAD/public/f4769f9bdb7466be65088239c12046d1.eot -------------------------------------------------------------------------------- /public/fa2772327f55d8198301fdb8bcfc8158.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/ampache_react/HEAD/public/fa2772327f55d8198301fdb8bcfc8158.woff -------------------------------------------------------------------------------- /public/448c34a56d699c29117adc64c43affeb.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/ampache_react/HEAD/public/448c34a56d699c29117adc64c43affeb.woff2 -------------------------------------------------------------------------------- /public/e6cf7c6ec7c2d6f670ae9d762604cb0b.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phyks/ampache_react/HEAD/public/e6cf7c6ec7c2d6f670ae9d762604cb0b.woff2 -------------------------------------------------------------------------------- /app/common/styles/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Common styles modifications and hacks. 3 | */ 4 | export * from "./hacks.scss"; 5 | export * from "./common.scss"; 6 | -------------------------------------------------------------------------------- /app/locales/index.js: -------------------------------------------------------------------------------- 1 | // Export all the existing locales 2 | module.exports = { 3 | "en-US": require("./en-US"), 4 | "fr-FR": require("./fr-FR"), 5 | }; 6 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = process.env.NODE_ENV === "production" ? require("./webpack.config.production.js") : require("./webpack.config.development.js"); 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | public/eventsourcePolyfill.js 3 | public/webpackHotMiddlewareClient.js 4 | public/*.hot-update.json 5 | public/*.hot-update.js 6 | .cache 7 | -------------------------------------------------------------------------------- /app/common/utils/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Prototype modifications, common utils loaded before the main script 3 | */ 4 | export * from "./jquery"; 5 | export * from "./string"; 6 | -------------------------------------------------------------------------------- /app/styles/elements/Pagination.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Styles for the Pagination component. 3 | */ 4 | .nav { 5 | text-align: center; 6 | } 7 | 8 | .pointer { 9 | cursor: pointer; 10 | } 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | sudo: required 3 | dist: trusty 4 | language: node_js 5 | node_js: 6 | - 4 7 | install: 8 | - npm install 9 | script: 10 | - "npm run build:dev" 11 | - "npm run clean" 12 | - "npm run build:prod" 13 | - "npm test" 14 | -------------------------------------------------------------------------------- /app/common/styles/common.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Common global styles. 3 | */ 4 | :global { 5 | /* No border on responsive table. */ 6 | @media (max-width: 767px) { 7 | .table-responsive { 8 | border: none; 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/locales/messagesDescriptors/Settings.js: -------------------------------------------------------------------------------- 1 | const messages = [ 2 | { 3 | "id": "app.settings.settings", 4 | "defaultMessage": "Settings", 5 | "description": "Settings translation", 6 | }, 7 | ]; 8 | 9 | export default messages; 10 | -------------------------------------------------------------------------------- /app/store/configureStore.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Store configuration 3 | */ 4 | if (process.env.NODE_ENV === "production") { 5 | module.exports = require("./configureStore.production.js"); 6 | } else { 7 | module.exports = require("./configureStore.development.js"); 8 | } 9 | -------------------------------------------------------------------------------- /app/common/styles/hacks.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Hacks for specific browsers and bugfixes. 3 | */ 4 | :global { 5 | /* Firefox hack for responsive table in Bootstrap */ 6 | @-moz-document url-prefix() { 7 | fieldset { 8 | display: table-cell; 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/utils/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Collection of utility functions and helpers. 3 | */ 4 | export * from "./ampache"; 5 | export * from "./immutable"; 6 | export * from "./locale"; 7 | export * from "./misc"; 8 | export * from "./pagination"; 9 | export * from "./reducers"; 10 | export * from "./url"; 11 | -------------------------------------------------------------------------------- /app/styles/Login.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Style for Login component. 3 | */ 4 | 5 | /** Variables */ 6 | $titleImage-size: $font-size-h1 + 10px; 7 | 8 | .titleImage { 9 | height: $titleImage-size; 10 | } 11 | 12 | @media (max-width: 767px) { 13 | .submit { 14 | text-align: center; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /index.production.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is the main JS entry point in production builds. 3 | */ 4 | 5 | // Load the common index 6 | const index = require("./index.all.js"); 7 | 8 | // Get the rendering function 9 | const render = index.onWindowIntl(); 10 | 11 | // Perform i18n and render 12 | index.Intl(render); 13 | -------------------------------------------------------------------------------- /app/views/DiscoverPage.jsx: -------------------------------------------------------------------------------- 1 | // NPM imports 2 | import React, { Component } from "react"; 3 | 4 | // Components 5 | import Discover from "../components/Discover"; 6 | 7 | /** 8 | * Discover page 9 | */ 10 | export default class DiscoverPage extends Component { 11 | render() { 12 | return ( 13 | 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is the main JS entry point. 3 | * It loads either the production or the development index file, based on the 4 | * environment variables in use. 5 | */ 6 | if (process.env.NODE_ENV === "production") { 7 | module.exports = require("./index.production.js"); 8 | } else { 9 | module.exports = require("./index.development.js"); 10 | } 11 | -------------------------------------------------------------------------------- /app/actions/store.js: -------------------------------------------------------------------------------- 1 | /** 2 | * These actions are actions acting directly on all the available stores. 3 | */ 4 | 5 | 6 | /** Define an action to invalidate all the stores, e.g. in case of logout. */ 7 | export const INVALIDATE_STORE = "INVALIDATE_STORE"; 8 | export function invalidateStore() { 9 | return { 10 | type: INVALIDATE_STORE, 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /app/components/layouts/Simple.jsx: -------------------------------------------------------------------------------- 1 | // NPM imports 2 | import React, { Component } from "react"; 3 | 4 | 5 | /** 6 | * Simple layout, meaning just enclosing children in a div. 7 | */ 8 | export default class SimpleLayout extends Component { 9 | render() { 10 | return ( 11 |
12 | {this.props.children} 13 |
14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/views/HomePage.jsx: -------------------------------------------------------------------------------- 1 | // NPM imports 2 | import React, { Component } from "react"; 3 | 4 | // Other views 5 | import ArtistsPage from "./ArtistsPage"; 6 | 7 | /** 8 | * Homepage is an alias for Artists page at the moment. 9 | */ 10 | export default class HomePage extends Component { 11 | render() { 12 | return ( 13 | 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/views/SettingsPage.jsx: -------------------------------------------------------------------------------- 1 | // NPM imports 2 | import React, { Component } from "react"; 3 | 4 | // Components 5 | import Settings from "../components/Settings"; 6 | 7 | /** 8 | * Paginated table of available songs 9 | */ 10 | class SettingsPage extends Component { 11 | render() { 12 | return ( 13 | 14 | ); 15 | } 16 | } 17 | 18 | export default SettingsPage; 19 | -------------------------------------------------------------------------------- /app/models/i18n.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file defines i18n related models. 3 | */ 4 | 5 | // NPM import 6 | import Immutable from "immutable"; 7 | 8 | /** i18n record for passing errors to be localized from actions to components */ 9 | export const i18nRecord = new Immutable.Record({ 10 | id: null, /** Translation message id */ 11 | values: new Immutable.Map(), /** Values to pass to formatMessage */ 12 | }); 13 | -------------------------------------------------------------------------------- /app/actions/pagination.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file defines pagination related actions. 3 | */ 4 | 5 | // NPM imports 6 | import { push } from "react-router-redux"; 7 | 8 | /** Define an action to go to a specific page. */ 9 | export function goToPage(pageLocation) { 10 | return (dispatch) => { 11 | // Just push the new page location in react-router. 12 | dispatch(push(pageLocation)); 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /app/views/BrowsePage.jsx: -------------------------------------------------------------------------------- 1 | // NPM imports 2 | import React, { Component } from "react"; 3 | 4 | // Other views 5 | import ArtistsPage from "./ArtistsPage"; 6 | 7 | 8 | /** 9 | * Browse page is an alias for artists page at the moment. 10 | */ 11 | export default class BrowsePage extends Component { 12 | render() { 13 | return ( 14 | 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard", 3 | "rules": { 4 | "indentation": 4, 5 | "selector-pseudo-class-no-unknown": [true, { 6 | ignorePseudoClasses: ["global"] 7 | }], 8 | "no-unsupported-browser-features": [true, { 9 | browsers: "ie >= 9, > 1%, last 3 versions, not op_mini all" 10 | }] 11 | }, 12 | "defaultSeverity": "error" 13 | } 14 | -------------------------------------------------------------------------------- /app/locales/messagesDescriptors/grid.js: -------------------------------------------------------------------------------- 1 | const messages = [ 2 | { 3 | id: "app.grid.goToArtistPage", 4 | defaultMessage: "Go to artist page", 5 | description: "Artist thumbnail link title", 6 | }, 7 | { 8 | id: "app.grid.goToAlbumPage", 9 | defaultMessage: "Go to album page", 10 | description: "Album thumbnail link title", 11 | }, 12 | ]; 13 | 14 | export default messages; 15 | -------------------------------------------------------------------------------- /app/styles/variables.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Global variables used across all CSS modules. 3 | */ 4 | 5 | /* Make Bootstrap variables and mixins available when using CSS modules. */ 6 | @import "node_modules/bootstrap-sass/assets/stylesheets/bootstrap/_variables"; 7 | @import "node_modules/bootstrap-sass/assets/stylesheets/bootstrap/_mixins"; 8 | 9 | $blue: #3e90fa; // Blue color from the logo 10 | $orange: #faa83e; // Orange color from the logo 11 | -------------------------------------------------------------------------------- /app/locales/messagesDescriptors/elements/FilterBar.js: -------------------------------------------------------------------------------- 1 | const messages = [ 2 | { 3 | id: "app.filter.filter", 4 | defaultMessage: "Filter…", 5 | description: "Filtering input placeholder", 6 | }, 7 | { 8 | id: "app.filter.whatAreWeListeningToToday", 9 | description: "Description for the filter bar", 10 | defaultMessage: "What are we listening to today?", 11 | }, 12 | ]; 13 | 14 | export default messages; 15 | -------------------------------------------------------------------------------- /app/models/paginated.js: -------------------------------------------------------------------------------- 1 | // NPM import 2 | import Immutable from "immutable"; 3 | 4 | /** Record to store the paginated pages state. */ 5 | export const stateRecord = new Immutable.Record({ 6 | type: null, /** Type of the paginated entries */ 7 | result: new Immutable.List(), /** List of IDs of the resulting entries, maps to the entities store */ 8 | currentPage: 1, /** Number of current page */ 9 | nPages: 1, /** Total number of page in this batch */ 10 | }); 11 | -------------------------------------------------------------------------------- /app/containers/App.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Main container at the top of our application components tree. 3 | * 4 | * Just a div wrapper around children for now. 5 | */ 6 | import React, { Component, PropTypes } from "react"; 7 | 8 | export default class App extends Component { 9 | render() { 10 | return ( 11 |
12 | {this.props.children} 13 |
14 | ); 15 | } 16 | } 17 | 18 | App.propTypes = { 19 | children: PropTypes.node, 20 | }; 21 | -------------------------------------------------------------------------------- /app/utils/immutable.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Collection of helper function to act on Immutable objects. 3 | */ 4 | 5 | 6 | /** 7 | * Diff two immutables objects supporting the filter method. 8 | * 9 | * @param a First Immutable object. 10 | * @param b Second Immutable object. 11 | * @returns An Immutable object equal to a except for the items in b. 12 | */ 13 | export function immutableDiff(a, b) { 14 | return a.filter(function (i) { 15 | return b.indexOf(i) < 0; 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /app/styles/elements/FilterBar.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Styles for the FilterBar component. 3 | */ 4 | 5 | /** Variables */ 6 | $marginBottom: 34px; 7 | 8 | .filter { 9 | margin-bottom: $marginBottom; 10 | } 11 | 12 | .legend { 13 | text-align: right; 14 | line-height: $marginBottom; 15 | } 16 | 17 | @media (max-width: 767px) { 18 | .legend { 19 | text-align: center; 20 | } 21 | } 22 | 23 | @media (min-width: 767px) { 24 | .form-group { 25 | width: 75%; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/locales/messagesDescriptors/Songs.js: -------------------------------------------------------------------------------- 1 | const messages = [ 2 | { 3 | "id": "app.songs.title", 4 | "description": "Title (song)", 5 | "defaultMessage": "Title", 6 | }, 7 | { 8 | "id": "app.songs.genre", 9 | "description": "Genre (song)", 10 | "defaultMessage": "Genre", 11 | }, 12 | { 13 | "id": "app.songs.length", 14 | "description": "Length (song)", 15 | "defaultMessage": "Length", 16 | }, 17 | ]; 18 | 19 | export default messages; 20 | -------------------------------------------------------------------------------- /app/styles/Songs.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Style for Songs component. 3 | */ 4 | .play, 5 | .playNext { 6 | background-color: transparent; 7 | border: none; 8 | text-align: center; 9 | vertical-align: middle; 10 | line-height: 1em; 11 | } 12 | 13 | @media (max-width: 767px) { 14 | .songs { 15 | > thead, 16 | > tbody, 17 | > tfoot { 18 | > tr { 19 | > th, 20 | > td { 21 | padding: $table-condensed-cell-padding; 22 | } 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/styles/Artist.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Styles for Artist component. 3 | */ 4 | 5 | /** Variables */ 6 | $artMarginBottom: 10px; 7 | 8 | .name > h1 { 9 | margin-bottom: 0; 10 | } 11 | 12 | .name > hr { 13 | margin-top: 0.5em; /* Default value. */ 14 | } 15 | 16 | .art { 17 | display: inline-block; 18 | margin-bottom: $artMarginBottom; 19 | width: 75%; 20 | height: auto; 21 | 22 | /* doiuse-disable viewport-units */ 23 | 24 | max-width: 25vw; 25 | 26 | /* doiuse-enable viewport-units */ 27 | } 28 | 29 | .art:hover { 30 | transform: scale(1.1); 31 | } 32 | -------------------------------------------------------------------------------- /webpack.config.development.js: -------------------------------------------------------------------------------- 1 | var webpack = require("webpack"); 2 | var config = require("./webpack.config.base.js"); 3 | 4 | // Use cheap source maps 5 | config.devtool = "cheap-module-eval-source-map"; 6 | 7 | // Necessary for hot reloading with IE: 8 | config.entry.index.splice(1, 0, 'eventsource-polyfill'); 9 | // Listen to code updates emitted by hot middleware: 10 | config.entry.index.splice(2, 0, 'webpack-hot-middleware/client'); 11 | 12 | // Hot reloading stuff 13 | config.plugins = config.plugins.concat([ 14 | new webpack.HotModuleReplacementPlugin() 15 | ]); 16 | 17 | module.exports = config; 18 | -------------------------------------------------------------------------------- /app/common/utils/string.js: -------------------------------------------------------------------------------- 1 | /** 2 | * String prototype extension. 3 | */ 4 | 5 | 6 | /** 7 | * Capitalize a string. 8 | * 9 | * @return Capitalized string. 10 | */ 11 | String.prototype.capitalize = function () { 12 | return this.charAt(0).toUpperCase() + this.slice(1); 13 | }; 14 | 15 | 16 | /** 17 | * Strip characters at the end of a string. 18 | * 19 | * @param chars A regex-like element to strip from the end. 20 | * @return Stripped string. 21 | */ 22 | String.prototype.rstrip = function (chars) { 23 | let regex = new RegExp(chars + "$"); 24 | return this.replace(regex, ""); 25 | }; 26 | -------------------------------------------------------------------------------- /app/locales/messagesDescriptors/api.js: -------------------------------------------------------------------------------- 1 | const messages = [ 2 | { 3 | id: "app.api.invalidResponse", 4 | defaultMessage: "Invalid response text.", 5 | description: "Invalid response from the API", 6 | }, 7 | { 8 | id: "app.api.emptyResponse", 9 | defaultMessage: "Empty response text.", 10 | description: "Empty response from the API", 11 | }, 12 | { 13 | id: "app.api.error", 14 | defaultMessage: "Unknown API error.", 15 | description: "An unknown error occurred from the API", 16 | }, 17 | ]; 18 | 19 | export default messages; 20 | -------------------------------------------------------------------------------- /app/utils/reducers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Collection of helper functions to deal with reducers. 3 | */ 4 | 5 | 6 | /** 7 | * Utility function to create a reducer. 8 | * 9 | * @param initialState Initial state of the reducer. 10 | * @param reducerMap Map between action types and reducing functions. 11 | * 12 | * @return A reducer. 13 | */ 14 | export function createReducer(initialState, reducerMap) { 15 | return (state = initialState, action) => { 16 | const reducer = reducerMap[action.type]; 17 | 18 | return reducer 19 | ? reducer(state, action.payload) 20 | : state; 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /public/ie10-viewport-bug-workaround/ie10-viewport-bug-workaround.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * IE10 viewport hack for Surface/desktop Windows 8 bug 3 | * Copyright 2014-2015 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | 7 | /* 8 | * See the Getting Started docs for more information: 9 | * http://getbootstrap.com/getting-started/#support-ie10-width 10 | */ 11 | @-webkit-viewport { width: device-width; } 12 | @-moz-viewport { width: device-width; } 13 | @-ms-viewport { width: device-width; } 14 | @-o-viewport { width: device-width; } 15 | @viewport { width: device-width; } -------------------------------------------------------------------------------- /app/vendor/ie10-viewport-bug-workaround/ie10-viewport-bug-workaround.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * IE10 viewport hack for Surface/desktop Windows 8 bug 3 | * Copyright 2014-2015 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | 7 | /* 8 | * See the Getting Started docs for more information: 9 | * http://getbootstrap.com/getting-started/#support-ie10-width 10 | */ 11 | @-webkit-viewport { width: device-width; } 12 | @-moz-viewport { width: device-width; } 13 | @-ms-viewport { width: device-width; } 14 | @-o-viewport { width: device-width; } 15 | @viewport { width: device-width; } -------------------------------------------------------------------------------- /app/styles/elements/Grid.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Style for the Grid component. 3 | */ 4 | 5 | /** Variables */ 6 | $marginBottom: 30px; 7 | $artMarginBottom: 10px; 8 | 9 | .placeholders { 10 | margin-bottom: $marginBottom; 11 | text-align: center; 12 | } 13 | 14 | .name { 15 | margin-bottom: 0; 16 | } 17 | 18 | .art { 19 | display: inline-block; 20 | margin-bottom: $artMarginBottom; 21 | width: 75%; 22 | height: auto; 23 | 24 | /* doiuse-disable viewport-units */ 25 | 26 | max-width: 25vw; 27 | 28 | /* doiuse-enable viewport-units */ 29 | } 30 | 31 | .art:hover { 32 | transform: scale(1.1); 33 | cursor: pointer; 34 | } 35 | -------------------------------------------------------------------------------- /app/store/configureStore.production.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from "redux"; 2 | import { hashHistory } from "react-router"; 3 | import { routerMiddleware } from "react-router-redux"; 4 | import thunkMiddleware from "redux-thunk"; 5 | 6 | import rootReducer from "../reducers"; 7 | import apiMiddleware from "../middleware/api"; 8 | 9 | // Use history 10 | const historyMiddleware = routerMiddleware(hashHistory); 11 | 12 | export default function configureStore(preloadedState) { 13 | return createStore( 14 | rootReducer, 15 | preloadedState, 16 | applyMiddleware(thunkMiddleware, apiMiddleware, historyMiddleware) 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /hooks/pre-commit: -------------------------------------------------------------------------------- 1 | set -e 2 | 3 | # Get against which ref to diff 4 | if git rev-parse --verify HEAD >/dev/null 2>&1 5 | then 6 | against=HEAD 7 | else 8 | # Something weird, initial commit 9 | exit 1 10 | fi 11 | 12 | # List all the modified CSS and JS files (not in output path) 13 | css_js_files=$(git diff-index --name-only $against | grep -e '.\(jsx\?\)\|\(s\?css\)$' | grep -v "^public") 14 | 15 | # Nothing more to do if no JS files was committed 16 | if [ -z "$css_js_files" ] 17 | then 18 | exit 0 19 | fi 20 | 21 | # Else, rebuild as production, run tests and add files 22 | echo "Rebuilding dist JavaScript files…" 23 | npm test 24 | npm run clean 25 | npm run build:prod 26 | git add public 27 | -------------------------------------------------------------------------------- /app/models/webplayer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file defines authentication related models. 3 | */ 4 | 5 | // NPM imports 6 | import Immutable from "immutable"; 7 | 8 | 9 | /** Record to store the webplayer state. */ 10 | export const stateRecord = new Immutable.Record({ 11 | isPlaying: false, /** Whether webplayer is playing */ 12 | isRandom: false, /** Whether random mode is on */ 13 | isRepeat: false, /** Whether repeat mode is on */ 14 | isMute: false, /** Whether sound is muted or not */ 15 | volume: 100, /** Current volume, between 0 and 100 */ 16 | currentIndex: 0, /** Current index in the playlist */ 17 | playlist: new Immutable.List(), /** List of songs IDs, references songs in the entities store */ 18 | error: null, /** An error string */ 19 | }); 20 | -------------------------------------------------------------------------------- /app/views/LogoutPage.jsx: -------------------------------------------------------------------------------- 1 | // NPM imports 2 | import React, { Component } from "react"; 3 | import { bindActionCreators } from "redux"; 4 | import { connect } from "react-redux"; 5 | 6 | // Actions 7 | import * as actionCreators from "../actions"; 8 | 9 | 10 | /** 11 | * Logout page 12 | */ 13 | export class LogoutPage extends Component { 14 | componentWillMount() { 15 | // Logout when component is mounted 16 | this.props.actions.logoutAndRedirect(); 17 | } 18 | 19 | render() { 20 | return ( 21 |
22 | ); 23 | } 24 | } 25 | 26 | const mapDispatchToProps = (dispatch) => ({ 27 | actions: bindActionCreators(actionCreators, dispatch), 28 | }); 29 | 30 | export default connect(null, mapDispatchToProps)(LogoutPage); 31 | -------------------------------------------------------------------------------- /app/vendor/ie10-viewport-bug-workaround/ie10-viewport-bug-workaround.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * IE10 viewport hack for Surface/desktop Windows 8 bug 3 | * Copyright 2014-2015 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | 7 | // See the Getting Started docs for more information: 8 | // http://getbootstrap.com/getting-started/#support-ie10-width 9 | 10 | (function () { 11 | 'use strict'; 12 | 13 | if (navigator.userAgent.match(/IEMobile\/10\.0/)) { 14 | var msViewportStyle = document.createElement('style') 15 | msViewportStyle.appendChild( 16 | document.createTextNode( 17 | '@-ms-viewport{width:auto!important}' 18 | ) 19 | ) 20 | document.querySelector('head').appendChild(msViewportStyle) 21 | } 22 | 23 | })(); 24 | -------------------------------------------------------------------------------- /public/ie10-viewport-bug-workaround/ie10-viewport-bug-workaround.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * IE10 viewport hack for Surface/desktop Windows 8 bug 3 | * Copyright 2014-2015 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | 7 | // See the Getting Started docs for more information: 8 | // http://getbootstrap.com/getting-started/#support-ie10-width 9 | 10 | (function () { 11 | 'use strict'; 12 | 13 | if (navigator.userAgent.match(/IEMobile\/10\.0/)) { 14 | var msViewportStyle = document.createElement('style') 15 | msViewportStyle.appendChild( 16 | document.createTextNode( 17 | '@-ms-viewport{width:auto!important}' 18 | ) 19 | ) 20 | document.querySelector('head').appendChild(msViewportStyle) 21 | } 22 | 23 | })(); 24 | -------------------------------------------------------------------------------- /app/reducers/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | */ 4 | 5 | // NPM imports 6 | import { routerReducer as routing } from "react-router-redux"; 7 | import { combineReducers } from "redux"; 8 | 9 | // Import all the available reducers 10 | import auth from "./auth"; 11 | import entities from "./entities"; 12 | import paginatedMaker from "./paginated"; 13 | import webplayer from "./webplayer"; 14 | 15 | // Actions 16 | import * as ActionTypes from "../actions"; 17 | 18 | // Build paginated reducer 19 | const paginated = paginatedMaker([ 20 | ActionTypes.API_REQUEST, 21 | ActionTypes.API_SUCCESS, 22 | ActionTypes.API_FAILURE, 23 | ]); 24 | 25 | // Export the combined reducers 26 | export default combineReducers({ 27 | routing, 28 | auth, 29 | entities, 30 | paginated, 31 | webplayer, 32 | }); 33 | -------------------------------------------------------------------------------- /app/store/configureStore.development.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from "redux"; 2 | import { hashHistory } from "react-router"; 3 | import { routerMiddleware } from "react-router-redux"; 4 | import thunkMiddleware from "redux-thunk"; 5 | import createLogger from "redux-logger"; 6 | 7 | import rootReducer from "../reducers"; 8 | import apiMiddleware from "../middleware/api"; 9 | 10 | // Use history and log everything during dev 11 | const historyMiddleware = routerMiddleware(hashHistory); 12 | const loggerMiddleware = createLogger(); 13 | 14 | export default function configureStore(preloadedState) { 15 | return createStore( 16 | rootReducer, 17 | preloadedState, 18 | applyMiddleware(thunkMiddleware, apiMiddleware, historyMiddleware, loggerMiddleware) 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /app/models/api.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file defines API related models. 3 | */ 4 | 5 | // NPM imports 6 | import { Schema, arrayOf } from "normalizr"; 7 | 8 | 9 | // Define normalizr schemas for major entities returned by the API 10 | export const artist = new Schema("artist"); /** Artist schema */ 11 | export const album = new Schema("album"); /** Album schema */ 12 | export const song = new Schema("song"); /** Song schema */ 13 | 14 | // Explicit relations between them 15 | artist.define({ // Artist has albums and songs (tracks) 16 | albums: arrayOf(album), 17 | songs: arrayOf(song), 18 | }); 19 | 20 | album.define({ // Album has artist, tracks and tags 21 | artist: artist, 22 | tracks: arrayOf(song), 23 | }); 24 | 25 | song.define({ // Track has artist and album 26 | artist: artist, 27 | album: album, 28 | }); 29 | -------------------------------------------------------------------------------- /app/actions/paginated.js: -------------------------------------------------------------------------------- 1 | /** 2 | * These actions are actions acting directly on the paginated views store. 3 | */ 4 | 5 | // Other actions 6 | import { decrementRefCount } from "./entities"; 7 | 8 | 9 | /** Define an action to invalidate results in paginated store. */ 10 | export const CLEAR_PAGINATED_RESULTS = "CLEAR_PAGINATED_RESULTS"; 11 | export function clearPaginatedResults() { 12 | return (dispatch, getState) => { 13 | // Decrement reference counter 14 | const paginatedStore = getState().paginated; 15 | const entities = {}; 16 | entities[paginatedStore.get("type")] = paginatedStore.get("result").toJS(); 17 | dispatch(decrementRefCount(entities)); 18 | 19 | // Clear results in store 20 | dispatch({ 21 | type: CLEAR_PAGINATED_RESULTS, 22 | }); 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /app/models/entities.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file defines entities storage models. 3 | */ 4 | 5 | // NPM imports 6 | import Immutable from "immutable"; 7 | 8 | /** Record to store the shared entities. */ 9 | export const stateRecord = new Immutable.Record({ 10 | isFetching: false, /** Whether API fetching is in progress */ 11 | error: null, /** An error string */ 12 | refCounts: new Immutable.Map({ 13 | album: new Immutable.Map(), 14 | artist: new Immutable.Map(), 15 | song: new Immutable.Map(), 16 | }), /** Map of id => reference count for each object type (garbage collection) */ 17 | entities: new Immutable.Map({ 18 | album: new Immutable.Map(), 19 | artist: new Immutable.Map(), 20 | song: new Immutable.Map(), 21 | }), /** Map of id => entity for each object type */ 22 | }); 23 | -------------------------------------------------------------------------------- /app/common/utils/jquery.js: -------------------------------------------------------------------------------- 1 | /** 2 | * jQuery prototype extensions. 3 | */ 4 | 5 | 6 | /** 7 | * Shake animation. 8 | * 9 | * @param intShakes Number of times to shake. 10 | * @param intDistance Distance to move the object. 11 | * @param intDuration Duration of the animation. 12 | * 13 | * @return The element it was applied one, for chaining. 14 | */ 15 | $.fn.shake = function (intShakes, intDistance, intDuration) { 16 | this.each(function () { 17 | $(this).css("position","relative"); 18 | for (let x=1; x<=intShakes; x++) { 19 | $(this).animate({left:(intDistance*-1)}, (((intDuration/intShakes)/4))) 20 | .animate({left:intDistance}, ((intDuration/intShakes)/2)) 21 | .animate({left:0}, (((intDuration/intShakes)/4))); 22 | } 23 | }); 24 | return this; 25 | }; 26 | -------------------------------------------------------------------------------- /app/utils/misc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Miscellaneous helper functions. 3 | */ 4 | 5 | 6 | /** 7 | * Strict int checking function. 8 | * 9 | * @param value The value to check for int. 10 | * @return Either NaN if the string was not a valid int representation, or the 11 | * int. 12 | */ 13 | export function filterInt(value) { 14 | if (/^(\-|\+)?([0-9]+|Infinity)$/.test(value)) { 15 | return Number(value); 16 | } 17 | return NaN; 18 | } 19 | 20 | 21 | /** 22 | * Helper to format song length. 23 | * 24 | * @param time Length of the song in seconds. 25 | * @return Formatted length as MM:SS. 26 | */ 27 | export function formatLength(time) { 28 | const min = Math.floor(time / 60); 29 | let sec = (time - 60 * min); 30 | if (sec < 10) { 31 | sec = "0" + sec; 32 | } 33 | return min + ":" + sec; 34 | } 35 | -------------------------------------------------------------------------------- /app/styles/Discover.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Style for Discover component. 3 | */ 4 | 5 | // TODO: Fix this style 6 | 7 | .noMarginTop { 8 | margin-top: 0; 9 | } 10 | 11 | .h2Title { 12 | border: none; 13 | font-size: $font-size-h2; 14 | padding: 0; 15 | line-height: 1em; 16 | } 17 | 18 | .h2Title:active, 19 | .h2Title:hover, 20 | .h2Title:focus { 21 | background-color: transparent; 22 | } 23 | 24 | .caret { 25 | display: inline-block; 26 | margin-top: 1em; 27 | } 28 | 29 | .h2Title .caret { 30 | margin-left: 3px; 31 | } 32 | 33 | .dashedUnderline { 34 | border-bottom: 1px dotted black; 35 | } 36 | 37 | .dashedUnderline, 38 | .caret { 39 | /* Fix for alignment of caret and border-bottom */ 40 | display: inline-block; 41 | float: left; 42 | } 43 | 44 | .dropdown-menu { 45 | left: 0; 46 | right: 0; 47 | min-width: 0; 48 | } 49 | -------------------------------------------------------------------------------- /app/utils/ampache.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Collection of helper function that are Ampache specific. 3 | */ 4 | 5 | // NPM imports 6 | import jsSHA from "jssha"; 7 | 8 | 9 | /** 10 | * Build an HMAC token for authentication against Ampache API. 11 | * 12 | * @param password User password to derive HMAC from. 13 | * @return An object with the generated HMAC and time used. 14 | * 15 | * @remark This builds an HMAC as expected by Ampache API, which is not a 16 | * standard HMAC. 17 | */ 18 | export function buildHMAC(password) { 19 | const time = Math.floor(Date.now() / 1000); 20 | 21 | let shaObj = new jsSHA("SHA-256", "TEXT"); 22 | shaObj.update(password); 23 | const key = shaObj.getHash("HEX"); 24 | 25 | shaObj = new jsSHA("SHA-256", "TEXT"); 26 | shaObj.update(time + key); 27 | 28 | return { 29 | time: time, 30 | passphrase: shaObj.getHash("HEX"), 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /app/locales/messagesDescriptors/Playlist.js: -------------------------------------------------------------------------------- 1 | const messages = [ 2 | { 3 | "id": "app.playlist.playlist", 4 | "defaultMessage": "Playlist", 5 | "description": "Playlist translation", 6 | }, 7 | { 8 | "id": "app.playlist.currentSongPlaying", 9 | "defaultMessage": "Current song playing", 10 | "description": "Current song playing", 11 | }, 12 | { 13 | "id": "app.playlist.fullPlaylist", 14 | "defaultMessage": "Full playlist", 15 | "description": "Full playlist", 16 | }, 17 | { 18 | "id": "app.playlist.emptyPlaylist", 19 | "defaultMessage": "Empty playlist", 20 | "description": "Empty playlist message", 21 | }, 22 | { 23 | "id": "app.playlist.flushPlaylist", 24 | "defaultMessage": "Empty the playlist", 25 | "description": "Empty the playlist link label", 26 | }, 27 | ]; 28 | 29 | export default messages; 30 | -------------------------------------------------------------------------------- /app/components/elements/DismissibleAlert.jsx: -------------------------------------------------------------------------------- 1 | // NPM imports 2 | import React, { Component, PropTypes } from "react"; 3 | 4 | 5 | /** 6 | * A dismissible Bootstrap alert. 7 | */ 8 | export default class DismissibleAlert extends Component { 9 | render() { 10 | // Set correct alert type 11 | let alertType = "alert-danger"; 12 | if (this.props.type) { 13 | alertType = "alert-" + this.props.type; 14 | } 15 | 16 | return ( 17 |
18 |

19 | 22 | {this.props.text} 23 |

24 |
25 | ); 26 | } 27 | } 28 | DismissibleAlert.propTypes = { 29 | type: PropTypes.string, 30 | text: PropTypes.string, 31 | }; 32 | -------------------------------------------------------------------------------- /app/models/auth.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file defines authentication related models. 3 | */ 4 | 5 | // NPM imports 6 | import Immutable from "immutable"; 7 | 8 | 9 | /** Record to store token parameters */ 10 | export const tokenRecord = Immutable.Record({ 11 | token: null, /** Token string */ 12 | expires: null, /** Token expiration date */ 13 | }); 14 | 15 | 16 | /** Record to store the full auth state */ 17 | export const stateRecord = new Immutable.Record({ 18 | token: new tokenRecord(), /** Auth token */ 19 | username: null, /** Username */ 20 | endpoint: null, /** Ampache server base URL */ 21 | rememberMe: false, /** Whether to remember me or not */ 22 | isAuthenticated: false, /** Whether authentication is ok or not */ 23 | isAuthenticating: false, /** Whether authentication is in progress or not */ 24 | error: null, /** An error string */ 25 | info: null, /** An info string */ 26 | timerID: null, /** Timer ID for setInterval calls to revive API session */ 27 | }); 28 | -------------------------------------------------------------------------------- /app/locales/messagesDescriptors/elements/Pagination.js: -------------------------------------------------------------------------------- 1 | const messages = [ 2 | { 3 | id: "app.pagination.goToPage", 4 | defaultMessage: "Go to page {pageNumber}", 5 | description: "Link content to go to page N. span is here for screen-readers", 6 | }, 7 | { 8 | id: "app.pagination.goToPageWithoutMarkup", 9 | defaultMessage: "Go to page {pageNumber}", 10 | description: "Link title to go to page N", 11 | }, 12 | { 13 | id: "app.pagination.pageNavigation", 14 | defaultMessage: "Page navigation", 15 | description: "ARIA label for the nav block containing pagination", 16 | }, 17 | { 18 | id: "app.pagination.pageToGoTo", 19 | description: "Title of the pagination modal", 20 | defaultMessage: "Page to go to?", 21 | }, 22 | { 23 | id: "app.pagination.current", 24 | description: "Current (page)", 25 | defaultMessage: "current", 26 | }, 27 | ]; 28 | 29 | export default messages; 30 | -------------------------------------------------------------------------------- /app/actions/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Export all the available actions 3 | */ 4 | 5 | // Auth related actions 6 | export * from "./auth"; 7 | 8 | // API related actions for all the available types 9 | import APIAction from "./APIActions"; 10 | 11 | // Actions related to API 12 | export const API_SUCCESS = "API_SUCCESS"; 13 | export const API_REQUEST = "API_REQUEST"; 14 | export const API_FAILURE = "API_FAILURE"; 15 | export var { 16 | loadPaginatedArtists, loadArtist } = APIAction("artists", API_REQUEST, API_SUCCESS, API_FAILURE); 17 | export var { 18 | loadPaginatedAlbums, loadAlbum } = APIAction("albums", API_REQUEST, API_SUCCESS, API_FAILURE); 19 | export var { 20 | loadPaginatedSongs, loadSong } = APIAction("songs", API_REQUEST, API_SUCCESS, API_FAILURE); 21 | 22 | // Entities actions 23 | export * from "./entities"; 24 | 25 | // Paginated views store actions 26 | export * from "./paginated"; 27 | 28 | // Pagination actions 29 | export * from "./pagination"; 30 | 31 | // Store actions 32 | export * from "./store"; 33 | 34 | // Webplayer actions 35 | export * from "./webplayer"; 36 | -------------------------------------------------------------------------------- /webpack.config.production.js: -------------------------------------------------------------------------------- 1 | var webpack = require("webpack"); 2 | var config = require("./webpack.config.base.js"); 3 | 4 | // Report first error as hard error 5 | config.bail = true; 6 | config.debug = false; 7 | // Do not capture timing information for each module 8 | config.profile = false; 9 | // Emit source map 10 | config.devtool = "#source-map"; 11 | 12 | config.plugins = config.plugins.concat([ 13 | new webpack.NoErrorsPlugin(), // Any error is considered a failure 14 | new webpack.DefinePlugin({ // Set production environment variable 15 | 'process.env': { 16 | 'NODE_ENV': JSON.stringify('production') 17 | } 18 | }), 19 | // Optimizations 20 | new webpack.optimize.OccurenceOrderPlugin(true), 21 | new webpack.optimize.DedupePlugin(), 22 | // Minifications 23 | new webpack.optimize.UglifyJsPlugin({ 24 | output: { 25 | comments: false 26 | }, 27 | compress: { 28 | warnings: false, 29 | screw_ie8: true 30 | } 31 | }) 32 | ]); 33 | 34 | module.exports = config; 35 | -------------------------------------------------------------------------------- /app/containers/Root.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Root component to render, setting locale, messages, Router and Store. 3 | */ 4 | import React, { Component, PropTypes } from "react"; 5 | import { Provider } from "react-redux"; 6 | import { Router } from "react-router"; 7 | import { IntlProvider } from "react-intl"; 8 | 9 | import routes from "../routes"; 10 | 11 | export default class Root extends Component { 12 | render() { 13 | const { locale, messages, defaultLocale, store, history, render } = this.props; 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | } 22 | } 23 | 24 | Root.propTypes = { 25 | store: PropTypes.object.isRequired, 26 | history: PropTypes.object.isRequired, 27 | render: PropTypes.func, 28 | locale: PropTypes.string.isRequired, 29 | messages: PropTypes.object.isRequired, 30 | defaultLocale: PropTypes.string.isRequired, 31 | }; 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Phyks(Lucas Verney) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Ampache music player 13 | 14 | 17 | 18 | 19 | 20 | 21 | 22 | 26 | 27 | 28 | 29 |
30 | 31 |
32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /app/styles/elements/WebPlayer.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Styles for the WebPlayer component. 3 | */ 4 | 5 | /** Variables */ 6 | $controlsMarginTop: 10px; 7 | 8 | .webplayer { 9 | margin-top: 1em; 10 | } 11 | 12 | .art { 13 | opacity: 0.75; 14 | position: absolute; 15 | z-index: -10; 16 | } 17 | 18 | .artRow { 19 | min-height: 200px; 20 | position: relative; 21 | } 22 | 23 | .artTimer { 24 | position: absolute; 25 | width: 50px; 26 | height: 200px; 27 | background-color: black; 28 | opacity: 0.75; 29 | z-index: -9; 30 | } 31 | 32 | .artDuration { 33 | position: absolute; 34 | bottom: 0; 35 | right: 0; 36 | text-align: right; 37 | } 38 | 39 | /** 40 | * Controls 41 | */ 42 | 43 | .controls { 44 | margin-top: $controlsMarginTop; 45 | } 46 | 47 | .btn { 48 | background: transparent; 49 | border: none; 50 | opacity: 0.4; 51 | } 52 | 53 | .btn:hover, 54 | .btn:active, 55 | .btn:focus { 56 | opacity: 1; 57 | outline: none; 58 | } 59 | 60 | .prevBtn, 61 | .playPauseBtn, 62 | .nextBtn, 63 | .volumeBtn, 64 | .repeatBtn, 65 | .randomBtn, 66 | .playlistBtn { 67 | composes: btn; 68 | } 69 | 70 | .playPauseBtn { 71 | font-size: $font-size-h2; 72 | } 73 | 74 | .playlistBtn { 75 | color: white; 76 | } 77 | 78 | .active { 79 | color: $blue; 80 | } 81 | -------------------------------------------------------------------------------- /index.development.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is the main JS entry point in development build. 3 | */ 4 | import React from "react"; 5 | import ReactDOM from "react-dom"; 6 | 7 | // Load react-a11y for accessibility overview 8 | var a11y = require("react-a11y"); 9 | a11y(React, { ReactDOM: ReactDOM, includeSrcNode: true }); 10 | 11 | // Load common index 12 | const index = require("./index.all.js"); 13 | 14 | // Initial rendering function from common index 15 | var render = index.onWindowIntl(); 16 | if (module.hot) { 17 | // If we support hot reloading of components, 18 | // display an overlay for runtime errors 19 | const renderApp = render; 20 | const renderError = (error) => { 21 | const RedBox = require("redbox-react").default; 22 | ReactDOM.render( 23 | , 24 | index.rootElement 25 | ); 26 | }; 27 | 28 | // Try to render, and display an overlay for runtime errors 29 | render = () => { 30 | try { 31 | renderApp(); 32 | } catch (error) { 33 | console.error(error); 34 | renderError(error); 35 | } 36 | }; 37 | 38 | module.hot.accept("./app/containers/Root", () => { 39 | setTimeout(render); 40 | }); 41 | } 42 | 43 | // Perform i18n and render 44 | index.Intl(render); 45 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Ampache music player 13 | 14 | 17 | 18 | 19 | 20 | 21 | 22 | 26 | 27 | 28 | 29 |
30 | 31 |
32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /app/styles/Album.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Album component style. 3 | */ 4 | 5 | /** Variables */ 6 | $rowMarginTop: 30px; 7 | $rowMarginBottom: 10px; 8 | $artMarginBottom: 10px; 9 | 10 | /* Style for an album row */ 11 | .row { 12 | margin-top: $rowMarginTop; 13 | } 14 | 15 | /* Style for album arts */ 16 | .art { 17 | display: inline-block; 18 | margin-bottom: $artMarginBottom; 19 | width: 75%; 20 | height: auto; 21 | 22 | /* doiuse-disable viewport-units */ 23 | 24 | max-width: 25vw; 25 | 26 | /* doiuse-enable viewport-units */ 27 | } 28 | 29 | .art:hover { 30 | transform: scale(1.1); 31 | } 32 | 33 | /* Play button is based on the one in Songs list. */ 34 | .play { 35 | composes: play from "./Songs.scss"; 36 | } 37 | 38 | /* Play next button is based on the one in Songs list. */ 39 | .playNext { 40 | composes: playNext from "./Songs.scss"; 41 | } 42 | 43 | @media (max-width: 767px) { 44 | .nameRow h2 { 45 | margin-top: 0; 46 | margin-bottom: 0; 47 | } 48 | 49 | .artRow p, 50 | .artRow img { 51 | margin: 0; 52 | } 53 | 54 | .nameRow, 55 | .artRow { 56 | float: none; 57 | display: inline-block; 58 | vertical-align: middle; 59 | margin-bottom: $rowMarginBottom; 60 | } 61 | 62 | .songs { 63 | composes: songs from "./Songs.scss"; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /app/components/Settings.jsx: -------------------------------------------------------------------------------- 1 | // NPM imports 2 | import React, { Component } from "react"; 3 | import { defineMessages, injectIntl, intlShape, FormattedMessage } from "react-intl"; 4 | 5 | // Local imports 6 | import { messagesMap } from "../utils"; 7 | 8 | // Translations 9 | import commonMessages from "../locales/messagesDescriptors/common"; 10 | import messages from "../locales/messagesDescriptors/Settings"; 11 | 12 | // Define translations 13 | const settingsMessages = defineMessages(messagesMap(Array.concat([], commonMessages, messages))); 14 | 15 | 16 | /** 17 | * A single row for a single song in the songs table. 18 | */ 19 | class SettingsIntl extends Component { 20 | render() { 21 | return ( 22 |
23 |
24 |
25 |

26 | 27 |

28 |
29 |
30 |
31 | 32 |
33 |
34 |

TODO

35 |
36 |
37 |
38 | ); 39 | } 40 | } 41 | SettingsIntl.propTypes = { 42 | intl: intlShape.isRequired, 43 | }; 44 | export default injectIntl(SettingsIntl); 45 | -------------------------------------------------------------------------------- /app/utils/url.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Collection of helper functions to deal with URLs. 3 | */ 4 | 5 | 6 | /** 7 | * Assemble a base URL and its GET parameters. 8 | * 9 | * @param endpoint Base URL. 10 | * @param params An object of GET params and their values. 11 | * 12 | * @return A string with the full URL with GET params. 13 | */ 14 | export function assembleURLAndParams(endpoint, params) { 15 | let url = endpoint + "?"; 16 | Object.keys(params).forEach( 17 | key => { 18 | if (Array.isArray(params[key])) { 19 | params[key].forEach(value => url += key + "[]=" + value + "&"); 20 | } else { 21 | url += key + "=" + params[key] + "&"; 22 | } 23 | } 24 | ); 25 | return url.rstrip("&"); 26 | } 27 | 28 | 29 | /** 30 | * Clean an endpoint URL. 31 | * 32 | * Adds the protocol prefix if not specified, remove trailing slash 33 | * 34 | * @param An URL 35 | * @return The cleaned URL 36 | */ 37 | export function cleanURL(endpoint) { 38 | if ( 39 | !endpoint.startsWith("//") && 40 | !endpoint.startsWith("http://") && 41 | !endpoint.startsWith("https://")) 42 | { 43 | // Handle endpoints of the form "ampache.example.com" 44 | // Append same protocol as currently in use, to avoid mixed content. 45 | endpoint = window.location.protocol + "//" + endpoint; 46 | } 47 | 48 | // Remove trailing slash 49 | endpoint = endpoint.replace(/\/$/, ""); 50 | 51 | return endpoint; 52 | } 53 | -------------------------------------------------------------------------------- /app/locales/messagesDescriptors/elements/WebPlayer.js: -------------------------------------------------------------------------------- 1 | const messages = [ 2 | { 3 | id: "app.webplayer.by", 4 | defaultMessage: "by", 5 | description: "Artist affiliation of a song", 6 | }, 7 | { 8 | id: "app.webplayer.previous", 9 | defaultMessage: "Previous", 10 | description: "Previous button description", 11 | }, 12 | { 13 | id: "app.webplayer.next", 14 | defaultMessage: "Next", 15 | description: "Next button description", 16 | }, 17 | { 18 | id: "app.webplayer.volume", 19 | defaultMessage: "Volume", 20 | description: "Volume button description", 21 | }, 22 | { 23 | id: "app.webplayer.repeat", 24 | defaultMessage: "Repeat", 25 | description: "Repeat button description", 26 | }, 27 | { 28 | id: "app.webplayer.random", 29 | defaultMessage: "Random", 30 | description: "Random button description", 31 | }, 32 | { 33 | id: "app.webplayer.playlist", 34 | defaultMessage: "Playlist", 35 | description: "Playlist button description", 36 | }, 37 | { 38 | "id": "app.webplayer.unsupported", 39 | "description": "Unsupported media type", 40 | "defaultMessage": "Unsupported media type", 41 | }, 42 | { 43 | "id": "app.webplayer.onLoadError", 44 | "description": "Error message in case a song could not be loaded", 45 | "defaultMessage": "Unable to load song", 46 | }, 47 | ]; 48 | 49 | export default messages; 50 | -------------------------------------------------------------------------------- /app/components/Artists.jsx: -------------------------------------------------------------------------------- 1 | // NPM imports 2 | import React, { Component, PropTypes } from "react"; 3 | import Immutable from "immutable"; 4 | 5 | // Other components 6 | import FilterablePaginatedGrid from "./elements/Grid"; 7 | import DismissibleAlert from "./elements/DismissibleAlert"; 8 | 9 | 10 | /** 11 | * Paginated artists grid 12 | */ 13 | export default class Artists extends Component { 14 | render() { 15 | // Handle error 16 | let error = null; 17 | if (this.props.error) { 18 | error = (); 19 | } 20 | 21 | // Define grid props 22 | const grid = { 23 | isFetching: this.props.isFetching, 24 | items: this.props.artists, 25 | itemsType: "artist", 26 | itemsLabel: "app.common.artist", 27 | subItemsType: "albums", 28 | subItemsLabel: "app.common.album", 29 | buildLinkTo: (itemType, item) => { 30 | return "/artist/" + item.get("id") + "-" + encodeURIComponent(item.get("name")); 31 | }, 32 | }; 33 | 34 | return ( 35 |
36 | { error } 37 | 38 |
39 | ); 40 | } 41 | } 42 | Artists.propTypes = { 43 | error: PropTypes.string, 44 | isFetching: PropTypes.bool.isRequired, 45 | artists: PropTypes.instanceOf(Immutable.List).isRequired, 46 | pagination: PropTypes.object.isRequired, 47 | }; 48 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "globals": { 7 | "$": false, 8 | "jQuery": false, 9 | "process": false, 10 | "module": true, 11 | "require": false 12 | }, 13 | "extends": "eslint:recommended", 14 | "installedESLint": true, 15 | "parserOptions": { 16 | "ecmaFeatures": { 17 | "experimentalObjectRestSpread": true, 18 | "jsx": true 19 | }, 20 | "sourceType": "module" 21 | }, 22 | "plugins": [ 23 | "react" 24 | ], 25 | "rules": { 26 | "indent": [ 27 | "error", 28 | 4, 29 | { "SwitchCase": 1 } 30 | ], 31 | "linebreak-style": [ 32 | "error", 33 | "unix" 34 | ], 35 | "quotes": [ 36 | "error", 37 | "double" 38 | ], 39 | "semi": [ 40 | "error", 41 | "always" 42 | ], 43 | "strict": [ 44 | "error", 45 | ], 46 | "comma-dangle": [ 47 | "error", 48 | "always-multiline" 49 | ], 50 | "space-before-function-paren": [ 51 | "error", 52 | { "anonymous": "always", "named": "never" } 53 | ], 54 | "react/jsx-uses-react": "error", 55 | "react/jsx-uses-vars": "error", 56 | 57 | // Disable no-console rule in production 58 | "no-console": process.env.NODE_ENV !== "production" ? "off" : "error" 59 | } 60 | }; 61 | -------------------------------------------------------------------------------- /app/locales/messagesDescriptors/layouts/Sidebar.js: -------------------------------------------------------------------------------- 1 | const messages = [ 2 | { 3 | id: "app.sidebarLayout.mainNavigationMenu", 4 | description: "ARIA label for the main navigation menu", 5 | defaultMessage: "Main navigation menu", 6 | }, 7 | { 8 | id: "app.sidebarLayout.home", 9 | description: "Home", 10 | defaultMessage: "Home", 11 | }, 12 | { 13 | id: "app.sidebarLayout.settings", 14 | description: "Settings", 15 | defaultMessage: "Settings", 16 | }, 17 | { 18 | id: "app.sidebarLayout.logout", 19 | description: "Logout", 20 | defaultMessage: "Logout", 21 | }, 22 | { 23 | id: "app.sidebarLayout.discover", 24 | description: "Discover", 25 | defaultMessage: "Discover", 26 | }, 27 | { 28 | id: "app.sidebarLayout.browse", 29 | description: "Browse", 30 | defaultMessage: "Browse", 31 | }, 32 | { 33 | id: "app.sidebarLayout.browseArtists", 34 | description: "Browse artists", 35 | defaultMessage: "Browse artists", 36 | }, 37 | { 38 | id: "app.sidebarLayout.browseAlbums", 39 | description: "Browse albums", 40 | defaultMessage: "Browse albums", 41 | }, 42 | { 43 | id: "app.sidebarLayout.browseSongs", 44 | description: "Browse songs", 45 | defaultMessage: "Browse songs", 46 | }, 47 | { 48 | id: "app.sidebarLayout.toggleNavigation", 49 | description: "Screen reader description of toggle navigation button", 50 | defaultMessage: "Toggle navigation", 51 | }, 52 | ]; 53 | 54 | export default messages; 55 | -------------------------------------------------------------------------------- /app/locales/messagesDescriptors/common.js: -------------------------------------------------------------------------------- 1 | const messages = [ 2 | { 3 | id: "app.common.close", 4 | defaultMessage: "Close", 5 | description: "Close", 6 | }, 7 | { 8 | id: "app.common.cancel", 9 | description: "Cancel", 10 | defaultMessage: "Cancel", 11 | }, 12 | { 13 | id: "app.common.go", 14 | description: "Go", 15 | defaultMessage: "Go", 16 | }, 17 | { 18 | id: "app.common.art", 19 | description: "Art", 20 | defaultMessage: "Art", 21 | }, 22 | { 23 | id: "app.common.artist", 24 | description: "Artist", 25 | defaultMessage: "{itemCount, plural, one {artist} other {artists}}", 26 | }, 27 | { 28 | id: "app.common.album", 29 | description: "Album", 30 | defaultMessage: "{itemCount, plural, one {album} other {albums}}", 31 | }, 32 | { 33 | id: "app.common.track", 34 | description: "Track", 35 | defaultMessage: "{itemCount, plural, one {track} other {tracks}}", 36 | }, 37 | { 38 | id: "app.common.loading", 39 | description: "Loading indicator", 40 | defaultMessage: "Loading…", 41 | }, 42 | { 43 | id: "app.common.play", 44 | description: "Play icon description", 45 | defaultMessage: "Play", 46 | }, 47 | { 48 | id: "app.common.pause", 49 | description: "Pause icon description", 50 | defaultMessage: "Pause", 51 | }, 52 | { 53 | id: "app.common.playNext", 54 | defaultMessage: "Play next", 55 | description: "Play next icon descripton", 56 | }, 57 | ]; 58 | 59 | export default messages; 60 | -------------------------------------------------------------------------------- /app/locales/messagesDescriptors/Login.js: -------------------------------------------------------------------------------- 1 | const messages = [ 2 | { 3 | id: "app.login.username", 4 | defaultMessage: "Username", 5 | description: "Username input placeholder", 6 | }, 7 | { 8 | id: "app.login.password", 9 | defaultMessage: "Password", 10 | description: "Password input placeholder", 11 | }, 12 | { 13 | id: "app.login.signIn", 14 | defaultMessage: "Sign in", 15 | description: "Sign in", 16 | }, 17 | { 18 | id: "app.login.endpointInputAriaLabel", 19 | defaultMessage: "URL of your Ampache instance (e.g. http://ampache.example.com)", 20 | description: "ARIA label for the endpoint input", 21 | }, 22 | { 23 | id: "app.login.rememberMe", 24 | description: "Remember me checkbox label", 25 | defaultMessage: "Remember me", 26 | }, 27 | { 28 | id: "app.login.greeting", 29 | description: "Greeting to welcome the user to the app", 30 | defaultMessage: "Welcome back on Ampache, let's go!", 31 | }, 32 | 33 | // From the auth reducer 34 | { 35 | id: "app.login.connecting", 36 | defaultMessage: "Connecting…", 37 | description: "Info message while trying to connect", 38 | }, 39 | { 40 | id: "app.login.success", 41 | defaultMessage: "Successfully logged in as { username }!", 42 | description: "Info message on successful login.", 43 | }, 44 | { 45 | id: "app.login.byebye", 46 | defaultMessage: "See you soon!", 47 | description: "Info message on successful logout", 48 | }, 49 | { 50 | id: "app.login.expired", 51 | defaultMessage: "Your session expired… =(", 52 | description: "Error message on expired session", 53 | }, 54 | ]; 55 | 56 | export default messages; 57 | -------------------------------------------------------------------------------- /scripts/extractTranslations.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This script extracts all the translations in the messagesDescriptors files 3 | * in the app, and generates a complete locale file for English. 4 | * 5 | * This script is meant to be run through `npm run extractTranslations`. 6 | * 7 | * TODO: Check that every identifier is actually used in the code. 8 | */ 9 | import * as fs from 'fs'; 10 | import {sync as globSync} from 'glob'; 11 | 12 | // Path to look for 13 | const MESSAGES_PATTERN = './app/locales/messagesDescriptors/**/*.js'; 14 | 15 | // Aggregates the default messages that were extracted from the example app's 16 | // React components via the React Intl Babel plugin. An error will be thrown if 17 | // there are messages in different components that use the same `id`. The result 18 | // is a flat collection of `id: message` pairs for the app's default locale. 19 | let defaultMessages = globSync(MESSAGES_PATTERN) 20 | .map((filename) => require("../" + filename).default) 21 | .reduce((collection, descriptors) => { 22 | descriptors.forEach(({id, description, defaultMessage}) => { 23 | if (collection.hasOwnProperty(id)) { 24 | throw new Error(`Duplicate message id: ${id}`); 25 | } 26 | 27 | collection.push({ 28 | id: id, 29 | description: description, 30 | defaultMessage: defaultMessage 31 | }); 32 | }); 33 | 34 | return collection; 35 | }, []); 36 | 37 | // Sort by id 38 | defaultMessages = defaultMessages.sort(function (item1, item2) { 39 | return item1.id.localeCompare(item2.id); 40 | }); 41 | 42 | // Output the English translation file 43 | console.log("module.exports = {"); 44 | defaultMessages.forEach(function (item) { 45 | console.log(" " + JSON.stringify(item.id) + ": " + JSON.stringify(item.defaultMessage) + ", // " + item.description); 46 | }); 47 | console.log("};"); 48 | -------------------------------------------------------------------------------- /app/reducers/paginated.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This implements a wrapper to create reducers for paginated content. 3 | */ 4 | 5 | // NPM imports 6 | import Immutable from "immutable"; 7 | 8 | // Local imports 9 | import { createReducer } from "../utils"; 10 | 11 | // Models 12 | import { stateRecord } from "../models/paginated"; 13 | 14 | // Actions 15 | import { CLEAR_PAGINATED_RESULTS, INVALIDATE_STORE } from "../actions"; 16 | 17 | 18 | /** Initial state of the reducer */ 19 | const initialState = new stateRecord(); 20 | 21 | 22 | /** 23 | * Creates a reducer managing pagination, given the action types to handle. 24 | */ 25 | export default function paginated(types) { 26 | // Check parameters 27 | if (!Array.isArray(types) || types.length !== 3) { 28 | throw new Error("Expected types to be an array of three elements."); 29 | } 30 | if (!types.every(t => typeof t === "string")) { 31 | throw new Error("Expected types to be strings."); 32 | } 33 | 34 | const [ requestType, successType, failureType ] = types; 35 | 36 | // Create reducer 37 | return createReducer(initialState, { 38 | [requestType]: (state) => { 39 | return state; 40 | }, 41 | [successType]: (state, payload) => { 42 | return ( 43 | state 44 | .set("type", payload.type) 45 | .set("result", Immutable.fromJS(payload.result)) 46 | .set("nPages", payload.nPages) 47 | .set("currentPage", payload.currentPage) 48 | ); 49 | }, 50 | [failureType]: (state) => { 51 | return state; 52 | }, 53 | [CLEAR_PAGINATED_RESULTS]: (state) => { 54 | return state.set("result", new Immutable.List()); 55 | }, 56 | [INVALIDATE_STORE]: () => { 57 | // Reset state on invalidation 58 | return new stateRecord(); 59 | }, 60 | }); 61 | } 62 | -------------------------------------------------------------------------------- /app/components/Albums.jsx: -------------------------------------------------------------------------------- 1 | // NPM imports 2 | import React, { Component, PropTypes } from "react"; 3 | import Immutable from "immutable"; 4 | 5 | // Local imports 6 | import FilterablePaginatedGrid from "./elements/Grid"; 7 | import DismissibleAlert from "./elements/DismissibleAlert"; 8 | 9 | 10 | /** 11 | * Paginated albums grid 12 | */ 13 | export default class Albums extends Component { 14 | render() { 15 | // Handle error 16 | let error = null; 17 | if (this.props.error) { 18 | error = (); 19 | } 20 | 21 | // Set grid props 22 | const artists = this.props.artists; 23 | const grid = { 24 | isFetching: this.props.isFetching, 25 | items: this.props.albums, 26 | itemsType: "album", 27 | itemsLabel: "app.common.album", 28 | subItemsType: "tracks", 29 | subItemsLabel: "app.common.track", 30 | buildLinkTo: (itemType, item) => { 31 | let artist = encodeURIComponent(item.get("artist")); 32 | if (artists && artists.size > 0) { 33 | const id = item.get("artist"); 34 | artist = encodeURIComponent(id + "-" + artists.getIn([id, "name"])); 35 | } 36 | return "/artist/" + artist + "/album/" + item.get("id") + "-" + encodeURIComponent(item.get("name")); 37 | }, 38 | }; 39 | 40 | return ( 41 |
42 | { error } 43 | 44 |
45 | ); 46 | } 47 | } 48 | Albums.propTypes = { 49 | error: PropTypes.string, 50 | isFetching: PropTypes.bool.isRequired, 51 | albums: PropTypes.instanceOf(Immutable.List).isRequired, 52 | artists: PropTypes.instanceOf(Immutable.Map), 53 | pagination: PropTypes.object.isRequired, 54 | }; 55 | -------------------------------------------------------------------------------- /app/actions/entities.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file implements actions related to global entities store. 3 | */ 4 | 5 | export const PUSH_ENTITIES = "PUSH_ENTITIES"; 6 | /** 7 | * Push some entities in the global entities store. 8 | * 9 | * @param entities An entities mapping, such as the one in the entities 10 | * store: type => id => entity. 11 | * @param refCountType An array of entities type to consider for 12 | * increasing reference counting (elements loaded as nested objects) 13 | * @return A PUSH_ENTITIES action. 14 | */ 15 | export function pushEntities(entities, refCountType=["album", "artist", "song"]) { 16 | return { 17 | type: PUSH_ENTITIES, 18 | payload: { 19 | entities: entities, 20 | refCountType: refCountType, 21 | }, 22 | }; 23 | } 24 | 25 | 26 | export const INCREMENT_REFCOUNT = "INCREMENT_REFCOUNT"; 27 | /** 28 | * Increment the reference counter for given entities. 29 | * 30 | * @param ids A mapping type => list of IDs, each ID being the one of an 31 | * entity to increment reference counter. List of IDs must be 32 | * a JS Object. 33 | * @return An INCREMENT_REFCOUNT action. 34 | */ 35 | export function incrementRefCount(entities) { 36 | return { 37 | type: INCREMENT_REFCOUNT, 38 | payload: { 39 | entities: entities, 40 | }, 41 | }; 42 | } 43 | 44 | 45 | export const DECREMENT_REFCOUNT = "DECREMENT_REFCOUNT"; 46 | /** 47 | * Decrement the reference counter for given entities. 48 | * 49 | * @param ids A mapping type => list of IDs, each ID being the one of an 50 | * entity to decrement reference counter. List of IDs must be 51 | * a JS Object. 52 | * @return A DECREMENT_REFCOUNT action. 53 | */ 54 | export function decrementRefCount(entities) { 55 | return { 56 | type: DECREMENT_REFCOUNT, 57 | payload: { 58 | entities: entities, 59 | }, 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /app/containers/RequireAuthentication.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Container wrapping elements neeeding a valid session. Automatically 3 | * redirects to login form in case such session does not exist. 4 | */ 5 | import React, { Component, PropTypes } from "react"; 6 | import { connect } from "react-redux"; 7 | 8 | 9 | export class RequireAuthentication extends Component { 10 | componentWillMount() { 11 | // Check authentication on mount 12 | this.checkAuth(this.props.isAuthenticated); 13 | } 14 | 15 | componentWillUpdate(newProps) { 16 | // Check authentication on update 17 | this.checkAuth(newProps.isAuthenticated); 18 | } 19 | 20 | /** 21 | * Handle redirection in case user is not authenticated. 22 | * 23 | * @param isAuthenticated A boolean stating whether user has a valid 24 | * session or not. 25 | */ 26 | checkAuth(isAuthenticated) { 27 | if (!isAuthenticated) { 28 | // Redirect to login, redirecting to the actual page after login. 29 | this.context.router.replace({ 30 | pathname: "/login", 31 | state: { 32 | nextPathname: this.props.location.pathname, 33 | nextQuery: this.props.location.query, 34 | }, 35 | }); 36 | } 37 | } 38 | 39 | render() { 40 | return ( 41 |
42 | {this.props.isAuthenticated === true 43 | ? this.props.children 44 | : null 45 | } 46 |
47 | ); 48 | } 49 | } 50 | 51 | RequireAuthentication.propTypes = { 52 | // Injected by React Router 53 | children: PropTypes.node, 54 | }; 55 | 56 | RequireAuthentication.contextTypes = { 57 | router: PropTypes.object.isRequired, 58 | }; 59 | 60 | const mapStateToProps = (state) => ({ 61 | isAuthenticated: state.auth.isAuthenticated, 62 | }); 63 | 64 | export default connect(mapStateToProps)(RequireAuthentication); 65 | -------------------------------------------------------------------------------- /app/views/PlaylistPage.jsx: -------------------------------------------------------------------------------- 1 | // NPM imports 2 | import React, { Component } from "react"; 3 | import { bindActionCreators } from "redux"; 4 | import { connect } from "react-redux"; 5 | import Immutable from "immutable"; 6 | 7 | // Actions 8 | import * as actionCreators from "../actions"; 9 | 10 | // Components 11 | import Playlist from "../components/Playlist"; 12 | 13 | 14 | /** 15 | * Table of songs in the current playlist. 16 | */ 17 | class PlaylistPage extends Component { 18 | render() { 19 | const actions = this.props.actions; 20 | const playAction = function (id) { 21 | actions.jumpToSong(id); 22 | actions.togglePlaying(true); 23 | }; 24 | return ( 25 | 26 | ); 27 | } 28 | } 29 | const mapStateToProps = (state) => { 30 | let songsList = new Immutable.List(); 31 | if (state.webplayer.playlist.size > 0) { 32 | songsList = state.webplayer.playlist.map(function (id) { 33 | let song = state.entities.getIn(["entities", "song", id]); 34 | // Add artist and album infos to song 35 | const artist = state.entities.getIn(["entities", "artist", song.get("artist")]); 36 | const album = state.entities.getIn(["entities", "album", song.get("album")]); 37 | return ( 38 | song 39 | .set("artist", new Immutable.Map({id: artist.get("id"), name: artist.get("name")})) 40 | .set("album", new Immutable.Map({id: album.get("id"), name: album.get("name")})) 41 | ); 42 | }); 43 | } 44 | return { 45 | songsList: songsList, 46 | currentIndex: state.webplayer.currentIndex, 47 | }; 48 | }; 49 | const mapDispatchToProps = (dispatch) => ({ 50 | actions: bindActionCreators(actionCreators, dispatch), 51 | }); 52 | export default connect(mapStateToProps, mapDispatchToProps)(PlaylistPage); 53 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | ## Building 5 | 6 | See `README.md` for instructions on how to build. Build is done with 7 | `webpack`. 8 | 9 | 10 | ## Useful scripts 11 | 12 | A few `npm` scripts are provided: 13 | * `npm run build:dev` to trigger a dev build. 14 | * `npm run build:prod` to trigger a production build. 15 | * `npm run watch:dev` to trigger a dev build and rebuild on changes. 16 | * `npm run watch:prod` to trigger a production build and rebuild on changes. 17 | * `npm run clean` to clean the `public` folder. 18 | * `npm run extractTranslations` to generate a translation file (see below). 19 | * `npm run lint:scss` and `npm run lint:js` for linting utilities. 20 | * `npm run test` which calls all the lint stuff. 21 | 22 | 23 | ## Translating 24 | 25 | Translations are handled by [react-intl](https://github.com/yahoo/react-intl/). 26 | 27 | `npm run --silent extractTranslations` output a file containing all the english 28 | translations, in the expected form. It is a mapping of ids and strings to 29 | translate, with an extra description provided as a comment at the end of the 30 | line, for some translation context. 31 | 32 | Typically, if you want to translate to another `$LOCALE` (say `fr-FR`), create 33 | a folder `./app/locales/$LOCALE`, put inside the generated file from `npm run 34 | --silent extractTranslations`, called `index.js`. Copy the lines in 35 | `./app/locales/index.js` to include your new translation and translate all the 36 | strings in the `./app/locales/$LOCALE/index.js` file you have just created. 37 | 38 | 39 | ## Coding style 40 | 41 | No strict coding style is used in this repo. ESLint and Stylelint, ran with 42 | `npm run test` ensures a certain coding style. Try to keep the coding style 43 | homogeneous. 44 | 45 | 46 | ## Hooks 47 | 48 | Usefuls Git hooks are located in `hooks` folder. 49 | 50 | 51 | ## Notes on URLs 52 | 53 | Text after any dash in a URL parameter is considered as a comment and 54 | discarded. In `/artist/1-foobar`, the artist ID is `1` and the `foobar` text 55 | is simply considered as a comment for human readable URLs. 56 | -------------------------------------------------------------------------------- /app/utils/pagination.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Collection of helper functions to deal with pagination. 3 | */ 4 | 5 | 6 | /** 7 | * Helper function to build a pagination object to pass down the component tree. 8 | * 9 | * @param location react-router location props. 10 | * @param currentPage Number of the current page. 11 | * @param nPages Total number of pages. 12 | * @param goToPageAction Action to dispatch to go to a specific page. 13 | * 14 | * @return An object containing all the props for the Pagination component. 15 | */ 16 | export function buildPaginationObject(location, currentPage, nPages, goToPageAction) { 17 | const buildLinkToPage = function (pageNumber) { 18 | return { 19 | pathname: location.pathname, 20 | query: Object.assign({}, location.query, { page: pageNumber }), 21 | }; 22 | }; 23 | return { 24 | currentPage: currentPage, 25 | nPages: nPages, 26 | goToPage: pageNumber => goToPageAction(buildLinkToPage(pageNumber)), 27 | buildLinkToPage: buildLinkToPage, 28 | }; 29 | } 30 | 31 | 32 | /** 33 | * Helper function to compute the buttons to display. 34 | * 35 | * Taken from http://stackoverflow.com/a/8608998/2626416 36 | * 37 | * @param currentPage Number of the current page. 38 | * @param nPages Total number of pages. 39 | * @param maxNumberPagesShown Maximum number of pages button to show. 40 | * 41 | * @return An object containing lower limit and upper limit bounds. 42 | */ 43 | export function computePaginationBounds(currentPage, nPages, maxNumberPagesShown=5) { 44 | let lowerLimit = currentPage; 45 | let upperLimit = currentPage; 46 | 47 | for (let b = 1; b < maxNumberPagesShown && b < nPages;) { 48 | if (lowerLimit > 1 ) { 49 | lowerLimit--; 50 | b++; 51 | } 52 | if (b < maxNumberPagesShown && upperLimit < nPages) { 53 | upperLimit++; 54 | b++; 55 | } 56 | } 57 | 58 | return { 59 | lowerLimit: lowerLimit, 60 | upperLimit: upperLimit + 1, // +1 to ease iteration in for with < 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /app/routes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Routes for the React app. 3 | */ 4 | import React from "react"; 5 | import { IndexRoute, Route } from "react-router"; 6 | 7 | import RequireAuthentication from "./containers/RequireAuthentication"; 8 | import App from "./containers/App"; 9 | import SimpleLayout from "./components/layouts/Simple"; 10 | import SidebarLayout from "./components/layouts/Sidebar"; 11 | import ArtistPage from "./views/ArtistPage"; 12 | import ArtistsPage from "./views/ArtistsPage"; 13 | import AlbumsPage from "./views/AlbumsPage"; 14 | import BrowsePage from "./views/BrowsePage"; 15 | import DiscoverPage from "./views/DiscoverPage"; 16 | import HomePage from "./views/HomePage"; 17 | import LoginPage from "./views/LoginPage"; 18 | import LogoutPage from "./views/LogoutPage"; 19 | import PlaylistPage from "./views/PlaylistPage"; 20 | import SongsPage from "./views/SongsPage"; 21 | import SettingsPage from "./views/SettingsPage"; 22 | 23 | export default ( 24 | // Main container is App 25 | // Login is a SimpleLayout 26 | 27 | 28 | // All the rest is a SidebarLayout 29 | 30 | // And some pages require authentication 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | ); 45 | -------------------------------------------------------------------------------- /app/utils/locale.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Collection of helper functions to deal with localization. 3 | */ 4 | import { i18nRecord } from "../models/i18n"; 5 | 6 | /** 7 | * Get the preferred locales from the browser, as an array sorted by preferences. 8 | */ 9 | export function getBrowserLocales() { 10 | let langs = []; 11 | 12 | if (navigator.languages) { 13 | // Chrome does not currently set navigator.language correctly 14 | // https://code.google.com/p/chromium/issues/detail?id=101138 15 | // but it does set the first element of navigator.languages correctly 16 | langs = navigator.languages; 17 | } else if (navigator.userLanguage) { 18 | // IE only 19 | langs = [navigator.userLanguage]; 20 | } else { 21 | // as of this writing the latest version of firefox + safari set this correctly 22 | langs = [navigator.language]; 23 | } 24 | 25 | // Some browsers does not return uppercase for second part 26 | let locales = langs.map(function (lang) { 27 | let locale = lang.split("-"); 28 | return locale[1] ? `${locale[0]}-${locale[1].toUpperCase()}` : lang; 29 | }); 30 | 31 | return locales; 32 | } 33 | 34 | 35 | /** 36 | * Convert an array of messagesDescriptors to a map. 37 | */ 38 | export function messagesMap(messagesDescriptorsArray) { 39 | let messagesDescriptorsMap = {}; 40 | 41 | messagesDescriptorsArray.forEach(function (item) { 42 | messagesDescriptorsMap[item.id] = item; 43 | }); 44 | 45 | return messagesDescriptorsMap; 46 | } 47 | 48 | 49 | /** 50 | * Format an error message from the state. 51 | * 52 | * Error message can be either an i18nRecord, which is to be formatted, or a 53 | * raw string. This function performs the check and returns the correctly 54 | * formatted string. 55 | * 56 | * @param errorMessage The error message from the state, either plain 57 | * string or i18nRecord. 58 | * @param formatMessage react-i18n formatMessage. 59 | * @param messages List of messages to use for formatting. 60 | * 61 | * @return A string for the error. 62 | */ 63 | export function handleErrorI18nObject(errorMessage, formatMessage, messages) { 64 | if (errorMessage instanceof i18nRecord) { 65 | // If it is an object, format it and return it 66 | return formatMessage(messages[errorMessage.id], errorMessage.values); 67 | } 68 | // Else, it's a string, just return it 69 | return errorMessage; 70 | } 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Ampache React 2 | ============= 3 | 4 | This is an alternative web interface for 5 | [Ampache](https://github.com/ampache/ampache/) built using Ampache XML API and 6 | React. 7 | 8 | ## Trying it out 9 | 10 | Just drop this repo in a location served by a webserver, make your webserver 11 | serve the `public` folder and head your browser to the correct URL :) 12 | 13 | Or you can use the [hosted version](https://phyks.github.io/ampache_react/public/). 14 | 15 | 16 | ## Requirements 17 | 18 | To use this interface, you need: 19 | * An Ampache server on which you have an account, serving the [XML 20 | API](https://github.com/ampache/ampache/wiki/XML-API). Ensures your server 21 | has correct [CORS header](https://www.w3.org/wiki/CORS_Enabled) set. 22 | * A modern browser. 23 | 24 | For now, this is a work in progress and as such, the [hosted 25 | version](https://phyks.github.io/ampache_react/public/) (or `gh-pages` branch) always 26 | require the latest `develop` branch of Ampache. As soon as this is stabilized 27 | and Ampache gets a new version, this note will be updated with the required 28 | Ampache version. 29 | 30 | Note that `master` branch may differ from `gh-pages` branch from time to time, 31 | and `master` branch may rely on commits that are not yet in Ampache `develop` 32 | branch. `gh-pages` branch is ensured to be working with latest Ampache 33 | `develop` branch. 34 | 35 | ## Support 36 | 37 | The supported browsers should be: 38 | 39 | * `IE >= 9` (previous versions of IE are no longer supported by Microsoft) 40 | * Any last three versions of major browsers (> 1% net share). 41 | * No support provided for Opera Mini. 42 | 43 | If you experience any issue, please report :) 44 | 45 | 46 | ## Building 47 | 48 | Building of this app relies on `webpack`. 49 | 50 | First do a `npm install` to install all the required dependencies. 51 | 52 | Then, to make a development build, just run `webpack` in the root folder. To 53 | make a production build, just run `NODE_ENV=production webpack` in the root 54 | folder. All files will be generated in the `public` folder. 55 | 56 | Please use the Git hooks (in `hooks` folder) to automatically make a build 57 | before comitting, as commit should always contain an up to date production 58 | build. 59 | 60 | Compilation cache is stored in `.cache` at the root of this repo. Remember to 61 | clean it in case of compilation issues. 62 | 63 | 64 | ## Contributing 65 | 66 | See `CONTRIBUTING.md` file for extra infos. 67 | 68 | 69 | ## License 70 | 71 | This code is distributed under an MIT license. 72 | 73 | Feel free to contribute and reuse. For more details, see `LICENSE` file. 74 | -------------------------------------------------------------------------------- /app/components/elements/FilterBar.jsx: -------------------------------------------------------------------------------- 1 | // NPM imports 2 | import React, { Component, PropTypes } from "react"; 3 | import CSSModules from "react-css-modules"; 4 | import { defineMessages, injectIntl, intlShape, FormattedMessage } from "react-intl"; 5 | 6 | // Local imports 7 | import { messagesMap } from "../../utils"; 8 | 9 | // Translations 10 | import messages from "../../locales/messagesDescriptors/elements/FilterBar"; 11 | 12 | // Styles 13 | import css from "../../styles/elements/FilterBar.scss"; 14 | 15 | // Define translations 16 | const filterMessages = defineMessages(messagesMap(Array.concat([], messages))); 17 | 18 | 19 | /** 20 | * Filter bar element with input filter. 21 | */ 22 | class FilterBarCSSIntl extends Component { 23 | constructor(props) { 24 | super(props); 25 | // Bind this on methods 26 | this.handleChange = this.handleChange.bind(this); 27 | } 28 | 29 | /** 30 | * Method to handle a change of filter input value. 31 | * 32 | * Calls the user input handler passed from parent component. 33 | * 34 | * @param e A JS event. 35 | */ 36 | handleChange(e) { 37 | e.preventDefault(); 38 | this.props.onUserInput(this.refs.filterTextInput.value); 39 | } 40 | 41 | render() { 42 | const {formatMessage} = this.props.intl; 43 | 44 | return ( 45 |
46 |

47 | 48 |

49 |
50 |
51 |
52 | 53 |
54 |
55 |
56 |
57 | ); 58 | } 59 | } 60 | FilterBarCSSIntl.propTypes = { 61 | onUserInput: PropTypes.func, 62 | filterText: PropTypes.string, 63 | intl: intlShape.isRequired, 64 | }; 65 | export default injectIntl(CSSModules(FilterBarCSSIntl, css)); 66 | -------------------------------------------------------------------------------- /index.all.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Main JS entry point for all the builds. 3 | * 4 | * Performs i18n and initial render. 5 | */ 6 | // React stuff 7 | import React from "react"; 8 | import ReactDOM from "react-dom"; 9 | import { applyRouterMiddleware, hashHistory } from "react-router"; 10 | import { syncHistoryWithStore } from "react-router-redux"; 11 | import useScroll from "react-router-scroll"; 12 | 13 | // Store 14 | import configureStore from "./app/store/configureStore"; 15 | 16 | // i18n stuff 17 | import { addLocaleData } from "react-intl"; 18 | import en from "react-intl/locale-data/en"; 19 | import fr from "react-intl/locale-data/fr"; 20 | 21 | import { getBrowserLocales } from "./app/utils"; 22 | import rawMessages from "./app/locales"; 23 | 24 | // Init store and history 25 | const store = configureStore(); 26 | const history = syncHistoryWithStore(hashHistory, store); 27 | 28 | // Get root element 29 | export const rootElement = document.getElementById("root"); 30 | 31 | /** 32 | * Main function to be called once window.Intl has been populated. 33 | * 34 | * Populates the locales messages and perform render. 35 | */ 36 | export function onWindowIntl () { 37 | // Add locales we support 38 | addLocaleData([...en, ...fr]); 39 | 40 | // Fetch current preferred locales from the browser 41 | const locales = getBrowserLocales(); 42 | 43 | var locale = "en-US"; // Safe default 44 | // Populate strings with best matching locale 45 | var strings = {}; 46 | for (var i = 0; i < locales.length; ++i) { 47 | if (rawMessages[locales[i]]) { 48 | locale = locales[i]; 49 | strings = rawMessages[locale]; 50 | break; // Break at first matching locale 51 | } 52 | } 53 | // Overload strings with default English translation, in case of missing translations 54 | strings = Object.assign(rawMessages["en-US"], strings); 55 | 56 | // Dynamically set html lang attribute 57 | document.documentElement.lang = locale; 58 | 59 | // Return a rendering function 60 | return () => { 61 | const Root = require("./app/containers/Root").default; 62 | ReactDOM.render( 63 | , 64 | rootElement 65 | ); 66 | }; 67 | }; 68 | 69 | /** 70 | * Ensure window.Intl exists, or polyfill it. 71 | * 72 | * @param render Initial rendering function. 73 | */ 74 | export function Intl (render) { 75 | if (!window.Intl) { 76 | require.ensure([ 77 | "intl", 78 | "intl/locale-data/jsonp/en.js", 79 | "intl/locale-data/jsonp/fr.js" 80 | ], function (require) { 81 | require("intl"); 82 | require("intl/locale-data/jsonp/en.js"); 83 | require("intl/locale-data/jsonp/fr.js"); 84 | render(); 85 | }); 86 | } else { 87 | render(); 88 | } 89 | }; 90 | -------------------------------------------------------------------------------- /.bootstraprc: -------------------------------------------------------------------------------- 1 | --- 2 | # Output debugging info 3 | loglevel: disabled 4 | 5 | # Major version of Bootstrap: 3 or 4 6 | bootstrapVersion: 3 7 | 8 | # If Bootstrap version 3 is used - turn on/off custom icon font path 9 | useCustomIconFontPath: false 10 | 11 | # Webpack loaders, order matters 12 | styleLoaders: 13 | - style 14 | - css 15 | - sass 16 | 17 | # Extract styles to stand-alone css file 18 | # Different settings for different environments can be used, 19 | # It depends on value of NODE_ENV environment variable 20 | # This param can also be set in webpack config: 21 | # entry: 'bootstrap-loader/extractStyles' 22 | extractStyles: true 23 | # env: 24 | # development: 25 | # extractStyles: false 26 | # production: 27 | # extractStyles: true 28 | 29 | 30 | # Customize Bootstrap variables that get imported before the original Bootstrap variables. 31 | # Thus, derived Bootstrap variables can depend on values from here. 32 | # See the Bootstrap _variables.scss file for examples of derived Bootstrap variables. 33 | # 34 | # preBootstrapCustomizations: ./path/to/bootstrap/pre-customizations.scss 35 | 36 | 37 | # This gets loaded after bootstrap/variables is loaded 38 | # Thus, you may customize Bootstrap variables 39 | # based on the values established in the Bootstrap _variables.scss file 40 | # 41 | # bootstrapCustomizations: ./path/to/bootstrap/customizations.scss 42 | 43 | 44 | # Import your custom styles here 45 | # Usually this endpoint-file contains list of @imports of your application styles 46 | # 47 | # appStyles: ./path/to/your/app/styles/endpoint.scss 48 | 49 | 50 | ### Bootstrap styles 51 | styles: 52 | 53 | # Mixins 54 | mixins: true 55 | 56 | # Reset and dependencies 57 | normalize: true 58 | print: true 59 | glyphicons: true 60 | 61 | # Core CSS 62 | scaffolding: true 63 | type: true 64 | code: true 65 | grid: true 66 | tables: true 67 | forms: true 68 | buttons: true 69 | 70 | # Components 71 | component-animations: true 72 | dropdowns: true 73 | button-groups: true 74 | input-groups: true 75 | navs: true 76 | navbar: true 77 | breadcrumbs: true 78 | pagination: true 79 | pager: true 80 | labels: true 81 | badges: true 82 | jumbotron: true 83 | thumbnails: true 84 | alerts: true 85 | progress-bars: true 86 | media: true 87 | list-group: true 88 | panels: true 89 | wells: true 90 | responsive-embed: true 91 | close: true 92 | 93 | # Components w/ JavaScript 94 | modals: true 95 | tooltip: true 96 | popovers: true 97 | carousel: true 98 | 99 | # Utility classes 100 | utilities: true 101 | responsive-utilities: true 102 | 103 | ### Bootstrap scripts 104 | scripts: 105 | transition: true 106 | alert: true 107 | button: true 108 | carousel: true 109 | collapse: true 110 | dropdown: true 111 | modal: true 112 | tooltip: true 113 | popover: true 114 | scrollspy: true 115 | tab: true 116 | affix: true 117 | -------------------------------------------------------------------------------- /public/fix.ie9.js: -------------------------------------------------------------------------------- 1 | !function(e){function t(r){if(n[r])return n[r].exports;var a=n[r]={exports:{},id:r,loaded:!1};return e[r].call(a.exports,a,a.exports,t),a.loaded=!0,a.exports}var n={};return t.m=e,t.c=n,t.p="./",t(0)}({0:function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r=n(640);Object.keys(r).forEach(function(e){"default"!==e&&"__esModule"!==e&&Object.defineProperty(t,e,{enumerable:!0,get:function(){return r[e]}})})},640:function(e,t){!function(t,n){function r(e,t){var n=e.createElement("p"),r=e.getElementsByTagName("head")[0]||e.documentElement;return n.innerHTML="x",r.insertBefore(n.lastChild,r.firstChild)}function a(){var e=b.elements;return"string"==typeof e?e.split(" "):e}function o(e,t){var n=b.elements;"string"!=typeof n&&(n=n.join(" ")),"string"!=typeof e&&(e=e.join(" ")),b.elements=n+" "+e,s(t)}function c(e){var t=E[e[v]];return t||(t={},y++,e[v]=y,E[y]=t),t}function i(e,t,r){if(t||(t=n),f)return t.createElement(e);r||(r=c(t));var a;return a=r.cache[e]?r.cache[e].cloneNode():g.test(e)?(r.cache[e]=r.createElem(e)).cloneNode():r.createElem(e),!a.canHaveChildren||p.test(e)||a.tagUrn?a:r.frag.appendChild(a)}function l(e,t){if(e||(e=n),f)return e.createDocumentFragment();t=t||c(e);for(var r=t.frag.cloneNode(),o=0,i=a(),l=i.length;o",d="hidden"in e,f=1==e.childNodes.length||function(){n.createElement("a");var e=n.createDocumentFragment();return"undefined"==typeof e.cloneNode||"undefined"==typeof e.createDocumentFragment||"undefined"==typeof e.createElement}()}catch(t){d=!0,f=!0}}();var b={elements:h.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output picture progress section summary template time video",version:m,shivCSS:h.shivCSS!==!1,supportsUnknownElements:f,shivMethods:h.shivMethods!==!1,type:"default",shivDocument:s,createElement:i,createDocumentFragment:l,addElements:o};t.html5=b,s(n),"object"==typeof e&&e.exports&&(e.exports=b)}("undefined"!=typeof window?window:this,document)}}); 2 | //# sourceMappingURL=fix.ie9.js.map -------------------------------------------------------------------------------- /app/views/ArtistsPage.jsx: -------------------------------------------------------------------------------- 1 | // NPM imports 2 | import React, { Component } from "react"; 3 | import { bindActionCreators } from "redux"; 4 | import { connect } from "react-redux"; 5 | import { defineMessages, injectIntl, intlShape } from "react-intl"; 6 | import Immutable from "immutable"; 7 | 8 | // Local imports 9 | import { buildPaginationObject, messagesMap, handleErrorI18nObject } from "../utils"; 10 | 11 | // Actions 12 | import * as actionCreators from "../actions"; 13 | 14 | // Components 15 | import Artists from "../components/Artists"; 16 | 17 | // Translations 18 | import APIMessages from "../locales/messagesDescriptors/api"; 19 | 20 | // Define translations 21 | const artistsMessages = defineMessages(messagesMap(Array.concat([], APIMessages))); 22 | 23 | 24 | /** 25 | * Grid of artists arts. 26 | */ 27 | class ArtistsPageIntl extends Component { 28 | componentWillMount() { 29 | // Load the data for the current page 30 | const currentPage = parseInt(this.props.location.query.page) || 1; 31 | this.props.actions.loadPaginatedArtists({pageNumber: currentPage}); 32 | } 33 | 34 | componentWillReceiveProps(nextProps) { 35 | // Load the data if page has changed 36 | const currentPage = parseInt(this.props.location.query.page) || 1; 37 | const nextPage = parseInt(nextProps.location.query.page) || 1; 38 | if (currentPage != nextPage) { 39 | // Unload data on page change 40 | this.props.actions.clearPaginatedResults(); 41 | // Load new data 42 | this.props.actions.loadPaginatedArtists({pageNumber: nextPage}); 43 | } 44 | } 45 | 46 | componentWillUnmount() { 47 | // Unload data on page change 48 | this.props.actions.clearPaginatedResults(); 49 | } 50 | 51 | render() { 52 | const {formatMessage} = this.props.intl; 53 | 54 | const pagination = buildPaginationObject(this.props.location, this.props.currentPage, this.props.nPages, this.props.actions.goToPage); 55 | 56 | const error = handleErrorI18nObject(this.props.error, formatMessage, artistsMessages); 57 | 58 | return ( 59 | 60 | ); 61 | } 62 | } 63 | 64 | ArtistsPageIntl.propTypes = { 65 | intl: intlShape.isRequired, 66 | }; 67 | 68 | const mapStateToProps = (state) => { 69 | let artistsList = new Immutable.List(); 70 | if (state.paginated.type == "artist" && state.paginated.result.size > 0) { 71 | artistsList = state.paginated.result.map( 72 | id => state.entities.getIn(["entities", "artist", id]) 73 | ); 74 | } 75 | return { 76 | isFetching: state.entities.isFetching, 77 | error: state.entities.error, 78 | artistsList: artistsList, 79 | currentPage: state.paginated.currentPage, 80 | nPages: state.paginated.nPages, 81 | }; 82 | }; 83 | 84 | const mapDispatchToProps = (dispatch) => ({ 85 | actions: bindActionCreators(actionCreators, dispatch), 86 | }); 87 | 88 | export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ArtistsPageIntl)); 89 | -------------------------------------------------------------------------------- /app/reducers/auth.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This implements the auth reducer, storing and updating authentication state. 3 | */ 4 | 5 | // NPM imports 6 | import Cookies from "js-cookie"; 7 | 8 | // Local imports 9 | import { createReducer } from "../utils"; 10 | 11 | // Models 12 | import { i18nRecord } from "../models/i18n"; 13 | import { tokenRecord, stateRecord } from "../models/auth"; 14 | 15 | // Actions 16 | import { 17 | LOGIN_USER_REQUEST, 18 | LOGIN_USER_SUCCESS, 19 | LOGIN_USER_FAILURE, 20 | LOGIN_USER_EXPIRED, 21 | LOGOUT_USER } from "../actions"; 22 | 23 | 24 | /** 25 | * Initial state, load data from cookies if set 26 | */ 27 | var initialState = new stateRecord(); 28 | // Get token 29 | const initialToken = Cookies.getJSON("token"); 30 | if (initialToken) { 31 | initialToken.expires = new Date(initialToken.expires); 32 | initialState = initialState.set( 33 | "token", 34 | new tokenRecord({ token: initialToken.token, expires: new Date(initialToken.expires) }) 35 | ); 36 | } 37 | // Get username 38 | const initialUsername = Cookies.get("username"); 39 | if (initialUsername) { 40 | initialState = initialState.set( 41 | "username", 42 | initialUsername 43 | ); 44 | } 45 | // Get endpoint 46 | const initialEndpoint = Cookies.get("endpoint"); 47 | if (initialEndpoint) { 48 | initialState = initialState.set( 49 | "endpoint", 50 | initialEndpoint 51 | ); 52 | } 53 | // Set remember me 54 | if (initialUsername && initialEndpoint) { 55 | initialState = initialState.set( 56 | "rememberMe", 57 | true 58 | ); 59 | } 60 | 61 | 62 | /** 63 | * Reducers 64 | */ 65 | export default createReducer(initialState, { 66 | [LOGIN_USER_REQUEST]: () => { 67 | return new stateRecord({ 68 | isAuthenticating: true, 69 | info: new i18nRecord({ 70 | id: "app.login.connecting", 71 | values: {}, 72 | }), 73 | }); 74 | }, 75 | [LOGIN_USER_SUCCESS]: (state, payload) => { 76 | return new stateRecord({ 77 | "isAuthenticated": true, 78 | "token": new tokenRecord(payload.token), 79 | "username": payload.username, 80 | "endpoint": payload.endpoint, 81 | "rememberMe": payload.rememberMe, 82 | "info": new i18nRecord({ 83 | id: "app.login.success", 84 | values: {username: payload.username}, 85 | }), 86 | "timerID": payload.timerID, 87 | }); 88 | }, 89 | [LOGIN_USER_FAILURE]: (state, payload) => { 90 | return new stateRecord({ 91 | "error": payload.error, 92 | }); 93 | }, 94 | [LOGIN_USER_EXPIRED]: (state, payload) => { 95 | return new stateRecord({ 96 | "isAuthenticated": false, 97 | "error": payload.error, 98 | }); 99 | }, 100 | [LOGOUT_USER]: () => { 101 | return new stateRecord({ 102 | info: new i18nRecord({ 103 | id: "app.login.byebye", 104 | values: {}, 105 | }), 106 | }); 107 | }, 108 | }); 109 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ampache", 3 | "version": "1.0.0", 4 | "description": "A web client for Ampache, relying on Ampache API.", 5 | "author": "Phyks (Lucas Verney)", 6 | "license": "MIT", 7 | "homepage": "https://github.com/Phyks/ampache_react", 8 | "repository": "git+https://github.com/Phyks/ampache_react.git", 9 | "scripts": { 10 | "clean": "rimraf .cache && rimraf public/", 11 | "build:dev": "webpack --progress", 12 | "build:prod": "NODE_ENV=production webpack --progress", 13 | "watch:dev": "webpack --progress --watch", 14 | "watch:prod": "NODE_ENV=production webpack --progress --watch", 15 | "extractTranslations": "babel-node scripts/extractTranslations.js", 16 | "lint:scss": "stylelint './app/**/*.scss' --syntax scss", 17 | "lint:js": "eslint './app/**/*.js' './app/**/*.jsx'", 18 | "test": "npm run lint:scss && npm run lint:js" 19 | }, 20 | "dependencies": { 21 | "autoprefixer": "^6.4.0", 22 | "babel-polyfill": "^6.9.1", 23 | "babel-preset-es2015": "^6.9.0", 24 | "bootstrap-loader": "^1.1.0", 25 | "bootstrap-sass": "^3.3.7", 26 | "eslint": "^3.2.2", 27 | "font-awesome": "^4.6.3", 28 | "fuse.js": "^2.4.1", 29 | "howler": "^2.0.0", 30 | "html5shiv": "^3.7.3", 31 | "humps": "^1.1.0", 32 | "imagesloaded": "^4.1.0", 33 | "immutable": "^3.8.1", 34 | "intl": "^1.2.4", 35 | "isomorphic-fetch": "^2.2.1", 36 | "isotope-layout": "^3.0.1", 37 | "jquery": "^3.1.0", 38 | "js-cookie": "^2.1.2", 39 | "jssha": "^2.1.0", 40 | "normalizr": "^2.2.1", 41 | "react": "^15.3.0", 42 | "react-addons-shallow-compare": "^15.3.0", 43 | "react-css-modules": "^3.7.9", 44 | "react-dom": "^15.3.0", 45 | "react-fontawesome": "^1.1.0", 46 | "react-intl": "^2.1.3", 47 | "react-redux": "^4.4.5", 48 | "react-router": "^2.6.1", 49 | "react-router-redux": "^4.0.5", 50 | "react-router-scroll": "^0.2.1", 51 | "redux": "^3.5.2", 52 | "redux-thunk": "^2.1.0", 53 | "stylelint": "^7.1.0", 54 | "x2js": "git+https://github.com/abdmob/x2js.git" 55 | }, 56 | "devDependencies": { 57 | "autoprefixer": "^6.3.7", 58 | "babel-cli": "^6.11.4", 59 | "babel-core": "^6.10.4", 60 | "babel-loader": "^6.2.4", 61 | "babel-preset-react": "^6.11.1", 62 | "copy-webpack-plugin": "^3.0.1", 63 | "css-loader": "^0.23.1", 64 | "doiuse": "^2.4.1", 65 | "eslint": "^3.2.0", 66 | "eslint-loader": "^1.5.0", 67 | "eslint-plugin-react": "^5.2.2", 68 | "eventsource-polyfill": "^0.9.6", 69 | "extract-text-webpack-plugin": "^1.0.1", 70 | "file-loader": "^0.9.0", 71 | "font-awesome-webpack": "0.0.4", 72 | "glob": "^7.0.5", 73 | "less": "^2.7.1", 74 | "node-sass": "^3.8.0", 75 | "postcss": "^5.1.1", 76 | "postcss-loader": "^0.9.1", 77 | "postcss-reporter": "^1.4.1", 78 | "react-a11y": "^0.3.3", 79 | "redbox-react": "^1.2.10", 80 | "redux-logger": "^2.6.1", 81 | "resolve-url-loader": "^1.6.0", 82 | "rimraf": "^2.5.4", 83 | "sass-loader": "^4.0.0", 84 | "sass-resources-loader": "^1.0.2", 85 | "style-loader": "^0.13.1", 86 | "stylelint": "^7.0.3", 87 | "stylelint-config-standard": "^11.0.0", 88 | "url-loader": "^0.5.7", 89 | "webpack": "^1.13.1", 90 | "webpack-dev-server": "^1.14.1", 91 | "webpack-hot-middleware": "^2.12.2" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /app/components/Playlist.jsx: -------------------------------------------------------------------------------- 1 | // TODO: Styling 2 | // NPM import 3 | import React, { Component, PropTypes } from "react"; 4 | import Immutable from "immutable"; 5 | import { defineMessages, injectIntl, intlShape, FormattedMessage } from "react-intl"; 6 | 7 | // Local imports 8 | import { messagesMap } from "../utils"; 9 | 10 | // Other components 11 | import { SongsTable } from "./Songs"; 12 | 13 | // Translations 14 | import commonMessages from "../locales/messagesDescriptors/common"; 15 | import messages from "../locales/messagesDescriptors/Playlist"; 16 | 17 | // Define translations 18 | const playlistMessages = defineMessages(messagesMap(Array.concat([], commonMessages, messages))); 19 | 20 | 21 | /** 22 | * An entire album row containing art and tracks table. 23 | */ 24 | class PlaylistIntl extends Component { 25 | render() { 26 | let playlistText = null; 27 | if (this.props.songs.size > 0) { 28 | const currentSongSongsTableProps = { 29 | playAction: this.props.playAction, 30 | playNextAction: this.props.playNextAction, 31 | songs: this.props.songs.slice(this.props.currentIndex, this.props.currentIndex + 1), 32 | }; 33 | const fullPlaylistSongsTableProps = { 34 | playAction: this.props.playAction, 35 | playNextAction: this.props.playNextAction, 36 | songs: this.props.songs, 37 | }; 38 | playlistText = ( 39 |
40 |

41 | 44 |

45 | 46 |

47 | 48 |

49 | 50 | 51 |

52 | 53 |

54 | 55 |
56 | ); 57 | } else { 58 | playlistText = ( 59 |

60 | 61 |

62 | ); 63 | } 64 | return ( 65 |
66 |
67 |
68 |

69 | 70 |

71 |
72 |
73 |
74 | 75 |
76 |
77 | { playlistText } 78 |
79 |
80 |
81 | ); 82 | 83 | } 84 | } 85 | PlaylistIntl.propTypes = { 86 | playAction: PropTypes.func.isRequired, 87 | playNextAction: PropTypes.func, 88 | flushAction: PropTypes.func.isRequired, 89 | songs: PropTypes.instanceOf(Immutable.List).isRequired, 90 | currentIndex: PropTypes.number.isRequired, 91 | intl: intlShape.isRequired, 92 | }; 93 | export default injectIntl(PlaylistIntl); 94 | -------------------------------------------------------------------------------- /app/views/LoginPage.jsx: -------------------------------------------------------------------------------- 1 | // NPM imports 2 | import React, { Component, PropTypes } from "react"; 3 | import { bindActionCreators } from "redux"; 4 | import { connect } from "react-redux"; 5 | 6 | // Actions 7 | import * as actionCreators from "../actions"; 8 | 9 | // Components 10 | import Login from "../components/Login"; 11 | 12 | 13 | /** 14 | * Login page 15 | */ 16 | export class LoginPage extends Component { 17 | constructor(props) { 18 | super(props); 19 | 20 | // Bind this 21 | this.handleSubmit = this.handleSubmit.bind(this); 22 | this._getRedirectTo = this._getRedirectTo.bind(this); 23 | } 24 | 25 | /** 26 | * Get URL to redirect to based on location props. 27 | */ 28 | _getRedirectTo() { 29 | let redirectPathname = "/"; 30 | let redirectQuery = {}; 31 | const { location } = this.props; 32 | if (location.state && location.state.nextPathname) { 33 | redirectPathname = location.state.nextPathname; 34 | } 35 | if (location.state && location.state.nextQuery) { 36 | redirectQuery = location.state.nextQuery; 37 | } 38 | return { 39 | pathname: redirectPathname, 40 | query: redirectQuery, 41 | }; 42 | } 43 | 44 | componentWillMount() { 45 | // This checks if the user is already connected or not and redirects 46 | // them if it is the case. 47 | 48 | // Get next page to redirect to 49 | const redirectTo = this._getRedirectTo(); 50 | 51 | if (this.props.isAuthenticated) { 52 | // If user is already authenticated, redirects them 53 | this.context.router.replace(redirectTo); 54 | } else if (this.props.rememberMe) { 55 | // Else if remember me is set, try to reconnect them 56 | this.props.actions.loginUser( 57 | this.props.username, 58 | this.props.token, 59 | this.props.endpoint, 60 | true, 61 | redirectTo, 62 | true 63 | ); 64 | } 65 | } 66 | 67 | /** 68 | * Handle click on submit button. 69 | */ 70 | handleSubmit(username, password, endpoint, rememberMe) { 71 | // Get page to redirect to 72 | const redirectTo = this._getRedirectTo(); 73 | // Trigger login action 74 | this.props.actions.loginUser(username, password, endpoint, rememberMe, redirectTo); 75 | } 76 | 77 | render() { 78 | return ( 79 | 80 | ); 81 | } 82 | } 83 | 84 | LoginPage.contextTypes = { 85 | router: PropTypes.object.isRequired, 86 | }; 87 | 88 | const mapStateToProps = (state) => ({ 89 | username: state.auth.username, 90 | endpoint: state.auth.endpoint, 91 | rememberMe: state.auth.rememberMe, 92 | isAuthenticating: state.auth.isAuthenticating, 93 | isAuthenticated: state.auth.isAuthenticated, 94 | token: state.auth.token, 95 | error: state.auth.error, 96 | info: state.auth.info, 97 | }); 98 | 99 | const mapDispatchToProps = (dispatch) => ({ 100 | actions: bindActionCreators(actionCreators, dispatch), 101 | }); 102 | 103 | export default connect(mapStateToProps, mapDispatchToProps)(LoginPage); 104 | -------------------------------------------------------------------------------- /app/views/AlbumsPage.jsx: -------------------------------------------------------------------------------- 1 | // NPM imports 2 | import React, { Component } from "react"; 3 | import { bindActionCreators } from "redux"; 4 | import { connect } from "react-redux"; 5 | import { defineMessages, injectIntl, intlShape } from "react-intl"; 6 | import Immutable from "immutable"; 7 | 8 | // Local imports 9 | import { buildPaginationObject, messagesMap, handleErrorI18nObject } from "../utils"; 10 | 11 | // Actions 12 | import * as actionCreators from "../actions"; 13 | 14 | // Components 15 | import Albums from "../components/Albums"; 16 | 17 | // Translations 18 | import APIMessages from "../locales/messagesDescriptors/api"; 19 | 20 | // Define translations 21 | const albumsMessages = defineMessages(messagesMap(Array.concat([], APIMessages))); 22 | 23 | 24 | /** 25 | * Albums page, grid layout of albums arts. 26 | */ 27 | class AlbumsPageIntl extends Component { 28 | componentWillMount() { 29 | // Load the data for current page 30 | const currentPage = parseInt(this.props.location.query.page) || 1; 31 | this.props.actions.loadPaginatedAlbums({ pageNumber: currentPage }); 32 | } 33 | 34 | componentWillReceiveProps(nextProps) { 35 | // Load the data if page has changed 36 | const currentPage = parseInt(this.props.location.query.page) || 1; 37 | const nextPage = parseInt(nextProps.location.query.page) || 1; 38 | if (currentPage != nextPage) { 39 | // Unload data on page change 40 | this.props.actions.clearPaginatedResults(); 41 | // Load new data 42 | this.props.actions.loadPaginatedAlbums({pageNumber: nextPage}); 43 | } 44 | } 45 | 46 | componentWillUnmount() { 47 | // Unload data on page change 48 | this.props.actions.clearPaginatedResults(); 49 | } 50 | 51 | render() { 52 | const {formatMessage} = this.props.intl; 53 | 54 | const pagination = buildPaginationObject(this.props.location, this.props.currentPage, this.props.nPages, this.props.actions.goToPage); 55 | 56 | const error = handleErrorI18nObject(this.props.error, formatMessage, albumsMessages); 57 | 58 | return ( 59 | 60 | ); 61 | } 62 | } 63 | 64 | AlbumsPageIntl.propTypes = { 65 | intl: intlShape.isRequired, 66 | }; 67 | 68 | const mapStateToProps = (state) => { 69 | let albumsList = new Immutable.List(); 70 | let artistsList = new Immutable.Map(); 71 | if (state.paginated.type == "album" && state.paginated.result.size > 0) { 72 | albumsList = state.paginated.result.map( 73 | id => state.entities.getIn(["entities", "album", id]) 74 | ); 75 | albumsList.forEach(function (album) { 76 | if (album) { 77 | const albumArtist = album.get("artist"); 78 | artistsList = artistsList.set(albumArtist, state.entities.getIn(["entities", "artist", albumArtist])); 79 | } 80 | }); 81 | } 82 | return { 83 | isFetching: state.entities.isFetching, 84 | error: state.entities.error, 85 | albumsList: albumsList, 86 | artistsList: artistsList, 87 | currentPage: state.paginated.currentPage, 88 | nPages: state.paginated.nPages, 89 | }; 90 | }; 91 | 92 | const mapDispatchToProps = (dispatch) => ({ 93 | actions: bindActionCreators(actionCreators, dispatch), 94 | }); 95 | 96 | export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(AlbumsPageIntl)); 97 | -------------------------------------------------------------------------------- /app/views/ArtistPage.jsx: -------------------------------------------------------------------------------- 1 | // NPM imports 2 | import React, { Component, PropTypes } from "react"; 3 | import { bindActionCreators } from "redux"; 4 | import { connect } from "react-redux"; 5 | import { defineMessages, injectIntl, intlShape } from "react-intl"; 6 | import Immutable from "immutable"; 7 | 8 | // Local imports 9 | import { messagesMap, handleErrorI18nObject, filterInt } from "../utils"; 10 | 11 | // Actions 12 | import * as actionCreators from "../actions"; 13 | 14 | // Components 15 | import Artist from "../components/Artist"; 16 | 17 | // Translations 18 | import APIMessages from "../locales/messagesDescriptors/api"; 19 | 20 | // Define translations 21 | const artistMessages = defineMessages(messagesMap(Array.concat([], APIMessages))); 22 | 23 | 24 | /** 25 | * Single artist page. 26 | */ 27 | class ArtistPageIntl extends Component { 28 | componentWillMount() { 29 | const id = filterInt(this.props.params.artist.split("-")[0]); 30 | if (isNaN(id)) { 31 | // Redirect to homepage 32 | this.context.router.replace({ 33 | pathname: "/", 34 | }); 35 | } 36 | // Load the data 37 | this.props.actions.loadArtist({ 38 | filter: id, 39 | include: ["albums", "songs"], 40 | }); 41 | } 42 | 43 | componentWillUnmount() { 44 | this.props.actions.decrementRefCount({ 45 | "artist": [this.props.artist.get("id")], 46 | }); 47 | } 48 | 49 | render() { 50 | const {formatMessage} = this.props.intl; 51 | 52 | const error = handleErrorI18nObject(this.props.error, formatMessage, artistMessages); 53 | 54 | return ( 55 | 56 | ); 57 | } 58 | } 59 | 60 | ArtistPageIntl.propTypes = { 61 | intl: intlShape.isRequired, 62 | }; 63 | ArtistPageIntl.contextTypes = { 64 | router: PropTypes.object.isRequired, 65 | }; 66 | 67 | const mapStateToProps = (state, ownProps) => { 68 | const id = ownProps.params.artist.split("-")[0]; 69 | // Get artist 70 | let artist = state.entities.getIn(["entities", "artist", id]); 71 | let albums = new Immutable.List(); 72 | let songs = new Immutable.Map(); 73 | if (artist) { 74 | // Get albums 75 | let artistAlbums = artist.get("albums"); 76 | if (Immutable.List.isList(artistAlbums)) { 77 | albums = artistAlbums.map( 78 | id => state.entities.getIn(["entities", "album", id]) 79 | ); 80 | } 81 | // Get songs 82 | let artistSongs = artist.get("songs"); 83 | if (Immutable.List.isList(artistSongs)) { 84 | songs = state.entities.getIn(["entities", "song"]).filter( 85 | song => artistSongs.includes(song.get("id")) 86 | ); 87 | } 88 | } else { 89 | artist = new Immutable.Map(); 90 | } 91 | return { 92 | isFetching: state.entities.isFetching, 93 | error: state.entities.error, 94 | artist: artist, 95 | albums: albums, 96 | songs: songs, 97 | }; 98 | }; 99 | 100 | const mapDispatchToProps = (dispatch) => ({ 101 | actions: bindActionCreators(actionCreators, dispatch), 102 | }); 103 | 104 | export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ArtistPageIntl)); 105 | -------------------------------------------------------------------------------- /app/styles/layouts/Sidebar.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Styles for Sidebar layout component. 3 | */ 4 | 5 | /** Variables */ 6 | $background: #333; 7 | $hoverBackground: #222; 8 | $activeBackground: $hoverBackground; 9 | $foreground: white; 10 | $lightgrey: #eee; 11 | $titleFontSize: $font-size-h1 - 10px; 12 | $titleMarginBottom: 20px; 13 | $mainPadding: 20px; 14 | $condensedNavPadding: 5px; 15 | 16 | .sidebar { 17 | position: fixed; 18 | top: 0; 19 | bottom: 0; 20 | left: 0; 21 | z-index: 1000; 22 | display: block; 23 | padding: $mainPadding; 24 | overflow-x: hidden; 25 | overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */ 26 | background-color: $background; 27 | color: white; 28 | } 29 | 30 | .collapse { 31 | display: block; 32 | } 33 | 34 | /* Sidebar elements */ 35 | .link { 36 | color: $foreground; 37 | text-decoration: none; 38 | } 39 | 40 | .link:focus { 41 | color: $foreground; 42 | background-color: transparent !important; 43 | text-decoration: none; 44 | } 45 | 46 | /** Note: Keep hover after focus pseudo-class so that hover overloads focus. */ 47 | .link:hover { 48 | color: $foreground; 49 | background-color: $hoverBackground !important; 50 | text-decoration: none; 51 | } 52 | 53 | .active { 54 | composes: link; 55 | background-color: $activeBackground !important; 56 | } 57 | 58 | .active:focus { 59 | background-color: $activeBackground !important; 60 | } 61 | 62 | .title { 63 | margin: 0; 64 | margin-bottom: $titleMarginBottom; 65 | font-size: $titleFontSize; 66 | } 67 | 68 | .imgTitle { 69 | height: $font-size-h1; 70 | } 71 | 72 | /* Sidebar navigation */ 73 | button.toggle { 74 | background-color: $foreground; 75 | } 76 | 77 | .icon-bar { 78 | background-color: $background; 79 | } 80 | 81 | .icon-navbar { 82 | background-color: #555; 83 | font-size: 1.2em; 84 | border: none; 85 | 86 | .container-fluid { 87 | padding-left: 0; 88 | padding-right: 0; 89 | } 90 | 91 | .nav { 92 | display: inline-block; 93 | float: none; 94 | vertical-align: top; 95 | text-align: center; 96 | 97 | li { 98 | float: left; 99 | } 100 | } 101 | } 102 | 103 | /* 104 | * Main content 105 | */ 106 | .main-panel { 107 | padding: $mainPadding; 108 | } 109 | 110 | /* 111 | * Media queries 112 | */ 113 | @media (min-width: 992px) and (max-width: 1199px) { 114 | .icon-navbar { 115 | .nav { 116 | li { 117 | float: none; 118 | } 119 | } 120 | } 121 | 122 | .nav-list { 123 | text-align: right; 124 | } 125 | 126 | .nav { 127 | li { 128 | a { 129 | padding-left: $condensedNavPadding; 130 | padding-right: $condensedNavPadding; 131 | } 132 | } 133 | } 134 | } 135 | 136 | @media (max-width: 991px) { 137 | .main-panel { 138 | z-index: -10; 139 | } 140 | 141 | .sidebar { 142 | position: static; 143 | } 144 | 145 | .title { 146 | float: left; 147 | margin-bottom: 0; 148 | } 149 | 150 | button.toggle { 151 | display: block; 152 | margin-top: 0; 153 | margin-bottom: 0; 154 | } 155 | 156 | .collapse { 157 | clear: both; 158 | } 159 | } 160 | 161 | :global { 162 | @media (max-width: 991px) { 163 | .collapse { 164 | display: none; 165 | padding-top: $titleMarginBottom; 166 | } 167 | 168 | .collapsing { 169 | padding-top: $titleMarginBottom; 170 | } 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /app/views/SongsPage.jsx: -------------------------------------------------------------------------------- 1 | // NPM imports 2 | import React, { Component } from "react"; 3 | import { bindActionCreators } from "redux"; 4 | import { connect } from "react-redux"; 5 | import { defineMessages, injectIntl, intlShape } from "react-intl"; 6 | import Immutable from "immutable"; 7 | 8 | // Local imports 9 | import { buildPaginationObject, messagesMap, handleErrorI18nObject } from "../utils"; 10 | 11 | // Actions 12 | import * as actionCreators from "../actions"; 13 | 14 | // Components 15 | import Songs from "../components/Songs"; 16 | 17 | // Translations 18 | import APIMessages from "../locales/messagesDescriptors/api"; 19 | 20 | // Define translations 21 | const songsMessages = defineMessages(messagesMap(Array.concat([], APIMessages))); 22 | 23 | 24 | /** 25 | * Paginated table of available songs 26 | */ 27 | class SongsPageIntl extends Component { 28 | componentWillMount() { 29 | // Load the data for current page 30 | const currentPage = parseInt(this.props.location.query.page) || 1; 31 | this.props.actions.loadPaginatedSongs({pageNumber: currentPage}); 32 | } 33 | 34 | componentWillReceiveProps(nextProps) { 35 | // Load the data if page has changed 36 | const currentPage = parseInt(this.props.location.query.page) || 1; 37 | const nextPage = parseInt(nextProps.location.query.page) || 1; 38 | if (currentPage != nextPage) { 39 | // Unload data on page change 40 | this.props.actions.clearPaginatedResults(); 41 | // Load new data 42 | this.props.actions.loadPaginatedSongs({pageNumber: nextPage}); 43 | } 44 | } 45 | 46 | componentWillUnmount() { 47 | // Unload data on page change 48 | this.props.actions.clearPaginatedResults(); 49 | } 50 | 51 | render() { 52 | const {formatMessage} = this.props.intl; 53 | 54 | const pagination = buildPaginationObject(this.props.location, this.props.currentPage, this.props.nPages, this.props.actions.goToPage); 55 | 56 | const error = handleErrorI18nObject(this.props.error, formatMessage, songsMessages); 57 | 58 | return ( 59 | 60 | ); 61 | } 62 | } 63 | 64 | SongsPageIntl.propTypes = { 65 | intl: intlShape.isRequired, 66 | }; 67 | 68 | const mapStateToProps = (state) => { 69 | let songsList = new Immutable.List(); 70 | if (state.paginated.type == "song" && state.paginated.result.size > 0) { 71 | songsList = state.paginated.result.map(function (id) { 72 | let song = state.entities.getIn(["entities", "song", id]); 73 | // Add artist and album infos to song 74 | const artist = state.entities.getIn(["entities", "artist", song.get("artist")]); // TODO: get on undefined 75 | const album = state.entities.getIn(["entities", "album", song.get("album")]); 76 | return ( 77 | song 78 | .set("artist", new Immutable.Map({id: artist.get("id"), name: artist.get("name")})) 79 | .set("album", new Immutable.Map({id: album.get("id"), name: album.get("name")})) 80 | ); 81 | }); 82 | } 83 | return { 84 | isFetching: state.entities.isFetching, 85 | error: state.entities.error, 86 | songsList: songsList, 87 | currentPage: state.paginated.currentPage, 88 | nPages: state.paginated.nPages, 89 | }; 90 | }; 91 | 92 | const mapDispatchToProps = (dispatch) => ({ 93 | actions: bindActionCreators(actionCreators, dispatch), 94 | }); 95 | 96 | export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SongsPageIntl)); 97 | -------------------------------------------------------------------------------- /webpack.config.base.js: -------------------------------------------------------------------------------- 1 | var path = require("path"); 2 | var webpack = require("webpack"); 3 | 4 | var CopyWebpackPlugin = require('copy-webpack-plugin'); 5 | var ExtractTextPlugin = require("extract-text-webpack-plugin"); 6 | var postcssReporter = require("postcss-reporter"); 7 | var autoprefixer = require("autoprefixer"); 8 | var browsers = ["ie >= 9", "> 1%", "last 3 versions", "not op_mini all"]; 9 | 10 | module.exports = { 11 | entry: { 12 | "index": [ 13 | "babel-polyfill", 14 | "bootstrap-loader", 15 | "font-awesome-webpack", 16 | // Add global style hacks 17 | "./app/common/styles/index.js", 18 | // Add utils in entry for prototypes modification 19 | "./app/common/utils/index.js", 20 | // Main entry point 21 | "./index.js"], 22 | "fix.ie9": "./fix.ie9.js" 23 | }, 24 | 25 | output: { 26 | path: path.join(__dirname, "public/"), 27 | filename: "[name].js", 28 | publicPath: "./" 29 | }, 30 | 31 | module: { 32 | loaders: [ 33 | // Handle JS/JSX files 34 | { 35 | test: /\.jsx?$/, 36 | exclude: /node_modules/, 37 | loader: "babel", 38 | query: { 39 | "cacheDirectory": ".cache/" 40 | }, 41 | include: __dirname 42 | }, 43 | // Handle CSS files 44 | { 45 | test: /\.css$/, 46 | loader: ExtractTextPlugin.extract( 47 | "style-loader", 48 | "css-loader?modules&importLoaders=1&localIdentName=[name]__[local]__[hash:base64:5]" + 49 | "!postcss-loader" 50 | ) 51 | }, 52 | // Handle SASS files 53 | { 54 | test: /\.scss$/, 55 | loader: ExtractTextPlugin.extract( 56 | "style-loader", 57 | "css-loader?modules&importLoaders=1&localIdentName=[name]__[local]__[hash:base64:5]" + 58 | "!postcss-loader" + 59 | "!sass-loader" + 60 | "!sass-resources-loader" 61 | ) 62 | }, 63 | // Fonts 64 | { 65 | test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, 66 | loader: "file" 67 | }, 68 | { 69 | test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/, 70 | loader: "url-loader?limit=10000&minetype=application/font-woff" 71 | }, 72 | { 73 | test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, 74 | loader: "url?limit=10000&mimetype=application/octet-stream" 75 | }, 76 | // SVG 77 | { 78 | test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, 79 | loader: "url?limit=10000&mimetype=image/svg+xml" 80 | } 81 | ] 82 | }, 83 | 84 | plugins: [ 85 | // Copy some useful files to the output path 86 | new CopyWebpackPlugin([ 87 | { from: "./index.html" }, 88 | { from: "./favicon.ico" }, 89 | { from: "./app/assets" }, 90 | { from: "./app/vendor" } 91 | ]), 92 | // Provide jQuery 93 | new webpack.ProvidePlugin({ 94 | $: "jquery", 95 | jQuery: "jquery" 96 | }), 97 | // Extract CSS 98 | new ExtractTextPlugin("style.css", { allChunks: true }) 99 | ], 100 | 101 | // PostCSS config 102 | postcss: [ 103 | autoprefixer({ browsers: browsers }), 104 | postcssReporter({ throwError: true, clearMessages: true }) 105 | ], 106 | 107 | sassResources: "./app/styles/variables.scss", 108 | 109 | resolve: { 110 | // Include empty string "" to resolve files by their explicit extension 111 | // (e.g. require("./somefile.ext")). 112 | // Include ".js", ".jsx" to resolve files by these implicit extensions 113 | // (e.g. require("underscore")). 114 | extensions: ["", ".js", ".jsx"], 115 | 116 | // Hack for isotope 117 | alias: { 118 | "masonry": "masonry-layout", 119 | "isotope": "isotope-layout" 120 | }, 121 | } 122 | }; 123 | -------------------------------------------------------------------------------- /app/components/Artist.jsx: -------------------------------------------------------------------------------- 1 | // NPM imports 2 | import React, { Component, PropTypes } from "react"; 3 | import ReactDOM from "react-dom"; 4 | import CSSModules from "react-css-modules"; 5 | import { defineMessages, FormattedMessage } from "react-intl"; 6 | import FontAwesome from "react-fontawesome"; 7 | import Immutable from "immutable"; 8 | 9 | // Local imports 10 | import { messagesMap } from "../utils/"; 11 | 12 | // Other components 13 | import { AlbumRow } from "./Album"; 14 | import DismissibleAlert from "./elements/DismissibleAlert"; 15 | 16 | // Translations 17 | import commonMessages from "../locales/messagesDescriptors/common"; 18 | 19 | // Styles 20 | import css from "../styles/Artist.scss"; 21 | 22 | // Define translations 23 | const artistMessages = defineMessages(messagesMap(Array.concat([], commonMessages))); 24 | 25 | 26 | /** 27 | * Single artist page 28 | */ 29 | class ArtistCSS extends Component { 30 | constructor(props) { 31 | super(props); 32 | 33 | // Set state 34 | this.state = { 35 | hasScrolled: false, // Not scrolled initially 36 | }; 37 | } 38 | 39 | componentDidUpdate() { 40 | // After each update, check if we need to scroll to a given element 41 | // State prevents scrolling at each and every update 42 | if (this.refs.scroll && !this.state.hasScrolled) { 43 | $("html, body").animate({ scrollTop: $(ReactDOM.findDOMNode(this.refs.scroll)).offset().top }, 600); 44 | this.setState({ 45 | hasScrolled: true, 46 | }); 47 | } 48 | } 49 | 50 | render() { 51 | // Define loading message 52 | let loading = null; 53 | if (this.props.isFetching) { 54 | loading = ( 55 |
56 |

57 |

60 |
61 | ); 62 | } 63 | 64 | // Handle error 65 | let error = null; 66 | if (this.props.error) { 67 | error = (); 68 | } 69 | 70 | // Build album rows 71 | let albumsRows = []; 72 | const { albums, songs, playAction, playNextAction, scrollToAlbum } = this.props; 73 | if (albums && songs) { 74 | albums.forEach(function (album) { 75 | // Get songs of this album 76 | const albumSongs = album.get("tracks").map( 77 | id => songs.get(id) 78 | ); 79 | // Handle scrolling to a specific album by applying a given ref 80 | const ref = (scrollToAlbum == album.get("id")) ? "scroll" : null; 81 | 82 | albumsRows.push(); 83 | }); 84 | } 85 | 86 | return ( 87 |
88 | { error } 89 |
90 |
91 |

{this.props.artist.get("name")}

92 |
93 |
94 |
95 |
96 |
97 |

{this.props.artist.get("summary")}

98 |
99 |
100 |

{this.props.artist.get("name")}/

101 |
102 |
103 | { albumsRows } 104 | { loading } 105 |
106 | ); 107 | } 108 | } 109 | ArtistCSS.propTypes = { 110 | error: PropTypes.string, 111 | isFetching: PropTypes.bool.isRequired, 112 | playAction: PropTypes.func.isRequired, 113 | playNextAction: PropTypes.func.isRequired, 114 | artist: PropTypes.instanceOf(Immutable.Map), 115 | albums: PropTypes.instanceOf(Immutable.List), 116 | songs: PropTypes.instanceOf(Immutable.Map), 117 | scrollToAlbum: PropTypes.number, 118 | }; 119 | export default CSSModules(ArtistCSS, css); 120 | -------------------------------------------------------------------------------- /app/locales/en-US/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "app.api.invalidResponse": "Invalid response text.", // Invalid response from the API 3 | "app.api.emptyResponse": "Empty response text.", // Empty response from the API 4 | "app.api.error": "Unknown API error.", // An unknown error occurred from the API 5 | "app.common.album": "{itemCount, plural, one {album} other {albums}}", // Album 6 | "app.common.art": "Art", // Art 7 | "app.common.artist": "{itemCount, plural, one {artist} other {artists}}", // Artist 8 | "app.common.cancel": "Cancel", // Cancel 9 | "app.common.close": "Close", // Close 10 | "app.common.go": "Go", // Go 11 | "app.common.loading": "Loading…", // Loading indicator 12 | "app.common.pause": "Pause", // Pause icon description 13 | "app.common.play": "Play", // Play icon description 14 | "app.common.playNext": "Play next", // Play next icon descripton 15 | "app.common.track": "{itemCount, plural, one {track} other {tracks}}", // Track 16 | "app.filter.filter": "Filter…", // Filtering input placeholder 17 | "app.filter.whatAreWeListeningToToday": "What are we listening to today?", // Description for the filter bar 18 | "app.grid.goToArtistPage": "Go to artist page", // Artist thumbnail link title 19 | "app.grid.goToAlbumPage": "Go to album page", // Album thumbnail link title 20 | "app.login.byebye": "See you soon!", // Info message on successful logout 21 | "app.login.connecting": "Connecting…", // Info message while trying to connect 22 | "app.login.endpointInputAriaLabel": "URL of your Ampache instance (e.g. http://ampache.example.com)", // ARIA label for the endpoint input 23 | "app.login.expired": "Your session expired… =(", // Error message on expired session 24 | "app.login.greeting": "Welcome back on Ampache, let's go!", // Greeting to welcome the user to the app 25 | "app.login.password": "Password", // Password input placeholder 26 | "app.login.rememberMe": "Remember me", // Remember me checkbox label 27 | "app.login.signIn": "Sign in", // Sign in 28 | "app.login.success": "Successfully logged in as { username }!", // Info message on successful login. 29 | "app.login.username": "Username", // Username input placeholder 30 | "app.pagination.current": "current", // Current (page) 31 | "app.pagination.goToPage": "Go to page {pageNumber}", // Link content to go to page N. span is here for screen-readers 32 | "app.pagination.goToPageWithoutMarkup": "Go to page {pageNumber}", // Link title to go to page N 33 | "app.pagination.pageNavigation": "Page navigation", // ARIA label for the nav block containing pagination 34 | "app.pagination.pageToGoTo": "Page to go to?", // Title of the pagination modal 35 | "app.playlist.currentSongPlaying": "Current song playing", // Current song playing 36 | "app.playlist.emptyPlaylist": "Empty playlist", // Empty playlist message 37 | "app.playlist.flushPlaylist": "Empty the playlist", // Empty the playlist link label 38 | "app.playlist.fullPlaylist": "Full playlist", // Full playlist 39 | "app.playlist.playlist": "Playlist", // Playlist translation 40 | "app.settings.settings": "Settings", // Settings translation 41 | "app.sidebarLayout.browse": "Browse", // Browse 42 | "app.sidebarLayout.browseAlbums": "Browse albums", // Browse albums 43 | "app.sidebarLayout.browseArtists": "Browse artists", // Browse artists 44 | "app.sidebarLayout.browseSongs": "Browse songs", // Browse songs 45 | "app.sidebarLayout.discover": "Discover", // Discover 46 | "app.sidebarLayout.home": "Home", // Home 47 | "app.sidebarLayout.logout": "Logout", // Logout 48 | "app.sidebarLayout.mainNavigationMenu": "Main navigation menu", // ARIA label for the main navigation menu 49 | "app.sidebarLayout.settings": "Settings", // Settings 50 | "app.sidebarLayout.toggleNavigation": "Toggle navigation", // Screen reader description of toggle navigation button 51 | "app.songs.genre": "Genre", // Genre (song) 52 | "app.songs.length": "Length", // Length (song) 53 | "app.songs.title": "Title", // Title (song) 54 | "app.webplayer.by": "by", // Artist affiliation of a song 55 | "app.webplayer.next": "Next", // Next button description 56 | "app.webplayer.onLoadError": "Unable to load song", // Error message in case a song could not be loaded 57 | "app.webplayer.playlist": "Playlist", // Playlist button description 58 | "app.webplayer.previous": "Previous", // Previous button description 59 | "app.webplayer.random": "Random", // Random button description 60 | "app.webplayer.repeat": "Repeat", // Repeat button description 61 | "app.webplayer.unsupported":"Unsupported media type", // Unsupported media type 62 | "app.webplayer.volume": "Volume", // Volume button description 63 | }; 64 | -------------------------------------------------------------------------------- /app/locales/fr-FR/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "app.api.invalidResponse": "Réponse invalide reçue.", // Invalid response from the API 3 | "app.api.emptyResponse": "Réponse vide reçue.", // Empty response from the API 4 | "app.api.error": "Erreur inconnue.", // An unknown error occurred from the API 5 | "app.common.album": "{itemCount, plural, one {album} other {albums}}", // Albums 6 | "app.common.art": "Pochette", // Art 7 | "app.common.artist": "{itemCount, plural, one {artiste} other {artistes}}", // Artists 8 | "app.common.cancel": "Annuler", // Cancel 9 | "app.common.close": "Fermer", // Close 10 | "app.common.go": "Aller", // Go 11 | "app.common.loading": "Chargement…", // Loading indicator 12 | "app.common.pause": "Pause", // Pause icon description 13 | "app.common.play": "Jouer", // PLay icon description 14 | "app.common.playNext": "Jouer après", // Play next icon descripton 15 | "app.common.track": "{itemCount, plural, one {piste} other {pistes}}", // Track 16 | "app.filter.filter": "Filtrer…", // Filtering input placeholder 17 | "app.filter.whatAreWeListeningToToday": "Que voulez-vous écouter aujourd'hui\u00a0?", // Description for the filter bar 18 | "app.grid.goToArtistPage": "Aller à la page de l'artiste", // Artist thumbnail link title 19 | "app.grid.goToAlbumPage": "Aller à la page de l'album", // Album thumbnail link title 20 | "app.login.byebye": "À bientôt\u00a0!", // Info message on successful logout 21 | "app.login.connecting": "Connexion…", // Info message while trying to connect 22 | "app.login.endpointInputAriaLabel": "URL de votre Ampache (e.g. http://ampache.example.com)", // ARIA label for the endpoint input 23 | "app.login.expired": "Session expirée… =(", // Error message on expired session 24 | "app.login.greeting": "Bon retour sur Ampache, c'est parti\u00a0!", // Greeting to welcome the user to the app 25 | "app.login.password": "Mot de passe", // Password input placeholder 26 | "app.login.rememberMe": "Se souvenir", // Remember me checkbox label 27 | "app.login.signIn": "Connexion", // Sign in 28 | "app.login.success": "Connecté en tant que { username }\u00a0!", // Info message on successful login. 29 | "app.login.username": "Utilisateur", // Username input placeholder 30 | "app.pagination.current": "actuelle", // Current (page) 31 | "app.pagination.goToPage": "Aller à la page {pageNumber}", // Link content to go to page N. span is here for screen-readers 32 | "app.pagination.goToPageWithoutMarkup": "Aller à la page {pageNumber}", // Link title to go to page N 33 | "app.pagination.pageNavigation": "Navigation entre les pages", // ARIA label for the nav block containing pagination 34 | "app.pagination.pageToGoTo": "Page à laquelle aller\u00a0?", // Title of the pagination modal 35 | "app.playlist.currentSongPlaying": "Piste en cours de lecture", // Current song playing 36 | "app.playlist.emptyPlaylist": "Liste de lecture vide", // Empty playlist message 37 | "app.playlist.flushPlaylist": "Vider la playlist", // Empty the playlist link label 38 | "app.playlist.fullPlaylist": "Playlist complète", // Full playlist 39 | "app.playlist.playlist": "Liste de lecture", // Playlist translation 40 | "app.settings.settings": "Préférences", // Settings translation 41 | "app.sidebarLayout.browse": "Explorer", // Browse 42 | "app.sidebarLayout.browseAlbums": "Parcourir les albums", // Browse albums 43 | "app.sidebarLayout.browseArtists": "Parcourir les artistes", // Browse artists 44 | "app.sidebarLayout.browseSongs": "Parcourir les pistes", // Browse songs 45 | "app.sidebarLayout.discover": "Découvrir", // Discover 46 | "app.sidebarLayout.home": "Accueil", // Home 47 | "app.sidebarLayout.logout": "Déconnexion", // Logout 48 | "app.sidebarLayout.mainNavigationMenu": "Menu principal", // ARIA label for the main navigation menu 49 | "app.sidebarLayout.settings": "Préférences", // Settings 50 | "app.sidebarLayout.toggleNavigation": "Afficher le menu", // Screen reader description of toggle navigation button 51 | "app.songs.genre": "Genre", // Genre (song) 52 | "app.songs.length": "Durée", // Length (song) 53 | "app.songs.title": "Titre", // Title (song) 54 | "app.webplayer.by": "par", // Artist affiliation of a song 55 | "app.webplayer.next": "Suivant", // Next button description 56 | "app.webplayer.onLoadError": "Impossible de charger la piste", // Error message in case a song could not be loaded 57 | "app.webplayer.playlist": "Liste de lecture", // Playlist button description 58 | "app.webplayer.previous": "Précédent", // Previous button description 59 | "app.webplayer.random": "Aléatoire", // Random button description 60 | "app.webplayer.repeat": "Répéter", // Repeat button description 61 | "app.webplayer.unsupported": "Format non supporté", // Unsupported media type 62 | "app.webplayer.volume": "Volume", // Volume button description 63 | }; 64 | -------------------------------------------------------------------------------- /app/components/Album.jsx: -------------------------------------------------------------------------------- 1 | // NPM import 2 | import React, { Component, PropTypes } from "react"; 3 | import CSSModules from "react-css-modules"; 4 | import { defineMessages, FormattedMessage, injectIntl, intlShape } from "react-intl"; 5 | import FontAwesome from "react-fontawesome"; 6 | import Immutable from "immutable"; 7 | 8 | // Local imports 9 | import { formatLength, messagesMap } from "../utils"; 10 | 11 | // Translations 12 | import commonMessages from "../locales/messagesDescriptors/common"; 13 | 14 | // Styles 15 | import css from "../styles/Album.scss"; 16 | 17 | // Set translations 18 | const albumMessages = defineMessages(messagesMap(Array.concat([], commonMessages))); 19 | 20 | 21 | /** 22 | * Track row in an album tracks table. 23 | */ 24 | class AlbumTrackRowCSSIntl extends Component { 25 | constructor(props) { 26 | super(props); 27 | 28 | // Bind this 29 | this.onPlayClick = this.onPlayClick.bind(this); 30 | this.onPlayNextClick = this.onPlayNextClick.bind(this); 31 | } 32 | 33 | /** 34 | * Handle click on play button. 35 | */ 36 | onPlayClick() { 37 | $(this.refs.play).blur(); 38 | this.props.playAction(this.props.track.get("id")); 39 | } 40 | 41 | /** 42 | * Handle click on play next button. 43 | */ 44 | onPlayNextClick() { 45 | $(this.refs.playNext).blur(); 46 | this.props.playNextAction(this.props.track.get("id")); 47 | } 48 | 49 | render() { 50 | const { formatMessage } = this.props.intl; 51 | const length = formatLength(this.props.track.get("time")); 52 | return ( 53 | 54 | 55 |   61 | 67 | 68 | {this.props.track.get("track")} 69 | {this.props.track.get("name")} 70 | {length} 71 | 72 | ); 73 | } 74 | } 75 | AlbumTrackRowCSSIntl.propTypes = { 76 | playAction: PropTypes.func.isRequired, 77 | playNextAction: PropTypes.func.isRequired, 78 | track: PropTypes.instanceOf(Immutable.Map).isRequired, 79 | intl: intlShape.isRequired, 80 | }; 81 | export let AlbumTrackRow = injectIntl(CSSModules(AlbumTrackRowCSSIntl, css)); 82 | 83 | 84 | /** 85 | * Tracks table of an album. 86 | */ 87 | class AlbumTracksTableCSS extends Component { 88 | render() { 89 | let rows = []; 90 | // Build rows for each track 91 | const { playAction, playNextAction } = this.props; 92 | this.props.tracks.forEach(function (item) { 93 | rows.push(); 94 | }); 95 | return ( 96 | 97 | 98 | {rows} 99 | 100 |
101 | ); 102 | } 103 | } 104 | AlbumTracksTableCSS.propTypes = { 105 | playAction: PropTypes.func.isRequired, 106 | playNextAction: PropTypes.func.isRequired, 107 | tracks: PropTypes.instanceOf(Immutable.List).isRequired, 108 | }; 109 | export let AlbumTracksTable = CSSModules(AlbumTracksTableCSS, css); 110 | 111 | 112 | /** 113 | * An entire album row containing art and tracks table. 114 | */ 115 | class AlbumRowCSS extends Component { 116 | render() { 117 | return ( 118 |
119 |
120 |

{this.props.album.get("name")}

121 |
122 |
123 |

{this.props.album.get("name")}

124 |
125 |
126 | { 127 | this.props.songs.size > 0 ? 128 | : 129 | null 130 | } 131 |
132 |
133 | ); 134 | } 135 | } 136 | AlbumRowCSS.propTypes = { 137 | playAction: PropTypes.func.isRequired, 138 | playNextAction: PropTypes.func.isRequired, 139 | album: PropTypes.instanceOf(Immutable.Map).isRequired, 140 | songs: PropTypes.instanceOf(Immutable.List).isRequired, 141 | }; 142 | export let AlbumRow = CSSModules(AlbumRowCSS, css); 143 | -------------------------------------------------------------------------------- /app/components/Discover.jsx: -------------------------------------------------------------------------------- 1 | // TODO: Discover view is not done 2 | import React, { Component } from "react"; 3 | import CSSModules from "react-css-modules"; 4 | import FontAwesome from "react-fontawesome"; 5 | 6 | import css from "../styles/Discover.scss"; 7 | 8 | export class DiscoverCSS extends Component { 9 | render() { 10 | const artistsAlbumsSongsDropdown = ( 11 |
12 | 16 | 21 |
22 | ); 23 | const bobDylan = ( 24 |
25 |
26 | 27 | Bob Dylan 28 | 29 |

30 | Bob Dylan 31 |

32 |
33 |
34 | ); 35 | return ( 36 |
37 |

38 |

41 | 42 |
43 |
44 |
45 |
46 | 47 | Bob Dylan 48 | 49 |

50 | Bob Dylan 51 |

52 |
53 |
54 | { bobDylan } 55 | { bobDylan } 56 | { bobDylan } 57 | { bobDylan } 58 |
59 |
60 |
61 | 62 | 63 |

64 |

67 |
68 |
69 |
70 |
71 | 72 | Bob Dylan 73 | 74 |

75 | Bob Dylan 76 |

77 |
78 |
79 | { bobDylan } 80 | { bobDylan } 81 | { bobDylan } 82 | { bobDylan } 83 |
84 |
85 |
86 | 87 | 88 |

89 |

92 |
93 |
94 |
95 |
96 | 97 | Bob Dylan 98 | 99 |

100 | Bob Dylan 101 |

102 |
103 |
104 | { bobDylan } 105 | { bobDylan } 106 | { bobDylan } 107 | { bobDylan } 108 |
109 |
110 |
111 | 112 | 113 |

114 |

117 | 118 | 119 |
120 |
121 | ); 122 | } 123 | } 124 | 125 | DiscoverCSS.propTypes = { 126 | }; 127 | 128 | export default CSSModules(DiscoverCSS, css); 129 | -------------------------------------------------------------------------------- /app/reducers/webplayer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This implements the webplayer reducers. 3 | */ 4 | 5 | // NPM imports 6 | import Immutable from "immutable"; 7 | 8 | // Local imports 9 | import { createReducer } from "../utils"; 10 | 11 | // Models 12 | import { stateRecord } from "../models/webplayer"; 13 | 14 | // Actions 15 | import { 16 | PLAY_PAUSE, 17 | STOP_PLAYBACK, 18 | SET_PLAYLIST, 19 | PUSH_SONG, 20 | POP_SONG, 21 | JUMP_TO_SONG, 22 | PLAY_PREVIOUS_SONG, 23 | PLAY_NEXT_SONG, 24 | TOGGLE_RANDOM, 25 | TOGGLE_REPEAT, 26 | TOGGLE_MUTE, 27 | SET_VOLUME, 28 | SET_ERROR, 29 | INVALIDATE_STORE } from "../actions"; 30 | 31 | 32 | /** 33 | * Initial state 34 | */ 35 | 36 | var initialState = new stateRecord(); 37 | 38 | 39 | /** 40 | * Reducers 41 | */ 42 | 43 | export default createReducer(initialState, { 44 | [PLAY_PAUSE]: (state, payload) => { 45 | // Force play or pause 46 | return ( 47 | state 48 | .set("isPlaying", payload.isPlaying) 49 | .set("error", null) 50 | ); 51 | }, 52 | [STOP_PLAYBACK]: (state) => { 53 | // Clear the playlist 54 | return ( 55 | state 56 | .set("isPlaying", false) 57 | .set("currentIndex", 0) 58 | .set("playlist", new Immutable.List()) 59 | .set("error", null) 60 | ); 61 | }, 62 | [SET_PLAYLIST]: (state, payload) => { 63 | // Set current playlist, reset playlist index 64 | return ( 65 | state 66 | .set("playlist", new Immutable.List(payload.playlist)) 67 | .set("currentIndex", 0) 68 | .set("error", null) 69 | ); 70 | }, 71 | [PUSH_SONG]: (state, payload) => { 72 | // Push song to playlist 73 | let newState = state; 74 | if (payload.index) { 75 | // If index is specified, insert it at this position 76 | newState = newState.set( 77 | "playlist", 78 | newState.get("playlist").insert(payload.index, payload.song) 79 | ); 80 | if (payload.index <= newState.get("currentIndex")) { // "<=" because insertion is made before 81 | // If we insert before the current position in the playlist, we 82 | // update the current position to keep the currently played 83 | // music 84 | newState = newState.set( 85 | "currentIndex", 86 | Math.min(newState.get("currentIndex") + 1, newState.get("playlist").size) 87 | ); 88 | } 89 | } else { 90 | // Else, push at the end of the playlist 91 | newState = newState.set( 92 | "playlist", 93 | newState.get("playlist").push(payload.song) 94 | ); 95 | } 96 | return newState; 97 | }, 98 | [POP_SONG]: (state, payload) => { 99 | // Pop song from playlist 100 | let newState = state.deleteIn(["playlist", payload.index]); 101 | if (payload.index < state.get("currentIndex")) { 102 | // If we delete before the current position in the playlist, we 103 | // update the current position to keep the currently played 104 | // music 105 | newState = newState.set( 106 | "currentIndex", 107 | Math.max(newState.get("currentIndex") - 1, 0) 108 | ); 109 | } else if (payload.index == state.get("currentIndex")) { 110 | // If we remove current song, clear the error as well 111 | newState = newState.set("error", null); 112 | } 113 | return newState; 114 | }, 115 | [JUMP_TO_SONG]: (state, payload) => { 116 | // Set current index 117 | const newCurrentIndex = state.get("playlist").findKey(x => x == payload.song); 118 | return state.set("currentIndex", newCurrentIndex); 119 | }, 120 | [PLAY_PREVIOUS_SONG]: (state) => { 121 | const newIndex = state.get("currentIndex") - 1; 122 | if (newIndex < 0) { 123 | // If there is an overlow on the left of the playlist, just play 124 | // first music again 125 | // TODO: Should seek to beginning of music 126 | return state.set("error", null); 127 | } else { 128 | return ( 129 | state 130 | .set("currentIndex", newIndex) 131 | .set("error", null) 132 | ); 133 | } 134 | }, 135 | [PLAY_NEXT_SONG]: (state) => { 136 | let newIndex = state.get("currentIndex") + 1; 137 | if (newIndex >= state.get("playlist").size) { 138 | // If there is an overflow 139 | if (state.get("isRepeat")) { 140 | // If we are at the end of the playlist and repeat mode is on, 141 | // just play back first song. 142 | newIndex = 0; 143 | } else { 144 | // Just stop playback 145 | return ( 146 | state 147 | .set("isPlaying", false) 148 | .set("error", null) 149 | ); 150 | } 151 | } else { 152 | // Else, play next item 153 | return ( 154 | state 155 | .set("currentIndex", newIndex) 156 | .set("error", null) 157 | ); 158 | } 159 | }, 160 | [TOGGLE_RANDOM]: (state) => { 161 | return state.set("isRandom", !state.get("isRandom")); 162 | }, 163 | [TOGGLE_REPEAT]: (state) => { 164 | return state.set("isRepeat", !state.get("isRepeat")); 165 | }, 166 | [TOGGLE_MUTE]: (state) => { 167 | return state.set("isMute", !state.get("isMute")); 168 | }, 169 | [SET_VOLUME]: (state, payload) => { 170 | return state.set("volume", payload.volume); 171 | }, 172 | [SET_ERROR]: (state, payload) => { 173 | return ( 174 | state 175 | .set("isPlaying", false) 176 | .set("error", payload.error) 177 | ); 178 | }, 179 | [INVALIDATE_STORE]: () => { 180 | return new stateRecord(); 181 | }, 182 | }); 183 | -------------------------------------------------------------------------------- /app/reducers/entities.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This implements the global entities reducer. 3 | */ 4 | 5 | // NPM imports 6 | import Immutable from "immutable"; 7 | 8 | // Local imports 9 | import { createReducer } from "../utils"; 10 | 11 | // Models 12 | import { stateRecord } from "../models/entities"; 13 | 14 | // Actions 15 | import { 16 | API_REQUEST, 17 | API_FAILURE, 18 | PUSH_ENTITIES, 19 | INCREMENT_REFCOUNT, 20 | DECREMENT_REFCOUNT, 21 | INVALIDATE_STORE, 22 | } from "../actions"; 23 | 24 | 25 | /** 26 | * Helper methods 27 | */ 28 | 29 | /** 30 | * Update the reference counter for a given item. 31 | * 32 | * Do not do any garbage collection. 33 | * 34 | * @param state The state object to update. 35 | * @param keyPath The keyPath to update, from the refCount key. 36 | * @param incr The increment (or decrement) for the reference counter. 37 | * 38 | * @return An updated state. 39 | */ 40 | function updateRefCount(state, keyPath, incr) { 41 | // Prepend refCounts to keyPath 42 | const refCountKeyPath = Array.concat(["refCounts"], keyPath); 43 | // Get updated value 44 | let newRefCount = state.getIn(refCountKeyPath) + incr; 45 | if (isNaN(newRefCount)) { 46 | // If NaN, reference does not exist, so set it to ±1 47 | newRefCount = Math.sign(incr); 48 | } 49 | // Update state 50 | return state.setIn(refCountKeyPath, newRefCount); 51 | } 52 | 53 | 54 | /** 55 | * Update the reference counter of a given entity, taking into account the 56 | * nested objects. 57 | * 58 | * Do not do any garbage collection. 59 | * 60 | * @param state The state object to update. 61 | * @param itemName The type of the entity object. 62 | * @param id The id of the entity. 63 | * @param entity The entity object, as Immutable. 64 | * @param incr The increment (or decrement) for the reference counter. 65 | * 66 | * @return An updated state. 67 | */ 68 | function updateEntityRefCount(state, itemName, id, entity, incr) { 69 | let newState = state; 70 | let albums = null; 71 | let tracks = null; 72 | switch (itemName) { 73 | case "artist": 74 | // Update artist refCount 75 | newState = updateRefCount(newState, ["artist", id], incr); 76 | // Update nested albums refCount 77 | albums = entity.get("albums"); 78 | if (Immutable.List.isList(albums)) { 79 | albums.forEach(function (id) { 80 | newState = updateRefCount(newState, ["album", id], incr); 81 | }); 82 | } 83 | // Update nested tracks refCount 84 | tracks = entity.get("songs"); 85 | if (Immutable.List.isList(tracks)) { 86 | tracks.forEach(function (id) { 87 | newState = updateRefCount(newState, ["song", id], incr); 88 | }); 89 | } 90 | break; 91 | case "album": 92 | // Update album refCount 93 | newState = updateRefCount(newState, ["album", id], incr); 94 | // Update nested artist refCount 95 | newState = updateRefCount(newState, ["artist", entity.get("artist")], incr); 96 | // Update nested tracks refCount 97 | tracks = entity.get("tracks"); 98 | if (Immutable.List.isList(tracks)) { 99 | tracks.forEach(function (id) { 100 | newState = updateRefCount(newState, ["song", id], incr); 101 | }); 102 | } 103 | break; 104 | case "song": 105 | // Update track refCount 106 | newState = updateRefCount(newState, ["song", id], incr); 107 | // Update nested artist refCount 108 | newState = updateRefCount(newState, ["artist", entity.get("artist")], incr); 109 | // Update nested album refCount 110 | newState = updateRefCount(newState, ["album", entity.get("album")], incr); 111 | break; 112 | default: 113 | // Just update the entity, no nested entities 114 | newState = updateRefCount(newState, [itemName, id], incr); 115 | break; 116 | } 117 | return newState; 118 | } 119 | 120 | 121 | /** 122 | * 123 | */ 124 | function garbageCollection(state) { 125 | let newState = state; 126 | state.refCounts.forEach(function (refCounts, itemName) { 127 | refCounts.forEach(function (refCount, id) { 128 | if (refCount < 1) { 129 | // Garbage collection 130 | newState = newState.deleteIn(["entities", itemName, id]); 131 | newState = newState.deleteIn(["refCounts", itemName, id]); 132 | } 133 | }); 134 | }); 135 | return newState; 136 | } 137 | 138 | 139 | /** 140 | * Initial state 141 | */ 142 | var initialState = new stateRecord(); 143 | 144 | 145 | /** 146 | * Reducer 147 | */ 148 | export default createReducer(initialState, { 149 | [API_REQUEST]: (state) => { 150 | return ( 151 | state 152 | .set("isFetching", true) 153 | .set("error", null) 154 | ); 155 | }, 156 | [API_FAILURE]: (state, payload) => { 157 | return ( 158 | state 159 | .set("isFetching", false) 160 | .set("error", payload.error) 161 | ); 162 | }, 163 | [PUSH_ENTITIES]: (state, payload) => { 164 | let newState = state; 165 | 166 | // Unset error and isFetching 167 | newState = state.set("isFetching", false).set("error", payload.error); 168 | 169 | // Merge entities 170 | newState = newState.mergeDeepIn(["entities"], payload.entities); 171 | 172 | // Increment reference counter 173 | payload.refCountType.forEach(function (itemName) { 174 | const entities = payload.entities[itemName]; 175 | for (let id in entities) { 176 | const entity = newState.getIn(["entities", itemName, id]); 177 | newState = updateEntityRefCount(newState, itemName, id, entity, 1); 178 | } 179 | }); 180 | 181 | return newState; 182 | }, 183 | [INCREMENT_REFCOUNT]: (state, payload) => { 184 | let newState = state; 185 | 186 | // Increment reference counter 187 | for (let itemName in payload.entities) { 188 | const entities = payload.entities[itemName]; 189 | entities.forEach(function (id) { 190 | const entity = newState.getIn(["entities", itemName, id]); 191 | newState = updateEntityRefCount(newState, itemName, id, entity, 1); 192 | }); 193 | } 194 | 195 | return newState; 196 | }, 197 | [DECREMENT_REFCOUNT]: (state, payload) => { 198 | let newState = state; 199 | 200 | // Decrement reference counter 201 | for (let itemName in payload.entities) { 202 | const entities = payload.entities[itemName]; 203 | entities.forEach(function (id) { 204 | const entity = newState.getIn(["entities", itemName, id]); 205 | newState = updateEntityRefCount(newState, itemName, id, entity, -1); 206 | }); 207 | } 208 | 209 | // Perform garbage collection 210 | newState = garbageCollection(newState); 211 | 212 | return newState; 213 | }, 214 | [INVALIDATE_STORE]: () => { 215 | return new stateRecord(); 216 | }, 217 | }); 218 | -------------------------------------------------------------------------------- /app/views/WebPlayer.jsx: -------------------------------------------------------------------------------- 1 | // NPM imports 2 | import React, { Component, PropTypes } from "react"; 3 | import { bindActionCreators } from "redux"; 4 | import { connect } from "react-redux"; 5 | import { defineMessages, injectIntl, intlShape } from "react-intl"; 6 | import { Howler, Howl } from "howler"; 7 | 8 | // Local imports 9 | import { messagesMap, handleErrorI18nObject } from "../utils"; 10 | import { UNSUPPORTED_MEDIA_TYPE, ONLOAD_ERROR } from "../actions/webplayer"; 11 | 12 | // Actions 13 | import * as actionCreators from "../actions"; 14 | 15 | // Components 16 | import WebPlayerComponent from "../components/elements/WebPlayer"; 17 | 18 | // Translations 19 | import messages from "../locales/messagesDescriptors/elements/WebPlayer"; 20 | 21 | // Define translations 22 | const webplayerMessages = defineMessages(messagesMap(Array.concat([], messages))); 23 | 24 | 25 | /** 26 | * Webplayer container. 27 | */ 28 | class WebPlayerIntl extends Component { 29 | constructor(props) { 30 | super(props); 31 | 32 | // Data attributes 33 | this.howl = null; 34 | 35 | // Bind this 36 | this.startPlaying = this.startPlaying.bind(this); 37 | this.stopPlaying = this.stopPlaying.bind(this); 38 | this.isPlaying = this.isPlaying.bind(this); 39 | } 40 | 41 | componentDidMount() { 42 | // Start playback upon component mount 43 | this.startPlaying(this.props); 44 | } 45 | 46 | componentWillUpdate(nextProps) { 47 | // Handle stop 48 | if ( 49 | // No current song in updated props 50 | !nextProps.currentSong || 51 | // No playlist in updated props 52 | nextProps.playlist.size < 1 || 53 | // Song played is not the song currently played 54 | (this.props.currentSong && nextProps.currentSong.get("id") != this.props.currentSong.get("id")) 55 | ) { 56 | if (this.howl) { 57 | this.stopPlaying(); 58 | } 59 | } 60 | 61 | // Toggle play / pause 62 | if ( 63 | // This check ensure we do not start playing multiple times the 64 | // same song 65 | (nextProps.isPlaying != this.props.isPlaying) || 66 | // Or we should be playing but there is no howl object playing 67 | (nextProps.isPlaying && !this.isPlaying()) 68 | ) { 69 | this.startPlaying(nextProps); 70 | } 71 | 72 | // If something is playing back 73 | if (this.howl) { 74 | // Set mute / unmute 75 | this.howl.mute(nextProps.isMute); 76 | // Set volume 77 | this.howl.volume(nextProps.volume / 100); 78 | } 79 | } 80 | 81 | /** 82 | * Handle playback through Howler and Web Audio API. 83 | * 84 | * @params props A set of props to use for setting play parameters. 85 | */ 86 | startPlaying(props) { 87 | if (props.isPlaying && props.currentSong) { 88 | // If it should be playing any song 89 | const url = props.currentSong.get("url"); 90 | const format = url.split(".").pop(); 91 | const isPlayable = Howler.codecs(format); 92 | if (isPlayable) { 93 | // Build a new Howler object with current song to play 94 | this.howl = new Howl({ 95 | src: [url], 96 | html5: true, // Use HTML5 by default to allow streaming 97 | mute: props.isMute, 98 | volume: props.volume / 100, // Set current volume 99 | autoplay: false, // No autoplay, we handle it manually 100 | format: format, // Specify format as Howler is unable to fetch it from URL 101 | onloaderror: () => props.actions.setError(ONLOAD_ERROR), // Display error if song cannot be loaded 102 | onend: () => props.actions.playNextSong(), // Play next song at the end 103 | }); 104 | // Start playing 105 | this.howl.play(); 106 | } else { 107 | // Howler already performs this check on his own, but we have 108 | // to do it ourselves to be able to display localized errors 109 | // for every possible error. 110 | // TODO: This could most likely be simplified. 111 | props.actions.setError(UNSUPPORTED_MEDIA_TYPE); 112 | } 113 | } 114 | else { 115 | // If it should not be playing 116 | if (this.howl) { 117 | // Pause any running music 118 | this.howl.pause(); 119 | } 120 | } 121 | } 122 | 123 | /** 124 | * Stop playback through Howler and Web Audio API. 125 | */ 126 | stopPlaying() { 127 | // Stop music playback 128 | this.howl.stop(); 129 | // Reset howl object 130 | this.howl = null; 131 | } 132 | 133 | /** 134 | * Check whether some music is currently playing or not. 135 | * 136 | * @return True / False whether music is playing. 137 | */ 138 | isPlaying() { 139 | if (this.howl) { 140 | return this.howl.playing(); 141 | } 142 | return false; 143 | } 144 | 145 | render() { 146 | const { formatMessage } = this.props.intl; 147 | 148 | const webplayerProps = { 149 | isPlaying: this.props.isPlaying, 150 | isRandom: this.props.isRandom, 151 | isRepeat: this.props.isRepeat, 152 | isMute: this.props.isMute, 153 | volume: this.props.volume, 154 | currentIndex: this.props.currentIndex, 155 | playlist: this.props.playlist, 156 | error: handleErrorI18nObject(this.props.error, formatMessage, webplayerMessages), 157 | currentSong: this.props.currentSong, 158 | currentArtist: this.props.currentArtist, 159 | // Use a lambda to ensure no first argument is passed to 160 | // togglePlaying 161 | onPlayPause: (() => this.props.actions.togglePlaying()), 162 | onPrev: this.props.actions.playPreviousSong, 163 | onSkip: this.props.actions.playNextSong, 164 | onRandom: this.props.actions.toggleRandom, 165 | onRepeat: this.props.actions.toggleRepeat, 166 | onMute: this.props.actions.toggleMute, 167 | isPlaylistViewActive: ( 168 | (this.props.location && this.props.location.pathname == "/playlist") 169 | ? true 170 | : false 171 | ), 172 | }; 173 | return ( 174 | (this.props.playlist.size > 0) 175 | ? 176 | :
177 | ); 178 | } 179 | } 180 | WebPlayerIntl.propTypes = { 181 | location: PropTypes.object, 182 | intl: intlShape.isRequired, 183 | }; 184 | const mapStateToProps = (state) => { 185 | const currentIndex = state.webplayer.currentIndex; 186 | const playlist = state.webplayer.playlist; 187 | 188 | // Get current song and artist from entities store 189 | const currentSong = state.entities.getIn(["entities", "song", playlist.get(currentIndex)]); 190 | let currentArtist = undefined; 191 | if (currentSong) { 192 | currentArtist = state.entities.getIn(["entities", "artist", currentSong.get("artist")]); 193 | } 194 | return { 195 | isPlaying: state.webplayer.isPlaying, 196 | isRandom: state.webplayer.isRandom, 197 | isRepeat: state.webplayer.isRepeat, 198 | isMute: state.webplayer.isMute, 199 | volume: state.webplayer.volume, 200 | currentIndex: currentIndex, 201 | playlist: playlist, 202 | error: state.webplayer.error, 203 | currentSong: currentSong, 204 | currentArtist: currentArtist, 205 | }; 206 | }; 207 | const mapDispatchToProps = (dispatch) => ({ 208 | actions: bindActionCreators(actionCreators, dispatch), 209 | }); 210 | export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(WebPlayerIntl)); 211 | -------------------------------------------------------------------------------- /app/actions/auth.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file implements authentication related actions. 3 | */ 4 | 5 | // NPM imports 6 | import { push } from "react-router-redux"; 7 | import Cookies from "js-cookie"; 8 | 9 | // Local imports 10 | import { buildHMAC, cleanURL } from "../utils"; 11 | 12 | // Models 13 | import { i18nRecord } from "../models/i18n"; 14 | 15 | // Other actions and payload types 16 | import { CALL_API } from "../middleware/api"; 17 | import { invalidateStore } from "./store"; 18 | 19 | 20 | // Constants 21 | export const DEFAULT_SESSION_INTERVAL = 1800 * 1000; // 30 mins long sessoins by default 22 | 23 | 24 | /** 25 | * Dispatch a ping query to the API for login keepalive and prevent session 26 | * from expiring. 27 | * 28 | * @param username Username to use 29 | * @param token Token to revive 30 | * @param endpoint Ampache base URL 31 | * 32 | * @return A CALL_API payload to keep session alive. 33 | */ 34 | export function loginKeepAlive(username, token, endpoint) { 35 | return { 36 | type: CALL_API, 37 | payload: { 38 | endpoint: endpoint, 39 | dispatch: [ 40 | null, 41 | null, 42 | error => dispatch => { 43 | dispatch(loginUserFailure(error || new i18nRecord({ id: "app.login.expired", values: {}}))); 44 | }, 45 | ], 46 | action: "ping", 47 | auth: token, 48 | username: username, 49 | extraParams: {}, 50 | }, 51 | }; 52 | } 53 | 54 | 55 | export const LOGIN_USER_SUCCESS = "LOGIN_USER_SUCCESS"; 56 | /** 57 | * Action to be called on successful login. 58 | * 59 | * @param username Username used for login 60 | * @param token Token got back from the API 61 | * @param endpoint Ampache server base URL 62 | * @param rememberMe Whether to remember me or not 63 | * @param timerID ID of the timer set for session keepalive. 64 | * 65 | * @return A login success payload. 66 | */ 67 | export function loginUserSuccess(username, token, endpoint, rememberMe, timerID) { 68 | return { 69 | type: LOGIN_USER_SUCCESS, 70 | payload: { 71 | username: username, 72 | token: token, 73 | endpoint: endpoint, 74 | rememberMe: rememberMe, 75 | timerID: timerID, 76 | }, 77 | }; 78 | } 79 | 80 | 81 | export const LOGIN_USER_FAILURE = "LOGIN_USER_FAILURE"; 82 | /** 83 | * Action to be called on failed login. 84 | * 85 | * This action removes any remember me cookie if any was set. 86 | * 87 | * @param error An error object, either string or i18nRecord. 88 | * @return A login failure payload. 89 | */ 90 | export function loginUserFailure(error) { 91 | Cookies.remove("username"); 92 | Cookies.remove("token"); 93 | Cookies.remove("endpoint"); 94 | return { 95 | type: LOGIN_USER_FAILURE, 96 | payload: { 97 | error: error, 98 | }, 99 | }; 100 | } 101 | 102 | 103 | export const LOGIN_USER_EXPIRED = "LOGIN_USER_EXPIRED"; 104 | /** 105 | * Action to be called when session is expired. 106 | * 107 | * @param error An error object, either a string or i18nRecord. 108 | * @return A session expired payload. 109 | */ 110 | export function loginUserExpired(error) { 111 | return { 112 | type: LOGIN_USER_EXPIRED, 113 | payload: { 114 | error: error, 115 | }, 116 | }; 117 | } 118 | 119 | 120 | export const LOGIN_USER_REQUEST = "LOGIN_USER_REQUEST"; 121 | /** 122 | * Action to be called when login is requested. 123 | * 124 | * @return A login request payload. 125 | */ 126 | export function loginUserRequest() { 127 | return { 128 | type: LOGIN_USER_REQUEST, 129 | }; 130 | } 131 | 132 | 133 | export const LOGOUT_USER = "LOGOUT_USER"; 134 | /** 135 | * Action to be called upon logout. 136 | * 137 | * This function clears the cookies set for remember me and the keep alive 138 | * timer. 139 | * 140 | * @remark This function does not clear the other stores, nor handle 141 | * redirection. 142 | * 143 | * @return A logout payload. 144 | */ 145 | export function logout() { 146 | return (dispatch, state) => { 147 | const { auth } = state(); 148 | if (auth.timerID) { 149 | clearInterval(auth.timerID); 150 | } 151 | Cookies.remove("username"); 152 | Cookies.remove("token"); 153 | Cookies.remove("endpoint"); 154 | dispatch({ 155 | type: LOGOUT_USER, 156 | }); 157 | }; 158 | } 159 | 160 | 161 | /** 162 | * Action to be called to log a user out. 163 | * 164 | * This function clears the remember me cookies and the keepalive timer. It 165 | * also clears the data behind authentication in the store and redirects to 166 | * login page. 167 | */ 168 | export function logoutAndRedirect() { 169 | return (dispatch) => { 170 | dispatch(logout()); 171 | dispatch(invalidateStore()); 172 | dispatch(push("/login")); 173 | }; 174 | } 175 | 176 | 177 | /** 178 | * Action to be called to log a user in. 179 | * 180 | * @param username Username to use. 181 | * @param passwordOrToken User password, or previous token to revive. 182 | * @param endpoint Ampache server base URL. 183 | * @param rememberMe Whether to rememberMe or not 184 | * @param[optional] redirect Page to redirect to after login. 185 | * @param[optional] isToken Whether passwordOrToken is a password or a 186 | * token. 187 | * 188 | * @return A CALL_API payload to perform login. 189 | */ 190 | export function loginUser(username, passwordOrToken, endpoint, rememberMe, redirect="/", isToken=false) { 191 | // Clean endpoint 192 | endpoint = cleanURL(endpoint); 193 | 194 | // Get passphrase and time parameters 195 | let time = 0; 196 | let passphrase = passwordOrToken; 197 | if (!isToken) { 198 | // Standard password connection 199 | const HMAC = buildHMAC(passwordOrToken); 200 | time = HMAC.time; 201 | passphrase = HMAC.passphrase; 202 | } else { 203 | // Remember me connection 204 | if (passwordOrToken.expires < new Date()) { 205 | // Token has expired 206 | return loginUserFailure("app.login.expired"); 207 | } 208 | time = Math.floor(Date.now() / 1000); 209 | passphrase = passwordOrToken.token; 210 | } 211 | 212 | return { 213 | type: CALL_API, 214 | payload: { 215 | endpoint: endpoint, 216 | dispatch: [ 217 | loginUserRequest, 218 | jsonData => dispatch => { 219 | if (!jsonData.auth || !jsonData.sessionExpire) { 220 | // On success, check that we are actually authenticated 221 | return dispatch(loginUserFailure(new i18nRecord({ id: "app.api.error", values: {} }))); 222 | } 223 | // Get token from the API 224 | const token = { 225 | token: jsonData.auth, 226 | expires: new Date(jsonData.sessionExpire), 227 | }; 228 | // Handle session keep alive timer 229 | const timerID = setInterval( 230 | () => dispatch(loginKeepAlive(username, token.token, endpoint)), 231 | DEFAULT_SESSION_INTERVAL 232 | ); 233 | if (rememberMe) { 234 | // Handle remember me option 235 | const cookiesOption = { expires: token.expires }; 236 | Cookies.set("username", username, cookiesOption); 237 | Cookies.set("token", token, cookiesOption); 238 | Cookies.set("endpoint", endpoint, cookiesOption); 239 | } 240 | // Dispatch login success 241 | dispatch(loginUserSuccess(username, token, endpoint, rememberMe, timerID)); 242 | // Redirect 243 | dispatch(push(redirect)); 244 | }, 245 | loginUserFailure, 246 | ], 247 | action: "handshake", 248 | auth: passphrase, 249 | username: username, 250 | extraParams: {timestamp: time}, 251 | }, 252 | }; 253 | } 254 | -------------------------------------------------------------------------------- /app/actions/APIActions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file implements actions to fetch and load data from the API. 3 | */ 4 | 5 | // NPM imports 6 | import { normalize, arrayOf } from "normalizr"; 7 | import humps from "humps"; 8 | 9 | // Other actions 10 | import { CALL_API } from "../middleware/api"; 11 | import { pushEntities } from "./entities"; 12 | 13 | // Models 14 | import { artist, song, album } from "../models/api"; 15 | 16 | // Constants 17 | export const DEFAULT_LIMIT = 32; /** Default max number of elements to retrieve. */ 18 | 19 | 20 | /** 21 | * This function wraps around an API action to generate actions trigger 22 | * functions to load items etc. 23 | * 24 | * @param action API action. 25 | * @param requestType Action type to trigger on request. 26 | * @param successType Action type to trigger on success. 27 | * @param failureType Action type to trigger on failure. 28 | */ 29 | export default function (action, requestType, successType, failureType) { 30 | /** Get the name of the item associated with action */ 31 | const itemName = action.rstrip("s"); 32 | 33 | /** 34 | * Normalizr helper to normalize API response. 35 | * 36 | * @param jsonData The JS object returned by the API. 37 | * @return A normalized object. 38 | */ 39 | const _normalizeAPIResponse = function (jsonData) { 40 | return normalize( 41 | jsonData, 42 | { 43 | artist: arrayOf(artist), 44 | album: arrayOf(album), 45 | song: arrayOf(song), 46 | }, 47 | { 48 | // Use custom assignEntity function to delete useless fields 49 | assignEntity: function (output, key, value) { 50 | if (key == "sessionExpire") { 51 | delete output.sessionExpire; 52 | } else { 53 | output[key] = value; 54 | } 55 | }, 56 | } 57 | ); 58 | }; 59 | 60 | /** 61 | * Callback on successful fetch of paginated items 62 | * 63 | * @param jsonData JS object returned from the API. 64 | * @param pageNumber Number of the page that was fetched. 65 | */ 66 | const fetchPaginatedItemsSuccess = function (jsonData, pageNumber, limit) { 67 | const totalCount = jsonData.totalCount; 68 | jsonData = _normalizeAPIResponse(jsonData); 69 | 70 | // Compute the total number of pages 71 | const nPages = Math.ceil(totalCount / limit); 72 | 73 | // Return success actions 74 | return [ 75 | // Action for the global entities store 76 | pushEntities(jsonData.entities, [itemName]), 77 | // Action for the paginated store 78 | { 79 | type: successType, 80 | payload: { 81 | type: itemName, 82 | result: jsonData.result[itemName], 83 | nPages: nPages, 84 | currentPage: pageNumber, 85 | }, 86 | }, 87 | ]; 88 | }; 89 | 90 | /** 91 | * Callback on successful fetch of single item 92 | * 93 | * @param jsonData JS object returned from the API. 94 | * @param pageNumber Number of the page that was fetched. 95 | */ 96 | const fetchItemSuccess = function (jsonData) { 97 | jsonData = _normalizeAPIResponse(jsonData); 98 | 99 | return pushEntities(jsonData.entities, [itemName]); 100 | }; 101 | 102 | /** Callback on request */ 103 | const fetchItemsRequest = function () { 104 | // Return a request type action 105 | return { 106 | type: requestType, 107 | payload: { 108 | }, 109 | }; 110 | }; 111 | 112 | /** 113 | * Callback on failed fetch 114 | * 115 | * @param error An error object, either a string or an i18nError 116 | * object. 117 | */ 118 | const fetchItemsFailure = function (error) { 119 | // Return a failure type action 120 | return { 121 | type: failureType, 122 | payload: { 123 | error: error, 124 | }, 125 | }; 126 | }; 127 | 128 | /** 129 | * Method to trigger a fetch of items. 130 | * 131 | * @param endpoint Ampache server base URL. 132 | * @param username Username to use for API request. 133 | * @param filter An eventual filter to apply (mapped to API filter 134 | * param) 135 | * @param pageNumber Number of the page to fetch items from. 136 | * @param limit Max number of items to fetch. 137 | * @param include [Optional] A list of includes to return as well 138 | * (mapped to API include param) 139 | * 140 | * @return A CALL_API action to fetch the specified items. 141 | */ 142 | const fetchItems = function (endpoint, username, passphrase, filter, pageNumber, limit, include = []) { 143 | // Compute offset in number of items from the page number 144 | const offset = (pageNumber - 1) * DEFAULT_LIMIT; 145 | // Set extra params for pagination 146 | let extraParams = { 147 | offset: offset, 148 | limit: limit, 149 | }; 150 | 151 | // Handle filter 152 | if (filter) { 153 | extraParams.filter = filter; 154 | } 155 | 156 | // Handle includes 157 | if (include && include.length > 0) { 158 | extraParams.include = include; 159 | } 160 | 161 | // Return a CALL_API action 162 | return { 163 | type: CALL_API, 164 | payload: { 165 | endpoint: endpoint, 166 | dispatch: [ 167 | fetchItemsRequest, 168 | null, 169 | fetchItemsFailure, 170 | ], 171 | action: action, 172 | auth: passphrase, 173 | username: username, 174 | extraParams: extraParams, 175 | }, 176 | }; 177 | }; 178 | 179 | /** 180 | * High level method to load paginated items from the API wihtout dealing about credentials. 181 | * 182 | * @param pageNumber [Optional] Number of the page to fetch items from. 183 | * @param filter [Optional] An eventual filter to apply (mapped to 184 | * API filter param) 185 | * @param include [Optional] A list of includes to return as well 186 | * (mapped to API include param) 187 | * 188 | * Dispatches the CALL_API action to fetch these items. 189 | */ 190 | const loadPaginatedItems = function ({ pageNumber = 1, limit = DEFAULT_LIMIT, filter = null, include = [] } = {}) { 191 | return (dispatch, getState) => { 192 | // Get credentials from the state 193 | const { auth } = getState(); 194 | // Get the fetch action to dispatch 195 | const fetchAction = fetchItems( 196 | auth.endpoint, 197 | auth.username, 198 | auth.token.token, 199 | filter, 200 | pageNumber, 201 | limit, 202 | include 203 | ); 204 | // Set success callback 205 | fetchAction.payload.dispatch[1] = ( 206 | jsonData => dispatch => { 207 | // Dispatch all the necessary actions 208 | const actions = fetchPaginatedItemsSuccess(jsonData, pageNumber, limit); 209 | actions.map(action => dispatch(action)); 210 | } 211 | ); 212 | // Dispatch action 213 | dispatch(fetchAction); 214 | }; 215 | }; 216 | 217 | /** 218 | * High level method to load a single item from the API wihtout dealing about credentials. 219 | * 220 | * @param filter The filter to apply (mapped to API filter param) 221 | * @param include [Optional] A list of includes to return as well 222 | * (mapped to API include param) 223 | * 224 | * Dispatches the CALL_API action to fetch this item. 225 | */ 226 | const loadItem = function ({ filter = null, include = [] } = {}) { 227 | return (dispatch, getState) => { 228 | // Get credentials from the state 229 | const { auth } = getState(); 230 | // Get the action to dispatch 231 | const fetchAction = fetchItems( 232 | auth.endpoint, 233 | auth.username, 234 | auth.token.token, 235 | filter, 236 | 1, 237 | DEFAULT_LIMIT, 238 | include 239 | ); 240 | // Set success callback 241 | fetchAction.payload.dispatch[1] = ( 242 | jsonData => dispatch => { 243 | dispatch(fetchItemSuccess(jsonData)); 244 | } 245 | ); 246 | // Dispatch action 247 | dispatch(fetchAction); 248 | }; 249 | }; 250 | 251 | // Remap the above methods to methods including item name 252 | var returned = {}; 253 | const camelizedAction = humps.pascalize(action); 254 | returned["loadPaginated" + camelizedAction] = loadPaginatedItems; 255 | returned["load" + camelizedAction.rstrip("s")] = loadItem; 256 | return returned; 257 | } 258 | -------------------------------------------------------------------------------- /app/components/Songs.jsx: -------------------------------------------------------------------------------- 1 | // NPM imports 2 | import React, { Component, PropTypes } from "react"; 3 | import { Link} from "react-router"; 4 | import CSSModules from "react-css-modules"; 5 | import { defineMessages, injectIntl, intlShape, FormattedMessage } from "react-intl"; 6 | import FontAwesome from "react-fontawesome"; 7 | import Immutable from "immutable"; 8 | import Fuse from "fuse.js"; 9 | 10 | // Local imports 11 | import { formatLength, messagesMap } from "../utils"; 12 | 13 | // Other components 14 | import DismissibleAlert from "./elements/DismissibleAlert"; 15 | import FilterBar from "./elements/FilterBar"; 16 | import Pagination from "./elements/Pagination"; 17 | 18 | // Translations 19 | import commonMessages from "../locales/messagesDescriptors/common"; 20 | import messages from "../locales/messagesDescriptors/Songs"; 21 | 22 | // Styles 23 | import css from "../styles/Songs.scss"; 24 | 25 | // Define translations 26 | const songsMessages = defineMessages(messagesMap(Array.concat([], commonMessages, messages))); 27 | 28 | 29 | /** 30 | * A single row for a single song in the songs table. 31 | */ 32 | class SongsTableRowCSSIntl extends Component { 33 | constructor(props) { 34 | super(props); 35 | 36 | // Bind this 37 | this.onPlayClick = this.onPlayClick.bind(this); 38 | this.onPlayNextClick = this.onPlayNextClick.bind(this); 39 | } 40 | 41 | /** 42 | * Handle click on play button. 43 | */ 44 | onPlayClick() { 45 | $(this.refs.play).blur(); 46 | this.props.playAction(this.props.song.get("id")); 47 | } 48 | 49 | /** 50 | * Handle click on play next button. 51 | */ 52 | onPlayNextClick() { 53 | $(this.refs.playNext).blur(); 54 | if (this.props.playNextAction) { 55 | this.props.playNextAction(this.props.song.get("id")); 56 | } 57 | } 58 | 59 | render() { 60 | const { formatMessage } = this.props.intl; 61 | 62 | const length = formatLength(this.props.song.get("time")); 63 | const linkToArtist = "/artist/" + this.props.song.getIn(["artist", "id"]) + "-" + encodeURIComponent(this.props.song.getIn(["artist", "name"])); 64 | const linkToAlbum = linkToArtist + "/album/" + this.props.song.getIn(["album", "id"]) + "-" + encodeURIComponent(this.props.song.getIn(["album", "name"])); 65 | 66 | let playNextButton = null; 67 | if (this.props.playNextAction) { 68 | playNextButton = ( 69 | 75 | ); 76 | } 77 | 78 | return ( 79 | 80 | 81 |   87 | { playNextButton } 88 | 89 | {this.props.song.get("name")} 90 | {this.props.song.getIn(["artist", "name"])} 91 | {this.props.song.getIn(["album", "name"])} 92 | {this.props.song.get("genre")} 93 | {length} 94 | 95 | ); 96 | } 97 | } 98 | SongsTableRowCSSIntl.propTypes = { 99 | playAction: PropTypes.func.isRequired, 100 | playNextAction: PropTypes.func, 101 | song: PropTypes.instanceOf(Immutable.Map).isRequired, 102 | intl: intlShape.isRequired, 103 | }; 104 | export let SongsTableRow = injectIntl(CSSModules(SongsTableRowCSSIntl, css)); 105 | 106 | 107 | /** 108 | * The songs table. 109 | */ 110 | class SongsTableCSS extends Component { 111 | render() { 112 | // Handle filtering 113 | let displayedSongs = this.props.songs; 114 | if (this.props.filterText) { 115 | // Use Fuse for the filter 116 | displayedSongs = new Fuse( 117 | this.props.songs.toJS(), 118 | { 119 | "keys": ["name"], 120 | "threshold": 0.4, 121 | "include": ["score"], 122 | }).search(this.props.filterText); 123 | // Keep only items in results 124 | displayedSongs = displayedSongs.map(function (item) { return new Immutable.Map(item.item); }); 125 | } 126 | 127 | // Build song rows 128 | let rows = []; 129 | const { playAction, playNextAction } = this.props; 130 | displayedSongs.forEach(function (song) { 131 | rows.push(); 132 | }); 133 | 134 | // Handle login icon 135 | let loading = null; 136 | if (this.props.isFetching) { 137 | loading = ( 138 |

139 |

142 | ); 143 | } 144 | 145 | return ( 146 |
147 | 148 | 149 | 150 | 151 | 154 | 157 | 160 | 163 | 166 | 167 | 168 | {rows} 169 |
152 | 153 | 155 | 156 | 158 | 159 | 161 | 162 | 164 | 165 |
170 | {loading} 171 |
172 | ); 173 | } 174 | } 175 | SongsTableCSS.propTypes = { 176 | playAction: PropTypes.func.isRequired, 177 | playNextAction: PropTypes.func, 178 | songs: PropTypes.instanceOf(Immutable.List).isRequired, 179 | filterText: PropTypes.string, 180 | }; 181 | export let SongsTable = CSSModules(SongsTableCSS, css); 182 | 183 | 184 | /** 185 | * Complete songs table view with filter and pagination 186 | */ 187 | export default class FilterablePaginatedSongsTable extends Component { 188 | constructor(props) { 189 | super(props); 190 | this.state = { 191 | filterText: "", // Initial state, no filter text 192 | }; 193 | 194 | this.handleUserInput = this.handleUserInput.bind(this); // Bind this on user input handling 195 | } 196 | 197 | /** 198 | * Method called whenever the filter input is changed. 199 | * 200 | * Update the state accordingly. 201 | * 202 | * @param filterText Content of the filter input. 203 | */ 204 | handleUserInput(filterText) { 205 | this.setState({ 206 | filterText: filterText, 207 | }); 208 | } 209 | 210 | render() { 211 | // Handle error 212 | let error = null; 213 | if (this.props.error) { 214 | error = (); 215 | } 216 | 217 | // Set props 218 | const filterProps = { 219 | filterText: this.state.filterText, 220 | onUserInput: this.handleUserInput, 221 | }; 222 | const songsTableProps = { 223 | playAction: this.props.playAction, 224 | playNextAction: this.props.playNextAction, 225 | isFetching: this.props.isFetching, 226 | songs: this.props.songs, 227 | filterText: this.state.filterText, 228 | }; 229 | 230 | return ( 231 |
232 | { error } 233 | 234 | 235 | 236 |
237 | ); 238 | } 239 | } 240 | FilterablePaginatedSongsTable.propTypes = { 241 | playAction: PropTypes.func.isRequired, 242 | playNextAction: PropTypes.func, 243 | isFetching: PropTypes.bool.isRequired, 244 | error: PropTypes.string, 245 | songs: PropTypes.instanceOf(Immutable.List).isRequired, 246 | pagination: PropTypes.object.isRequired, 247 | }; 248 | -------------------------------------------------------------------------------- /app/components/elements/Pagination.jsx: -------------------------------------------------------------------------------- 1 | // NPM imports 2 | import React, { Component, PropTypes } from "react"; 3 | import { Link } from "react-router"; 4 | import CSSModules from "react-css-modules"; 5 | import { defineMessages, injectIntl, intlShape, FormattedMessage, FormattedHTMLMessage } from "react-intl"; 6 | 7 | // Local imports 8 | import { computePaginationBounds, filterInt, messagesMap } from "../../utils"; 9 | 10 | // Translations 11 | import commonMessages from "../../locales/messagesDescriptors/common"; 12 | import messages from "../../locales/messagesDescriptors/elements/Pagination"; 13 | 14 | // Styles 15 | import css from "../../styles/elements/Pagination.scss"; 16 | 17 | // Define translations 18 | const paginationMessages = defineMessages(messagesMap(Array.concat([], commonMessages, messages))); 19 | 20 | 21 | /** 22 | * Pagination button bar 23 | */ 24 | class PaginationCSSIntl extends Component { 25 | constructor(props) { 26 | super (props); 27 | 28 | // Bind this 29 | this.goToPage = this.goToPage.bind(this); 30 | this.dotsOnClick = this.dotsOnClick.bind(this); 31 | this.dotsOnKeyDown = this.dotsOnKeyDown.bind(this); 32 | this.cancelModalBox = this.cancelModalBox.bind(this); 33 | } 34 | 35 | /** 36 | * Handle click on the "go to page" button in the modal. 37 | */ 38 | goToPage(e) { 39 | e.preventDefault(); 40 | 41 | // Parse and check page number 42 | const pageNumber = filterInt(this.refs.pageInput.value); 43 | if (pageNumber && !isNaN(pageNumber) && pageNumber > 0 && pageNumber <= this.props.nPages) { 44 | // Remove error class from input form 45 | this.refs.pageFormGroup.classList.remove("has-error"); 46 | this.refs.pageFormGroup.classList.add("has-success"); 47 | // Hide the modal and go to page 48 | $(this.refs.paginationModal).modal("hide"); 49 | this.props.goToPage(pageNumber); 50 | } else { 51 | // Set error class on input form 52 | this.refs.pageFormGroup.classList.add("has-error"); 53 | this.refs.pageFormGroup.classList.remove("has-success"); 54 | return; 55 | } 56 | } 57 | 58 | /** 59 | * Handle click on the ellipsis dots. 60 | */ 61 | dotsOnClick() { 62 | // Show modal 63 | $(this.refs.paginationModal).modal(); 64 | } 65 | 66 | /** 67 | * Bind key down events on ellipsis dots for a11y. 68 | */ 69 | dotsOnKeyDown(e) { 70 | e.preventDefault; 71 | const code = e.keyCode || e.which; 72 | if (code == 13 || code == 32) { // Enter or Space key 73 | this.dotsOnClick(); // Fire same event as onClick 74 | } 75 | } 76 | 77 | /** 78 | * Handle click on "cancel" in the modal box. 79 | */ 80 | cancelModalBox() { 81 | // Hide modal 82 | $(this.refs.paginationModal).modal("hide"); 83 | } 84 | 85 | render() { 86 | const { formatMessage } = this.props.intl; 87 | 88 | // Get bounds 89 | const { lowerLimit, upperLimit } = computePaginationBounds(this.props.currentPage, this.props.nPages); 90 | // Store buttons 91 | let pagesButton = []; 92 | let key = 0; // key increment to ensure correct ordering 93 | 94 | // If lower limit is above 1, push 1 and ellipsis 95 | if (lowerLimit > 1) { 96 | pagesButton.push( 97 |
  • 98 | 99 | 100 | 101 |
  • 102 | ); 103 | key++; // Always increment key after a push 104 | if (lowerLimit > 2) { 105 | // Eventually push "…" 106 | pagesButton.push( 107 |
  • 108 | 109 |
  • 110 | ); 111 | key++; 112 | } 113 | } 114 | // Main buttons, between lower and upper limits 115 | for (let i = lowerLimit; i < upperLimit; i++) { 116 | let classNames = ["page-item"]; 117 | let currentSpan = null; 118 | if (this.props.currentPage == i) { 119 | classNames.push("active"); 120 | currentSpan = (); 121 | } 122 | const title = formatMessage(paginationMessages["app.pagination.goToPageWithoutMarkup"], { pageNumber: i }); 123 | pagesButton.push( 124 |
  • 125 | 126 | 127 | {currentSpan} 128 | 129 |
  • 130 | ); 131 | key++; 132 | } 133 | // If upper limit is below the total number of page, show last page button 134 | if (upperLimit < this.props.nPages) { 135 | if (upperLimit < this.props.nPages - 1) { 136 | // Eventually push "…" 137 | pagesButton.push( 138 |
  • 139 | 140 |
  • 141 | ); 142 | key++; 143 | } 144 | const title = formatMessage(paginationMessages["app.pagination.goToPageWithoutMarkup"], { pageNumber: this.props.nPages }); 145 | // Push last page 146 | pagesButton.push( 147 |
  • 148 | 149 | 150 | 151 |
  • 152 | ); 153 | } 154 | 155 | // If there are actually some buttons, show them 156 | if (pagesButton.length > 1) { 157 | return ( 158 |
    159 | 164 |
    165 |
    166 |
    167 |
    168 | 169 |

    170 | 171 |

    172 |
    173 |
    174 |
    175 |
    176 | 177 |
    178 |
    179 |
    180 |
    181 | 184 | 187 |
    188 |
    189 |
    190 |
    191 |
    192 | ); 193 | } 194 | return null; 195 | } 196 | } 197 | PaginationCSSIntl.propTypes = { 198 | currentPage: PropTypes.number.isRequired, 199 | goToPage: PropTypes.func.isRequired, 200 | buildLinkToPage: PropTypes.func.isRequired, 201 | nPages: PropTypes.number.isRequired, 202 | intl: intlShape.isRequired, 203 | }; 204 | export default injectIntl(CSSModules(PaginationCSSIntl, css)); 205 | --------------------------------------------------------------------------------