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:
parent
247c71c9a7
commit
fffe9c4cd3
@ -1,4 +1,4 @@
|
|||||||
public/*
|
public/*
|
||||||
node_modules/*
|
node_modules/*
|
||||||
vendor/*
|
app/vendor/*
|
||||||
webpack.config.*
|
webpack.config.*
|
||||||
|
@ -25,7 +25,8 @@ module.exports = {
|
|||||||
"rules": {
|
"rules": {
|
||||||
"indent": [
|
"indent": [
|
||||||
"error",
|
"error",
|
||||||
4
|
4,
|
||||||
|
{ "SwitchCase": 1 }
|
||||||
],
|
],
|
||||||
"linebreak-style": [
|
"linebreak-style": [
|
||||||
"error",
|
"error",
|
||||||
|
@ -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.
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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
61
app/actions/entities.js
Normal 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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
@ -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";
|
||||||
|
@ -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
24
app/actions/paginated.js
Normal 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
14
app/actions/pagination.js
Normal 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));
|
||||||
|
};
|
||||||
|
}
|
@ -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 {
|
||||||
|
@ -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.
|
||||||
|
@ -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;
|
@ -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;
|
@ -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";
|
5
app/common/utils/index.js
Normal file
5
app/common/utils/index.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Prototype modifications, common utils loaded before the main script
|
||||||
|
*/
|
||||||
|
export * from "./jquery";
|
||||||
|
export * from "./string";
|
@ -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() {
|
@ -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 + "$");
|
@ -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
|
|
||||||
};
|
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
|
||||||
|
@ -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";
|
||||||
|
@ -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);
|
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
@ -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));
|
||||||
|
@ -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
|
||||||
|
@ -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)}>…</span>
|
<span tabIndex="0" role="button" onKeyDown={this.dotsOnKeyDown} onClick={this.dotsOnClick}>…</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)}>…</span>
|
<span tabIndex="0" role="button" onKeyDown={this.dotsOnKeyDown} onClick={this.dotsOnClick}>…</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));
|
||||||
|
@ -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";
|
||||||
|
@ -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">
|
|
||||||
<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));
|
||||||
|
@ -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 (
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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";
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -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")
|
||||||
|
@ -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",
|
||||||
|
@ -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.
|
||||||
|
@ -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
|
||||||
});
|
});
|
||||||
|
@ -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
22
app/models/entities.js
Normal 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 */
|
||||||
|
});
|
@ -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 */
|
||||||
});
|
});
|
||||||
|
@ -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
10
app/models/paginated.js
Normal 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 */
|
||||||
|
});
|
@ -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 */
|
||||||
});
|
});
|
||||||
|
@ -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
211
app/reducers/entities.js
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
@ -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;
|
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
@ -1,3 +1,4 @@
|
|||||||
|
// TODO: This is a WIP
|
||||||
import Immutable from "immutable";
|
import Immutable from "immutable";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -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} />
|
||||||
|
@ -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();
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -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) {
|
||||||
|
@ -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";
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Style for Discover component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// TODO: Fix this style
|
||||||
|
|
||||||
.noMarginTop {
|
.noMarginTop {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Style for Login component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Variables */
|
||||||
$titleImage-size: $font-size-h1 + 10px;
|
$titleImage-size: $font-size-h1 + 10px;
|
||||||
|
|
||||||
.titleImage {
|
.titleImage {
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Style for Songs component.
|
||||||
|
*/
|
||||||
.play {
|
.play {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
|
@ -1,3 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Styles for the FilterBar component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Variables */
|
||||||
$marginBottom: 34px;
|
$marginBottom: 34px;
|
||||||
|
|
||||||
.filter {
|
.filter {
|
||||||
|
@ -1,3 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Style for the Grid component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Variables */
|
||||||
$marginBottom: 30px;
|
$marginBottom: 30px;
|
||||||
$artMarginBottom: 10px;
|
$artMarginBottom: 10px;
|
||||||
|
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Styles for the Pagination component.
|
||||||
|
*/
|
||||||
.nav {
|
.nav {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Styles for the WebPlayer component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Variables */
|
||||||
$controlsMarginTop: 10px;
|
$controlsMarginTop: 10px;
|
||||||
|
|
||||||
.webplayer {
|
.webplayer {
|
||||||
|
@ -1,3 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Styles for Sidebar layout component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Variables */
|
||||||
$background: #333;
|
$background: #333;
|
||||||
$hoverBackground: #222;
|
$hoverBackground: #222;
|
||||||
$activeBackground: $hoverBackground;
|
$activeBackground: $hoverBackground;
|
||||||
|
@ -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
32
app/utils/ampache.js
Normal 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")
|
||||||
|
};
|
||||||
|
}
|
@ -1,3 +0,0 @@
|
|||||||
Array.prototype.diff = function (a) {
|
|
||||||
return this.filter(function (i) {return a.indexOf(i) < 0;});
|
|
||||||
};
|
|
@ -1,3 +0,0 @@
|
|||||||
export * from "./array";
|
|
||||||
export * from "./jquery";
|
|
||||||
export * from "./string";
|
|
@ -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;
|
||||||
|
@ -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";
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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.
|
||||||
*
|
*
|
||||||
|
@ -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 <
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -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];
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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)
|
||||||
});
|
});
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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 (
|
||||||
|
@ -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 (
|
||||||
|
@ -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 (
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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) => ({
|
||||||
|
@ -1 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* Special JS entry point to add IE9-dedicated fixes.
|
||||||
|
*/
|
||||||
export * from "html5shiv";
|
export * from "html5shiv";
|
||||||
|
@ -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
|
||||||
|
48
index.all.js
48
index.all.js
@ -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",
|
||||||
|
@ -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);
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
5
index.js
5
index.js
@ -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 {
|
||||||
|
@ -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
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
Loading…
Reference in New Issue
Block a user