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/*
|
||||
node_modules/*
|
||||
vendor/*
|
||||
app/vendor/*
|
||||
webpack.config.*
|
||||
|
@ -25,7 +25,8 @@ module.exports = {
|
||||
"rules": {
|
||||
"indent": [
|
||||
"error",
|
||||
4
|
||||
4,
|
||||
{ "SwitchCase": 1 }
|
||||
],
|
||||
"linebreak-style": [
|
||||
"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
|
||||
`npm run test` ensures a certain coding style. Try to keep the coding style
|
||||
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 humps from "humps";
|
||||
|
||||
// Other actions
|
||||
import { CALL_API } from "../middleware/api";
|
||||
import { pushEntities } from "./entities";
|
||||
|
||||
import { artist, track, album } from "../models/api";
|
||||
// Models
|
||||
import { artist, song, album } from "../models/api";
|
||||
|
||||
// Constants
|
||||
export const DEFAULT_LIMIT = 32; /** Default max number of elements to retrieve. */
|
||||
|
||||
|
||||
/**
|
||||
* This function wraps around an API action to generate actions trigger
|
||||
* functions to load items etc.
|
||||
*
|
||||
* @param action API action.
|
||||
* @param requestType Action type to trigger on request.
|
||||
* @param successType Action type to trigger on success.
|
||||
* @param failureType Action type to trigger on failure.
|
||||
*/
|
||||
export default function (action, requestType, successType, failureType) {
|
||||
/** Get the name of the item associated with action */
|
||||
const itemName = action.rstrip("s");
|
||||
|
||||
const fetchItemsSuccess = function (jsonData, pageNumber) {
|
||||
// Normalize data
|
||||
jsonData = normalize(
|
||||
/**
|
||||
* Normalizr helper to normalize API response.
|
||||
*
|
||||
* @param jsonData The JS object returned by the API.
|
||||
* @return A normalized object.
|
||||
*/
|
||||
const _normalizeAPIResponse = function (jsonData) {
|
||||
return normalize(
|
||||
jsonData,
|
||||
{
|
||||
artist: arrayOf(artist),
|
||||
album: arrayOf(album),
|
||||
song: arrayOf(track)
|
||||
song: arrayOf(song)
|
||||
},
|
||||
{
|
||||
// Use custom assignEntity function to delete useless fields
|
||||
assignEntity: function (output, key, value) {
|
||||
// Delete useless fields
|
||||
if (key == "sessionExpire") {
|
||||
delete output.sessionExpire;
|
||||
} else {
|
||||
@ -30,26 +55,67 @@ export default function (action, requestType, successType, failureType) {
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const nPages = Math.ceil(jsonData.result[itemName].length / DEFAULT_LIMIT);
|
||||
return {
|
||||
type: successType,
|
||||
payload: {
|
||||
result: jsonData.result,
|
||||
entities: jsonData.entities,
|
||||
nPages: nPages,
|
||||
currentPage: pageNumber
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback on successful fetch of paginated items
|
||||
*
|
||||
* @param jsonData JS object returned from the API.
|
||||
* @param pageNumber Number of the page that was fetched.
|
||||
*/
|
||||
const fetchPaginatedItemsSuccess = function (jsonData, pageNumber, limit) {
|
||||
jsonData = _normalizeAPIResponse(jsonData);
|
||||
|
||||
// Compute the total number of pages
|
||||
const nPages = Math.ceil(jsonData.result[itemName].length / limit);
|
||||
|
||||
// Return success actions
|
||||
return [
|
||||
// Action for the global entities store
|
||||
pushEntities(jsonData.entities, [itemName]),
|
||||
// Action for the paginated store
|
||||
{
|
||||
type: successType,
|
||||
payload: {
|
||||
type: itemName,
|
||||
result: jsonData.result[itemName],
|
||||
nPages: nPages,
|
||||
currentPage: pageNumber
|
||||
}
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback on successful fetch of single item
|
||||
*
|
||||
* @param jsonData JS object returned from the API.
|
||||
* @param pageNumber Number of the page that was fetched.
|
||||
*/
|
||||
const fetchItemSuccess = function (jsonData) {
|
||||
jsonData = _normalizeAPIResponse(jsonData);
|
||||
|
||||
return pushEntities(jsonData.entities, [itemName]);
|
||||
};
|
||||
|
||||
/** Callback on request */
|
||||
const fetchItemsRequest = function () {
|
||||
// Return a request type action
|
||||
return {
|
||||
type: requestType,
|
||||
payload: {
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback on failed fetch
|
||||
*
|
||||
* @param error An error object, either a string or an i18nError
|
||||
* object.
|
||||
*/
|
||||
const fetchItemsFailure = function (error) {
|
||||
// Return a failure type action
|
||||
return {
|
||||
type: failureType,
|
||||
payload: {
|
||||
@ -57,27 +123,48 @@ export default function (action, requestType, successType, failureType) {
|
||||
}
|
||||
};
|
||||
};
|
||||
const fetchItems = function (endpoint, username, passphrase, filter, pageNumber, include = [], limit=DEFAULT_LIMIT) {
|
||||
|
||||
/**
|
||||
* Method to trigger a fetch of items.
|
||||
*
|
||||
* @param endpoint Ampache server base URL.
|
||||
* @param username Username to use for API request.
|
||||
* @param filter An eventual filter to apply (mapped to API filter
|
||||
* param)
|
||||
* @param pageNumber Number of the page to fetch items from.
|
||||
* @param limit Max number of items to fetch.
|
||||
* @param include [Optional] A list of includes to return as well
|
||||
* (mapped to API include param)
|
||||
*
|
||||
* @return A CALL_API action to fetch the specified items.
|
||||
*/
|
||||
const fetchItems = function (endpoint, username, passphrase, filter, pageNumber, limit, include = []) {
|
||||
// Compute offset in number of items from the page number
|
||||
const offset = (pageNumber - 1) * DEFAULT_LIMIT;
|
||||
// Set extra params for pagination
|
||||
let extraParams = {
|
||||
offset: offset,
|
||||
limit: limit
|
||||
};
|
||||
|
||||
// Handle filter
|
||||
if (filter) {
|
||||
extraParams.filter = filter;
|
||||
}
|
||||
|
||||
// Handle includes
|
||||
if (include && include.length > 0) {
|
||||
extraParams.include = include;
|
||||
}
|
||||
|
||||
// Return a CALL_API action
|
||||
return {
|
||||
type: CALL_API,
|
||||
payload: {
|
||||
endpoint: endpoint,
|
||||
dispatch: [
|
||||
fetchItemsRequest,
|
||||
jsonData => dispatch => {
|
||||
dispatch(fetchItemsSuccess(jsonData, pageNumber));
|
||||
},
|
||||
null,
|
||||
fetchItemsFailure
|
||||
],
|
||||
action: action,
|
||||
@ -87,19 +174,83 @@ export default function (action, requestType, successType, failureType) {
|
||||
}
|
||||
};
|
||||
};
|
||||
const loadItems = function({ pageNumber = 1, filter = null, include = [] } = {}) {
|
||||
|
||||
/**
|
||||
* High level method to load paginated items from the API wihtout dealing about credentials.
|
||||
*
|
||||
* @param pageNumber [Optional] Number of the page to fetch items from.
|
||||
* @param filter [Optional] An eventual filter to apply (mapped to
|
||||
* API filter param)
|
||||
* @param include [Optional] A list of includes to return as well
|
||||
* (mapped to API include param)
|
||||
*
|
||||
* Dispatches the CALL_API action to fetch these items.
|
||||
*/
|
||||
const loadPaginatedItems = function({ pageNumber = 1, limit = DEFAULT_LIMIT, filter = null, include = [] } = {}) {
|
||||
return (dispatch, getState) => {
|
||||
// Get credentials from the state
|
||||
const { auth } = getState();
|
||||
dispatch(fetchItems(auth.endpoint, auth.username, auth.token.token, filter, pageNumber, include));
|
||||
// Get the fetch action to dispatch
|
||||
const fetchAction = fetchItems(
|
||||
auth.endpoint,
|
||||
auth.username,
|
||||
auth.token.token,
|
||||
filter,
|
||||
pageNumber,
|
||||
limit,
|
||||
include
|
||||
);
|
||||
// Set success callback
|
||||
fetchAction.payload.dispatch[1] = (
|
||||
jsonData => dispatch => {
|
||||
// Dispatch all the necessary actions
|
||||
const actions = fetchPaginatedItemsSuccess(jsonData, pageNumber, limit);
|
||||
actions.map(action => dispatch(action));
|
||||
}
|
||||
);
|
||||
// Dispatch action
|
||||
dispatch(fetchAction);
|
||||
};
|
||||
};
|
||||
|
||||
const camelizedAction = humps.pascalize(action);
|
||||
/**
|
||||
* High level method to load a single item from the API wihtout dealing about credentials.
|
||||
*
|
||||
* @param filter The filter to apply (mapped to API filter param)
|
||||
* @param include [Optional] A list of includes to return as well
|
||||
* (mapped to API include param)
|
||||
*
|
||||
* Dispatches the CALL_API action to fetch this item.
|
||||
*/
|
||||
const loadItem = function({ filter = null, include = [] } = {}) {
|
||||
return (dispatch, getState) => {
|
||||
// Get credentials from the state
|
||||
const { auth } = getState();
|
||||
// Get the action to dispatch
|
||||
const fetchAction = fetchItems(
|
||||
auth.endpoint,
|
||||
auth.username,
|
||||
auth.token.token,
|
||||
filter,
|
||||
1,
|
||||
DEFAULT_LIMIT,
|
||||
include
|
||||
);
|
||||
// Set success callback
|
||||
fetchAction.payload.dispatch[1] = (
|
||||
jsonData => dispatch => {
|
||||
dispatch(fetchItemSuccess(jsonData));
|
||||
}
|
||||
);
|
||||
// Dispatch action
|
||||
dispatch(fetchAction);
|
||||
};
|
||||
};
|
||||
|
||||
// Remap the above methods to methods including item name
|
||||
var returned = {};
|
||||
returned["fetch" + camelizedAction + "Success"] = fetchItemsSuccess;
|
||||
returned["fetch" + camelizedAction + "Request"] = fetchItemsRequest;
|
||||
returned["fetch" + camelizedAction + "Failure"] = fetchItemsFailure;
|
||||
returned["fetch" + camelizedAction] = fetchItems;
|
||||
returned["load" + camelizedAction] = loadItems;
|
||||
const camelizedAction = humps.pascalize(action);
|
||||
returned["loadPaginated" + camelizedAction] = loadPaginatedItems;
|
||||
returned["load" + camelizedAction.rstrip("s")] = loadItem;
|
||||
return returned;
|
||||
}
|
||||
|
@ -1,45 +1,36 @@
|
||||
/**
|
||||
* This file implements authentication related actions.
|
||||
*/
|
||||
|
||||
// NPM imports
|
||||
import { push } from "react-router-redux";
|
||||
import jsSHA from "jssha";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
// Local imports
|
||||
import { buildHMAC, cleanURL } from "../utils";
|
||||
|
||||
// Models
|
||||
import { i18nRecord } from "../models/i18n";
|
||||
|
||||
// Other actions and payload types
|
||||
import { CALL_API } from "../middleware/api";
|
||||
import { invalidateStore } from "./store";
|
||||
|
||||
import { i18nRecord } from "../models/i18n";
|
||||
|
||||
export const DEFAULT_SESSION_INTERVAL = 1800 * 1000; // 30 mins default
|
||||
// Constants
|
||||
export const DEFAULT_SESSION_INTERVAL = 1800 * 1000; // 30 mins long sessoins by default
|
||||
|
||||
function _cleanEndpoint (endpoint) {
|
||||
// Handle endpoints of the form "ampache.example.com"
|
||||
if (
|
||||
!endpoint.startsWith("//") &&
|
||||
!endpoint.startsWith("http://") &&
|
||||
!endpoint.startsWith("https://"))
|
||||
{
|
||||
endpoint = window.location.protocol + "//" + endpoint;
|
||||
}
|
||||
// Remove trailing slash and store endpoint
|
||||
endpoint = endpoint.replace(/\/$/, "");
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
function _buildHMAC (password) {
|
||||
// Handle Ampache HMAC generation
|
||||
const time = Math.floor(Date.now() / 1000);
|
||||
|
||||
let shaObj = new jsSHA("SHA-256", "TEXT");
|
||||
shaObj.update(password);
|
||||
const key = shaObj.getHash("HEX");
|
||||
|
||||
shaObj = new jsSHA("SHA-256", "TEXT");
|
||||
shaObj.update(time + key);
|
||||
|
||||
return {
|
||||
time: time,
|
||||
passphrase: shaObj.getHash("HEX")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a ping query to the API for login keepalive and prevent session
|
||||
* from expiring.
|
||||
*
|
||||
* @param username Username to use
|
||||
* @param token Token to revive
|
||||
* @param endpoint Ampache base URL
|
||||
*
|
||||
* @return A CALL_API payload to keep session alive.
|
||||
*/
|
||||
export function loginKeepAlive(username, token, endpoint) {
|
||||
return {
|
||||
type: CALL_API,
|
||||
@ -60,7 +51,19 @@ export function loginKeepAlive(username, token, endpoint) {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export const LOGIN_USER_SUCCESS = "LOGIN_USER_SUCCESS";
|
||||
/**
|
||||
* Action to be called on successful login.
|
||||
*
|
||||
* @param username Username used for login
|
||||
* @param token Token got back from the API
|
||||
* @param endpoint Ampache server base URL
|
||||
* @param rememberMe Whether to remember me or not
|
||||
* @param timerID ID of the timer set for session keepalive.
|
||||
*
|
||||
* @return A login success payload.
|
||||
*/
|
||||
export function loginUserSuccess(username, token, endpoint, rememberMe, timerID) {
|
||||
return {
|
||||
type: LOGIN_USER_SUCCESS,
|
||||
@ -74,7 +77,16 @@ export function loginUserSuccess(username, token, endpoint, rememberMe, timerID)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export const LOGIN_USER_FAILURE = "LOGIN_USER_FAILURE";
|
||||
/**
|
||||
* Action to be called on failed login.
|
||||
*
|
||||
* This action removes any remember me cookie if any was set.
|
||||
*
|
||||
* @param error An error object, either string or i18nRecord.
|
||||
* @return A login failure payload.
|
||||
*/
|
||||
export function loginUserFailure(error) {
|
||||
Cookies.remove("username");
|
||||
Cookies.remove("token");
|
||||
@ -87,7 +99,14 @@ export function loginUserFailure(error) {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export const LOGIN_USER_EXPIRED = "LOGIN_USER_EXPIRED";
|
||||
/**
|
||||
* Action to be called when session is expired.
|
||||
*
|
||||
* @param error An error object, either a string or i18nRecord.
|
||||
* @return A session expired payload.
|
||||
*/
|
||||
export function loginUserExpired(error) {
|
||||
return {
|
||||
type: LOGIN_USER_EXPIRED,
|
||||
@ -97,14 +116,32 @@ export function loginUserExpired(error) {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export const LOGIN_USER_REQUEST = "LOGIN_USER_REQUEST";
|
||||
/**
|
||||
* Action to be called when login is requested.
|
||||
*
|
||||
* @return A login request payload.
|
||||
*/
|
||||
export function loginUserRequest() {
|
||||
return {
|
||||
type: LOGIN_USER_REQUEST
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export const LOGOUT_USER = "LOGOUT_USER";
|
||||
/**
|
||||
* Action to be called upon logout.
|
||||
*
|
||||
* This function clears the cookies set for remember me and the keep alive
|
||||
* timer.
|
||||
*
|
||||
* @remark This function does not clear the other stores, nor handle
|
||||
* redirection.
|
||||
*
|
||||
* @return A logout payload.
|
||||
*/
|
||||
export function logout() {
|
||||
return (dispatch, state) => {
|
||||
const { auth } = state();
|
||||
@ -120,6 +157,14 @@ export function logout() {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Action to be called to log a user out.
|
||||
*
|
||||
* This function clears the remember me cookies and the keepalive timer. It
|
||||
* also clears the data behind authentication in the store and redirects to
|
||||
* login page.
|
||||
*/
|
||||
export function logoutAndRedirect() {
|
||||
return (dispatch) => {
|
||||
dispatch(logout());
|
||||
@ -128,14 +173,30 @@ export function logoutAndRedirect() {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Action to be called to log a user in.
|
||||
*
|
||||
* @param username Username to use.
|
||||
* @param passwordOrToken User password, or previous token to revive.
|
||||
* @param endpoint Ampache server base URL.
|
||||
* @param rememberMe Whether to rememberMe or not
|
||||
* @param[optional] redirect Page to redirect to after login.
|
||||
* @param[optional] isToken Whether passwordOrToken is a password or a
|
||||
* token.
|
||||
*
|
||||
* @return A CALL_API payload to perform login.
|
||||
*/
|
||||
export function loginUser(username, passwordOrToken, endpoint, rememberMe, redirect="/", isToken=false) {
|
||||
endpoint = _cleanEndpoint(endpoint);
|
||||
// Clean endpoint
|
||||
endpoint = cleanURL(endpoint);
|
||||
|
||||
// Get passphrase and time parameters
|
||||
let time = 0;
|
||||
let passphrase = passwordOrToken;
|
||||
|
||||
if (!isToken) {
|
||||
// Standard password connection
|
||||
const HMAC = _buildHMAC(passwordOrToken);
|
||||
const HMAC = buildHMAC(passwordOrToken);
|
||||
time = HMAC.time;
|
||||
passphrase = HMAC.passphrase;
|
||||
} else {
|
||||
@ -147,6 +208,7 @@ export function loginUser(username, passwordOrToken, endpoint, rememberMe, redir
|
||||
time = Math.floor(Date.now() / 1000);
|
||||
passphrase = passwordOrToken.token;
|
||||
}
|
||||
|
||||
return {
|
||||
type: CALL_API,
|
||||
payload: {
|
||||
@ -155,23 +217,27 @@ export function loginUser(username, passwordOrToken, endpoint, rememberMe, redir
|
||||
loginUserRequest,
|
||||
jsonData => dispatch => {
|
||||
if (!jsonData.auth || !jsonData.sessionExpire) {
|
||||
// On success, check that we are actually authenticated
|
||||
return dispatch(loginUserFailure(new i18nRecord({ id: "app.api.error", values: {} })));
|
||||
}
|
||||
// Get token from the API
|
||||
const token = {
|
||||
token: jsonData.auth,
|
||||
expires: new Date(jsonData.sessionExpire)
|
||||
};
|
||||
// Dispatch success
|
||||
// Handle session keep alive timer
|
||||
const timerID = setInterval(
|
||||
() => dispatch(loginKeepAlive(username, token.token, endpoint)),
|
||||
DEFAULT_SESSION_INTERVAL
|
||||
);
|
||||
if (rememberMe) {
|
||||
// Handle remember me option
|
||||
const cookiesOption = { expires: token.expires };
|
||||
Cookies.set("username", username, cookiesOption);
|
||||
Cookies.set("token", token, cookiesOption);
|
||||
Cookies.set("endpoint", endpoint, cookiesOption);
|
||||
}
|
||||
// Dispatch login success
|
||||
dispatch(loginUserSuccess(username, token, endpoint, rememberMe, timerID));
|
||||
// Redirect
|
||||
dispatch(push(redirect));
|
||||
|
61
app/actions/entities.js
Normal file
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";
|
||||
|
||||
// API related actions for all the available types
|
||||
import APIAction from "./APIActions";
|
||||
|
||||
// Actions related to API
|
||||
export const API_SUCCESS = "API_SUCCESS";
|
||||
export const API_REQUEST = "API_REQUEST";
|
||||
export const API_FAILURE = "API_FAILURE";
|
||||
export var { loadArtists } = APIAction("artists", API_REQUEST, API_SUCCESS, API_FAILURE);
|
||||
export var { loadAlbums } = APIAction("albums", API_REQUEST, API_SUCCESS, API_FAILURE);
|
||||
export var { loadSongs } = APIAction("songs", API_REQUEST, API_SUCCESS, API_FAILURE);
|
||||
export var {
|
||||
loadPaginatedArtists, loadArtist } = APIAction("artists", API_REQUEST, API_SUCCESS, API_FAILURE);
|
||||
export var {
|
||||
loadPaginatedAlbums, loadAlbum } = APIAction("albums", API_REQUEST, API_SUCCESS, API_FAILURE);
|
||||
export var {
|
||||
loadPaginatedSongs, loadSong } = APIAction("songs", API_REQUEST, API_SUCCESS, API_FAILURE);
|
||||
|
||||
export * from "./paginate";
|
||||
// Entities actions
|
||||
export * from "./entities";
|
||||
|
||||
// Paginated views store actions
|
||||
export * from "./paginated";
|
||||
|
||||
// Pagination actions
|
||||
export * from "./pagination";
|
||||
|
||||
// Store actions
|
||||
export * from "./store";
|
||||
|
||||
// Webplayer actions
|
||||
export * from "./webplayer";
|
||||
|
@ -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 function invalidateStore() {
|
||||
return {
|
||||
|
@ -1,3 +1,4 @@
|
||||
// TODO: This file is not finished
|
||||
export const PLAY_PAUSE = "PLAY_PAUSE";
|
||||
/**
|
||||
* true to play, false to pause.
|
||||
|
@ -1,4 +1,8 @@
|
||||
/**
|
||||
* Common global styles.
|
||||
*/
|
||||
:global {
|
||||
/* No border on responsive table. */
|
||||
@media (max-width: 767px) {
|
||||
.table-responsive {
|
||||
border: none;
|
@ -1,5 +1,8 @@
|
||||
/**
|
||||
* Hacks for specific browsers and bugfixes.
|
||||
*/
|
||||
:global {
|
||||
/* Firefox hack for responsive table */
|
||||
/* Firefox hack for responsive table in Bootstrap */
|
||||
@-moz-document url-prefix() {
|
||||
fieldset {
|
||||
display: table-cell;
|
@ -1,2 +1,5 @@
|
||||
/**
|
||||
* Common styles modifications and hacks.
|
||||
*/
|
||||
export * from "./hacks.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.
|
||||
*
|
||||
* @param intShakes Number of times to shake.
|
||||
* @param intDistance Distance to move the object.
|
||||
* @param intDuration Duration of the animation.
|
||||
*
|
||||
* @return The element it was applied one, for chaining.
|
||||
*/
|
||||
$.fn.shake = function(intShakes, intDistance, intDuration) {
|
||||
this.each(function() {
|
@ -1,14 +1,23 @@
|
||||
/**
|
||||
* Capitalize function on strings.
|
||||
* String prototype extension.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Capitalize a string.
|
||||
*
|
||||
* @return Capitalized string.
|
||||
*/
|
||||
String.prototype.capitalize = function () {
|
||||
return this.charAt(0).toUpperCase() + this.slice(1);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Strip characters at the end of a string.
|
||||
*
|
||||
* @param chars A regex-like element to strip from the end.
|
||||
* @return Stripped string.
|
||||
*/
|
||||
String.prototype.rstrip = function (chars) {
|
||||
let regex = new RegExp(chars + "$");
|
@ -1,17 +1,26 @@
|
||||
// NPM import
|
||||
import React, { Component, PropTypes } from "react";
|
||||
import CSSModules from "react-css-modules";
|
||||
import { defineMessages, FormattedMessage, injectIntl, intlShape } from "react-intl";
|
||||
import FontAwesome from "react-fontawesome";
|
||||
import Immutable from "immutable";
|
||||
|
||||
// Local imports
|
||||
import { formatLength, messagesMap } from "../utils";
|
||||
|
||||
// Translations
|
||||
import commonMessages from "../locales/messagesDescriptors/common";
|
||||
|
||||
// Styles
|
||||
import css from "../styles/Album.scss";
|
||||
|
||||
// Set translations
|
||||
const albumMessages = defineMessages(messagesMap(Array.concat([], commonMessages)));
|
||||
|
||||
|
||||
/**
|
||||
* Track row in an album tracks table.
|
||||
*/
|
||||
class AlbumTrackRowCSSIntl extends Component {
|
||||
render () {
|
||||
const { formatMessage } = this.props.intl;
|
||||
@ -33,19 +42,21 @@ class AlbumTrackRowCSSIntl extends Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AlbumTrackRowCSSIntl.propTypes = {
|
||||
playAction: PropTypes.func.isRequired,
|
||||
track: PropTypes.instanceOf(Immutable.Map).isRequired,
|
||||
intl: intlShape.isRequired
|
||||
};
|
||||
|
||||
export let AlbumTrackRow = injectIntl(CSSModules(AlbumTrackRowCSSIntl, css));
|
||||
|
||||
|
||||
/**
|
||||
* Tracks table of an album.
|
||||
*/
|
||||
class AlbumTracksTableCSS extends Component {
|
||||
render () {
|
||||
let rows = [];
|
||||
// Build rows for each track
|
||||
const playAction = this.props.playAction;
|
||||
this.props.tracks.forEach(function (item) {
|
||||
rows.push(<AlbumTrackRow playAction={playAction} track={item} key={item.get("id")} />);
|
||||
@ -59,14 +70,16 @@ class AlbumTracksTableCSS extends Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AlbumTracksTableCSS.propTypes = {
|
||||
playAction: PropTypes.func.isRequired,
|
||||
tracks: PropTypes.instanceOf(Immutable.List).isRequired
|
||||
};
|
||||
|
||||
export let AlbumTracksTable = CSSModules(AlbumTracksTableCSS, css);
|
||||
|
||||
|
||||
/**
|
||||
* An entire album row containing art and tracks table.
|
||||
*/
|
||||
class AlbumRowCSS extends Component {
|
||||
render () {
|
||||
return (
|
||||
@ -88,24 +101,9 @@ class AlbumRowCSS extends Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AlbumRowCSS.propTypes = {
|
||||
playAction: PropTypes.func.isRequired,
|
||||
album: PropTypes.instanceOf(Immutable.Map).isRequired,
|
||||
songs: PropTypes.instanceOf(Immutable.List).isRequired
|
||||
};
|
||||
|
||||
export let AlbumRow = CSSModules(AlbumRowCSS, css);
|
||||
|
||||
export default class Album extends Component {
|
||||
render () {
|
||||
return (
|
||||
<AlbumRow album={this.props.album} songs={this.props.songs} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Album.propTypes = {
|
||||
album: PropTypes.instanceOf(Immutable.Map).isRequired,
|
||||
songs: PropTypes.instanceOf(Immutable.List).isRequired
|
||||
};
|
||||
|
@ -1,16 +1,24 @@
|
||||
// NPM imports
|
||||
import React, { Component, PropTypes } from "react";
|
||||
import Immutable from "immutable";
|
||||
|
||||
// Local imports
|
||||
import FilterablePaginatedGrid from "./elements/Grid";
|
||||
import DismissibleAlert from "./elements/DismissibleAlert";
|
||||
|
||||
|
||||
/**
|
||||
* Paginated albums grid
|
||||
*/
|
||||
export default class Albums extends Component {
|
||||
render () {
|
||||
// Handle error
|
||||
let error = null;
|
||||
if (this.props.error) {
|
||||
error = (<DismissibleAlert type="danger" text={this.props.error} />);
|
||||
}
|
||||
|
||||
// Set grid props
|
||||
const grid = {
|
||||
isFetching: this.props.isFetching,
|
||||
items: this.props.albums,
|
||||
@ -19,6 +27,7 @@ export default class Albums extends Component {
|
||||
subItemsType: "tracks",
|
||||
subItemsLabel: "app.common.track"
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{ error }
|
||||
@ -27,10 +36,9 @@ export default class Albums extends Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Albums.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
error: PropTypes.string,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
albums: PropTypes.instanceOf(Immutable.List).isRequired,
|
||||
pagination: PropTypes.object.isRequired,
|
||||
};
|
||||
|
@ -1,57 +1,63 @@
|
||||
// NPM imports
|
||||
import React, { Component, PropTypes } from "react";
|
||||
import CSSModules from "react-css-modules";
|
||||
import { defineMessages, FormattedMessage } from "react-intl";
|
||||
import FontAwesome from "react-fontawesome";
|
||||
import Immutable from "immutable";
|
||||
|
||||
// Local imports
|
||||
import { messagesMap } from "../utils/";
|
||||
|
||||
// Other components
|
||||
import { AlbumRow } from "./Album";
|
||||
import DismissibleAlert from "./elements/DismissibleAlert";
|
||||
|
||||
// Translations
|
||||
import commonMessages from "../locales/messagesDescriptors/common";
|
||||
|
||||
// Styles
|
||||
import css from "../styles/Artist.scss";
|
||||
|
||||
// Define translations
|
||||
const artistMessages = defineMessages(messagesMap(Array.concat([], commonMessages)));
|
||||
|
||||
|
||||
/**
|
||||
* Single artist page
|
||||
*/
|
||||
class ArtistCSS extends Component {
|
||||
render () {
|
||||
const loading = (
|
||||
<div className="row text-center">
|
||||
<p>
|
||||
<FontAwesome name="spinner" className="fa-pulse fa-3x fa-fw" aria-hidden="true" />
|
||||
<span className="sr-only"><FormattedMessage {...artistMessages["app.common.loading"]} /></span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (this.props.isFetching && !this.props.artist.size > 0) {
|
||||
// Loading
|
||||
return loading;
|
||||
// Define loading message
|
||||
let loading = null;
|
||||
if (this.props.isFetching) {
|
||||
loading = (
|
||||
<div className="row text-center">
|
||||
<p>
|
||||
<FontAwesome name="spinner" className="fa-pulse fa-3x fa-fw" aria-hidden="true" />
|
||||
<span className="sr-only"><FormattedMessage {...artistMessages["app.common.loading"]} /></span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle error
|
||||
let error = null;
|
||||
if (this.props.error) {
|
||||
error = (<DismissibleAlert type="danger" text={this.props.error} />);
|
||||
}
|
||||
|
||||
// Build album rows
|
||||
let albumsRows = [];
|
||||
const { albums, songs, playAction } = this.props;
|
||||
const artistAlbums = this.props.artist.get("albums");
|
||||
if (albums && songs && artistAlbums && artistAlbums.size > 0) {
|
||||
this.props.artist.get("albums").forEach(function (album) {
|
||||
album = albums.get(album);
|
||||
if (albums && songs) {
|
||||
albums.forEach(function (album) {
|
||||
const albumSongs = album.get("tracks").map(
|
||||
id => songs.get(id)
|
||||
);
|
||||
albumsRows.push(<AlbumRow playAction={playAction} album={album} songs={albumSongs} key={album.get("id")} />);
|
||||
});
|
||||
}
|
||||
else {
|
||||
// Loading
|
||||
albumsRows = loading;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{ error }
|
||||
@ -70,18 +76,17 @@ class ArtistCSS extends Component {
|
||||
</div>
|
||||
</div>
|
||||
{ albumsRows }
|
||||
{ loading }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ArtistCSS.propTypes = {
|
||||
playAction: PropTypes.func.isRequired,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
error: PropTypes.string,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
playAction: PropTypes.func.isRequired,
|
||||
artist: PropTypes.instanceOf(Immutable.Map),
|
||||
albums: PropTypes.instanceOf(Immutable.Map),
|
||||
albums: PropTypes.instanceOf(Immutable.List),
|
||||
songs: PropTypes.instanceOf(Immutable.Map)
|
||||
};
|
||||
|
||||
export default CSSModules(ArtistCSS, css);
|
||||
|
@ -1,16 +1,24 @@
|
||||
// NPM imports
|
||||
import React, { Component, PropTypes } from "react";
|
||||
import Immutable from "immutable";
|
||||
|
||||
// Other components
|
||||
import FilterablePaginatedGrid from "./elements/Grid";
|
||||
import DismissibleAlert from "./elements/DismissibleAlert";
|
||||
|
||||
class Artists extends Component {
|
||||
|
||||
/**
|
||||
* Paginated artists grid
|
||||
*/
|
||||
export default class Artists extends Component {
|
||||
render () {
|
||||
// Handle error
|
||||
let error = null;
|
||||
if (this.props.error) {
|
||||
error = (<DismissibleAlert type="danger" text={this.props.error} />);
|
||||
}
|
||||
|
||||
// Define grid props
|
||||
const grid = {
|
||||
isFetching: this.props.isFetching,
|
||||
items: this.props.artists,
|
||||
@ -19,6 +27,7 @@ class Artists extends Component {
|
||||
subItemsType: "albums",
|
||||
subItemsLabel: "app.common.album"
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{ error }
|
||||
@ -27,12 +36,9 @@ class Artists extends Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Artists.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
error: PropTypes.string,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
artists: PropTypes.instanceOf(Immutable.List).isRequired,
|
||||
pagination: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default Artists;
|
||||
|
@ -1,3 +1,4 @@
|
||||
// TODO: Discover view is not done
|
||||
import React, { Component } from "react";
|
||||
import CSSModules from "react-css-modules";
|
||||
import FontAwesome from "react-fontawesome";
|
||||
|
@ -1,57 +1,87 @@
|
||||
// NPM imports
|
||||
import React, { Component, PropTypes } from "react";
|
||||
import CSSModules from "react-css-modules";
|
||||
import { defineMessages, injectIntl, intlShape, FormattedMessage } from "react-intl";
|
||||
import FontAwesome from "react-fontawesome";
|
||||
|
||||
// Local imports
|
||||
import { i18nRecord } from "../models/i18n";
|
||||
import { messagesMap } from "../utils";
|
||||
|
||||
// Translations
|
||||
import APIMessages from "../locales/messagesDescriptors/api";
|
||||
import messages from "../locales/messagesDescriptors/Login";
|
||||
|
||||
// Styles
|
||||
import css from "../styles/Login.scss";
|
||||
|
||||
// Define translations
|
||||
const loginMessages = defineMessages(messagesMap(Array.concat([], APIMessages, messages)));
|
||||
|
||||
|
||||
/**
|
||||
* Login form component
|
||||
*/
|
||||
class LoginFormCSSIntl extends Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this); // bind this to handleSubmit
|
||||
}
|
||||
|
||||
setError (formGroup, error) {
|
||||
if (error) {
|
||||
/**
|
||||
* Set an error on a form element.
|
||||
*
|
||||
* @param formGroup A form element.
|
||||
* @param hasError Whether or not an error should be set.
|
||||
*
|
||||
* @return True if an error is set, false otherwise
|
||||
*/
|
||||
setError (formGroup, hasError) {
|
||||
if (hasError) {
|
||||
// If error is true, then add error class
|
||||
formGroup.classList.add("has-error");
|
||||
formGroup.classList.remove("has-success");
|
||||
return true;
|
||||
}
|
||||
// Else, drop it and put success class
|
||||
formGroup.classList.remove("has-error");
|
||||
formGroup.classList.add("has-success");
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Form submission handler.
|
||||
*
|
||||
* @param e JS Event.
|
||||
*/
|
||||
handleSubmit (e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Don't handle submit if already logging in
|
||||
if (this.props.isAuthenticating) {
|
||||
// Don't handle submit if already logging in
|
||||
return;
|
||||
}
|
||||
|
||||
// Get field values
|
||||
const username = this.refs.username.value.trim();
|
||||
const password = this.refs.password.value.trim();
|
||||
const endpoint = this.refs.endpoint.value.trim();
|
||||
const rememberMe = this.refs.rememberMe.checked;
|
||||
|
||||
// Check for errors on each field
|
||||
let hasError = this.setError(this.refs.usernameFormGroup, !username);
|
||||
hasError |= this.setError(this.refs.passwordFormGroup, !password);
|
||||
hasError |= this.setError(this.refs.endpointFormGroup, !endpoint);
|
||||
|
||||
if (!hasError) {
|
||||
// Submit if no error is found
|
||||
this.props.onSubmit(username, password, endpoint, rememberMe);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate () {
|
||||
if (this.props.error) {
|
||||
// On unsuccessful login, set error classes and shake the form
|
||||
$(this.refs.loginForm).shake(3, 10, 300);
|
||||
this.setError(this.refs.usernameFormGroup, this.props.error);
|
||||
this.setError(this.refs.passwordFormGroup, this.props.error);
|
||||
@ -61,18 +91,23 @@ class LoginFormCSSIntl extends Component {
|
||||
|
||||
render () {
|
||||
const {formatMessage} = this.props.intl;
|
||||
|
||||
// Handle info message
|
||||
let infoMessage = this.props.info;
|
||||
if (this.props.info && this.props.info instanceof i18nRecord) {
|
||||
infoMessage = (
|
||||
<FormattedMessage {...loginMessages[this.props.info.id]} values={ this.props.info.values} />
|
||||
);
|
||||
}
|
||||
|
||||
// Handle error message
|
||||
let errorMessage = this.props.error;
|
||||
if (this.props.error && this.props.error instanceof i18nRecord) {
|
||||
errorMessage = (
|
||||
<FormattedMessage {...loginMessages[this.props.error.id]} values={ this.props.error.values} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
@ -135,7 +170,6 @@ class LoginFormCSSIntl extends Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
LoginFormCSSIntl.propTypes = {
|
||||
username: PropTypes.string,
|
||||
endpoint: PropTypes.string,
|
||||
@ -146,11 +180,13 @@ LoginFormCSSIntl.propTypes = {
|
||||
info: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(i18nRecord)]),
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export let LoginForm = injectIntl(CSSModules(LoginFormCSSIntl, css));
|
||||
|
||||
|
||||
class Login extends Component {
|
||||
/**
|
||||
* Main login page, including title and login form.
|
||||
*/
|
||||
class LoginCSS extends Component {
|
||||
render () {
|
||||
const greeting = (
|
||||
<p>
|
||||
@ -169,8 +205,7 @@ class Login extends Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Login.propTypes = {
|
||||
LoginCSS.propTypes = {
|
||||
username: PropTypes.string,
|
||||
endpoint: PropTypes.string,
|
||||
rememberMe: PropTypes.bool,
|
||||
@ -179,5 +214,4 @@ Login.propTypes = {
|
||||
info: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
|
||||
error: PropTypes.oneOfType([PropTypes.string, PropTypes.object])
|
||||
};
|
||||
|
||||
export default CSSModules(Login, css);
|
||||
export default CSSModules(LoginCSS, css);
|
||||
|
@ -1,3 +1,4 @@
|
||||
// NPM imports
|
||||
import React, { Component, PropTypes } from "react";
|
||||
import { Link} from "react-router";
|
||||
import CSSModules from "react-css-modules";
|
||||
@ -6,24 +7,36 @@ import FontAwesome from "react-fontawesome";
|
||||
import Immutable from "immutable";
|
||||
import Fuse from "fuse.js";
|
||||
|
||||
// Local imports
|
||||
import { formatLength, messagesMap } from "../utils";
|
||||
|
||||
// Other components
|
||||
import DismissibleAlert from "./elements/DismissibleAlert";
|
||||
import FilterBar from "./elements/FilterBar";
|
||||
import Pagination from "./elements/Pagination";
|
||||
import { formatLength, messagesMap } from "../utils";
|
||||
|
||||
// Translations
|
||||
import commonMessages from "../locales/messagesDescriptors/common";
|
||||
import messages from "../locales/messagesDescriptors/Songs";
|
||||
|
||||
// Styles
|
||||
import css from "../styles/Songs.scss";
|
||||
|
||||
// Define translations
|
||||
const songsMessages = defineMessages(messagesMap(Array.concat([], commonMessages, messages)));
|
||||
|
||||
|
||||
/**
|
||||
* A single row for a single song in the songs table.
|
||||
*/
|
||||
class SongsTableRowCSSIntl extends Component {
|
||||
render () {
|
||||
const { formatMessage } = this.props.intl;
|
||||
|
||||
const length |