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/* public/*
node_modules/* node_modules/*
vendor/* app/vendor/*
webpack.config.* webpack.config.*

View File

@ -25,7 +25,8 @@ module.exports = {
"rules": { "rules": {
"indent": [ "indent": [
"error", "error",
4 4,
{ "SwitchCase": 1 }
], ],
"linebreak-style": [ "linebreak-style": [
"error", "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 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 `npm run test` ensures a certain coding style. Try to keep the coding style
homogeneous. 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 { normalize, arrayOf } from "normalizr";
import humps from "humps"; import humps from "humps";
// Other actions
import { CALL_API } from "../middleware/api"; 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. */ 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) { export default function (action, requestType, successType, failureType) {
/** Get the name of the item associated with action */
const itemName = action.rstrip("s"); const itemName = action.rstrip("s");
const fetchItemsSuccess = function (jsonData, pageNumber) { /**
// Normalize data * Normalizr helper to normalize API response.
jsonData = normalize( *
* @param jsonData The JS object returned by the API.
* @return A normalized object.
*/
const _normalizeAPIResponse = function (jsonData) {
return normalize(
jsonData, jsonData,
{ {
artist: arrayOf(artist), artist: arrayOf(artist),
album: arrayOf(album), album: arrayOf(album),
song: arrayOf(track) song: arrayOf(song)
}, },
{ {
// Use custom assignEntity function to delete useless fields
assignEntity: function (output, key, value) { assignEntity: function (output, key, value) {
// Delete useless fields
if (key == "sessionExpire") { if (key == "sessionExpire") {
delete output.sessionExpire; delete output.sessionExpire;
} else { } 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 () { const fetchItemsRequest = function () {
// Return a request type action
return { return {
type: requestType, type: requestType,
payload: { payload: {
} }
}; };
}; };
/**
* Callback on failed fetch
*
* @param error An error object, either a string or an i18nError
* object.
*/
const fetchItemsFailure = function (error) { const fetchItemsFailure = function (error) {
// Return a failure type action
return { return {
type: failureType, type: failureType,
payload: { 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; const offset = (pageNumber - 1) * DEFAULT_LIMIT;
// Set extra params for pagination
let extraParams = { let extraParams = {
offset: offset, offset: offset,
limit: limit limit: limit
}; };
// Handle filter
if (filter) { if (filter) {
extraParams.filter = filter; extraParams.filter = filter;
} }
// Handle includes
if (include && include.length > 0) { if (include && include.length > 0) {
extraParams.include = include; extraParams.include = include;
} }
// Return a CALL_API action
return { return {
type: CALL_API, type: CALL_API,
payload: { payload: {
endpoint: endpoint, endpoint: endpoint,
dispatch: [ dispatch: [
fetchItemsRequest, fetchItemsRequest,
jsonData => dispatch => { null,
dispatch(fetchItemsSuccess(jsonData, pageNumber));
},
fetchItemsFailure fetchItemsFailure
], ],
action: action, 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) => { return (dispatch, getState) => {
// Get credentials from the state
const { auth } = getState(); 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 = {}; var returned = {};
returned["fetch" + camelizedAction + "Success"] = fetchItemsSuccess; const camelizedAction = humps.pascalize(action);
returned["fetch" + camelizedAction + "Request"] = fetchItemsRequest; returned["loadPaginated" + camelizedAction] = loadPaginatedItems;
returned["fetch" + camelizedAction + "Failure"] = fetchItemsFailure; returned["load" + camelizedAction.rstrip("s")] = loadItem;
returned["fetch" + camelizedAction] = fetchItems;
returned["load" + camelizedAction] = loadItems;
return returned; return returned;
} }

View File

@ -1,45 +1,36 @@
/**
* This file implements authentication related actions.
*/
// NPM imports
import { push } from "react-router-redux"; import { push } from "react-router-redux";
import jsSHA from "jssha";
import Cookies from "js-cookie"; 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 { CALL_API } from "../middleware/api";
import { invalidateStore } from "./store"; 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) { export function loginKeepAlive(username, token, endpoint) {
return { return {
type: CALL_API, type: CALL_API,
@ -60,7 +51,19 @@ export function loginKeepAlive(username, token, endpoint) {
}; };
} }
export const LOGIN_USER_SUCCESS = "LOGIN_USER_SUCCESS"; 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) { export function loginUserSuccess(username, token, endpoint, rememberMe, timerID) {
return { return {
type: LOGIN_USER_SUCCESS, type: LOGIN_USER_SUCCESS,
@ -74,7 +77,16 @@ export function loginUserSuccess(username, token, endpoint, rememberMe, timerID)
}; };
} }
export const LOGIN_USER_FAILURE = "LOGIN_USER_FAILURE"; 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) { export function loginUserFailure(error) {
Cookies.remove("username"); Cookies.remove("username");
Cookies.remove("token"); Cookies.remove("token");
@ -87,7 +99,14 @@ export function loginUserFailure(error) {
}; };
} }
export const LOGIN_USER_EXPIRED = "LOGIN_USER_EXPIRED"; 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) { export function loginUserExpired(error) {
return { return {
type: LOGIN_USER_EXPIRED, type: LOGIN_USER_EXPIRED,
@ -97,14 +116,32 @@ export function loginUserExpired(error) {
}; };
} }
export const LOGIN_USER_REQUEST = "LOGIN_USER_REQUEST"; export const LOGIN_USER_REQUEST = "LOGIN_USER_REQUEST";
/**
* Action to be called when login is requested.
*
* @return A login request payload.
*/
export function loginUserRequest() { export function loginUserRequest() {
return { return {
type: LOGIN_USER_REQUEST type: LOGIN_USER_REQUEST
}; };
} }
export const LOGOUT_USER = "LOGOUT_USER"; 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() { export function logout() {
return (dispatch, state) => { return (dispatch, state) => {
const { auth } = 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() { export function logoutAndRedirect() {
return (dispatch) => { return (dispatch) => {
dispatch(logout()); 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) { 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 time = 0;
let passphrase = passwordOrToken; let passphrase = passwordOrToken;
if (!isToken) { if (!isToken) {
// Standard password connection // Standard password connection
const HMAC = _buildHMAC(passwordOrToken); const HMAC = buildHMAC(passwordOrToken);
time = HMAC.time; time = HMAC.time;
passphrase = HMAC.passphrase; passphrase = HMAC.passphrase;
} else { } else {
@ -147,6 +208,7 @@ export function loginUser(username, passwordOrToken, endpoint, rememberMe, redir
time = Math.floor(Date.now() / 1000); time = Math.floor(Date.now() / 1000);
passphrase = passwordOrToken.token; passphrase = passwordOrToken.token;
} }
return { return {
type: CALL_API, type: CALL_API,
payload: { payload: {
@ -155,23 +217,27 @@ export function loginUser(username, passwordOrToken, endpoint, rememberMe, redir
loginUserRequest, loginUserRequest,
jsonData => dispatch => { jsonData => dispatch => {
if (!jsonData.auth || !jsonData.sessionExpire) { if (!jsonData.auth || !jsonData.sessionExpire) {
// On success, check that we are actually authenticated
return dispatch(loginUserFailure(new i18nRecord({ id: "app.api.error", values: {} }))); return dispatch(loginUserFailure(new i18nRecord({ id: "app.api.error", values: {} })));
} }
// Get token from the API
const token = { const token = {
token: jsonData.auth, token: jsonData.auth,
expires: new Date(jsonData.sessionExpire) expires: new Date(jsonData.sessionExpire)
}; };
// Dispatch success // Handle session keep alive timer
const timerID = setInterval( const timerID = setInterval(
() => dispatch(loginKeepAlive(username, token.token, endpoint)), () => dispatch(loginKeepAlive(username, token.token, endpoint)),
DEFAULT_SESSION_INTERVAL DEFAULT_SESSION_INTERVAL
); );
if (rememberMe) { if (rememberMe) {
// Handle remember me option
const cookiesOption = { expires: token.expires }; const cookiesOption = { expires: token.expires };
Cookies.set("username", username, cookiesOption); Cookies.set("username", username, cookiesOption);
Cookies.set("token", token, cookiesOption); Cookies.set("token", token, cookiesOption);
Cookies.set("endpoint", endpoint, cookiesOption); Cookies.set("endpoint", endpoint, cookiesOption);
} }
// Dispatch login success
dispatch(loginUserSuccess(username, token, endpoint, rememberMe, timerID)); dispatch(loginUserSuccess(username, token, endpoint, rememberMe, timerID));
// Redirect // Redirect
dispatch(push(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"; export * from "./auth";
// API related actions for all the available types
import APIAction from "./APIActions"; import APIAction from "./APIActions";
// Actions related to API
export const API_SUCCESS = "API_SUCCESS"; export const API_SUCCESS = "API_SUCCESS";
export const API_REQUEST = "API_REQUEST"; export const API_REQUEST = "API_REQUEST";
export const API_FAILURE = "API_FAILURE"; export const API_FAILURE = "API_FAILURE";
export var { loadArtists } = APIAction("artists", API_REQUEST, API_SUCCESS, API_FAILURE); export var {
export var { loadAlbums } = APIAction("albums", API_REQUEST, API_SUCCESS, API_FAILURE); loadPaginatedArtists, loadArtist } = APIAction("artists", API_REQUEST, API_SUCCESS, API_FAILURE);
export var { loadSongs } = APIAction("songs", 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"; export * from "./store";
// Webplayer actions
export * from "./webplayer"; 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 const INVALIDATE_STORE = "INVALIDATE_STORE";
export function invalidateStore() { export function invalidateStore() {
return { return {

View File

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

View File

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

View File

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

View File

@ -1,2 +1,5 @@
/**
* Common styles modifications and hacks.
*/
export * from "./hacks.scss"; export * from "./hacks.scss";
export * from "./common.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. * Shake animation.
* *
* @param intShakes Number of times to shake. * @param intShakes Number of times to shake.
* @param intDistance Distance to move the object. * @param intDistance Distance to move the object.
* @param intDuration Duration of the animation. * @param intDuration Duration of the animation.
*
* @return The element it was applied one, for chaining.
*/ */
$.fn.shake = function(intShakes, intDistance, intDuration) { $.fn.shake = function(intShakes, intDistance, intDuration) {
this.each(function() { 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 () { String.prototype.capitalize = function () {
return this.charAt(0).toUpperCase() + this.slice(1); return this.charAt(0).toUpperCase() + this.slice(1);
}; };
/** /**
* Strip characters at the end of a string. * Strip characters at the end of a string.
* *
* @param chars A regex-like element to strip from the end. * @param chars A regex-like element to strip from the end.
* @return Stripped string.
*/ */
String.prototype.rstrip = function (chars) { String.prototype.rstrip = function (chars) {
let regex = new RegExp(chars + "$"); let regex = new RegExp(chars + "$");

View File

@ -1,17 +1,26 @@
// NPM import
import React, { Component, PropTypes } from "react"; import React, { Component, PropTypes } from "react";
import CSSModules from "react-css-modules"; import CSSModules from "react-css-modules";
import { defineMessages, FormattedMessage, injectIntl, intlShape } from "react-intl"; import { defineMessages, FormattedMessage, injectIntl, intlShape } from "react-intl";
import FontAwesome from "react-fontawesome"; import FontAwesome from "react-fontawesome";
import Immutable from "immutable"; import Immutable from "immutable";
// Local imports
import { formatLength, messagesMap } from "../utils"; import { formatLength, messagesMap } from "../utils";
// Translations
import commonMessages from "../locales/messagesDescriptors/common"; import commonMessages from "../locales/messagesDescriptors/common";
// Styles
import css from "../styles/Album.scss"; import css from "../styles/Album.scss";
// Set translations
const albumMessages = defineMessages(messagesMap(Array.concat([], commonMessages))); const albumMessages = defineMessages(messagesMap(Array.concat([], commonMessages)));
/**
* Track row in an album tracks table.
*/
class AlbumTrackRowCSSIntl extends Component { class AlbumTrackRowCSSIntl extends Component {
render () { render () {
const { formatMessage } = this.props.intl; const { formatMessage } = this.props.intl;
@ -33,19 +42,21 @@ class AlbumTrackRowCSSIntl extends Component {
); );
} }
} }
AlbumTrackRowCSSIntl.propTypes = { AlbumTrackRowCSSIntl.propTypes = {
playAction: PropTypes.func.isRequired, playAction: PropTypes.func.isRequired,
track: PropTypes.instanceOf(Immutable.Map).isRequired, track: PropTypes.instanceOf(Immutable.Map).isRequired,
intl: intlShape.isRequired intl: intlShape.isRequired
}; };
export let AlbumTrackRow = injectIntl(CSSModules(AlbumTrackRowCSSIntl, css)); export let AlbumTrackRow = injectIntl(CSSModules(AlbumTrackRowCSSIntl, css));
/**
* Tracks table of an album.
*/
class AlbumTracksTableCSS extends Component { class AlbumTracksTableCSS extends Component {
render () { render () {
let rows = []; let rows = [];
// Build rows for each track
const playAction = this.props.playAction; const playAction = this.props.playAction;
this.props.tracks.forEach(function (item) { this.props.tracks.forEach(function (item) {
rows.push(<AlbumTrackRow playAction={playAction} track={item} key={item.get("id")} />); rows.push(<AlbumTrackRow playAction={playAction} track={item} key={item.get("id")} />);
@ -59,14 +70,16 @@ class AlbumTracksTableCSS extends Component {
); );
} }
} }
AlbumTracksTableCSS.propTypes = { AlbumTracksTableCSS.propTypes = {
playAction: PropTypes.func.isRequired, playAction: PropTypes.func.isRequired,
tracks: PropTypes.instanceOf(Immutable.List).isRequired tracks: PropTypes.instanceOf(Immutable.List).isRequired
}; };
export let AlbumTracksTable = CSSModules(AlbumTracksTableCSS, css); export let AlbumTracksTable = CSSModules(AlbumTracksTableCSS, css);
/**
* An entire album row containing art and tracks table.
*/
class AlbumRowCSS extends Component { class AlbumRowCSS extends Component {
render () { render () {
return ( return (
@ -88,24 +101,9 @@ class AlbumRowCSS extends Component {
); );
} }
} }
AlbumRowCSS.propTypes = { AlbumRowCSS.propTypes = {
playAction: PropTypes.func.isRequired, playAction: PropTypes.func.isRequired,
album: PropTypes.instanceOf(Immutable.Map).isRequired, album: PropTypes.instanceOf(Immutable.Map).isRequired,
songs: PropTypes.instanceOf(Immutable.List).isRequired songs: PropTypes.instanceOf(Immutable.List).isRequired
}; };
export let AlbumRow = CSSModules(AlbumRowCSS, css); 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 React, { Component, PropTypes } from "react";
import Immutable from "immutable"; import Immutable from "immutable";
// Local imports
import FilterablePaginatedGrid from "./elements/Grid"; import FilterablePaginatedGrid from "./elements/Grid";
import DismissibleAlert from "./elements/DismissibleAlert"; import DismissibleAlert from "./elements/DismissibleAlert";
/**
* Paginated albums grid
*/
export default class Albums extends Component { export default class Albums extends Component {
render () { render () {
// Handle error
let error = null; let error = null;
if (this.props.error) { if (this.props.error) {
error = (<DismissibleAlert type="danger" text={this.props.error} />); error = (<DismissibleAlert type="danger" text={this.props.error} />);
} }
// Set grid props
const grid = { const grid = {
isFetching: this.props.isFetching, isFetching: this.props.isFetching,
items: this.props.albums, items: this.props.albums,
@ -19,6 +27,7 @@ export default class Albums extends Component {
subItemsType: "tracks", subItemsType: "tracks",
subItemsLabel: "app.common.track" subItemsLabel: "app.common.track"
}; };
return ( return (
<div> <div>
{ error } { error }
@ -27,10 +36,9 @@ export default class Albums extends Component {
); );
} }
} }
Albums.propTypes = { Albums.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.string, error: PropTypes.string,
isFetching: PropTypes.bool.isRequired,
albums: PropTypes.instanceOf(Immutable.List).isRequired, albums: PropTypes.instanceOf(Immutable.List).isRequired,
pagination: PropTypes.object.isRequired, pagination: PropTypes.object.isRequired,
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
// NPM imports
import React, { Component, PropTypes } from "react"; import React, { Component, PropTypes } from "react";
import { Link} from "react-router"; import { Link} from "react-router";
import CSSModules from "react-css-modules"; import CSSModules from "react-css-modules";
@ -9,56 +10,24 @@ import Isotope from "isotope-layout";
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import shallowCompare from "react-addons-shallow-compare"; import shallowCompare from "react-addons-shallow-compare";
import FilterBar from "./FilterBar"; // Local imports
import Pagination from "./Pagination";
import { immutableDiff, messagesMap } from "../../utils/"; import { immutableDiff, messagesMap } from "../../utils/";
// Other components
import FilterBar from "./FilterBar";
import Pagination from "./Pagination";
// Translations
import commonMessages from "../../locales/messagesDescriptors/common"; import commonMessages from "../../locales/messagesDescriptors/common";
import messages from "../../locales/messagesDescriptors/grid"; import messages from "../../locales/messagesDescriptors/grid";
// Styles
import css from "../../styles/elements/Grid.scss"; import css from "../../styles/elements/Grid.scss";
// Define translations
const gridMessages = defineMessages(messagesMap(Array.concat([], commonMessages, messages))); const gridMessages = defineMessages(messagesMap(Array.concat([], commonMessages, messages)));
class GridItemCSSIntl extends Component { // Constants
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));
const ISOTOPE_OPTIONS = { /** Default options for Isotope grid layout. */ const ISOTOPE_OPTIONS = { /** Default options for Isotope grid layout. */
getSortData: { getSortData: {
name: ".name", 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 { export class Grid extends Component {
constructor (props) { constructor (props) {
super(props); super(props);
@ -82,20 +100,29 @@ export class Grid extends Component {
// Init grid data member // Init grid data member
this.iso = null; this.iso = null;
// Bind this
this.createIsotopeContainer = this.createIsotopeContainer.bind(this);
this.handleFiltering = this.handleFiltering.bind(this); this.handleFiltering = this.handleFiltering.bind(this);
} }
/**
* Create an isotope container if none already exist.
*/
createIsotopeContainer () { createIsotopeContainer () {
if (this.iso == null) { if (this.iso == null) {
this.iso = new Isotope(this.refs.grid, ISOTOPE_OPTIONS); this.iso = new Isotope(this.refs.grid, ISOTOPE_OPTIONS);
} }
} }
/**
* Handle filtering on the grid.
*/
handleFiltering (props) { handleFiltering (props) {
// If no query provided, drop any filter in use // If no query provided, drop any filter in use
if (props.filterText == "") { if (props.filterText == "") {
return this.iso.arrange(ISOTOPE_OPTIONS); return this.iso.arrange(ISOTOPE_OPTIONS);
} }
// Use Fuse for the filter // Use Fuse for the filter
let result = new Fuse( let result = new Fuse(
props.items.toJS(), props.items.toJS(),
@ -103,7 +130,8 @@ export class Grid extends Component {
"keys": ["name"], "keys": ["name"],
"threshold": 0.4, "threshold": 0.4,
"include": ["score"] "include": ["score"]
}).search(props.filterText); }
).search(props.filterText);
// Apply filter on grid // Apply filter on grid
this.iso.arrange({ this.iso.arrange({
@ -130,10 +158,12 @@ export class Grid extends Component {
} }
shouldComponentUpdate(nextProps, nextState) { shouldComponentUpdate(nextProps, nextState) {
// Shallow comparison, render is pure
return shallowCompare(this, nextProps, nextState); return shallowCompare(this, nextProps, nextState);
} }
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
// Handle filtering if filterText is changed
if (nextProps.filterText !== this.props.filterText) { if (nextProps.filterText !== this.props.filterText) {
this.handleFiltering(nextProps); this.handleFiltering(nextProps);
} }
@ -143,8 +173,7 @@ export class Grid extends Component {
// Setup grid // Setup grid
this.createIsotopeContainer(); this.createIsotopeContainer();
// Only arrange if there are elements to arrange // Only arrange if there are elements to arrange
const length = this.props.items.length || 0; if (this.props.items.size > 0) {
if (length > 0) {
this.iso.arrange(); this.iso.arrange();
} }
} }
@ -152,25 +181,31 @@ export class Grid extends Component {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
// The list of keys seen in the previous render // The list of keys seen in the previous render
let currentKeys = prevProps.items.map( 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 // The latest list of keys that have been rendered
const {itemsType} = this.props; const {itemsType} = this.props;
let newKeys = this.props.items.map( 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); 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 removeKeys = immutableDiff(currentKeys, newKeys);
let iso = this.iso; let iso = this.iso;
if (removeKeys.count() > 0) { // Remove removed items
if (removeKeys.size > 0) {
removeKeys.forEach(removeKey => iso.remove(document.getElementById(removeKey))); removeKeys.forEach(removeKey => iso.remove(document.getElementById(removeKey)));
iso.arrange(); iso.arrange();
} }
if (addKeys.count() > 0) { // Add new items
if (addKeys.size > 0) {
const itemsToAdd = addKeys.map((addKey) => document.getElementById(addKey)).toArray(); const itemsToAdd = addKeys.map((addKey) => document.getElementById(addKey)).toArray();
iso.addItems(itemsToAdd); iso.addItems(itemsToAdd);
iso.arrange(); iso.arrange();
@ -187,13 +222,9 @@ export class Grid extends Component {
} }
render () { render () {
let gridItems = []; // Handle loading
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")} />);
});
let loading = null; let loading = null;
if (gridItems.length == 0 && this.props.isFetching) { if (this.props.isFetching) {
loading = ( loading = (
<div className="row text-center"> <div className="row text-center">
<p> <p>
@ -203,9 +234,16 @@ export class Grid extends Component {
</div> </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 ( return (
<div> <div>
{ loading }
<div className="row"> <div className="row">
<div className="grid" ref="grid"> <div className="grid" ref="grid">
{/* Sizing element */} {/* Sizing element */}
@ -214,11 +252,11 @@ export class Grid extends Component {
{ gridItems } { gridItems }
</div> </div>
</div> </div>
{ loading }
</div> </div>
); );
} }
} }
Grid.propTypes = { Grid.propTypes = {
isFetching: PropTypes.bool.isRequired, isFetching: PropTypes.bool.isRequired,
items: PropTypes.instanceOf(Immutable.List).isRequired, items: PropTypes.instanceOf(Immutable.List).isRequired,
@ -229,16 +267,29 @@ Grid.propTypes = {
filterText: PropTypes.string filterText: PropTypes.string
}; };
/**
* Full grid with pagination and filtering input.
*/
export default class FilterablePaginatedGrid extends Component { export default class FilterablePaginatedGrid extends Component {
constructor (props) { constructor (props) {
super(props); super(props);
this.state = { this.state = {
filterText: "" filterText: "" // No filterText at init
}; };
// Bind this
this.handleUserInput = this.handleUserInput.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) { handleUserInput (filterText) {
this.setState({ this.setState({
filterText: filterText filterText: filterText

View File

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

View File

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

View File

@ -1,5 +1,10 @@
// NPM imports
import React, { Component } from "react"; import React, { Component } from "react";
/**
* Simple layout, meaning just enclosing children in a div.
*/
export default class SimpleLayout extends Component { export default class SimpleLayout extends Component {
render () { render () {
return ( 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"; import React, { Component, PropTypes } from "react";
export default class App extends Component { export default class App extends Component {
render () { render () {
return ( return (
<div> <div>
{this.props.children && React.cloneElement(this.props.children, { {this.props.children}
error: this.props.error
})}
</div> </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 React, { Component, PropTypes } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
// TODO: Handle expired session
export class RequireAuthentication extends Component { export class RequireAuthentication extends Component {
componentWillMount () { componentWillMount () {
// Check authentication on mount
this.checkAuth(this.props.isAuthenticated); this.checkAuth(this.props.isAuthenticated);
} }
componentWillUpdate (newProps) { componentWillUpdate (newProps) {
// Check authentication on update
this.checkAuth(newProps.isAuthenticated); 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) { checkAuth (isAuthenticated) {
if (!isAuthenticated) { if (!isAuthenticated) {
// Redirect to login, redirecting to the actual page after login.
this.context.router.replace({ this.context.router.replace({
pathname: "/login", pathname: "/login",
state: { state: {
@ -26,10 +39,10 @@ export class RequireAuthentication extends Component {
render () { render () {
return ( return (
<div> <div>
{this.props.isAuthenticated === true {this.props.isAuthenticated === true
? this.props.children ? this.props.children
: null : null
} }
</div> </div>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -44,11 +44,6 @@ const messages = [
description: "Browse songs", description: "Browse songs",
defaultMessage: "Browse songs" defaultMessage: "Browse songs"
}, },
{
id: "app.sidebarLayout.search",
description: "Search",
defaultMessage: "Search"
},
{ {
id: "app.sidebarLayout.toggleNavigation", id: "app.sidebarLayout.toggleNavigation",
description: "Screen reader description of toggle navigation button", 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 fetch from "isomorphic-fetch";
import humps from "humps"; import humps from "humps";
import X2JS from "x2js"; import X2JS from "x2js";
@ -10,9 +16,19 @@ import { loginUserExpired } from "../actions/auth";
export const API_VERSION = 350001; /** API version to use. */ export const API_VERSION = 350001; /** API version to use. */
export const BASE_API_PATH = "/server/xml.server.php"; /** Base API path after endpoint. */ 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. // Error class to represents errors from these actions.
class APIError extends Error {} 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) { function _checkHTTPStatus (response) {
if (response.status >= 200 && response.status < 300) { if (response.status >= 200 && response.status < 300) {
return response; 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) { function _parseToJSON (responseText) {
let x2js = new X2JS({ let x2js = new X2JS({
attributePrefix: "", attributePrefix: "", // No prefix for attributes
keepCData: false keepCData: false // Do not store __cdata and toString functions
}); });
if (responseText) { if (responseText) {
return x2js.xml_str2json(responseText).root; 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) { function _checkAPIErrors (jsonData) {
if (jsonData.error) { if (jsonData.error) {
return Promise.reject(jsonData.error); return Promise.reject(jsonData.error);
@ -48,7 +78,15 @@ function _checkAPIErrors (jsonData) {
return 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) { function _uglyFixes (jsonData) {
// Fix songs array
let _uglyFixesSongs = function (songs) { let _uglyFixesSongs = function (songs) {
return songs.map(function (song) { return songs.map(function (song) {
// Fix for cdata left in artist and album // Fix for cdata left in artist and album
@ -58,9 +96,10 @@ function _uglyFixes (jsonData) {
}); });
}; };
// Fix albums array
let _uglyFixesAlbums = function (albums) { let _uglyFixesAlbums = function (albums) {
return albums.map(function (album) { return albums.map(function (album) {
// TODO // TODO: Should go in Ampache core
// Fix for absence of distinction between disks in the same album // Fix for absence of distinction between disks in the same album
if (album.disk > 1) { if (album.disk > 1) {
album.name = album.name + " [Disk " + album.disk + "]"; album.name = album.name + " [Disk " + album.disk + "]";
@ -75,13 +114,14 @@ function _uglyFixes (jsonData) {
album.tracks = [album.tracks]; album.tracks = [album.tracks];
} }
// Fix tracks // Fix tracks array
album.tracks = _uglyFixesSongs(album.tracks); album.tracks = _uglyFixesSongs(album.tracks);
} }
return album; return album;
}); });
}; };
// Fix artists array
let _uglyFixesArtists = function (artists) { let _uglyFixesArtists = function (artists) {
return artists.map(function (artist) { return artists.map(function (artist) {
// Move albums one node top // Move albums one node top
@ -131,17 +171,15 @@ function _uglyFixes (jsonData) {
// Fix albums // Fix albums
if (jsonData.album) { if (jsonData.album) {
// Fix albums
jsonData.album = _uglyFixesAlbums(jsonData.album); jsonData.album = _uglyFixesAlbums(jsonData.album);
} }
// Fix songs // Fix songs
if (jsonData.song) { if (jsonData.song) {
// Fix songs
jsonData.song = _uglyFixesSongs(jsonData.song); jsonData.song = _uglyFixesSongs(jsonData.song);
} }
// TODO // TODO: Should go in Ampache core
// Add sessionExpire information // Add sessionExpire information
if (!jsonData.sessionExpire) { if (!jsonData.sessionExpire) {
// Fix for Ampache not returning updated sessionExpire // Fix for Ampache not returning updated sessionExpire
@ -151,17 +189,31 @@ function _uglyFixes (jsonData) {
return 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) { function doAPICall (endpoint, action, auth, username, extraParams) {
// Translate the API action to real API action
const APIAction = extraParams.filter ? action.rstrip("s") : action; const APIAction = extraParams.filter ? action.rstrip("s") : action;
// Set base params
const baseParams = { const baseParams = {
version: API_VERSION, version: API_VERSION,
action: APIAction, action: APIAction,
auth: auth, auth: auth,
user: username user: username
}; };
// Extend with extraParams
const params = Object.assign({}, baseParams, 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); const fullURL = assembleURLAndParams(endpoint + BASE_API_PATH, params);
return fetch(fullURL, { return fetch(fullURL, {
@ -175,19 +227,19 @@ function doAPICall (endpoint, action, auth, username, extraParams) {
.then(_uglyFixes); .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 => { export default store => next => reduxAction => {
if (reduxAction.type !== CALL_API) { if (reduxAction.type !== CALL_API) {
// Do not apply on every action // Do not apply on other actions
return next(reduxAction); return next(reduxAction);
} }
// Check payload
const { endpoint, action, auth, username, dispatch, extraParams } = reduxAction.payload; const { endpoint, action, auth, username, dispatch, extraParams } = reduxAction.payload;
if (!endpoint || typeof endpoint !== "string") { if (!endpoint || typeof endpoint !== "string") {
throw new APIError("Specify a string endpoint URL."); 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."); throw new APIError("Expected action to dispatch to be functions or null.");
} }
// Get the actions to dispatch
const [ requestDispatch, successDispatch, failureDispatch ] = dispatch; const [ requestDispatch, successDispatch, failureDispatch ] = dispatch;
if (requestDispatch) { if (requestDispatch) {
// Dispatch request action if needed
store.dispatch(requestDispatch()); store.dispatch(requestDispatch());
} }
// Run the API call
return doAPICall(endpoint, action, auth, username, extraParams).then( return doAPICall(endpoint, action, auth, username, extraParams).then(
response => { response => {
if (successDispatch) { if (successDispatch) {
// Dispatch success if needed
store.dispatch(successDispatch(response)); store.dispatch(successDispatch(response));
} }
}, },
error => { error => {
if (failureDispatch) { if (failureDispatch) {
const errorMessage = error.__cdata + " (" + error._code + ")"; // Error object from the API (in the JS object)
// Error object from the API
if (error._code && error.__cdata) { if (error._code && error.__cdata) {
// Format the error message
const errorMessage = error.__cdata + " (" + error._code + ")";
if (401 == error._code) { if (401 == error._code) {
// This is an error meaning no valid session was // This is an error meaning no valid session was
// passed. We must perform a new handshake. // 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"; 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), albums: arrayOf(album),
songs: arrayOf(track) songs: arrayOf(song)
}); });
album.define({ album.define({ // Album has artist, tracks and tags
artist: artist, artist: artist,
tracks: arrayOf(track), tracks: arrayOf(song)
tag: arrayOf(tag)
}); });
track.define({ song.define({ // Track has artist and album
artist: artist, artist: artist,
album: album album: album
}); });

View File

@ -1,18 +1,27 @@
/**
* This file defines authentication related models.
*/
// NPM imports
import Immutable from "immutable"; import Immutable from "immutable";
/** Record to store token parameters */
export const tokenRecord = Immutable.Record({ export const tokenRecord = Immutable.Record({
token: null, token: null, /** Token string */
expires: null expires: null /** Token expiration date */
}); });
/** Record to store the full auth state */
export const stateRecord = new Immutable.Record({ export const stateRecord = new Immutable.Record({
token: tokenRecord, token: new tokenRecord(), /** Auth token */
username: null, username: null, /** Username */
endpoint: null, endpoint: null, /** Ampache server base URL */
rememberMe: false, rememberMe: false, /** Whether to remember me or not */
isAuthenticated: false, isAuthenticated: false, /** Whether authentication is ok or not */
isAuthenticating: false, isAuthenticating: false, /** Whether authentication is in progress or not */
error: null, error: null, /** An error string */
info: null, info: null, /** An info string */
timerID: null 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"; import Immutable from "immutable";
/** i18n record for passing errors to be localized from actions to components */
export const i18nRecord = new Immutable.Record({ export const i18nRecord = new Immutable.Record({
id: null, id: null, /** Translation message id */
values: new Immutable.Map() 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"; 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({ export const stateRecord = new Immutable.Record({
isPlaying: false, isPlaying: false, /** Whether webplayer is playing */
isRandom: false, isRandom: false, /** Whether random mode is on */
isRepeat: false, isRepeat: false, /** Whether repeat mode is on */
isMute: false, isMute: false, /** Whether sound is muted or not */
currentIndex: 0, volume: 100, /** Current volume, between 0 and 100 */
playlist: new Immutable.List(), currentIndex: 0, /** Current index in the playlist */
entities: new entitiesRecord() 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 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"; import { createReducer } from "../utils";
// Models
import { i18nRecord } from "../models/i18n"; import { i18nRecord } from "../models/i18n";
import { tokenRecord, stateRecord } from "../models/auth"; import { tokenRecord, stateRecord } from "../models/auth";
/** // Actions
* Initial state 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(); var initialState = new stateRecord();
// Get token
const initialToken = Cookies.getJSON("token"); const initialToken = Cookies.getJSON("token");
if (initialToken) { if (initialToken) {
initialToken.expires = new Date(initialToken.expires); initialToken.expires = new Date(initialToken.expires);
@ -18,6 +34,7 @@ if (initialToken) {
new tokenRecord({ token: initialToken.token, expires: new Date(initialToken.expires) }) new tokenRecord({ token: initialToken.token, expires: new Date(initialToken.expires) })
); );
} }
// Get username
const initialUsername = Cookies.get("username"); const initialUsername = Cookies.get("username");
if (initialUsername) { if (initialUsername) {
initialState = initialState.set( initialState = initialState.set(
@ -25,6 +42,7 @@ if (initialUsername) {
initialUsername initialUsername
); );
} }
// Get endpoint
const initialEndpoint = Cookies.get("endpoint"); const initialEndpoint = Cookies.get("endpoint");
if (initialEndpoint) { if (initialEndpoint) {
initialState = initialState.set( initialState = initialState.set(
@ -32,6 +50,7 @@ if (initialEndpoint) {
initialEndpoint initialEndpoint
); );
} }
// Set remember me
if (initialUsername && initialEndpoint) { if (initialUsername && initialEndpoint) {
initialState = initialState.set( initialState = initialState.set(
"rememberMe", "rememberMe",
@ -39,10 +58,10 @@ if (initialUsername && initialEndpoint) {
); );
} }
/** /**
* Reducers * Reducers
*/ */
export default createReducer(initialState, { export default createReducer(initialState, {
[LOGIN_USER_REQUEST]: () => { [LOGIN_USER_REQUEST]: () => {
return new stateRecord({ 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 { routerReducer as routing } from "react-router-redux";
import { combineReducers } from "redux"; import { combineReducers } from "redux";
// Import all the available reducers
import auth from "./auth"; import auth from "./auth";
import paginate from "./paginate"; import entities from "./entities";
import paginatedMaker from "./paginated";
import webplayer from "./webplayer"; import webplayer from "./webplayer";
// Actions
import * as ActionTypes from "../actions"; import * as ActionTypes from "../actions";
// Updates the pagination data for different actions. // Build paginated reducer
const api = paginate([ const paginated = paginatedMaker([
ActionTypes.API_REQUEST, ActionTypes.API_REQUEST,
ActionTypes.API_SUCCESS, ActionTypes.API_SUCCESS,
ActionTypes.API_FAILURE ActionTypes.API_FAILURE
]); ]);
const rootReducer = combineReducers({ // Export the combined reducers
export default combineReducers({
routing, routing,
auth, auth,
api, entities,
paginated,
webplayer 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"; import Immutable from "immutable";
// Local imports
import { createReducer } from "../utils"; 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(); 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) { if (!Array.isArray(types) || types.length !== 3) {
throw new Error("Expected types to be an array of three elements."); 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; const [ requestType, successType, failureType ] = types;
// Create reducer
return createReducer(initialState, { return createReducer(initialState, {
[requestType]: (state) => { [requestType]: (state) => {
return ( return state;
state
.set("isFetching", true)
.set("error", null)
);
}, },
[successType]: (state, payload) => { [successType]: (state, payload) => {
return ( return (
state state
.set("isFetching", false) .set("type", payload.type)
.set("result", Immutable.fromJS(payload.result)) .set("result", Immutable.fromJS(payload.result))
.set("entities", Immutable.fromJS(payload.entities))
.set("error", null)
.set("nPages", payload.nPages) .set("nPages", payload.nPages)
.set("currentPage", payload.currentPage) .set("currentPage", payload.currentPage)
); );
}, },
[failureType]: (state, payload) => { [failureType]: (state) => {
return ( return state;
state },
.set("isFetching", false) [CLEAR_RESULTS]: (state) => {
.set("error", payload.error) return state.set("result", new Immutable.List());
);
}, },
[INVALIDATE_STORE]: () => { [INVALIDATE_STORE]: () => {
// Reset state on invalidation
return new stateRecord(); return new stateRecord();
} }
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,18 +1,36 @@
/**
* Album component style.
*/
/** Variables */
$rowMarginTop: 30px; $rowMarginTop: 30px;
$rowMarginBottom: 10px; $rowMarginBottom: 10px;
$artMarginBottom: 10px;
/* Style for an album row */
.row { .row {
margin-top: $rowMarginTop; margin-top: $rowMarginTop;
} }
/* Style for album arts */
.art { .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 { .art:hover {
cursor: initial; transform: scale(1.1);
} }
/* Play button is based on the one in Songs list. */
.play { .play {
composes: play from "./Songs.scss"; composes: play from "./Songs.scss";
} }

View File

@ -1,3 +1,10 @@
/**
* Styles for Artist component.
*/
/** Variables */
$artMarginBottom: 10px;
.name > h1 { .name > h1 {
margin-bottom: 0; margin-bottom: 0;
} }
@ -7,5 +14,18 @@
} }
.art { .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 { .noMarginTop {
margin-top: 0; margin-top: 0;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,8 @@
/**
* Styles for Sidebar layout component.
*/
/** Variables */
$background: #333; $background: #333;
$hoverBackground: #222; $hoverBackground: #222;
$activeBackground: $hoverBackground; $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/_variables";
@import "node_modules/bootstrap-sass/assets/stylesheets/bootstrap/_mixins"; @import "node_modules/bootstrap-sass/assets/stylesheets/bootstrap/_mixins";
$blue: #3e90fa; $blue: #3e90fa; // Blue color from the logo
$orange: #faa83e; $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) { export function immutableDiff (a, b) {
return a.filter(function (i) { return a.filter(function (i) {
return b.indexOf(i) < 0; 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 "./immutable";
export * from "./locale"; export * from "./locale";
export * from "./misc"; export * from "./misc";

View File

@ -1,10 +1,17 @@
/**
* Collection of helper functions to deal with localization.
*/
import { i18nRecord } from "../models/i18n"; import { i18nRecord } from "../models/i18n";
/**
* Get the preferred locales from the browser, as an array sorted by preferences.
*/
export function getBrowserLocales () { export function getBrowserLocales () {
let langs; let langs = [];
if (navigator.languages) { 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 // but it does set the first element of navigator.languages correctly
langs = navigator.languages; langs = navigator.languages;
} else if (navigator.userLanguage) { } else if (navigator.userLanguage) {
@ -24,6 +31,10 @@ export function getBrowserLocales () {
return locales; return locales;
} }
/**
* Convert an array of messagesDescriptors to a map.
*/
export function messagesMap(messagesDescriptorsArray) { export function messagesMap(messagesDescriptorsArray) {
let messagesDescriptorsMap = {}; 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) { export function handleErrorI18nObject(errorMessage, formatMessage, messages) {
if (errorMessage instanceof i18nRecord) { if (errorMessage instanceof i18nRecord) {
// If it is an object, format it and return it
return formatMessage(messages[errorMessage.id], errorMessage.values); return formatMessage(messages[errorMessage.id], errorMessage.values);
} }
// Else, it's a string, just return it
return errorMessage; return errorMessage;
} }

View File

@ -1,7 +1,14 @@
/**
* Miscellaneous helper functions.
*/
/** /**
* Strict int checking function. * Strict int checking function.
* *
* @param value The value to check for int. * @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) { export function filterInt (value) {
if (/^(\-|\+)?([0-9]+|Infinity)$/.test(value)) { if (/^(\-|\+)?([0-9]+|Infinity)$/.test(value)) {
@ -10,6 +17,7 @@ export function filterInt (value) {
return NaN; return NaN;
} }
/** /**
* Helper to format song length. * 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) { export function buildPaginationObject(location, currentPage, nPages, goToPageAction) {
const buildLinkToPage = function (pageNumber) { const buildLinkToPage = function (pageNumber) {
return { return {
@ -12,3 +27,36 @@ export function buildPaginationObject(location, currentPage, nPages, goToPageAct
buildLinkToPage: buildLinkToPage 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) { export function createReducer(initialState, reducerMap) {
return (state = initialState, action) => { return (state = initialState, action) => {
const reducer = reducerMap[action.type]; 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) { export function assembleURLAndParams (endpoint, params) {
let url = endpoint + "?"; let url = endpoint + "?";
Object.keys(params).forEach( Object.keys(params).forEach(
@ -11,3 +24,29 @@ export function assembleURLAndParams (endpoint, params) {
); );
return url.rstrip("&"); 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 React, { Component } from "react";
import { bindActionCreators } from "redux"; import { bindActionCreators } from "redux";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { defineMessages, injectIntl, intlShape } from "react-intl"; import { defineMessages, injectIntl, intlShape } from "react-intl";
import Immutable from "immutable"; import Immutable from "immutable";
import * as actionCreators from "../actions"; // Local imports
import { buildPaginationObject, messagesMap, handleErrorI18nObject } from "../utils"; import { buildPaginationObject, messagesMap, handleErrorI18nObject } from "../utils";
// Actions
import * as actionCreators from "../actions";
// Components
import Albums from "../components/Albums"; import Albums from "../components/Albums";
// Translations
import APIMessages from "../locales/messagesDescriptors/api"; import APIMessages from "../locales/messagesDescriptors/api";
// Define translations
const albumsMessages = defineMessages(messagesMap(Array.concat([], APIMessages))); const albumsMessages = defineMessages(messagesMap(Array.concat([], APIMessages)));
/**
* Albums page, grid layout of albums arts.
*/
class AlbumsPageIntl extends Component { class AlbumsPageIntl extends Component {
componentWillMount () { componentWillMount () {
// Load the data for current page
const currentPage = parseInt(this.props.location.query.page) || 1; const currentPage = parseInt(this.props.location.query.page) || 1;
// Load the data this.props.actions.loadPaginatedAlbums({ pageNumber: currentPage });
this.props.actions.loadAlbums({pageNumber: currentPage});
} }
componentWillReceiveProps (nextProps) { componentWillReceiveProps (nextProps) {
// Load the data if page has changed
const currentPage = parseInt(this.props.location.query.page) || 1; const currentPage = parseInt(this.props.location.query.page) || 1;
const nextPage = parseInt(nextProps.location.query.page) || 1; const nextPage = parseInt(nextProps.location.query.page) || 1;
if (currentPage != nextPage) { if (currentPage != nextPage) {
// Load the data
this.props.actions.loadAlbums({pageNumber: nextPage}); this.props.actions.loadAlbums({pageNumber: nextPage});
} }
} }
render () { componentWillUnmount () {
const pagination = buildPaginationObject(this.props.location, this.props.currentPage, this.props.nPages, this.props.actions.goToPageAction); // Unload data on page change
this.props.actions.clearResults();
}
render () {
const {formatMessage} = this.props.intl; 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); const error = handleErrorI18nObject(this.props.error, formatMessage, albumsMessages);
return ( return (
<Albums isFetching={this.props.isFetching} error={error} albums={this.props.albumsList} pagination={pagination} /> <Albums isFetching={this.props.isFetching} error={error} albums={this.props.albumsList} pagination={pagination} />
); );
@ -46,18 +64,17 @@ AlbumsPageIntl.propTypes = {
const mapStateToProps = (state) => { const mapStateToProps = (state) => {
let albumsList = new Immutable.List(); let albumsList = new Immutable.List();
let albums = state.api.result.get("album"); if (state.paginated.type == "album" && state.paginated.result.size > 0) {
if (albums) { albumsList = state.paginated.result.map(
albumsList = albums.map( id => state.entities.getIn(["entities", "album", id])
id => state.api.entities.getIn(["album", id])
); );
} }
return { return {
isFetching: state.api.isFetching, isFetching: state.entities.isFetching,
error: state.api.error, error: state.entities.error,
albumsList: albumsList, albumsList: albumsList,
currentPage: state.api.currentPage, currentPage: state.paginated.currentPage,
nPages: state.api.nPages nPages: state.paginated.nPages
}; };
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,49 +1,76 @@
// NPM imports
import React, { Component, PropTypes } from "react"; import React, { Component, PropTypes } from "react";
import { bindActionCreators } from "redux"; import { bindActionCreators } from "redux";
import { connect } from "react-redux"; import { connect } from "react-redux";
// Actions
import * as actionCreators from "../actions"; import * as actionCreators from "../actions";
// Components
import Login from "../components/Login"; 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 { 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) { constructor (props) {
super(props); super(props);
// Bind this
this.handleSubmit = this.handleSubmit.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) { 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); this.props.actions.loginUser(username, password, endpoint, rememberMe, redirectTo);
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,15 @@
set -e
# Get against which ref to diff # Get against which ref to diff
if git rev-parse --verify HEAD >/dev/null 2>&1 if git rev-parse --verify HEAD >/dev/null 2>&1
then then
against=HEAD against=HEAD
else else
# Initial commit # Something weird, initial commit
exit 1 exit 1
fi 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") 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 # Nothing more to do if no JS files was committed
@ -15,8 +18,9 @@ then
exit 0 exit 0
fi fi
# Else, rebuild as production, run tests and add files
echo "Rebuilding dist JavaScript files…" echo "Rebuilding dist JavaScript files…"
npm test
npm run clean npm run clean
npm run build:prod npm run build:prod
npm test
git add public 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 React from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { applyRouterMiddleware, hashHistory } from "react-router"; import { applyRouterMiddleware, hashHistory } from "react-router";
import { syncHistoryWithStore } from "react-router-redux"; import { syncHistoryWithStore } from "react-router-redux";
import useScroll from "react-router-scroll"; import useScroll from "react-router-scroll";
// i18n // Store
import configureStore from "./app/store/configureStore";
// i18n stuff
import { addLocaleData } from "react-intl"; import { addLocaleData } from "react-intl";
import en from "react-intl/locale-data/en"; import en from "react-intl/locale-data/en";
import fr from "react-intl/locale-data/fr"; import fr from "react-intl/locale-data/fr";
import configureStore from "./app/store/configureStore";
import { getBrowserLocales } from "./app/utils"; import { getBrowserLocales } from "./app/utils";
import rawMessages from "./app/locales"; import rawMessages from "./app/locales";
// Init store and history
const store = configureStore(); const store = configureStore();
const history = syncHistoryWithStore(hashHistory, store); const history = syncHistoryWithStore(hashHistory, store);
// Get root element
export const rootElement = document.getElementById("root"); 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]); addLocaleData([...en, ...fr]);
// Fetch current preferred locales from the browser
const locales = getBrowserLocales(); const locales = getBrowserLocales();
var locale = "en-US"; var locale = "en-US"; // Safe default
// Populate strings with best matching locale
var strings = {}; var strings = {};
for (var i = 0; i < locales.length; ++i) { for (var i = 0; i < locales.length; ++i) {
if (rawMessages[locales[i]]) { if (rawMessages[locales[i]]) {
locale = locales[i]; locale = locales[i];
strings = rawMessages[locale]; 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); strings = Object.assign(rawMessages["en-US"], strings);
// Set html lang attribute
// Dynamically set html lang attribute
document.documentElement.lang = locale; document.documentElement.lang = locale;
let render = () => { // Return a rendering function
return () => {
const Root = require("./app/containers/Root").default; const Root = require("./app/containers/Root").default;
ReactDOM.render( ReactDOM.render(
<Root store={store} history={history} render={applyRouterMiddleware(useScroll())} locale={locale} defaultLocale="en-US" messages={strings} />, <Root store={store} history={history} render={applyRouterMiddleware(useScroll())} locale={locale} defaultLocale="en-US" messages={strings} />,
rootElement 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) { if (!window.Intl) {
require.ensure([ require.ensure([
"intl", "intl",

View File

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

View File

@ -7,7 +7,7 @@
<meta name="description" content=""> <meta name="description" content="">
<meta name="author" content=""> <meta name="author" content="">
<link rel="icon" href="favicon.ico"> <link rel="icon" href="./favicon.ico">
<title>Ampache music player</title> <title>Ampache music player</title>
@ -20,8 +20,8 @@
<!-- IE10 viewport hack for Surface/desktop Windows 8 bug --> <!-- IE10 viewport hack for Surface/desktop Windows 8 bug -->
<!--[if IE 10]> <!--[if IE 10]>
<link href="./vendor/ie10-viewport-bug-workaround/ie10-viewport-bug-workaround.css" rel="stylesheet"> <link href="./ie10-viewport-bug-workaround/ie10-viewport-bug-workaround.css" rel="stylesheet">
<script src="./vendor/ie10-viewport-bug-workaround/ie10-viewport-bug-workaround.js"></script> <script src="./ie10-viewport-bug-workaround/ie10-viewport-bug-workaround.js"></script>
<![endif]--> <![endif]-->
</head> </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") { if (process.env.NODE_ENV === "production") {
module.exports = require("./index.production.js"); module.exports = require("./index.production.js");
} else { } 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"); const index = require("./index.all.js");
// Get the rendering function
const render = index.onWindowIntl(); const render = index.onWindowIntl();
// Perform i18n and render
index.Intl(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