├── .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 |
20 | ×
21 |
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 |
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 |
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 | this.props.flushAction() }>
42 |
43 |
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 |
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 |
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 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
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 |
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 |
13 | albums
14 |
15 |
16 |
21 |
22 | );
23 | const bobDylan = (
24 |
34 | );
35 | return (
36 |
37 |
38 |
39 | More { artistsAlbumsSongsDropdown } you might like
40 |
41 |
42 |
43 |
44 |
54 | { bobDylan }
55 | { bobDylan }
56 | { bobDylan }
57 | { bobDylan }
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 | Popular { artistsAlbumsSongsDropdown }
66 |
67 |
68 |
69 |
79 | { bobDylan }
80 | { bobDylan }
81 | { bobDylan }
82 | { bobDylan }
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 | Recent additions
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 | Bob Dylan
101 |
102 |
103 |
104 | { bobDylan }
105 | { bobDylan }
106 | { bobDylan }
107 | { bobDylan }
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 | Currently playing
116 |
117 |
118 |
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 |
70 |
71 |
72 |
73 |
74 |
75 | );
76 | }
77 |
78 | return (
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
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 |
140 |
141 |
142 | );
143 | }
144 |
145 | return (
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 | {rows}
169 |
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 |
160 |
161 | { pagesButton }
162 |
163 |
164 |
165 |
166 |
167 |
168 | ×
169 |
172 |
173 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
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 |
--------------------------------------------------------------------------------