Major code review

Major code review, cleaning the code and adding a lot of comments. Also
implements a separate store to keep entities with a reference count and
garbage collection. This closes #15.

Known issues at the moment are:
* Webplayer is no longer working, it has to be refactored.
* AlbumPage is to be implemented.
This commit is contained in:
Lucas Verney 2016-08-10 21:36:11 +02:00
parent 247c71c9a7
commit fffe9c4cd3
112 changed files with 2006 additions and 581 deletions

View File

@ -1,4 +1,4 @@
public/*
node_modules/*
vendor/*
app/vendor/*
webpack.config.*

View File

@ -25,7 +25,8 @@ module.exports = {
"rules": {
"indent": [
"error",
4
4,
{ "SwitchCase": 1 }
],
"linebreak-style": [
"error",

View File

@ -41,3 +41,8 @@ strings in the `./app/locales/$LOCALE/index.js` file you have just created.
No strict coding style is used in this repo. ESLint and Stylelint, ran with
`npm run test` ensures a certain coding style. Try to keep the coding style
homogeneous.
## Hooks
Usefuls Git hooks are located in `hooks` folder.

5
TODO
View File

@ -1,5 +0,0 @@
5. Web player
6. Homepage
7. Settings
8. Search
9. Discover

View File

@ -1,27 +1,52 @@
/**
* This file implements actions to fetch and load data from the API.
*/
// NPM imports
import { normalize, arrayOf } from "normalizr";
import humps from "humps";
// Other actions
import { CALL_API } from "../middleware/api";
import { pushEntities } from "./entities";
import { artist, track, album } from "../models/api";
// Models
import { artist, song, album } from "../models/api";
// Constants
export const DEFAULT_LIMIT = 32; /** Default max number of elements to retrieve. */
/**
* This function wraps around an API action to generate actions trigger
* functions to load items etc.
*
* @param action API action.
* @param requestType Action type to trigger on request.
* @param successType Action type to trigger on success.
* @param failureType Action type to trigger on failure.
*/
export default function (action, requestType, successType, failureType) {
/** Get the name of the item associated with action */
const itemName = action.rstrip("s");
const fetchItemsSuccess = function (jsonData, pageNumber) {
// Normalize data
jsonData = normalize(
/**
* Normalizr helper to normalize API response.
*
* @param jsonData The JS object returned by the API.
* @return A normalized object.
*/
const _normalizeAPIResponse = function (jsonData) {
return normalize(
jsonData,
{
artist: arrayOf(artist),
album: arrayOf(album),
song: arrayOf(track)
song: arrayOf(song)
},
{
// Use custom assignEntity function to delete useless fields
assignEntity: function (output, key, value) {
// Delete useless fields
if (key == "sessionExpire") {
delete output.sessionExpire;
} else {
@ -30,26 +55,67 @@ export default function (action, requestType, successType, failureType) {
}
}
);
const nPages = Math.ceil(jsonData.result[itemName].length / DEFAULT_LIMIT);
return {
type: successType,
payload: {
result: jsonData.result,
entities: jsonData.entities,
nPages: nPages,
currentPage: pageNumber
}
};
};
/**
* Callback on successful fetch of paginated items
*
* @param jsonData JS object returned from the API.
* @param pageNumber Number of the page that was fetched.
*/
const fetchPaginatedItemsSuccess = function (jsonData, pageNumber, limit) {
jsonData = _normalizeAPIResponse(jsonData);
// Compute the total number of pages
const nPages = Math.ceil(jsonData.result[itemName].length / limit);
// Return success actions
return [
// Action for the global entities store
pushEntities(jsonData.entities, [itemName]),
// Action for the paginated store
{
type: successType,
payload: {
type: itemName,
result: jsonData.result[itemName],
nPages: nPages,
currentPage: pageNumber
}
}
];
};
/**
* Callback on successful fetch of single item
*
* @param jsonData JS object returned from the API.
* @param pageNumber Number of the page that was fetched.
*/
const fetchItemSuccess = function (jsonData) {
jsonData = _normalizeAPIResponse(jsonData);
return pushEntities(jsonData.entities, [itemName]);
};
/** Callback on request */
const fetchItemsRequest = function () {
// Return a request type action
return {
type: requestType,
payload: {
}
};
};
/**
* Callback on failed fetch
*
* @param error An error object, either a string or an i18nError
* object.
*/
const fetchItemsFailure = function (error) {
// Return a failure type action
return {
type: failureType,
payload: {
@ -57,27 +123,48 @@ export default function (action, requestType, successType, failureType) {
}
};
};
const fetchItems = function (endpoint, username, passphrase, filter, pageNumber, include = [], limit=DEFAULT_LIMIT) {
/**
* Method to trigger a fetch of items.
*
* @param endpoint Ampache server base URL.
* @param username Username to use for API request.
* @param filter An eventual filter to apply (mapped to API filter
* param)
* @param pageNumber Number of the page to fetch items from.
* @param limit Max number of items to fetch.
* @param include [Optional] A list of includes to return as well
* (mapped to API include param)
*
* @return A CALL_API action to fetch the specified items.
*/
const fetchItems = function (endpoint, username, passphrase, filter, pageNumber, limit, include = []) {
// Compute offset in number of items from the page number
const offset = (pageNumber - 1) * DEFAULT_LIMIT;
// Set extra params for pagination
let extraParams = {
offset: offset,
limit: limit
};
// Handle filter
if (filter) {
extraParams.filter = filter;
}
// Handle includes
if (include && include.length > 0) {
extraParams.include = include;
}
// Return a CALL_API action
return {
type: CALL_API,
payload: {
endpoint: endpoint,
dispatch: [
fetchItemsRequest,
jsonData => dispatch => {
dispatch(fetchItemsSuccess(jsonData, pageNumber));
},
null,
fetchItemsFailure
],
action: action,
@ -87,19 +174,83 @@ export default function (action, requestType, successType, failureType) {
}
};
};
const loadItems = function({ pageNumber = 1, filter = null, include = [] } = {}) {
/**
* High level method to load paginated items from the API wihtout dealing about credentials.
*
* @param pageNumber [Optional] Number of the page to fetch items from.
* @param filter [Optional] An eventual filter to apply (mapped to
* API filter param)
* @param include [Optional] A list of includes to return as well
* (mapped to API include param)
*
* Dispatches the CALL_API action to fetch these items.
*/
const loadPaginatedItems = function({ pageNumber = 1, limit = DEFAULT_LIMIT, filter = null, include = [] } = {}) {
return (dispatch, getState) => {
// Get credentials from the state
const { auth } = getState();
dispatch(fetchItems(auth.endpoint, auth.username, auth.token.token, filter, pageNumber, include));
// Get the fetch action to dispatch
const fetchAction = fetchItems(
auth.endpoint,
auth.username,
auth.token.token,
filter,
pageNumber,
limit,
include
);
// Set success callback
fetchAction.payload.dispatch[1] = (
jsonData => dispatch => {
// Dispatch all the necessary actions
const actions = fetchPaginatedItemsSuccess(jsonData, pageNumber, limit);
actions.map(action => dispatch(action));
}
);
// Dispatch action
dispatch(fetchAction);
};
};
const camelizedAction = humps.pascalize(action);
/**
* High level method to load a single item from the API wihtout dealing about credentials.
*
* @param filter The filter to apply (mapped to API filter param)
* @param include [Optional] A list of includes to return as well
* (mapped to API include param)
*
* Dispatches the CALL_API action to fetch this item.
*/
const loadItem = function({ filter = null, include = [] } = {}) {
return (dispatch, getState) => {
// Get credentials from the state
const { auth } = getState();
// Get the action to dispatch
const fetchAction = fetchItems(
auth.endpoint,
auth.username,
auth.token.token,
filter,
1,
DEFAULT_LIMIT,
include
);
// Set success callback
fetchAction.payload.dispatch[1] = (
jsonData => dispatch => {
dispatch(fetchItemSuccess(jsonData));
}
);
// Dispatch action
dispatch(fetchAction);
};
};
// Remap the above methods to methods including item name
var returned = {};
returned["fetch" + camelizedAction + "Success"] = fetchItemsSuccess;
returned["fetch" + camelizedAction + "Request"] = fetchItemsRequest;
returned["fetch" + camelizedAction + "Failure"] = fetchItemsFailure;
returned["fetch" + camelizedAction] = fetchItems;
returned["load" + camelizedAction] = loadItems;
const camelizedAction = humps.pascalize(action);
returned["loadPaginated" + camelizedAction] = loadPaginatedItems;
returned["load" + camelizedAction.rstrip("s")] = loadItem;
return returned;
}

View File

@ -1,45 +1,36 @@
/**
* This file implements authentication related actions.
*/
// NPM imports
import { push } from "react-router-redux";
import jsSHA from "jssha";
import Cookies from "js-cookie";
// Local imports
import { buildHMAC, cleanURL } from "../utils";
// Models
import { i18nRecord } from "../models/i18n";
// Other actions and payload types
import { CALL_API } from "../middleware/api";
import { invalidateStore } from "./store";
import { i18nRecord } from "../models/i18n";
export const DEFAULT_SESSION_INTERVAL = 1800 * 1000; // 30 mins default
// Constants
export const DEFAULT_SESSION_INTERVAL = 1800 * 1000; // 30 mins long sessoins by default
function _cleanEndpoint (endpoint) {
// Handle endpoints of the form "ampache.example.com"
if (
!endpoint.startsWith("//") &&
!endpoint.startsWith("http://") &&
!endpoint.startsWith("https://"))
{
endpoint = window.location.protocol + "//" + endpoint;
}
// Remove trailing slash and store endpoint
endpoint = endpoint.replace(/\/$/, "");
return endpoint;
}
function _buildHMAC (password) {
// Handle Ampache HMAC generation
const time = Math.floor(Date.now() / 1000);
let shaObj = new jsSHA("SHA-256", "TEXT");
shaObj.update(password);
const key = shaObj.getHash("HEX");
shaObj = new jsSHA("SHA-256", "TEXT");
shaObj.update(time + key);
return {
time: time,
passphrase: shaObj.getHash("HEX")
};
}
/**
* Dispatch a ping query to the API for login keepalive and prevent session
* from expiring.
*
* @param username Username to use
* @param token Token to revive
* @param endpoint Ampache base URL
*
* @return A CALL_API payload to keep session alive.
*/
export function loginKeepAlive(username, token, endpoint) {
return {
type: CALL_API,
@ -60,7 +51,19 @@ export function loginKeepAlive(username, token, endpoint) {
};
}
export const LOGIN_USER_SUCCESS = "LOGIN_USER_SUCCESS";
/**
* Action to be called on successful login.
*
* @param username Username used for login
* @param token Token got back from the API
* @param endpoint Ampache server base URL
* @param rememberMe Whether to remember me or not
* @param timerID ID of the timer set for session keepalive.
*
* @return A login success payload.
*/
export function loginUserSuccess(username, token, endpoint, rememberMe, timerID) {
return {
type: LOGIN_USER_SUCCESS,
@ -74,7 +77,16 @@ export function loginUserSuccess(username, token, endpoint, rememberMe, timerID)
};
}
export const LOGIN_USER_FAILURE = "LOGIN_USER_FAILURE";
/**
* Action to be called on failed login.
*
* This action removes any remember me cookie if any was set.
*
* @param error An error object, either string or i18nRecord.
* @return A login failure payload.
*/
export function loginUserFailure(error) {
Cookies.remove("username");
Cookies.remove("token");
@ -87,7 +99,14 @@ export function loginUserFailure(error) {
};
}
export const LOGIN_USER_EXPIRED = "LOGIN_USER_EXPIRED";
/**
* Action to be called when session is expired.
*
* @param error An error object, either a string or i18nRecord.
* @return A session expired payload.
*/
export function loginUserExpired(error) {
return {
type: LOGIN_USER_EXPIRED,
@ -97,14 +116,32 @@ export function loginUserExpired(error) {
};
}
export const LOGIN_USER_REQUEST = "LOGIN_USER_REQUEST";
/**
* Action to be called when login is requested.
*
* @return A login request payload.
*/
export function loginUserRequest() {
return {
type: LOGIN_USER_REQUEST
};
}
export const LOGOUT_USER = "LOGOUT_USER";
/**
* Action to be called upon logout.
*
* This function clears the cookies set for remember me and the keep alive
* timer.
*
* @remark This function does not clear the other stores, nor handle
* redirection.
*
* @return A logout payload.
*/
export function logout() {
return (dispatch, state) => {
const { auth } = state();
@ -120,6 +157,14 @@ export function logout() {
};
}
/**
* Action to be called to log a user out.
*
* This function clears the remember me cookies and the keepalive timer. It
* also clears the data behind authentication in the store and redirects to
* login page.
*/
export function logoutAndRedirect() {
return (dispatch) => {
dispatch(logout());
@ -128,14 +173,30 @@ export function logoutAndRedirect() {
};
}
/**
* Action to be called to log a user in.
*
* @param username Username to use.
* @param passwordOrToken User password, or previous token to revive.
* @param endpoint Ampache server base URL.
* @param rememberMe Whether to rememberMe or not
* @param[optional] redirect Page to redirect to after login.
* @param[optional] isToken Whether passwordOrToken is a password or a
* token.
*
* @return A CALL_API payload to perform login.
*/
export function loginUser(username, passwordOrToken, endpoint, rememberMe, redirect="/", isToken=false) {
endpoint = _cleanEndpoint(endpoint);
// Clean endpoint
endpoint = cleanURL(endpoint);
// Get passphrase and time parameters
let time = 0;
let passphrase = passwordOrToken;
if (!isToken) {
// Standard password connection
const HMAC = _buildHMAC(passwordOrToken);
const HMAC = buildHMAC(passwordOrToken);
time = HMAC.time;
passphrase = HMAC.passphrase;
} else {
@ -147,6 +208,7 @@ export function loginUser(username, passwordOrToken, endpoint, rememberMe, redir
time = Math.floor(Date.now() / 1000);
passphrase = passwordOrToken.token;
}
return {
type: CALL_API,
payload: {
@ -155,23 +217,27 @@ export function loginUser(username, passwordOrToken, endpoint, rememberMe, redir
loginUserRequest,
jsonData => dispatch => {
if (!jsonData.auth || !jsonData.sessionExpire) {
// On success, check that we are actually authenticated
return dispatch(loginUserFailure(new i18nRecord({ id: "app.api.error", values: {} })));
}
// Get token from the API
const token = {
token: jsonData.auth,
expires: new Date(jsonData.sessionExpire)
};
// Dispatch success
// Handle session keep alive timer
const timerID = setInterval(
() => dispatch(loginKeepAlive(username, token.token, endpoint)),
DEFAULT_SESSION_INTERVAL
);
if (rememberMe) {
// Handle remember me option
const cookiesOption = { expires: token.expires };
Cookies.set("username", username, cookiesOption);
Cookies.set("token", token, cookiesOption);
Cookies.set("endpoint", endpoint, cookiesOption);
}
// Dispatch login success
dispatch(loginUserSuccess(username, token, endpoint, rememberMe, timerID));
// Redirect
dispatch(push(redirect));

61
app/actions/entities.js Normal file
View File

@ -0,0 +1,61 @@
/**
* This file implements actions related to global entities store.
*/
export const PUSH_ENTITIES = "PUSH_ENTITIES";
/**
* Push some entities in the global entities store.
*
* @param entities An entities mapping, such as the one in the entities
* store: type => id => entity.
* @param refCountType An array of entities type to consider for
* increasing reference counting (elements loaded as nested objects)
* @return A PUSH_ENTITIES action.
*/
export function pushEntities(entities, refCountType=["album", "artist", "song"]) {
return {
type: PUSH_ENTITIES,
payload: {
entities: entities,
refCountType: refCountType
}
};
}
export const INCREMENT_REFCOUNT = "INCREMENT_REFCOUNT";
/**
* Increment the reference counter for given entities.
*
* @param ids A mapping type => list of IDs, each ID being the one of an
* entity to increment reference counter. List of IDs must be
* a JS Object.
* @return An INCREMENT_REFCOUNT action.
*/
export function incrementRefCount(entities) {
return {
type: INCREMENT_REFCOUNT,
payload: {
entities: entities
}
};
}
export const DECREMENT_REFCOUNT = "DECREMENT_REFCOUNT";
/**
* Decrement the reference counter for given entities.
*
* @param ids A mapping type => list of IDs, each ID being the one of an
* entity to decrement reference counter. List of IDs must be
* a JS Object.
* @return A DECREMENT_REFCOUNT action.
*/
export function decrementRefCount(entities) {
return {
type: DECREMENT_REFCOUNT,
payload: {
entities: entities
}
};
}

View File

@ -1,14 +1,35 @@
/**
* Export all the available actions
*/
// Auth related actions
export * from "./auth";
// API related actions for all the available types
import APIAction from "./APIActions";
// Actions related to API
export const API_SUCCESS = "API_SUCCESS";
export const API_REQUEST = "API_REQUEST";
export const API_FAILURE = "API_FAILURE";
export var { loadArtists } = APIAction("artists", API_REQUEST, API_SUCCESS, API_FAILURE);
export var { loadAlbums } = APIAction("albums", API_REQUEST, API_SUCCESS, API_FAILURE);
export var { loadSongs } = APIAction("songs", API_REQUEST, API_SUCCESS, API_FAILURE);
export var {
loadPaginatedArtists, loadArtist } = APIAction("artists", API_REQUEST, API_SUCCESS, API_FAILURE);
export var {
loadPaginatedAlbums, loadAlbum } = APIAction("albums", API_REQUEST, API_SUCCESS, API_FAILURE);
export var {
loadPaginatedSongs, loadSong } = APIAction("songs", API_REQUEST, API_SUCCESS, API_FAILURE);
export * from "./paginate";
// Entities actions
export * from "./entities";
// Paginated views store actions
export * from "./paginated";
// Pagination actions
export * from "./pagination";
// Store actions
export * from "./store";
// Webplayer actions
export * from "./webplayer";

View File

@ -1,7 +0,0 @@
import { push } from "react-router-redux";
export function goToPage(pageLocation) {
return (dispatch) => {
dispatch(push(pageLocation));
};
}

24
app/actions/paginated.js Normal file
View File

@ -0,0 +1,24 @@
/**
* These actions are actions acting directly on the paginated views store.
*/
// Other actions
import { decrementRefCount } from "./entities";
/** Define an action to invalidate results in paginated store. */
export const CLEAR_RESULTS = "CLEAR_RESULTS";
export function clearResults() {
return (dispatch, getState) => {
// Decrement reference counter
const paginatedStore = getState().paginated;
const entities = {};
entities[paginatedStore.get("type")] = paginatedStore.get("result").toJS();
dispatch(decrementRefCount(entities));
// Clear results in store
dispatch({
type: CLEAR_RESULTS
});
};
}

14
app/actions/pagination.js Normal file
View File

@ -0,0 +1,14 @@
/**
* This file defines pagination related actions.
*/
// NPM imports
import { push } from "react-router-redux";
/** Define an action to go to a specific page. */
export function goToPage(pageLocation) {
return (dispatch) => {
// Just push the new page location in react-router.
dispatch(push(pageLocation));
};
}

View File

@ -1,3 +1,9 @@
/**
* These actions are actions acting directly on all the available stores.
*/
/** Define an action to invalidate all the stores, e.g. in case of logout. */
export const INVALIDATE_STORE = "INVALIDATE_STORE";
export function invalidateStore() {
return {

View File

@ -1,3 +1,4 @@
// TODO: This file is not finished
export const PLAY_PAUSE = "PLAY_PAUSE";
/**
* true to play, false to pause.

View File

@ -1,4 +1,8 @@
/**
* Common global styles.
*/
:global {
/* No border on responsive table. */
@media (max-width: 767px) {
.table-responsive {
border: none;

View File

@ -1,5 +1,8 @@
/**
* Hacks for specific browsers and bugfixes.
*/
:global {
/* Firefox hack for responsive table */
/* Firefox hack for responsive table in Bootstrap */
@-moz-document url-prefix() {
fieldset {
display: table-cell;

View File

@ -1,2 +1,5 @@
/**
* Common styles modifications and hacks.
*/
export * from "./hacks.scss";
export * from "./common.scss";

View File

@ -0,0 +1,5 @@
/**
* Prototype modifications, common utils loaded before the main script
*/
export * from "./jquery";
export * from "./string";

View File

@ -1,9 +1,16 @@
/**
* jQuery prototype extensions.
*/
/**
* Shake animation.
*
* @param intShakes Number of times to shake.
* @param intDistance Distance to move the object.
* @param intDuration Duration of the animation.
*
* @return The element it was applied one, for chaining.
*/
$.fn.shake = function(intShakes, intDistance, intDuration) {
this.each(function() {

View File

@ -1,14 +1,23 @@
/**
* Capitalize function on strings.
* String prototype extension.
*/
/**
* Capitalize a string.
*
* @return Capitalized string.
*/
String.prototype.capitalize = function () {
return this.charAt(0).toUpperCase() + this.slice(1);
};
/**
* Strip characters at the end of a string.
*
* @param chars A regex-like element to strip from the end.
* @return Stripped string.
*/
String.prototype.rstrip = function (chars) {
let regex = new RegExp(chars + "$");

View File

@ -1,17 +1,26 @@
// NPM import
import React, { Component, PropTypes } from "react";
import CSSModules from "react-css-modules";
import { defineMessages, FormattedMessage, injectIntl, intlShape } from "react-intl";
import FontAwesome from "react-fontawesome";
import Immutable from "immutable";
// Local imports
import { formatLength, messagesMap } from "../utils";
// Translations
import commonMessages from "../locales/messagesDescriptors/common";
// Styles
import css from "../styles/Album.scss";
// Set translations
const albumMessages = defineMessages(messagesMap(Array.concat([], commonMessages)));
/**
* Track row in an album tracks table.
*/
class AlbumTrackRowCSSIntl extends Component {
render () {
const { formatMessage } = this.props.intl;
@ -33,19 +42,21 @@ class AlbumTrackRowCSSIntl extends Component {
);
}
}
AlbumTrackRowCSSIntl.propTypes = {
playAction: PropTypes.func.isRequired,
track: PropTypes.instanceOf(Immutable.Map).isRequired,
intl: intlShape.isRequired
};
export let AlbumTrackRow = injectIntl(CSSModules(AlbumTrackRowCSSIntl, css));
/**
* Tracks table of an album.
*/
class AlbumTracksTableCSS extends Component {
render () {
let rows = [];
// Build rows for each track
const playAction = this.props.playAction;
this.props.tracks.forEach(function (item) {
rows.push(<AlbumTrackRow playAction={playAction} track={item} key={item.get("id")} />);
@ -59,14 +70,16 @@ class AlbumTracksTableCSS extends Component {
);
}
}
AlbumTracksTableCSS.propTypes = {
playAction: PropTypes.func.isRequired,
tracks: PropTypes.instanceOf(Immutable.List).isRequired
};
export let AlbumTracksTable = CSSModules(AlbumTracksTableCSS, css);
/**
* An entire album row containing art and tracks table.
*/
class AlbumRowCSS extends Component {
render () {
return (
@ -88,24 +101,9 @@ class AlbumRowCSS extends Component {
);
}
}
AlbumRowCSS.propTypes = {
playAction: PropTypes.func.isRequired,
album: PropTypes.instanceOf(Immutable.Map).isRequired,
songs: PropTypes.instanceOf(Immutable.List).isRequired
};
export let AlbumRow = CSSModules(AlbumRowCSS, css);
export default class Album extends Component {
render () {
return (
<AlbumRow album={this.props.album} songs={this.props.songs} />
);
}
}
Album.propTypes = {
album: PropTypes.instanceOf(Immutable.Map).isRequired,
songs: PropTypes.instanceOf(Immutable.List).isRequired
};

View File

@ -1,16 +1,24 @@
// NPM imports
import React, { Component, PropTypes } from "react";
import Immutable from "immutable";
// Local imports
import FilterablePaginatedGrid from "./elements/Grid";
import DismissibleAlert from "./elements/DismissibleAlert";
/**
* Paginated albums grid
*/
export default class Albums extends Component {
render () {
// Handle error
let error = null;
if (this.props.error) {
error = (<DismissibleAlert type="danger" text={this.props.error} />);
}
// Set grid props
const grid = {
isFetching: this.props.isFetching,
items: this.props.albums,
@ -19,6 +27,7 @@ export default class Albums extends Component {
subItemsType: "tracks",
subItemsLabel: "app.common.track"
};
return (
<div>
{ error }
@ -27,10 +36,9 @@ export default class Albums extends Component {
);
}
}
Albums.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.string,
isFetching: PropTypes.bool.isRequired,
albums: PropTypes.instanceOf(Immutable.List).isRequired,
pagination: PropTypes.object.isRequired,
};

View File

@ -1,57 +1,63 @@
// NPM imports
import React, { Component, PropTypes } from "react";
import CSSModules from "react-css-modules";
import { defineMessages, FormattedMessage } from "react-intl";
import FontAwesome from "react-fontawesome";
import Immutable from "immutable";
// Local imports
import { messagesMap } from "../utils/";
// Other components
import { AlbumRow } from "./Album";
import DismissibleAlert from "./elements/DismissibleAlert";
// Translations
import commonMessages from "../locales/messagesDescriptors/common";
// Styles
import css from "../styles/Artist.scss";
// Define translations
const artistMessages = defineMessages(messagesMap(Array.concat([], commonMessages)));
/**
* Single artist page
*/
class ArtistCSS extends Component {
render () {
const loading = (
<div className="row text-center">
<p>
<FontAwesome name="spinner" className="fa-pulse fa-3x fa-fw" aria-hidden="true" />
<span className="sr-only"><FormattedMessage {...artistMessages["app.common.loading"]} /></span>
</p>
</div>
);
if (this.props.isFetching && !this.props.artist.size > 0) {
// Loading
return loading;
// Define loading message
let loading = null;
if (this.props.isFetching) {
loading = (
<div className="row text-center">
<p>
<FontAwesome name="spinner" className="fa-pulse fa-3x fa-fw" aria-hidden="true" />
<span className="sr-only"><FormattedMessage {...artistMessages["app.common.loading"]} /></span>
</p>
</div>
);
}
// Handle error
let error = null;
if (this.props.error) {
error = (<DismissibleAlert type="danger" text={this.props.error} />);
}
// Build album rows
let albumsRows = [];
const { albums, songs, playAction } = this.props;
const artistAlbums = this.props.artist.get("albums");
if (albums && songs && artistAlbums && artistAlbums.size > 0) {
this.props.artist.get("albums").forEach(function (album) {
album = albums.get(album);
if (albums && songs) {
albums.forEach(function (album) {
const albumSongs = album.get("tracks").map(
id => songs.get(id)
);
albumsRows.push(<AlbumRow playAction={playAction} album={album} songs={albumSongs} key={album.get("id")} />);
});
}
else {
// Loading
albumsRows = loading;
}
return (
<div>
{ error }
@ -70,18 +76,17 @@ class ArtistCSS extends Component {
</div>
</div>
{ albumsRows }
{ loading }
</div>
);
}
}
ArtistCSS.propTypes = {
playAction: PropTypes.func.isRequired,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.string,
isFetching: PropTypes.bool.isRequired,
playAction: PropTypes.func.isRequired,
artist: PropTypes.instanceOf(Immutable.Map),
albums: PropTypes.instanceOf(Immutable.Map),
albums: PropTypes.instanceOf(Immutable.List),
songs: PropTypes.instanceOf(Immutable.Map)
};
export default CSSModules(ArtistCSS, css);

View File

@ -1,16 +1,24 @@
// NPM imports
import React, { Component, PropTypes } from "react";
import Immutable from "immutable";
// Other components
import FilterablePaginatedGrid from "./elements/Grid";
import DismissibleAlert from "./elements/DismissibleAlert";
class Artists extends Component {
/**
* Paginated artists grid
*/
export default class Artists extends Component {
render () {
// Handle error
let error = null;
if (this.props.error) {
error = (<DismissibleAlert type="danger" text={this.props.error} />);
}
// Define grid props
const grid = {
isFetching: this.props.isFetching,
items: this.props.artists,
@ -19,6 +27,7 @@ class Artists extends Component {
subItemsType: "albums",
subItemsLabel: "app.common.album"
};
return (
<div>
{ error }
@ -27,12 +36,9 @@ class Artists extends Component {
);
}
}
Artists.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.string,
isFetching: PropTypes.bool.isRequired,
artists: PropTypes.instanceOf(Immutable.List).isRequired,
pagination: PropTypes.object.isRequired,
};
export default Artists;

View File

@ -1,3 +1,4 @@
// TODO: Discover view is not done
import React, { Component } from "react";
import CSSModules from "react-css-modules";
import FontAwesome from "react-fontawesome";

View File

@ -1,57 +1,87 @@
// NPM imports
import React, { Component, PropTypes } from "react";
import CSSModules from "react-css-modules";
import { defineMessages, injectIntl, intlShape, FormattedMessage } from "react-intl";
import FontAwesome from "react-fontawesome";
// Local imports
import { i18nRecord } from "../models/i18n";
import { messagesMap } from "../utils";
// Translations
import APIMessages from "../locales/messagesDescriptors/api";
import messages from "../locales/messagesDescriptors/Login";
// Styles
import css from "../styles/Login.scss";
// Define translations
const loginMessages = defineMessages(messagesMap(Array.concat([], APIMessages, messages)));
/**
* Login form component
*/
class LoginFormCSSIntl extends Component {
constructor (props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleSubmit = this.handleSubmit.bind(this); // bind this to handleSubmit
}
setError (formGroup, error) {
if (error) {
/**
* Set an error on a form element.
*
* @param formGroup A form element.
* @param hasError Whether or not an error should be set.
*
* @return True if an error is set, false otherwise
*/
setError (formGroup, hasError) {
if (hasError) {
// If error is true, then add error class
formGroup.classList.add("has-error");
formGroup.classList.remove("has-success");
return true;
}
// Else, drop it and put success class
formGroup.classList.remove("has-error");
formGroup.classList.add("has-success");
return false;
}
/**
* Form submission handler.
*
* @param e JS Event.
*/
handleSubmit (e) {
e.preventDefault();
// Don't handle submit if already logging in
if (this.props.isAuthenticating) {
// Don't handle submit if already logging in
return;
}
// Get field values
const username = this.refs.username.value.trim();
const password = this.refs.password.value.trim();
const endpoint = this.refs.endpoint.value.trim();
const rememberMe = this.refs.rememberMe.checked;
// Check for errors on each field
let hasError = this.setError(this.refs.usernameFormGroup, !username);
hasError |= this.setError(this.refs.passwordFormGroup, !password);
hasError |= this.setError(this.refs.endpointFormGroup, !endpoint);
if (!hasError) {
// Submit if no error is found
this.props.onSubmit(username, password, endpoint, rememberMe);
}
}
componentDidUpdate () {
if (this.props.error) {
// On unsuccessful login, set error classes and shake the form
$(this.refs.loginForm).shake(3, 10, 300);
this.setError(this.refs.usernameFormGroup, this.props.error);
this.setError(this.refs.passwordFormGroup, this.props.error);
@ -61,18 +91,23 @@ class LoginFormCSSIntl extends Component {
render () {
const {formatMessage} = this.props.intl;
// Handle info message
let infoMessage = this.props.info;
if (this.props.info && this.props.info instanceof i18nRecord) {
infoMessage = (
<FormattedMessage {...loginMessages[this.props.info.id]} values={ this.props.info.values} />
);
}
// Handle error message
let errorMessage = this.props.error;
if (this.props.error && this.props.error instanceof i18nRecord) {
errorMessage = (
<FormattedMessage {...loginMessages[this.props.error.id]} values={ this.props.error.values} />
);
}
return (
<div>
{
@ -135,7 +170,6 @@ class LoginFormCSSIntl extends Component {
);
}
}
LoginFormCSSIntl.propTypes = {
username: PropTypes.string,
endpoint: PropTypes.string,
@ -146,11 +180,13 @@ LoginFormCSSIntl.propTypes = {
info: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(i18nRecord)]),
intl: intlShape.isRequired,
};
export let LoginForm = injectIntl(CSSModules(LoginFormCSSIntl, css));
class Login extends Component {
/**
* Main login page, including title and login form.
*/
class LoginCSS extends Component {
render () {
const greeting = (
<p>
@ -169,8 +205,7 @@ class Login extends Component {
);
}
}
Login.propTypes = {
LoginCSS.propTypes = {
username: PropTypes.string,
endpoint: PropTypes.string,
rememberMe: PropTypes.bool,
@ -179,5 +214,4 @@ Login.propTypes = {
info: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
error: PropTypes.oneOfType([PropTypes.string, PropTypes.object])
};
export default CSSModules(Login, css);
export default CSSModules(LoginCSS, css);

View File

@ -1,3 +1,4 @@
// NPM imports
import React, { Component, PropTypes } from "react";
import { Link} from "react-router";
import CSSModules from "react-css-modules";
@ -6,24 +7,36 @@ import FontAwesome from "react-fontawesome";
import Immutable from "immutable";
import Fuse from "fuse.js";
// Local imports
import { formatLength, messagesMap } from "../utils";
// Other components
import DismissibleAlert from "./elements/DismissibleAlert";
import FilterBar from "./elements/FilterBar";
import Pagination from "./elements/Pagination";
import { formatLength, messagesMap } from "../utils";
// Translations
import commonMessages from "../locales/messagesDescriptors/common";
import messages from "../locales/messagesDescriptors/Songs";
// Styles
import css from "../styles/Songs.scss";
// Define translations
const songsMessages = defineMessages(messagesMap(Array.concat([], commonMessages, messages)));
/**
* A single row for a single song in the songs table.
*/
class SongsTableRowCSSIntl extends Component {
render () {
const { formatMessage } = this.props.intl;
const length = formatLength(this.props.song.get("time"));
const linkToArtist = "/artist/" + this.props.song.getIn(["artist", "id"]);
const linkToAlbum = "/album/" + this.props.song.getIn(["album", "id"]);
return (
<tr>
<td>
@ -43,18 +56,20 @@ class SongsTableRowCSSIntl extends Component {
);
}
}
SongsTableRowCSSIntl.propTypes = {
playAction: PropTypes.func.isRequired,
song: PropTypes.instanceOf(Immutable.Map).isRequired,
intl: intlShape.isRequired
};
export let SongsTableRow = injectIntl(CSSModules(SongsTableRowCSSIntl, css));
/**
* The songs table.
*/
class SongsTableCSS extends Component {
render () {
// Handle filtering
let displayedSongs = this.props.songs;
if (this.props.filterText) {
// Use Fuse for the filter
@ -69,14 +84,16 @@ class SongsTableCSS extends Component {
displayedSongs = displayedSongs.map(function (item) { return new Immutable.Map(item.item); });
}
// Build song rows
let rows = [];
const { playAction } = this.props;
displayedSongs.forEach(function (song) {
rows.push(<SongsTableRow playAction={playAction} song={song} key={song.get("id")} />);
});
// Handle login icon
let loading = null;
if (rows.length == 0 && this.props.isFetching) {
// If we are fetching and there is nothing to show
if (this.props.isFetching) {
loading = (
<p className="text-center">
<FontAwesome name="spinner" className="fa-pulse fa-3x fa-fw" aria-hidden="true" />
@ -84,6 +101,7 @@ class SongsTableCSS extends Component {
</p>
);
}
return (
<div className="table-responsive">
<table className="table table-hover" styleName="songs">
@ -114,26 +132,34 @@ class SongsTableCSS extends Component {
);
}
}
SongsTableCSS.propTypes = {
playAction: PropTypes.func.isRequired,
songs: PropTypes.instanceOf(Immutable.List).isRequired,
filterText: PropTypes.string
};
export let SongsTable = CSSModules(SongsTableCSS, css);
/**
* Complete songs table view with filter and pagination
*/
export default class FilterablePaginatedSongsTable extends Component {
constructor (props) {
super(props);
this.state = {
filterText: ""
filterText: "" // Initial state, no filter text
};
this.handleUserInput = this.handleUserInput.bind(this);
this.handleUserInput = this.handleUserInput.bind(this); // Bind this on user input handling
}
/**
* Method called whenever the filter input is changed.
*
* Update the state accordingly.
*
* @param filterText Content of the filter input.
*/
handleUserInput (filterText) {
this.setState({
filterText: filterText
@ -141,22 +167,34 @@ export default class FilterablePaginatedSongsTable extends Component {
}
render () {
// Handle error
let error = null;
if (this.props.error) {
error = (<DismissibleAlert type="danger" text={this.props.error} />);
}
// Set props
const filterProps = {
filterText: this.state.filterText,
onUserInput: this.handleUserInput
};
const songsTableProps = {
playAction: this.props.playAction,
isFetching: this.props.isFetching,
songs: this.props.songs,
filterText: this.state.filterText
};
return (
<div>
{ error }
<FilterBar filterText={this.state.filterText} onUserInput={this.handleUserInput} />
<SongsTable playAction={this.props.playAction} isFetching={this.props.isFetching} songs={this.props.songs} filterText={this.state.filterText} />
<FilterBar {...filterProps} />
<SongsTable {...songsTableProps} />
<Pagination {...this.props.pagination} />
</div>
);
}
}
FilterablePaginatedSongsTable.propTypes = {
playAction: PropTypes.func.isRequired,
isFetching: PropTypes.bool.isRequired,

View File

@ -1,11 +1,18 @@
// NPM imports
import React, { Component, PropTypes } from "react";
/**
* A dismissible Bootstrap alert.
*/
export default class DismissibleAlert extends Component {
render () {
// Set correct alert type
let alertType = "alert-danger";
if (this.props.type) {
alertType = "alert-" + this.props.type;
}
return (
<div className={["alert", alertType].join(" ")} role="alert">
<p>
@ -18,7 +25,6 @@ export default class DismissibleAlert extends Component {
);
}
}
DismissibleAlert.propTypes = {
type: PropTypes.string,
text: PropTypes.string

View File

@ -1,28 +1,46 @@
// NPM imports
import React, { Component, PropTypes } from "react";
import CSSModules from "react-css-modules";
import { defineMessages, injectIntl, intlShape, FormattedMessage } from "react-intl";
// Local imports
import { messagesMap } from "../../utils";
// Translations
import messages from "../../locales/messagesDescriptors/elements/FilterBar";
// Styles
import css from "../../styles/elements/FilterBar.scss";
// Define translations
const filterMessages = defineMessages(messagesMap(Array.concat([], messages)));
/**
* Filter bar element with input filter.
*/
class FilterBarCSSIntl extends Component {
constructor (props) {
super(props);
// Bind this on methods
this.handleChange = this.handleChange.bind(this);
}
/**
* Method to handle a change of filter input value.
*
* Calls the user input handler passed from parent component.
*
* @param e A JS event.
*/
handleChange (e) {
e.preventDefault();
this.props.onUserInput(this.refs.filterTextInput.value);
}
render () {
const {formatMessage} = this.props.intl;
return (
<div styleName="filter">
<p className="col-xs-12 col-sm-6 col-md-4 col-md-offset-1" styleName="legend" id="filterInputDescription">
@ -39,11 +57,9 @@ class FilterBarCSSIntl extends Component {
);
}
}
FilterBarCSSIntl.propTypes = {
onUserInput: PropTypes.func,
filterText: PropTypes.string,
intl: intlShape.isRequired
};
export default injectIntl(CSSModules(FilterBarCSSIntl, css));

View File

@ -1,3 +1,4 @@
// NPM imports
import React, { Component, PropTypes } from "react";
import { Link} from "react-router";
import CSSModules from "react-css-modules";
@ -9,56 +10,24 @@ import Isotope from "isotope-layout";
import Fuse from "fuse.js";
import shallowCompare from "react-addons-shallow-compare";
import FilterBar from "./FilterBar";
import Pagination from "./Pagination";
// Local imports
import { immutableDiff, messagesMap } from "../../utils/";
// Other components
import FilterBar from "./FilterBar";
import Pagination from "./Pagination";
// Translations
import commonMessages from "../../locales/messagesDescriptors/common";
import messages from "../../locales/messagesDescriptors/grid";
// Styles
import css from "../../styles/elements/Grid.scss";
// Define translations
const gridMessages = defineMessages(messagesMap(Array.concat([], commonMessages, messages)));
class GridItemCSSIntl extends Component {
render () {
const {formatMessage} = this.props.intl;
let nSubItems = this.props.item.get(this.props.subItemsType);
if (Immutable.List.isList(nSubItems)) {
nSubItems = nSubItems.size;
}
let subItemsLabel = formatMessage(gridMessages[this.props.subItemsLabel], { itemCount: nSubItems });
const to = "/" + this.props.itemsType + "/" + this.props.item.get("id");
const id = "grid-item-" + this.props.itemsType + "/" + this.props.item.get("id");
const title = formatMessage(gridMessages["app.grid.goTo" + this.props.itemsType.capitalize() + "Page"]);
return (
<div className="grid-item col-xs-6 col-sm-3" styleName="placeholders" id={id}>
<div className="grid-item-content text-center">
<Link title={title} to={to}><img src={this.props.item.get("art")} width="200" height="200" className="img-responsive img-circle art" styleName="art" alt={this.props.item.get("name")}/></Link>
<h4 className="name" styleName="name">{this.props.item.get("name")}</h4>
<span className="sub-items text-muted"><span className="n-sub-items">{nSubItems}</span> <span className="sub-items-type">{subItemsLabel}</span></span>
</div>
</div>
);
}
}
GridItemCSSIntl.propTypes = {
item: PropTypes.instanceOf(Immutable.Map).isRequired,
itemsType: PropTypes.string.isRequired,
itemsLabel: PropTypes.string.isRequired,
subItemsType: PropTypes.string.isRequired,
subItemsLabel: PropTypes.string.isRequired,
intl: intlShape.isRequired
};
export let GridItem = injectIntl(CSSModules(GridItemCSSIntl, css));
// Constants
const ISOTOPE_OPTIONS = { /** Default options for Isotope grid layout. */
getSortData: {
name: ".name",
@ -75,6 +44,55 @@ const ISOTOPE_OPTIONS = { /** Default options for Isotope grid layout. */
}
};
/**
* A single item in the grid, art + text under the art.
*/
class GridItemCSSIntl extends Component {
render () {
const {formatMessage} = this.props.intl;
// Get number of sub-items
let nSubItems = this.props.item.get(this.props.subItemsType);
if (Immutable.List.isList(nSubItems)) {
nSubItems = nSubItems.size;
}
// Define correct sub-items label (plural)
let subItemsLabel = formatMessage(
gridMessages[this.props.subItemsLabel],
{ itemCount: nSubItems }
);
const to = "/" + this.props.itemsType + "/" + this.props.item.get("id");
const id = "grid-item-" + this.props.itemsType + "/" + this.props.item.get("id");
const title = formatMessage(gridMessages["app.grid.goTo" + this.props.itemsType.capitalize() + "Page"]);
return (
<div className="grid-item col-xs-6 col-sm-3" styleName="placeholders" id={id}>
<div className="grid-item-content text-center">
<Link title={title} to={to}><img src={this.props.item.get("art")} width="200" height="200" className="img-responsive img-circle art" styleName="art" alt={this.props.item.get("name")}/></Link>
<h4 className="name" styleName="name">{this.props.item.get("name")}</h4>
<span className="sub-items text-muted"><span className="n-sub-items">{nSubItems}</span> <span className="sub-items-type">{subItemsLabel}</span></span>
</div>
</div>
);
}
}
GridItemCSSIntl.propTypes = {
item: PropTypes.instanceOf(Immutable.Map).isRequired,
itemsType: PropTypes.string.isRequired,
itemsLabel: PropTypes.string.isRequired,
subItemsType: PropTypes.string.isRequired,
subItemsLabel: PropTypes.string.isRequired,
intl: intlShape.isRequired
};
export let GridItem = injectIntl(CSSModules(GridItemCSSIntl, css));
/**
* A grid, formatted using Isotope.JS
*/
export class Grid extends Component {
constructor (props) {
super(props);
@ -82,20 +100,29 @@ export class Grid extends Component {
// Init grid data member
this.iso = null;
// Bind this
this.createIsotopeContainer = this.createIsotopeContainer.bind(this);
this.handleFiltering = this.handleFiltering.bind(this);
}
/**
* Create an isotope container if none already exist.
*/
createIsotopeContainer () {
if (this.iso == null) {
this.iso = new Isotope(this.refs.grid, ISOTOPE_OPTIONS);
}
}
/**
* Handle filtering on the grid.
*/
handleFiltering (props) {
// If no query provided, drop any filter in use
if (props.filterText == "") {
return this.iso.arrange(ISOTOPE_OPTIONS);
}
// Use Fuse for the filter
let result = new Fuse(
props.items.toJS(),
@ -103,7 +130,8 @@ export class Grid extends Component {
"keys": ["name"],
"threshold": 0.4,
"include": ["score"]
}).search(props.filterText);
}
).search(props.filterText);
// Apply filter on grid
this.iso.arrange({
@ -130,10 +158,12 @@ export class Grid extends Component {
}
shouldComponentUpdate(nextProps, nextState) {
// Shallow comparison, render is pure
return shallowCompare(this, nextProps, nextState);
}
componentWillReceiveProps(nextProps) {
// Handle filtering if filterText is changed
if (nextProps.filterText !== this.props.filterText) {
this.handleFiltering(nextProps);
}
@ -143,8 +173,7 @@ export class Grid extends Component {
// Setup grid
this.createIsotopeContainer();
// Only arrange if there are elements to arrange
const length = this.props.items.length || 0;
if (length > 0) {
if (this.props.items.size > 0) {
this.iso.arrange();
}
}
@ -152,25 +181,31 @@ export class Grid extends Component {
componentDidUpdate(prevProps) {
// The list of keys seen in the previous render
let currentKeys = prevProps.items.map(
(n) => "grid-item-" + prevProps.itemsType + "/" + n.get("id"));
(n) => "grid-item-" + prevProps.itemsType + "/" + n.get("id")
);
// The latest list of keys that have been rendered
const {itemsType} = this.props;
let newKeys = this.props.items.map(
(n) => "grid-item-" + itemsType + "/" + n.get("id"));
(n) => "grid-item-" + itemsType + "/" + n.get("id")
);
// Find which keys are new between the current set of keys and any new children passed to this component
// Find which keys are new between the current set of keys and any new
// children passed to this component
let addKeys = immutableDiff(newKeys, currentKeys);
// Find which keys have been removed between the current set of keys and any new children passed to this component
// Find which keys have been removed between the current set of keys
// and any new children passed to this component
let removeKeys = immutableDiff(currentKeys, newKeys);
let iso = this.iso;
if (removeKeys.count() > 0) {
// Remove removed items
if (removeKeys.size > 0) {
removeKeys.forEach(removeKey => iso.remove(document.getElementById(removeKey)));
iso.arrange();
}
if (addKeys.count() > 0) {
// Add new items
if (addKeys.size > 0) {
const itemsToAdd = addKeys.map((addKey) => document.getElementById(addKey)).toArray();
iso.addItems(itemsToAdd);
iso.arrange();
@ -187,13 +222,9 @@ export class Grid extends Component {
}
render () {
let gridItems = [];
const { itemsType, itemsLabel, subItemsType, subItemsLabel } = this.props;
this.props.items.forEach(function (item) {
gridItems.push(<GridItem item={item} itemsType={itemsType} itemsLabel={itemsLabel} subItemsType={subItemsType} subItemsLabel={subItemsLabel} key={item.get("id")} />);
});
// Handle loading
let loading = null;
if (gridItems.length == 0 && this.props.isFetching) {
if (this.props.isFetching) {
loading = (
<div className="row text-center">
<p>
@ -203,9 +234,16 @@ export class Grid extends Component {
</div>
);
}
// Build grid items
let gridItems = [];
const { itemsType, itemsLabel, subItemsType, subItemsLabel } = this.props;
this.props.items.forEach(function (item) {
gridItems.push(<GridItem item={item} itemsType={itemsType} itemsLabel={itemsLabel} subItemsType={subItemsType} subItemsLabel={subItemsLabel} key={item.get("id")} />);
});
return (
<div>
{ loading }
<div className="row">
<div className="grid" ref="grid">
{/* Sizing element */}
@ -214,11 +252,11 @@ export class Grid extends Component {
{ gridItems }
</div>
</div>
{ loading }
</div>
);
}
}
Grid.propTypes = {
isFetching: PropTypes.bool.isRequired,
items: PropTypes.instanceOf(Immutable.List).isRequired,
@ -229,16 +267,29 @@ Grid.propTypes = {
filterText: PropTypes.string
};
/**
* Full grid with pagination and filtering input.
*/
export default class FilterablePaginatedGrid extends Component {
constructor (props) {
super(props);
this.state = {
filterText: ""
filterText: "" // No filterText at init
};
// Bind this
this.handleUserInput = this.handleUserInput.bind(this);
}
/**
* Method called whenever the filter input is changed.
*
* Update the state accordingly.
*
* @param filterText Content of the filter input.
*/
handleUserInput (filterText) {
this.setState({
filterText: filterText

View File

@ -1,71 +1,90 @@
// NPM imports
import React, { Component, PropTypes } from "react";
import { Link } from "react-router";
import CSSModules from "react-css-modules";
import { defineMessages, injectIntl, intlShape, FormattedMessage, FormattedHTMLMessage } from "react-intl";
import { messagesMap } from "../../utils";
// Local imports
import { computePaginationBounds, filterInt, messagesMap } from "../../utils";
// Translations
import commonMessages from "../../locales/messagesDescriptors/common";
import messages from "../../locales/messagesDescriptors/elements/Pagination";
// Styles
import css from "../../styles/elements/Pagination.scss";
// Define translations
const paginationMessages = defineMessages(messagesMap(Array.concat([], commonMessages, messages)));
/**
* Pagination button bar
*/
class PaginationCSSIntl extends Component {
computePaginationBounds(currentPage, nPages, maxNumberPagesShown=5) {
// Taken from http://stackoverflow.com/a/8608998/2626416
let lowerLimit = currentPage;
let upperLimit = currentPage;
constructor (props) {
super (props);
for (let b = 1; b < maxNumberPagesShown && b < nPages;) {
if (lowerLimit > 1 ) {
lowerLimit--;
b++;
}
if (b < maxNumberPagesShown && upperLimit < nPages) {
upperLimit++;
b++;
}
}
return {
lowerLimit: lowerLimit,
upperLimit: upperLimit + 1 // +1 to ease iteration in for with <
};
// Bind this
this.goToPage = this.goToPage.bind(this);
this.dotsOnClick = this.dotsOnClick.bind(this);
this.dotsOnKeyDown = this.dotsOnKeyDown.bind(this);
this.cancelModalBox = this.cancelModalBox.bind(this);
}
goToPage(ev) {
ev.preventDefault();
const pageNumber = parseInt(this.refs.pageInput.value);
$(this.refs.paginationModal).modal("hide");
if (pageNumber) {
/**
* Handle click on the "go to page" button in the modal.
*/
goToPage(e) {
e.preventDefault();
// Parse and check page number
const pageNumber = filterInt(this.refs.pageInput.value);
if (pageNumber && !isNaN(pageNumber)) {
// Hide the modal and go to page
$(this.refs.paginationModal).modal("hide");
this.props.goToPage(pageNumber);
}
}
/**
* Handle click on the ellipsis dots.
*/
dotsOnClick() {
// Show modal
$(this.refs.paginationModal).modal();
}
dotsOnKeyDown(ev) {
ev.preventDefault;
const code = ev.keyCode || ev.which;
/**
* Bind key down events on ellipsis dots for a11y.
*/
dotsOnKeyDown(e) {
e.preventDefault;
const code = e.keyCode || e.which;
if (code == 13 || code == 32) { // Enter or Space key
this.dotsOnClick(); // Fire same event as onClick
}
}
/**
* Handle click on "cancel" in the modal box.
*/
cancelModalBox() {
// Hide modal
$(this.refs.paginationModal).modal("hide");
}
render () {
const { formatMessage } = this.props.intl;
const { lowerLimit, upperLimit } = this.computePaginationBounds(this.props.currentPage, this.props.nPages);
// Get bounds
const { lowerLimit, upperLimit } = computePaginationBounds(this.props.currentPage, this.props.nPages);
// Store buttons
let pagesButton = [];
let key = 0; // key increment to ensure correct ordering
// If lower limit is above 1, push 1 and ellipsis
if (lowerLimit > 1) {
// Push first page
pagesButton.push(
<li className="page-item" key={key}>
<Link className="page-link" title={formatMessage(paginationMessages["app.pagination.goToPageWithoutMarkup"], { pageNumber: 1})} to={this.props.buildLinkToPage(1)}>
@ -73,27 +92,28 @@ class PaginationCSSIntl extends Component {
</Link>
</li>
);
key++;
key++; // Always increment key after a push
if (lowerLimit > 2) {
// Eventually push ""
pagesButton.push(
<li className="page-item" key={key}>
<span tabIndex="0" role="button" onKeyDown={this.dotsOnKeyDown.bind(this)} onClick={this.dotsOnClick.bind(this)}>&hellip;</span>
<span tabIndex="0" role="button" onKeyDown={this.dotsOnKeyDown} onClick={this.dotsOnClick}>&hellip;</span>
</li>
);
key++;
}
}
// Main buttons, between lower and upper limits
for (let i = lowerLimit; i < upperLimit; i++) {
let className = "page-item";
let classNames = ["page-item"];
let currentSpan = null;
if (this.props.currentPage == i) {
className += " active";
classNames.push("active");
currentSpan = <span className="sr-only">(<FormattedMessage {...paginationMessages["app.pagination.current"]} />)</span>;
}
const title = formatMessage(paginationMessages["app.pagination.goToPageWithoutMarkup"], { pageNumber: i });
pagesButton.push(
<li className={className} key={key}>
<li className={classNames.join(" ")} key={key}>
<Link className="page-link" title={title} to={this.props.buildLinkToPage(i)}>
<FormattedHTMLMessage {...paginationMessages["app.pagination.goToPage"]} values={{ pageNumber: i }} />
{currentSpan}
@ -102,12 +122,13 @@ class PaginationCSSIntl extends Component {
);
key++;
}
// If upper limit is below the total number of page, show last page button
if (upperLimit < this.props.nPages) {
if (upperLimit < this.props.nPages - 1) {
// Eventually push ""
pagesButton.push(
<li className="page-item" key={key}>
<span tabIndex="0" role="button" onKeyDown={this.dotsOnKeyDown.bind(this)} onClick={this.dotsOnClick.bind(this)}>&hellip;</span>
<span tabIndex="0" role="button" onKeyDown={this.dotsOnKeyDown} onClick={this.dotsOnClick}>&hellip;</span>
</li>
);
key++;
@ -122,6 +143,8 @@ class PaginationCSSIntl extends Component {
</li>
);
}
// If there are actually some buttons, show them
if (pagesButton.length > 1) {
return (
<div>
@ -140,15 +163,15 @@ class PaginationCSSIntl extends Component {
</h4>
</div>
<div className="modal-body">
<form onSubmit={this.goToPage.bind(this)}>
<form onSubmit={this.goToPage}>
<input className="form-control" autoComplete="off" type="number" ref="pageInput" aria-label={formatMessage(paginationMessages["app.pagination.pageToGoTo"])} autoFocus />
</form>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-default" onClick={this.cancelModalBox.bind(this)}>
<button type="button" className="btn btn-default" onClick={this.cancelModalBox}>
<FormattedMessage {...paginationMessages["app.common.cancel"]} />
</button>
<button type="button" className="btn btn-primary" onClick={this.goToPage.bind(this)}>
<button type="button" className="btn btn-primary" onClick={this.goToPage}>
<FormattedMessage {...paginationMessages["app.common.go"]} />
</button>
</div>
@ -161,7 +184,6 @@ class PaginationCSSIntl extends Component {
return null;
}
}
PaginationCSSIntl.propTypes = {
currentPage: PropTypes.number.isRequired,
goToPage: PropTypes.func.isRequired,
@ -169,5 +191,4 @@ PaginationCSSIntl.propTypes = {
nPages: PropTypes.number.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(CSSModules(PaginationCSSIntl, css));

View File

@ -1,3 +1,4 @@
// TODO: This file is to review
import React, { Component, PropTypes } from "react";
import CSSModules from "react-css-modules";
import { defineMessages, injectIntl, intlShape, FormattedMessage } from "react-intl";

View File

@ -1,21 +1,34 @@
// NPM imports
import React, { Component, PropTypes } from "react";
import { IndexLink, Link} from "react-router";
import CSSModules from "react-css-modules";
import { defineMessages, injectIntl, intlShape, FormattedMessage } from "react-intl";
// Local imports
import { messagesMap } from "../../utils";
// Other components
/* import WebPlayer from "../../views/WebPlayer"; TODO */
// Translations
import commonMessages from "../../locales/messagesDescriptors/common";
import messages from "../../locales/messagesDescriptors/layouts/Sidebar";
import WebPlayer from "../../views/WebPlayer";
// Styles
import css from "../../styles/layouts/Sidebar.scss";
// Define translations
const sidebarLayoutMessages = defineMessages(messagesMap(Array.concat([], commonMessages, messages)));
/**
* Sidebar layout component, putting children next to the sidebar menu.
*/
class SidebarLayoutIntl extends Component {
render () {
const { formatMessage } = this.props.intl;
// Check active links
const isActive = {
discover: (this.props.location.pathname == "/discover") ? "active" : "link",
browse: (this.props.location.pathname == "/browse") ? "active" : "link",
@ -24,9 +37,12 @@ class SidebarLayoutIntl extends Component {
songs: (this.props.location.pathname == "/songs") ? "active" : "link",
search: (this.props.location.pathname == "/search") ? "active" : "link"
};
// Hamburger collapsing function
const collapseHamburger = function () {
$("#main-navbar").collapse("hide");
};
return (
<div>
<div className="row">
@ -128,17 +144,9 @@ class SidebarLayoutIntl extends Component {
</li>
</ul>
</li>
<li>
<Link to="/search" title={formatMessage(sidebarLayoutMessages["app.sidebarLayout.search"])} styleName={isActive.search} onClick={collapseHamburger}>
<span className="glyphicon glyphicon-search" aria-hidden="true"></span>
<span className="hidden-md">
&nbsp;<FormattedMessage {...sidebarLayoutMessages["app.sidebarLayout.search"]} />
</span>
</Link>
</li>
</ul>
</nav>
<WebPlayer />
{ /** TODO <WebPlayer /> */ }
</div>
</div>
@ -149,11 +157,8 @@ class SidebarLayoutIntl extends Component {
);
}
}
SidebarLayoutIntl.propTypes = {
children: PropTypes.node,
intl: intlShape.isRequired
};
export default injectIntl(CSSModules(SidebarLayoutIntl, css));

View File

@ -1,5 +1,10 @@
// NPM imports
import React, { Component } from "react";
/**
* Simple layout, meaning just enclosing children in a div.
*/
export default class SimpleLayout extends Component {
render () {
return (

View File

@ -1,12 +1,15 @@
/**
* Main container at the top of our application components tree.
*
* Just a div wrapper around children for now.
*/
import React, { Component, PropTypes } from "react";
export default class App extends Component {
render () {
return (
<div>
{this.props.children && React.cloneElement(this.props.children, {
error: this.props.error
})}
{this.props.children}
</div>
);
}

View File

@ -1,18 +1,31 @@
/**
* Container wrapping elements neeeding a valid session. Automatically
* redirects to login form in case such session does not exist.
*/
import React, { Component, PropTypes } from "react";
import { connect } from "react-redux";
// TODO: Handle expired session
export class RequireAuthentication extends Component {
componentWillMount () {
// Check authentication on mount
this.checkAuth(this.props.isAuthenticated);
}
componentWillUpdate (newProps) {
// Check authentication on update
this.checkAuth(newProps.isAuthenticated);
}
/**
* Handle redirection in case user is not authenticated.
*
* @param isAuthenticated A boolean stating whether user has a valid
* session or not.
*/
checkAuth (isAuthenticated) {
if (!isAuthenticated) {
// Redirect to login, redirecting to the actual page after login.
this.context.router.replace({
pathname: "/login",
state: {
@ -26,10 +39,10 @@ export class RequireAuthentication extends Component {
render () {
return (
<div>
{this.props.isAuthenticated === true
? this.props.children
: null
}
{this.props.isAuthenticated === true
? this.props.children
: null
}
</div>
);
}

View File

@ -1,3 +1,6 @@
/**
* Root component to render, setting locale, messages, Router and Store.
*/
import React, { Component, PropTypes } from "react";
import { Provider } from "react-redux";
import { Router } from "react-router";

View File

@ -39,7 +39,6 @@ module.exports = {
"app.sidebarLayout.home": "Home", // Home
"app.sidebarLayout.logout": "Logout", // Logout
"app.sidebarLayout.mainNavigationMenu": "Main navigation menu", // ARIA label for the main navigation menu
"app.sidebarLayout.search": "Search", // Search
"app.sidebarLayout.settings": "Settings", // Settings
"app.sidebarLayout.toggleNavigation": "Toggle navigation", // Screen reader description of toggle navigation button
"app.songs.genre": "Genre", // Genre (song)

View File

@ -39,7 +39,6 @@ module.exports = {
"app.sidebarLayout.home": "Accueil", // Home
"app.sidebarLayout.logout": "Déconnexion", // Logout
"app.sidebarLayout.mainNavigationMenu": "Menu principal", // ARIA label for the main navigation menu
"app.sidebarLayout.search": "Rechercher", // Search
"app.sidebarLayout.settings": "Préférences", // Settings
"app.sidebarLayout.toggleNavigation": "Afficher le menu", // Screen reader description of toggle navigation button
"app.songs.genre": "Genre", // Genre (song)

View File

@ -1,3 +1,4 @@
// Export all the existing locales
module.exports = {
"en-US": require("./en-US"),
"fr-FR": require("./fr-FR")

View File

@ -44,11 +44,6 @@ const messages = [
description: "Browse songs",
defaultMessage: "Browse songs"
},
{
id: "app.sidebarLayout.search",
description: "Search",
defaultMessage: "Search"
},
{
id: "app.sidebarLayout.toggleNavigation",
description: "Screen reader description of toggle navigation button",

View File

@ -1,3 +1,9 @@
/**
* Redux middleware to perform API queries.
*
* This middleware catches the API requests and replaces them with API
* responses.
*/
import fetch from "isomorphic-fetch";
import humps from "humps";
import X2JS from "x2js";
@ -10,9 +16,19 @@ import { loginUserExpired } from "../actions/auth";
export const API_VERSION = 350001; /** API version to use. */
export const BASE_API_PATH = "/server/xml.server.php"; /** Base API path after endpoint. */
// Action key that carries API call info interpreted by this Redux middleware.
export const CALL_API = "CALL_API";
// Error class to represents errors from these actions.
class APIError extends Error {}
/**
* Check the HTTP status of the response.
*
* @param response A XHR response object.
* @return The response or a rejected Promise if the check failed.
*/
function _checkHTTPStatus (response) {
if (response.status >= 200 && response.status < 300) {
return response;
@ -21,10 +37,17 @@ function _checkHTTPStatus (response) {
}
}
/**
* Parse the XML resulting from the API to JS object.
*
* @param responseText The text from the API response.
* @return The response as a JS object or a rejected Promise on error.
*/
function _parseToJSON (responseText) {
let x2js = new X2JS({
attributePrefix: "",
keepCData: false
attributePrefix: "", // No prefix for attributes
keepCData: false // Do not store __cdata and toString functions
});
if (responseText) {
return x2js.xml_str2json(responseText).root;
@ -35,6 +58,13 @@ function _parseToJSON (responseText) {
}));
}
/**
* Check the errors returned by the API itself, in its response.
*
* @param jsonData A JS object representing the API response.
* @return The input data or a rejected Promise if errors are present.
*/
function _checkAPIErrors (jsonData) {
if (jsonData.error) {
return Promise.reject(jsonData.error);
@ -48,7 +78,15 @@ function _checkAPIErrors (jsonData) {
return jsonData;
}
/**
* Apply some fixes on the API data.
*
* @param jsonData A JS object representing the API response.
* @return A fixed JS object.
*/
function _uglyFixes (jsonData) {
// Fix songs array
let _uglyFixesSongs = function (songs) {
return songs.map(function (song) {
// Fix for cdata left in artist and album
@ -58,9 +96,10 @@ function _uglyFixes (jsonData) {
});
};
// Fix albums array
let _uglyFixesAlbums = function (albums) {
return albums.map(function (album) {
// TODO
// TODO: Should go in Ampache core
// Fix for absence of distinction between disks in the same album
if (album.disk > 1) {
album.name = album.name + " [Disk " + album.disk + "]";
@ -75,13 +114,14 @@ function _uglyFixes (jsonData) {
album.tracks = [album.tracks];
}
// Fix tracks
// Fix tracks array
album.tracks = _uglyFixesSongs(album.tracks);
}
return album;
});
};
// Fix artists array
let _uglyFixesArtists = function (artists) {
return artists.map(function (artist) {
// Move albums one node top
@ -131,17 +171,15 @@ function _uglyFixes (jsonData) {
// Fix albums
if (jsonData.album) {
// Fix albums
jsonData.album = _uglyFixesAlbums(jsonData.album);
}
// Fix songs
if (jsonData.song) {
// Fix songs
jsonData.song = _uglyFixesSongs(jsonData.song);
}
// TODO
// TODO: Should go in Ampache core
// Add sessionExpire information
if (!jsonData.sessionExpire) {
// Fix for Ampache not returning updated sessionExpire
@ -151,17 +189,31 @@ function _uglyFixes (jsonData) {
return jsonData;
}
// Fetches an API response and normalizes the result JSON according to schema.
// This makes every API response have the same shape, regardless of how nested it was.
/**
* Fetches an API response and normalizes the result.
*
* @param endpoint Base URL of your Ampache server.
* @param action API action name.
* @param auth API token to use.
* @param username Username to use in the API.
* @param extraParams An object of extra parameters to pass to the API.
*
* @return A fetching Promise.
*/
function doAPICall (endpoint, action, auth, username, extraParams) {
// Translate the API action to real API action
const APIAction = extraParams.filter ? action.rstrip("s") : action;
// Set base params
const baseParams = {
version: API_VERSION,
action: APIAction,
auth: auth,
user: username
};
// Extend with extraParams
const params = Object.assign({}, baseParams, extraParams);
// Assemble the full URL with endpoint, API path and GET params
const fullURL = assembleURLAndParams(endpoint + BASE_API_PATH, params);
return fetch(fullURL, {
@ -175,19 +227,19 @@ function doAPICall (endpoint, action, auth, username, extraParams) {
.then(_uglyFixes);
}
// Action key that carries API call info interpreted by this Redux middleware.
export const CALL_API = "CALL_API";
// A Redux middleware that interprets actions with CALL_API info specified.
// Performs the call and promises when such actions are dispatched.
/**
* A Redux middleware that interprets actions with CALL_API info specified.
* Performs the call and promises when such actions are dispatched.
*/
export default store => next => reduxAction => {
if (reduxAction.type !== CALL_API) {
// Do not apply on every action
// Do not apply on other actions
return next(reduxAction);
}
// Check payload
const { endpoint, action, auth, username, dispatch, extraParams } = reduxAction.payload;
if (!endpoint || typeof endpoint !== "string") {
throw new APIError("Specify a string endpoint URL.");
}
@ -207,22 +259,27 @@ export default store => next => reduxAction => {
throw new APIError("Expected action to dispatch to be functions or null.");
}
// Get the actions to dispatch
const [ requestDispatch, successDispatch, failureDispatch ] = dispatch;
if (requestDispatch) {
// Dispatch request action if needed
store.dispatch(requestDispatch());
}
// Run the API call
return doAPICall(endpoint, action, auth, username, extraParams).then(
response => {
if (successDispatch) {
// Dispatch success if needed
store.dispatch(successDispatch(response));
}
},
error => {
if (failureDispatch) {
const errorMessage = error.__cdata + " (" + error._code + ")";
// Error object from the API
// Error object from the API (in the JS object)
if (error._code && error.__cdata) {
// Format the error message
const errorMessage = error.__cdata + " (" + error._code + ")";
if (401 == error._code) {
// This is an error meaning no valid session was
// passed. We must perform a new handshake.

View File

@ -1,22 +1,28 @@
/**
* This file defines API related models.
*/
// NPM imports
import { Schema, arrayOf } from "normalizr";
export const artist = new Schema("artist");
export const album = new Schema("album");
export const track = new Schema("track");
export const tag = new Schema("tag");
artist.define({
// Define normalizr schemas for major entities returned by the API
export const artist = new Schema("artist"); /** Artist schema */
export const album = new Schema("album"); /** Album schema */
export const song = new Schema("song"); /** Song schema */
// Explicit relations between them
artist.define({ // Artist has albums and songs (tracks)
albums: arrayOf(album),
songs: arrayOf(track)
songs: arrayOf(song)
});
album.define({
album.define({ // Album has artist, tracks and tags
artist: artist,
tracks: arrayOf(track),
tag: arrayOf(tag)
tracks: arrayOf(song)
});
track.define({
song.define({ // Track has artist and album
artist: artist,
album: album
});

View File

@ -1,18 +1,27 @@
/**
* This file defines authentication related models.
*/
// NPM imports
import Immutable from "immutable";
/** Record to store token parameters */
export const tokenRecord = Immutable.Record({
token: null,
expires: null
token: null, /** Token string */
expires: null /** Token expiration date */
});
/** Record to store the full auth state */
export const stateRecord = new Immutable.Record({
token: tokenRecord,
username: null,
endpoint: null,
rememberMe: false,
isAuthenticated: false,
isAuthenticating: false,
error: null,
info: null,
timerID: null
token: new tokenRecord(), /** Auth token */
username: null, /** Username */
endpoint: null, /** Ampache server base URL */
rememberMe: false, /** Whether to remember me or not */
isAuthenticated: false, /** Whether authentication is ok or not */
isAuthenticating: false, /** Whether authentication is in progress or not */
error: null, /** An error string */
info: null, /** An info string */
timerID: null /** Timer ID for setInterval calls to revive API session */
});

22
app/models/entities.js Normal file
View File

@ -0,0 +1,22 @@
/**
* This file defines entities storage models.
*/
// NPM imports
import Immutable from "immutable";
/** Record to store the shared entities. */
export const stateRecord = new Immutable.Record({
isFetching: false, /** Whether API fetching is in progress */
error: null, /** An error string */
refCounts: new Immutable.Map({
album: new Immutable.Map(),
artist: new Immutable.Map(),
song: new Immutable.Map()
}), /** Map of id => reference count for each object type (garbage collection) */
entities: new Immutable.Map({
album: new Immutable.Map(),
artist: new Immutable.Map(),
song: new Immutable.Map()
}) /** Map of id => entity for each object type */
});

View File

@ -1,6 +1,12 @@
/**
* This file defines i18n related models.
*/
// NPM import
import Immutable from "immutable";
/** i18n record for passing errors to be localized from actions to components */
export const i18nRecord = new Immutable.Record({
id: null,
values: new Immutable.Map()
id: null, /** Translation message id */
values: new Immutable.Map() /** Values to pass to formatMessage */
});

View File

@ -1,10 +0,0 @@
import Immutable from "immutable";
export const stateRecord = new Immutable.Record({
isFetching: false,
result: new Immutable.Map(),
entities: new Immutable.Map(),
error: null,
currentPage: 1,
nPages: 1
});

10
app/models/paginated.js Normal file
View File

@ -0,0 +1,10 @@
// NPM import
import Immutable from "immutable";
/** Record to store the paginated pages state. */
export const stateRecord = new Immutable.Record({
type: null, /** Type of the paginated entries */
result: new Immutable.List(), /** List of IDs of the resulting entries, maps to the entities store */
currentPage: 1, /** Number of current page */
nPages: 1 /** Total number of page in this batch */
});

View File

@ -1,17 +1,18 @@
/**
* This file defines authentication related models.
*/
// NPM imports
import Immutable from "immutable";
export const entitiesRecord = new Immutable.Record({
artists: new Immutable.Map(),
albums: new Immutable.Map(),
tracks: new Immutable.Map()
});
/** Record to store the webplayer state. */
export const stateRecord = new Immutable.Record({
isPlaying: false,
isRandom: false,
isRepeat: false,
isMute: false,
currentIndex: 0,
playlist: new Immutable.List(),
entities: new entitiesRecord()
isPlaying: false, /** Whether webplayer is playing */
isRandom: false, /** Whether random mode is on */
isRepeat: false, /** Whether repeat mode is on */
isMute: false, /** Whether sound is muted or not */
volume: 100, /** Current volume, between 0 and 100 */
currentIndex: 0, /** Current index in the playlist */
playlist: new Immutable.List() /** List of songs IDs, references songs in the entities store */
});

View File

@ -1,15 +1,31 @@
/**
* This implements the auth reducer, storing and updating authentication state.
*/
// NPM imports
import Cookies from "js-cookie";
import { LOGIN_USER_REQUEST, LOGIN_USER_SUCCESS, LOGIN_USER_FAILURE, LOGIN_USER_EXPIRED, LOGOUT_USER } from "../actions";
// Local imports
import { createReducer } from "../utils";
// Models
import { i18nRecord } from "../models/i18n";
import { tokenRecord, stateRecord } from "../models/auth";
/**
* Initial state
*/
// Actions
import {
LOGIN_USER_REQUEST,
LOGIN_USER_SUCCESS,
LOGIN_USER_FAILURE,
LOGIN_USER_EXPIRED,
LOGOUT_USER } from "../actions";
/**
* Initial state, load data from cookies if set
*/
var initialState = new stateRecord();
// Get token
const initialToken = Cookies.getJSON("token");
if (initialToken) {
initialToken.expires = new Date(initialToken.expires);
@ -18,6 +34,7 @@ if (initialToken) {
new tokenRecord({ token: initialToken.token, expires: new Date(initialToken.expires) })
);
}
// Get username
const initialUsername = Cookies.get("username");
if (initialUsername) {
initialState = initialState.set(
@ -25,6 +42,7 @@ if (initialUsername) {
initialUsername
);
}
// Get endpoint
const initialEndpoint = Cookies.get("endpoint");
if (initialEndpoint) {
initialState = initialState.set(
@ -32,6 +50,7 @@ if (initialEndpoint) {
initialEndpoint
);
}
// Set remember me
if (initialUsername && initialEndpoint) {
initialState = initialState.set(
"rememberMe",
@ -39,10 +58,10 @@ if (initialUsername && initialEndpoint) {
);
}
/**
* Reducers
*/
export default createReducer(initialState, {
[LOGIN_USER_REQUEST]: () => {
return new stateRecord({

211
app/reducers/entities.js Normal file
View File

@ -0,0 +1,211 @@
/**
* This implements the global entities reducer.
*/
// NPM imports
import Immutable from "immutable";
// Local imports
import { createReducer } from "../utils";
// Models
import { stateRecord } from "../models/entities";
// Actions
import {
API_REQUEST,
API_FAILURE,
PUSH_ENTITIES,
INCREMENT_REFCOUNT,
DECREMENT_REFCOUNT,
INVALIDATE_STORE
} from "../actions";
/**
* Helper methods
*/
/**
* Update the reference counter for a given item.
*
* Do not do any garbage collection.
*
* @param state The state object to update.
* @param keyPath The keyPath to update, from the refCount key.
* @param incr The increment (or decrement) for the reference counter.
*
* @return An updated state.
*/
function updateRefCount(state, keyPath, incr) {
// Prepend refCounts to keyPath
const refCountKeyPath = Array.concat(["refCounts"], keyPath);
// Get updated value
let newRefCount = state.getIn(refCountKeyPath) + incr;
if (isNaN(newRefCount)) {
// If NaN, reference does not exist, so set it to ±1
newRefCount = Math.sign(incr);
}
// Update state
return state.setIn(refCountKeyPath, newRefCount);
}
/**
* Update the reference counter of a given entity, taking into account the
* nested objects.
*
* Do not do any garbage collection.
*
* @param state The state object to update.
* @param itemName The type of the entity object.
* @param id The id of the entity.
* @param entity The entity object, as Immutable.
* @param incr The increment (or decrement) for the reference counter.
*
* @return An updated state.
*/
function updateEntityRefCount(state, itemName, id, entity, incr) {
let newState = state;
let albums = null;
let tracks = null;
switch (itemName) {
case "artist":
// Update artist refCount
newState = updateRefCount(newState, ["artist", id], incr);
// Update nested albums refCount
albums = entity.get("albums");
if (Immutable.List.isList(albums)) {
albums.forEach(function (id) {
newState = updateRefCount(newState, ["album", id], incr);
});
}
// Update nested tracks refCount
tracks = entity.get("songs");
if (Immutable.List.isList(tracks)) {
tracks.forEach(function (id) {
newState = updateRefCount(newState, ["song", id], incr);
});
}
break;
case "album":
// Update album refCount
newState = updateRefCount(newState, ["album", id], incr);
// Update nested artist refCount
newState = updateRefCount(newState, ["artist", entity.get("artist")], incr);
// Update nested tracks refCount
tracks = entity.get("tracks");
if (Immutable.List.isList(tracks)) {
tracks.forEach(function (id) {
newState = updateRefCount(newState, ["song", id], incr);
});
}
break;
case "song":
// Update track refCount
newState = updateRefCount(newState, ["song", id], incr);
// Update nested artist refCount
newState = updateRefCount(newState, ["artist", entity.get("artist")], incr);
// Update nested album refCount
newState = updateRefCount(newState, ["album", entity.get("album")], incr);
break;
default:
// Just update the entity, no nested entities
newState = updateRefCount(newState, [itemName, id], incr);
break;
}
return newState;
}
/**
*
*/
function garbageCollection(state) {
let newState = state;
state.refCounts.forEach(function (refCounts, itemName) {
refCounts.forEach(function (refCount, id) {
if (refCount < 1) {
// Garbage collection
newState = newState.deleteIn(["entities", itemName, id]);
newState = newState.deleteIn(["refCounts", itemName, id]);
}
});
});
return newState;
}
/**
* Initial state
*/
var initialState = new stateRecord();
/**
* Reducer
*/
export default createReducer(initialState, {
[API_REQUEST]: (state) => {
return (
state
.set("isFetching", true)
.set("error", null)
);
},
[API_FAILURE]: (state, payload) => {
return (
state
.set("isFetching", false)
.set("error", payload.error)
);
},
[PUSH_ENTITIES]: (state, payload) => {
let newState = state;
// Unset error and isFetching
newState = state.set("isFetching", false).set("error", payload.error);
// Merge entities
newState = newState.mergeIn(["entities"], payload.entities);
// Increment reference counter
payload.refCountType.forEach(function (itemName) {
newState.getIn(["entities", itemName]).forEach(function (entity, id) {
newState = updateEntityRefCount(newState, itemName, id, entity, 1);
});
});
return newState;
},
[INCREMENT_REFCOUNT]: (state, payload) => {
let newState = state;
// Increment reference counter
for (let itemName in payload.entities) {
newState.getIn(["entities", itemName]).forEach(function (entity, id) {
newState = updateEntityRefCount(newState, itemName, id, entity, 1);
});
}
return newState;
},
[DECREMENT_REFCOUNT]: (state, payload) => {
let newState = state;
// Decrement reference counter
for (let itemName in payload.entities) {
newState.getIn(["entities", itemName]).forEach(function (entity, id) {
newState = updateEntityRefCount(newState, itemName, id, entity, -1);
});
}
// Perform garbage collection
newState = garbageCollection(newState);
return newState;
},
[INVALIDATE_STORE]: () => {
return new stateRecord();
}
});

View File

@ -1,24 +1,32 @@
/**
*
*/
// NPM imports
import { routerReducer as routing } from "react-router-redux";
import { combineReducers } from "redux";
// Import all the available reducers
import auth from "./auth";
import paginate from "./paginate";
import entities from "./entities";
import paginatedMaker from "./paginated";
import webplayer from "./webplayer";
// Actions
import * as ActionTypes from "../actions";
// Updates the pagination data for different actions.
const api = paginate([
// Build paginated reducer
const paginated = paginatedMaker([
ActionTypes.API_REQUEST,
ActionTypes.API_SUCCESS,
ActionTypes.API_FAILURE
]);
const rootReducer = combineReducers({
// Export the combined reducers
export default combineReducers({
routing,
auth,
api,
entities,
paginated,
webplayer
});
export default rootReducer;

View File

@ -1,14 +1,29 @@
/**
* This implements a wrapper to create reducers for paginated content.
*/
// NPM imports
import Immutable from "immutable";
// Local imports
import { createReducer } from "../utils";
import { stateRecord } from "../models/paginate";
import { INVALIDATE_STORE } from "../actions";
// Models
import { stateRecord } from "../models/paginated";
// Actions
import { CLEAR_RESULTS, INVALIDATE_STORE } from "../actions";
/** Initial state of the reducer */
const initialState = new stateRecord();
// Creates a reducer managing pagination, given the action types to handle,
// and a function telling how to extract the key from an action.
export default function paginate(types) {
/**
* Creates a reducer managing pagination, given the action types to handle.
*/
export default function paginated(types) {
// Check parameters
if (!Array.isArray(types) || types.length !== 3) {
throw new Error("Expected types to be an array of three elements.");
}
@ -18,33 +33,28 @@ export default function paginate(types) {
const [ requestType, successType, failureType ] = types;
// Create reducer
return createReducer(initialState, {
[requestType]: (state) => {
return (
state
.set("isFetching", true)
.set("error", null)
);
return state;
},
[successType]: (state, payload) => {
return (
state
.set("isFetching", false)
.set("type", payload.type)
.set("result", Immutable.fromJS(payload.result))
.set("entities", Immutable.fromJS(payload.entities))
.set("error", null)
.set("nPages", payload.nPages)
.set("currentPage", payload.currentPage)
);
},
[failureType]: (state, payload) => {
return (
state
.set("isFetching", false)
.set("error", payload.error)
);
[failureType]: (state) => {
return state;
},
[CLEAR_RESULTS]: (state) => {
return state.set("result", new Immutable.List());
},
[INVALIDATE_STORE]: () => {
// Reset state on invalidation
return new stateRecord();
}
});

View File

@ -1,3 +1,4 @@
// TODO: This is a WIP
import Immutable from "immutable";
import {

View File

@ -1,3 +1,6 @@
/**
* Routes for the React app.
*/
import React from "react";
import { IndexRoute, Route } from "react-router";
@ -17,13 +20,13 @@ import ArtistPage from "./views/ArtistPage";
import AlbumPage from "./views/AlbumPage";
export default (
<Route path="/" component={App}>
<Route path="login" component={SimpleLayout}>
<Route path="/" component={App}> // Main container is App
<Route path="login" component={SimpleLayout}> // Login is a SimpleLayout
<IndexRoute component={LoginPage} />
</Route>
<Route component={SidebarLayout}>
<Route component={SidebarLayout}> // All the rest is a SidebarLayout
<Route path="logout" component={LogoutPage} />
<Route component={RequireAuthentication}>
<Route component={RequireAuthentication}> // And some pages require authentication
<Route path="discover" component={DiscoverPage} />
<Route path="browse" component={BrowsePage} />
<Route path="artists" component={ArtistsPage} />

View File

@ -7,6 +7,7 @@ import createLogger from "redux-logger";
import rootReducer from "../reducers";
import apiMiddleware from "../middleware/api";
// Use history and log everything during dev
const historyMiddleware = routerMiddleware(hashHistory);
const loggerMiddleware = createLogger();

View File

@ -1,3 +1,6 @@
/**
* Store configuration
*/
if (process.env.NODE_ENV === "production") {
module.exports = require("./configureStore.production.js");
} else {

View File

@ -6,6 +6,7 @@ import thunkMiddleware from "redux-thunk";
import rootReducer from "../reducers";
import apiMiddleware from "../middleware/api";
// Use history
const historyMiddleware = routerMiddleware(hashHistory);
export default function configureStore(preloadedState) {

View File

@ -1,18 +1,36 @@
/**
* Album component style.
*/
/** Variables */
$rowMarginTop: 30px;
$rowMarginBottom: 10px;
$artMarginBottom: 10px;
/* Style for an album row */
.row {
margin-top: $rowMarginTop;
}
/* Style for album arts */
.art {
composes: art from "./elements/Grid.scss";
display: inline-block;
margin-bottom: $artMarginBottom;
width: 75%;
height: auto;
/* doiuse-disable viewport-units */
max-width: 25vw;
/* doiuse-enable viewport-units */
}
.art:hover {
cursor: initial;
transform: scale(1.1);
}
/* Play button is based on the one in Songs list. */
.play {
composes: play from "./Songs.scss";
}

View File

@ -1,3 +1,10 @@
/**
* Styles for Artist component.
*/
/** Variables */
$artMarginBottom: 10px;
.name > h1 {
margin-bottom: 0;
}
@ -7,5 +14,18 @@
}
.art {
composes: art from "./elements/Grid.scss";
display: inline-block;
margin-bottom: $artMarginBottom;
width: 75%;
height: auto;
/* doiuse-disable viewport-units */
max-width: 25vw;
/* doiuse-enable viewport-units */
}
.art:hover {
transform: scale(1.1);
}

View File

@ -1,3 +1,9 @@
/**
* Style for Discover component.
*/
// TODO: Fix this style
.noMarginTop {
margin-top: 0;
}

View File

@ -1,3 +1,8 @@
/**
* Style for Login component.
*/
/** Variables */
$titleImage-size: $font-size-h1 + 10px;
.titleImage {

View File

@ -1,3 +1,6 @@
/**
* Style for Songs component.
*/
.play {
background-color: transparent;
border: none;

View File

@ -1,3 +1,8 @@
/**
* Styles for the FilterBar component.
*/
/** Variables */
$marginBottom: 34px;
.filter {

View File

@ -1,3 +1,8 @@
/**
* Style for the Grid component.
*/
/** Variables */
$marginBottom: 30px;
$artMarginBottom: 10px;

View File

@ -1,3 +1,6 @@
/**
* Styles for the Pagination component.
*/
.nav {
text-align: center;
}

View File

@ -1,3 +1,8 @@
/**
* Styles for the WebPlayer component.
*/
/** Variables */
$controlsMarginTop: 10px;
.webplayer {

View File

@ -1,3 +1,8 @@
/**
* Styles for Sidebar layout component.
*/
/** Variables */
$background: #333;
$hoverBackground: #222;
$activeBackground: $hoverBackground;

View File

@ -1,6 +1,10 @@
// Make variables and mixins available when using CSS modules.
/**
* Global variables used across all CSS modules.
*/
/* Make Bootstrap variables and mixins available when using CSS modules. */
@import "node_modules/bootstrap-sass/assets/stylesheets/bootstrap/_variables";
@import "node_modules/bootstrap-sass/assets/stylesheets/bootstrap/_mixins";
$blue: #3e90fa;
$orange: #faa83e;
$blue: #3e90fa; // Blue color from the logo
$orange: #faa83e; // Orange color from the logo

32
app/utils/ampache.js Normal file
View File

@ -0,0 +1,32 @@
/**
* Collection of helper function that are Ampache specific.
*/
// NPM imports
import jsSHA from "jssha";
/**
* Build an HMAC token for authentication against Ampache API.
*
* @param password User password to derive HMAC from.
* @return An object with the generated HMAC and time used.
*
* @remark This builds an HMAC as expected by Ampache API, which is not a
* standard HMAC.
*/
export function buildHMAC (password) {
const time = Math.floor(Date.now() / 1000);
let shaObj = new jsSHA("SHA-256", "TEXT");
shaObj.update(password);
const key = shaObj.getHash("HEX");
shaObj = new jsSHA("SHA-256", "TEXT");
shaObj.update(time + key);
return {
time: time,
passphrase: shaObj.getHash("HEX")
};
}

View File

@ -1,3 +0,0 @@
Array.prototype.diff = function (a) {
return this.filter(function (i) {return a.indexOf(i) < 0;});
};

View File

@ -1,3 +0,0 @@
export * from "./array";
export * from "./jquery";
export * from "./string";

View File

@ -1,3 +1,15 @@
/**
* Collection of helper function to act on Immutable objects.
*/
/**
* Diff two immutables objects supporting the filter method.
*
* @param a First Immutable object.
* @param b Second Immutable object.
* @returns An Immutable object equal to a except for the items in b.
*/
export function immutableDiff (a, b) {
return a.filter(function (i) {
return b.indexOf(i) < 0;

View File

@ -1,3 +1,7 @@
/**
* Collection of utility functions and helpers.
*/
export * from "./ampache";
export * from "./immutable";
export * from "./locale";
export * from "./misc";

View File

@ -1,10 +1,17 @@
/**
* Collection of helper functions to deal with localization.
*/
import { i18nRecord } from "../models/i18n";
/**
* Get the preferred locales from the browser, as an array sorted by preferences.
*/
export function getBrowserLocales () {
let langs;
let langs = [];
if (navigator.languages) {
// chrome does not currently set navigator.language correctly https://code.google.com/p/chromium/issues/detail?id=101138
// Chrome does not currently set navigator.language correctly
// https://code.google.com/p/chromium/issues/detail?id=101138
// but it does set the first element of navigator.languages correctly
langs = navigator.languages;
} else if (navigator.userLanguage) {
@ -24,6 +31,10 @@ export function getBrowserLocales () {
return locales;
}
/**
* Convert an array of messagesDescriptors to a map.
*/
export function messagesMap(messagesDescriptorsArray) {
let messagesDescriptorsMap = {};
@ -35,9 +46,25 @@ export function messagesMap(messagesDescriptorsArray) {
}
/**
* Format an error message from the state.
*
* Error message can be either an i18nRecord, which is to be formatted, or a
* raw string. This function performs the check and returns the correctly
* formatted string.
*
* @param errorMessage The error message from the state, either plain
* string or i18nRecord.
* @param formatMessage react-i18n formatMessage.
* @param messages List of messages to use for formatting.
*
* @return A string for the error.
*/
export function handleErrorI18nObject(errorMessage, formatMessage, messages) {
if (errorMessage instanceof i18nRecord) {
// If it is an object, format it and return it
return formatMessage(messages[errorMessage.id], errorMessage.values);
}
// Else, it's a string, just return it
return errorMessage;
}

View File

@ -1,7 +1,14 @@
/**
* Miscellaneous helper functions.
*/
/**
* Strict int checking function.
*
* @param value The value to check for int.
* @return Either NaN if the string was not a valid int representation, or the
* int.
*/
export function filterInt (value) {
if (/^(\-|\+)?([0-9]+|Infinity)$/.test(value)) {
@ -10,6 +17,7 @@ export function filterInt (value) {
return NaN;
}
/**
* Helper to format song length.
*

View File

@ -1,3 +1,18 @@
/**
* Collection of helper functions to deal with pagination.
*/
/**
* Helper function to build a pagination object to pass down the component tree.
*
* @param location react-router location props.
* @param currentPage Number of the current page.
* @param nPages Total number of pages.
* @param goToPageAction Action to dispatch to go to a specific page.
*
* @return An object containing all the props for the Pagination component.
*/
export function buildPaginationObject(location, currentPage, nPages, goToPageAction) {
const buildLinkToPage = function (pageNumber) {
return {
@ -12,3 +27,36 @@ export function buildPaginationObject(location, currentPage, nPages, goToPageAct
buildLinkToPage: buildLinkToPage
};
}
/**
* Helper function to compute the buttons to display.
*
* Taken from http://stackoverflow.com/a/8608998/2626416
*
* @param currentPage Number of the current page.
* @param nPages Total number of pages.
* @param maxNumberPagesShown Maximum number of pages button to show.
*
* @return An object containing lower limit and upper limit bounds.
*/
export function computePaginationBounds(currentPage, nPages, maxNumberPagesShown=5) {
let lowerLimit = currentPage;
let upperLimit = currentPage;
for (let b = 1; b < maxNumberPagesShown && b < nPages;) {
if (lowerLimit > 1 ) {
lowerLimit--;
b++;
}
if (b < maxNumberPagesShown && upperLimit < nPages) {
upperLimit++;
b++;
}
}
return {
lowerLimit: lowerLimit,
upperLimit: upperLimit + 1 // +1 to ease iteration in for with <
};
}

View File

@ -1,3 +1,16 @@
/**
* Collection of helper functions to deal with reducers.
*/
/**
* Utility function to create a reducer.
*
* @param initialState Initial state of the reducer.
* @param reducerMap Map between action types and reducing functions.
*
* @return A reducer.
*/
export function createReducer(initialState, reducerMap) {
return (state = initialState, action) => {
const reducer = reducerMap[action.type];

View File

@ -1,3 +1,16 @@
/**
* Collection of helper functions to deal with URLs.
*/
/**
* Assemble a base URL and its GET parameters.
*
* @param endpoint Base URL.
* @param params An object of GET params and their values.
*
* @return A string with the full URL with GET params.
*/
export function assembleURLAndParams (endpoint, params) {
let url = endpoint + "?";
Object.keys(params).forEach(
@ -11,3 +24,29 @@ export function assembleURLAndParams (endpoint, params) {
);
return url.rstrip("&");
}
/**
* Clean an endpoint URL.
*
* Adds the protocol prefix if not specified, remove trailing slash
*
* @param An URL
* @return The cleaned URL
*/
export function cleanURL (endpoint) {
if (
!endpoint.startsWith("//") &&
!endpoint.startsWith("http://") &&
!endpoint.startsWith("https://"))
{
// Handle endpoints of the form "ampache.example.com"
// Append same protocol as currently in use, to avoid mixed content.
endpoint = window.location.protocol + "//" + endpoint;
}
// Remove trailing slash
endpoint = endpoint.replace(/\/$/, "");
return endpoint;
}

View File

@ -1,39 +1,57 @@
// NPM imports
import React, { Component } from "react";
import { bindActionCreators } from "redux";
import { connect } from "react-redux";
import { defineMessages, injectIntl, intlShape } from "react-intl";
import Immutable from "immutable";
import * as actionCreators from "../actions";
// Local imports
import { buildPaginationObject, messagesMap, handleErrorI18nObject } from "../utils";
// Actions
import * as actionCreators from "../actions";
// Components
import Albums from "../components/Albums";
// Translations
import APIMessages from "../locales/messagesDescriptors/api";
// Define translations
const albumsMessages = defineMessages(messagesMap(Array.concat([], APIMessages)));
/**
* Albums page, grid layout of albums arts.
*/
class AlbumsPageIntl extends Component {
componentWillMount () {
// Load the data for current page
const currentPage = parseInt(this.props.location.query.page) || 1;
// Load the data
this.props.actions.loadAlbums({pageNumber: currentPage});
this.props.actions.loadPaginatedAlbums({ pageNumber: currentPage });
}
componentWillReceiveProps (nextProps) {
// Load the data if page has changed
const currentPage = parseInt(this.props.location.query.page) || 1;
const nextPage = parseInt(nextProps.location.query.page) || 1;
if (currentPage != nextPage) {
// Load the data
this.props.actions.loadAlbums({pageNumber: nextPage});
}
}
render () {
const pagination = buildPaginationObject(this.props.location, this.props.currentPage, this.props.nPages, this.props.actions.goToPageAction);
componentWillUnmount () {
// Unload data on page change
this.props.actions.clearResults();
}
render () {
const {formatMessage} = this.props.intl;
const pagination = buildPaginationObject(this.props.location, this.props.currentPage, this.props.nPages, this.props.actions.goToPage);
const error = handleErrorI18nObject(this.props.error, formatMessage, albumsMessages);
return (
<Albums isFetching={this.props.isFetching} error={error} albums={this.props.albumsList} pagination={pagination} />
);
@ -46,18 +64,17 @@ AlbumsPageIntl.propTypes = {
const mapStateToProps = (state) => {
let albumsList = new Immutable.List();
let albums = state.api.result.get("album");
if (albums) {
albumsList = albums.map(
id => state.api.entities.getIn(["album", id])
if (state.paginated.type == "album" && state.paginated.result.size > 0) {
albumsList = state.paginated.result.map(
id => state.entities.getIn(["entities", "album", id])
);
}
return {
isFetching: state.api.isFetching,
error: state.api.error,
isFetching: state.entities.isFetching,
error: state.entities.error,
albumsList: albumsList,
currentPage: state.api.currentPage,
nPages: state.api.nPages
currentPage: state.paginated.currentPage,
nPages: state.paginated.nPages
};
};

View File

@ -1,79 +1,91 @@
// NPM imports
import React, { Component } from "react";
import { bindActionCreators } from "redux";
import { connect } from "react-redux";
import { defineMessages, injectIntl, intlShape } from "react-intl";
import Immutable from "immutable";
import * as actionCreators from "../actions";
// Local imports
import { messagesMap, handleErrorI18nObject } from "../utils";
// Actions
import * as actionCreators from "../actions";
// Components
import Artist from "../components/Artist";
// Translations
import APIMessages from "../locales/messagesDescriptors/api";
// Define translations
const artistMessages = defineMessages(messagesMap(Array.concat([], APIMessages)));
/**
* Single artist page.
*/
class ArtistPageIntl extends Component {
componentWillMount () {
// Load the data
this.props.actions.loadArtists({
pageNumber: 1,
this.props.actions.loadArtist({
filter: this.props.params.id,
include: ["albums", "songs"]
});
}
componentWillUnmount () {
this.props.actions.decrementRefCount({
"artist": [this.props.artist.get("id")]
});
}
render () {
const {formatMessage} = this.props.intl;
const error = handleErrorI18nObject(this.props.error, formatMessage, artistMessages);
return (
<Artist playAction={this.props.actions.playTrack} isFetching={this.props.isFetching} error={error} artist={this.props.artist} albums={this.props.albums} songs={this.props.songs} />
);
}
}
ArtistPageIntl.propTypes = {
intl: intlShape.isRequired,
};
const mapStateToProps = (state, ownProps) => {
const artists = state.api.entities.get("artist");
let artist = new Immutable.Map();
let albums = new Immutable.Map();
// Get artist
let artist = state.entities.getIn(["entities", "artist", ownProps.params.id]);
let albums = new Immutable.List();
let songs = new Immutable.Map();
if (artists) {
// Get artist
artist = artists.find(
item => item.get("id") == ownProps.params.id
);
if (artist) {
// Get albums
const artistAlbums = artist.get("albums");
let artistAlbums = artist.get("albums");
if (Immutable.List.isList(artistAlbums)) {
albums = new Immutable.Map(
artistAlbums.map(
id => [id, state.api.entities.getIn(["album", id])]
)
albums = artistAlbums.map(
id => state.entities.getIn(["entities", "album", id])
);
}
// Get songs
const artistSongs = artist.get("songs");
let artistSongs = artist.get("songs");
if (Immutable.List.isList(artistSongs)) {
songs = new Immutable.Map(
artistSongs.map(
id => [id, state.api.entities.getIn(["track", id])]
)
songs = state.entities.getIn(["entities", "song"]).filter(
song => artistSongs.includes(song.get("id"))
);
}
} else {
artist = new Immutable.Map();
}
return {
isFetching: state.api.isFetching,
error: state.api.error,
isFetching: state.entities.isFetching,
error: state.entities.error,
artist: artist,
albums: albums,
songs: songs
};
};
ArtistPageIntl.propTypes = {
intl: intlShape.isRequired,
};
const mapDispatchToProps = (dispatch) => ({
actions: bindActionCreators(actionCreators, dispatch)
});

View File

@ -1,39 +1,57 @@
// NPM imports
import React, { Component } from "react";
import { bindActionCreators } from "redux";
import { connect } from "react-redux";
import { defineMessages, injectIntl, intlShape } from "react-intl";
import Immutable from "immutable";
import * as actionCreators from "../actions";
// Local imports
import { buildPaginationObject, messagesMap, handleErrorI18nObject } from "../utils";
// Actions
import * as actionCreators from "../actions";
// Components
import Artists from "../components/Artists";
// Translations
import APIMessages from "../locales/messagesDescriptors/api";
// Define translations
const artistsMessages = defineMessages(messagesMap(Array.concat([], APIMessages)));
/**
* Grid of artists arts.
*/
class ArtistsPageIntl extends Component {
componentWillMount () {
// Load the data for the current page
const currentPage = parseInt(this.props.location.query.page) || 1;
// Load the data
this.props.actions.loadArtists({pageNumber: currentPage});
this.props.actions.loadPaginatedArtists({pageNumber: currentPage});
}
componentWillReceiveProps (nextProps) {
// Load the data if page has changed
const currentPage = parseInt(this.props.location.query.page) || 1;
const nextPage = parseInt(nextProps.location.query.page) || 1;
if (currentPage != nextPage) {
// Load the data
this.props.actions.loadArtists({pageNumber: nextPage});
this.props.actions.loadPaginatedArtists({pageNumber: nextPage});
}
}
render () {
const pagination = buildPaginationObject(this.props.location, this.props.currentPage, this.props.nPages, this.props.actions.goToPageAction);
componentWillUnmount () {
// Unload data on page change
this.props.actions.clearResults();
}
render () {
const {formatMessage} = this.props.intl;
const pagination = buildPaginationObject(this.props.location, this.props.currentPage, this.props.nPages, this.props.actions.goToPage);
const error = handleErrorI18nObject(this.props.error, formatMessage, artistsMessages);
return (
<Artists isFetching={this.props.isFetching} error={error} artists={this.props.artistsList} pagination={pagination} />
);
@ -46,17 +64,17 @@ ArtistsPageIntl.propTypes = {
const mapStateToProps = (state) => {
let artistsList = new Immutable.List();
if (state.api.result.get("artist")) {
artistsList = state.api.result.get("artist").map(
id => state.api.entities.getIn(["artist", id])
if (state.paginated.type == "artist" && state.paginated.result.size > 0) {
artistsList = state.paginated.result.map(
id => state.entities.getIn(["entities", "artist", id])
);
}
return {
isFetching: state.api.isFetching,
error: state.api.error,
isFetching: state.entities.isFetching,
error: state.entities.error,
artistsList: artistsList,
currentPage: state.api.currentPage,
nPages: state.api.nPages,
currentPage: state.paginated.currentPage,
nPages: state.paginated.nPages,
};
};

View File

@ -1,7 +1,13 @@
// NPM imports
import React, { Component } from "react";
// Other views
import ArtistsPage from "./ArtistsPage";
/**
* Browse page is an alias for artists page at the moment.
*/
export default class BrowsePage extends Component {
render () {
return (

View File

@ -1,7 +1,12 @@
// NPM imports
import React, { Component } from "react";
// Components
import Discover from "../components/Discover";
/**
* Discover page
*/
export default class DiscoverPage extends Component {
render () {
return (

View File

@ -1,7 +1,12 @@
// NPM imports
import React, { Component } from "react";
// Other views
import ArtistsPage from "./ArtistsPage";
/**
* Homepage is an alias for Artists page at the moment.
*/
export default class HomePage extends Component {
render () {
return (

View File

@ -1,49 +1,76 @@
// NPM imports
import React, { Component, PropTypes } from "react";
import { bindActionCreators } from "redux";
import { connect } from "react-redux";
// Actions
import * as actionCreators from "../actions";
// Components
import Login from "../components/Login";
function _getRedirectTo(props) {
let redirectPathname = "/";
let redirectQuery = {};
const { location } = props;
if (location.state && location.state.nextPathname) {
redirectPathname = location.state.nextPathname;
}
if (location.state && location.state.nextQuery) {
redirectQuery = location.state.nextQuery;
}
return {
pathname: redirectPathname,
query: redirectQuery
};
}
/**
* Login page
*/
export class LoginPage extends Component {
componentWillMount () {
this.checkAuth(this.props);
}
checkAuth (propsIn) {
const redirectTo = _getRedirectTo(propsIn);
if (propsIn.isAuthenticated) {
this.context.router.replace(redirectTo);
} else if (propsIn.rememberMe) {
this.props.actions.loginUser(propsIn.username, propsIn.token, propsIn.endpoint, true, redirectTo, true);
}
}
constructor (props) {
super(props);
// Bind this
this.handleSubmit = this.handleSubmit.bind(this);
this._getRedirectTo = this._getRedirectTo.bind(this);
}
/**
* Get URL to redirect to based on location props.
*/
_getRedirectTo() {
let redirectPathname = "/";
let redirectQuery = {};
const { location } = this.props;
if (location.state && location.state.nextPathname) {
redirectPathname = location.state.nextPathname;
}
if (location.state && location.state.nextQuery) {
redirectQuery = location.state.nextQuery;
}
return {
pathname: redirectPathname,
query: redirectQuery
};
}
componentWillMount () {
// This checks if the user is already connected or not and redirects
// them if it is the case.
// Get next page to redirect to
const redirectTo = this._getRedirectTo();
if (this.props.isAuthenticated) {
// If user is already authenticated, redirects them
this.context.router.replace(redirectTo);
} else if (this.props.rememberMe) {
// Else if remember me is set, try to reconnect them
this.props.actions.loginUser(
this.props.username,
this.props.token,
this.props.endpoint,
true,
redirectTo,
true
);
}
}
/**
* Handle click on submit button.
*/
handleSubmit (username, password, endpoint, rememberMe) {
const redirectTo = _getRedirectTo(this.props);
// Get page to redirect to
const redirectTo = this._getRedirectTo();
// Trigger login action
this.props.actions.loginUser(username, password, endpoint, rememberMe, redirectTo);
}

View File

@ -1,11 +1,18 @@
// NPM imports
import React, { Component } from "react";
import { bindActionCreators } from "redux";
import { connect } from "react-redux";
// Actions
import * as actionCreators from "../actions";
/**
* Logout page
*/
export class LogoutPage extends Component {
componentDidMount () {
componentWillMount () {
// Logout when component is mounted
this.props.actions.logoutAndRedirect();
}

View File

@ -1,39 +1,57 @@
// NPM imports
import React, { Component } from "react";
import { bindActionCreators } from "redux";
import { connect } from "react-redux";
import { defineMessages, injectIntl, intlShape } from "react-intl";
import Immutable from "immutable";
import * as actionCreators from "../actions";
// Local imports
import { buildPaginationObject, messagesMap, handleErrorI18nObject } from "../utils";
// Actions
import * as actionCreators from "../actions";
// Components
import Songs from "../components/Songs";
// Translations
import APIMessages from "../locales/messagesDescriptors/api";
// Define translations
const songsMessages = defineMessages(messagesMap(Array.concat([], APIMessages)));
/**
* Paginated table of available songs
*/
class SongsPageIntl extends Component {
componentWillMount () {
// Load the data for current page
const currentPage = parseInt(this.props.location.query.page) || 1;
// Load the data
this.props.actions.loadSongs({pageNumber: currentPage});
this.props.actions.loadPaginatedSongs({pageNumber: currentPage});
}
componentWillReceiveProps (nextProps) {
// Load the data if page has changed
const currentPage = parseInt(this.props.location.query.page) || 1;
const nextPage = parseInt(nextProps.location.query.page) || 1;
if (currentPage != nextPage) {
// Load the data
this.props.actions.loadSongs({pageNumber: nextPage});
this.props.actions.loadPaginatedSongs({pageNumber: nextPage});
}
}
render () {
const pagination = buildPaginationObject(this.props.location, this.props.currentPage, this.props.nPages, this.props.actions.goToPageAction);
componentWillUnmount () {
// Unload data on page change
this.props.actions.clearResults();
}
render () {
const {formatMessage} = this.props.intl;
const pagination = buildPaginationObject(this.props.location, this.props.currentPage, this.props.nPages, this.props.actions.goToPage);
const error = handleErrorI18nObject(this.props.error, formatMessage, songsMessages);
return (
<Songs playAction={this.props.actions.playTrack} isFetching={this.props.isFetching} error={error} songs={this.props.songsList} pagination={pagination} />
);
@ -46,25 +64,25 @@ SongsPageIntl.propTypes = {
const mapStateToProps = (state) => {
let songsList = new Immutable.List();
if (state.api.result.get("song")) {
songsList = state.api.result.get("song").map(function (id) {
let song = state.api.entities.getIn(["track", id]);
// Add artist and album infos
const artist = state.api.entities.getIn(["artist", song.get("artist")]);
const album = state.api.entities.getIn(["album", song.get("album")]);
song = song.set("artist", new Immutable.Map({id: artist.get("id"), name: artist.get("name")}));
song = song.set("album", new Immutable.Map({id: album.get("id"), name: album.get("name")}));
return song;
if (state.paginated.type == "song" && state.paginated.result.size > 0) {
songsList = state.paginated.result.map(function (id) {
let song = state.entities.getIn(["entities", "song", id]);
// Add artist and album infos to song
const artist = state.entities.getIn(["entities", "artist", song.get("artist")]);
const album = state.entities.getIn(["entities", "album", song.get("album")]);
return (
song
.set("artist", new Immutable.Map({id: artist.get("id"), name: artist.get("name")}))
.set("album", new Immutable.Map({id: album.get("id"), name: album.get("name")}))
);
});
}
return {
isFetching: state.api.isFetching,
error: state.api.error,
artistsList: state.api.entities.get("artist"),
albumsList: state.api.entities.get("album"),
isFetching: state.entities.isFetching,
error: state.entities.error,
songsList: songsList,
currentPage: state.api.currentPage,
nPages: state.api.nPages
currentPage: state.paginated.currentPage,
nPages: state.paginated.nPages
};
};

View File

@ -1,3 +1,4 @@
// TODO: This file is not finished
import React, { Component } from "react";
import { bindActionCreators } from "redux";
import { connect } from "react-redux";
@ -101,8 +102,7 @@ const mapStateToProps = (state) => ({
isRepeat: state.webplayer.isRepeat,
isMute: state.webplayer.isMute,
currentIndex: state.webplayer.currentIndex,
playlist: state.webplayer.playlist,
entities: state.webplayer.entities
playlist: state.webplayer.playlist
});
const mapDispatchToProps = (dispatch) => ({

View File

@ -1 +1,4 @@
/**
* Special JS entry point to add IE9-dedicated fixes.
*/
export * from "html5shiv";

View File

@ -1,12 +1,15 @@
set -e
# Get against which ref to diff
if git rev-parse --verify HEAD >/dev/null 2>&1
then
against=HEAD
else
# Initial commit
# Something weird, initial commit
exit 1
fi
# List all the modified CSS and JS files (not in output path)
css_js_files=$(git diff-index --name-only $against | grep -e '.\(jsx\?\)\|\(s\?css\)$' | grep -v "^public")
# Nothing more to do if no JS files was committed
@ -15,8 +18,9 @@ then
exit 0
fi
# Else, rebuild as production, run tests and add files
echo "Rebuilding dist JavaScript files…"
npm test
npm run clean
npm run build:prod
npm test
git add public

View File

@ -1,55 +1,77 @@
// Handle app init
/**
* Main JS entry point for all the builds.
*
* Performs i18n and initial render.
*/
// React stuff
import React from "react";
import ReactDOM from "react-dom";
import { applyRouterMiddleware, hashHistory } from "react-router";
import { syncHistoryWithStore } from "react-router-redux";
import useScroll from "react-router-scroll";
// i18n
// Store
import configureStore from "./app/store/configureStore";
// i18n stuff
import { addLocaleData } from "react-intl";
import en from "react-intl/locale-data/en";
import fr from "react-intl/locale-data/fr";
import configureStore from "./app/store/configureStore";
import { getBrowserLocales } from "./app/utils";
import rawMessages from "./app/locales";
// Init store and history
const store = configureStore();
const history = syncHistoryWithStore(hashHistory, store);
// Get root element
export const rootElement = document.getElementById("root");
// i18n
export const onWindowIntl = () => {
/**
* Main function to be called once window.Intl has been populated.
*
* Populates the locales messages and perform render.
*/
export function onWindowIntl () {
// Add locales we support
addLocaleData([...en, ...fr]);
// Fetch current preferred locales from the browser
const locales = getBrowserLocales();
var locale = "en-US";
var locale = "en-US"; // Safe default
// Populate strings with best matching locale
var strings = {};
for (var i = 0; i < locales.length; ++i) {
if (rawMessages[locales[i]]) {
locale = locales[i];
strings = rawMessages[locale];
break;
break; // Break at first matching locale
}
}
// Overload strings with default English translation, in case of missing translations
strings = Object.assign(rawMessages["en-US"], strings);
// Set html lang attribute
// Dynamically set html lang attribute
document.documentElement.lang = locale;
let render = () => {
// Return a rendering function
return () => {
const Root = require("./app/containers/Root").default;
ReactDOM.render(
<Root store={store} history={history} render={applyRouterMiddleware(useScroll())} locale={locale} defaultLocale="en-US" messages={strings} />,
rootElement
);
};
return render;
};
export const Intl = (render) => {
/**
* Ensure window.Intl exists, or polyfill it.
*
* @param render Initial rendering function.
*/
export function Intl (render) {
if (!window.Intl) {
require.ensure([
"intl",

View File

@ -1,15 +1,21 @@
/**
* This is the main JS entry point in development build.
*/
import React from "react";
import ReactDOM from "react-dom";
// Load react-a11y for accessibility overview
var a11y = require("react-a11y");
a11y(React, { ReactDOM: ReactDOM, includeSrcNode: true });
// Load common index
const index = require("./index.all.js");
// Initial rendering function from common index
var render = index.onWindowIntl();
if (process.env.NODE_ENV !== "production" && module.hot) {
// Support hot reloading of components
// and display an overlay for runtime errors
if (module.hot) {
// If we support hot reloading of components,
// display an overlay for runtime errors
const renderApp = render;
const renderError = (error) => {
const RedBox = require("redbox-react").default;
@ -18,6 +24,8 @@ if (process.env.NODE_ENV !== "production" && module.hot) {
index.rootElement
);
};
// Try to render, and display an overlay for runtime errors
render = () => {
try {
renderApp();
@ -26,8 +34,11 @@ if (process.env.NODE_ENV !== "production" && module.hot) {
renderError(error);
}
};
module.hot.accept("./app/containers/Root", () => {
setTimeout(render);
});
}
// Perform i18n and render
index.Intl(render);

View File

@ -7,7 +7,7 @@
<meta name="description" content="">
<meta name="author" content="">
<link rel="icon" href="favicon.ico">
<link rel="icon" href="./favicon.ico">
<title>Ampache music player</title>
@ -20,8 +20,8 @@
<!-- IE10 viewport hack for Surface/desktop Windows 8 bug -->
<!--[if IE 10]>
<link href="./vendor/ie10-viewport-bug-workaround/ie10-viewport-bug-workaround.css" rel="stylesheet">
<script src="./vendor/ie10-viewport-bug-workaround/ie10-viewport-bug-workaround.js"></script>
<link href="./ie10-viewport-bug-workaround/ie10-viewport-bug-workaround.css" rel="stylesheet">
<script src="./ie10-viewport-bug-workaround/ie10-viewport-bug-workaround.js"></script>
<![endif]-->
</head>

View File

@ -1,3 +1,8 @@
/**
* This is the main JS entry point.
* It loads either the production or the development index file, based on the
* environment variables in use.
*/
if (process.env.NODE_ENV === "production") {
module.exports = require("./index.production.js");
} else {

View File

@ -1,3 +1,12 @@
/**
* This is the main JS entry point in production builds.
*/
// Load the common index
const index = require("./index.all.js");
// Get the rendering function
const render = index.onWindowIntl();
// Perform i18n and render
index.Intl(render);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Some files were not shown because too many files have changed in this diff Show More