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 {
|
||||
/**
|
||||
* 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: {
|
||||
result: jsonData.result,
|
||||
entities: jsonData.entities,
|
||||
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,23 +1,36 @@
|
||||
// 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 = (
|
||||
// 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" />
|
||||
@ -25,33 +38,26 @@ class ArtistCSS extends Component {
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (this.props.isFetching && !this.props.artist.size > 0) {
|
||||
// Loading
|
||||
return loading;
|
||||
}
|
||||
|
||||
// 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();
|
||||
if (this.props.isAuthenticating) {
|
||||
|
||||
// Don't handle submit if already logging in
|
||||
if (this.props.isAuthenticating) {
|
||||
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 = formatLength(this.props.song.get("time"));
|
||||
const linkToArtist = "/artist/" + this.props.song.getIn(["artist", "id"]);
|
||||
const linkToAlbum = "/album/" + this.props.song.getIn(["album", "id"]);
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
@ -43,18 +56,20 @@ class SongsTableRowCSSIntl extends Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SongsTableRowCSSIntl.propTypes = {
|
||||
playAction: PropTypes.func.isRequired,
|
||||
song: PropTypes.instanceOf(Immutable.Map).isRequired,
|
||||
intl: intlShape.isRequired
|
||||
};
|
||||
|
||||
export let SongsTableRow = injectIntl(CSSModules(SongsTableRowCSSIntl, css));
|
||||
|
||||
|
||||
/**
|
||||
* The songs table.
|
||||
*/
|
||||
class SongsTableCSS extends Component {
|
||||
render () {
|
||||
// Handle filtering
|
||||
let displayedSongs = this.props.songs;
|
||||
if (this.props.filterText) {
|
||||
// Use Fuse for the filter
|
||||
@ -69,14 +84,16 @@ class SongsTableCSS extends Component {
|
||||
displayedSongs = displayedSongs.map(function (item) { return new Immutable.Map(item.item); });
|
||||
}
|
||||
|
||||
// Build song rows
|
||||
let rows = [];
|
||||
const { playAction } = this.props;
|
||||
displayedSongs.forEach(function (song) {
|
||||
rows.push(<SongsTableRow playAction={playAction} song={song} key={song.get("id")} />);
|
||||
});
|
||||
|
||||
// Handle login icon
|
||||
let loading = null;
|
||||
if (rows.length == 0 && this.props.isFetching) {
|
||||
// If we are fetching and there is nothing to show
|
||||
if (this.props.isFetching) {
|
||||
loading = (
|
||||
<p className="text-center">
|
||||
<FontAwesome name="spinner" className="fa-pulse fa-3x fa-fw" aria-hidden="true" />
|
||||
@ -84,6 +101,7 @@ class SongsTableCSS extends Component {
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-hover" styleName="songs">
|
||||
@ -114,26 +132,34 @@ class SongsTableCSS extends Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SongsTableCSS.propTypes = {
|
||||
playAction: PropTypes.func.isRequired,
|
||||
songs: PropTypes.instanceOf(Immutable.List).isRequired,
|
||||
filterText: PropTypes.string
|
||||
};
|
||||
|
||||
export let SongsTable = CSSModules(SongsTableCSS, css);
|
||||
|
||||
|
||||
/**
|
||||
* Complete songs table view with filter and pagination
|
||||
*/
|
||||
export default class FilterablePaginatedSongsTable extends Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
filterText: ""
|
||||
filterText: "" // Initial state, no filter text
|
||||
};
|
||||
|
||||
this.handleUserInput = this.handleUserInput.bind(this);
|
||||
this.handleUserInput = this.handleUserInput.bind(this); // Bind this on user input handling
|
||||
}
|
||||
|
||||
/**
|
||||
* Method called whenever the filter input is changed.
|
||||
*
|
||||
* Update the state accordingly.
|
||||
*
|
||||
* @param filterText Content of the filter input.
|
||||
*/
|
||||
handleUserInput (filterText) {
|
||||
this.setState({
|
||||
filterText: filterText
|
||||
@ -141,22 +167,34 @@ export default class FilterablePaginatedSongsTable extends Component {
|
||||
}
|
||||
|
||||
render () {
|
||||
// Handle error
|
||||
let error = null;
|
||||
if (this.props.error) {
|
||||
error = (<DismissibleAlert type="danger" text={this.props.error} />);
|
||||
}
|
||||
|
||||
// Set props
|
||||
const filterProps = {
|
||||
filterText: this.state.filterText,
|
||||
onUserInput: this.handleUserInput
|
||||
};
|
||||
const songsTableProps = {
|
||||
playAction: this.props.playAction,
|
||||
isFetching: this.props.isFetching,
|
||||
songs: this.props.songs,
|
||||
filterText: this.state.filterText
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{ error }
|
||||
<FilterBar filterText={this.state.filterText} onUserInput={this.handleUserInput} />
|
||||
<SongsTable playAction={this.props.playAction} isFetching={this.props.isFetching} songs={this.props.songs} filterText={this.state.filterText} />
|
||||
<FilterBar {...filterProps} />
|
||||
<SongsTable {...songsTableProps} />
|
||||
<Pagination {...this.props.pagination} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FilterablePaginatedSongsTable.propTypes = {
|
||||
playAction: PropTypes.func.isRequired,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
|
@ -1,11 +1,18 @@
|
||||
// NPM imports
|
||||
import React, { Component, PropTypes } from "react";
|
||||
|
||||
|
||||
/**
|
||||
* A dismissible Bootstrap alert.
|
||||
*/
|
||||
export default class DismissibleAlert extends Component {
|
||||
render () {
|
||||
// Set correct alert type
|
||||
let alertType = "alert-danger";
|
||||
if (this.props.type) {
|
||||
alertType = "alert-" + this.props.type;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={["alert", alertType].join(" ")} role="alert">
|
||||
<p>
|
||||
@ -18,7 +25,6 @@ export default class DismissibleAlert extends Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DismissibleAlert.propTypes = {
|
||||
type: PropTypes.string,
|
||||
text: PropTypes.string
|
||||
|
@ -1,28 +1,46 @@
|
||||
// NPM imports
|
||||
import React, { Component, PropTypes } from "react";
|
||||
import CSSModules from "react-css-modules";
|
||||
import { defineMessages, injectIntl, intlShape, FormattedMessage } from "react-intl";
|
||||
|
||||
// Local imports
|
||||
import { messagesMap } from "../../utils";
|
||||
|
||||
// Translations
|
||||
import messages from "../../locales/messagesDescriptors/elements/FilterBar";
|
||||
|
||||
// Styles
|
||||
import css from "../../styles/elements/FilterBar.scss";
|
||||
|
||||
// Define translations
|
||||
const filterMessages = defineMessages(messagesMap(Array.concat([], messages)));
|
||||
|
||||
|
||||
/**
|
||||
* Filter bar element with input filter.
|
||||
*/
|
||||
class FilterBarCSSIntl extends Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
// Bind this on methods
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to handle a change of filter input value.
|
||||
*
|
||||
* Calls the user input handler passed from parent component.
|
||||
*
|
||||
* @param e A JS event.
|
||||
*/
|
||||
handleChange (e) {
|
||||
e.preventDefault();
|
||||
|
||||
this.props.onUserInput(this.refs.filterTextInput.value);
|
||||
}
|
||||
|
||||
render () {
|
||||
const {formatMessage} = this.props.intl;
|
||||
|
||||
return (
|
||||
<div styleName="filter">
|
||||
<p className="col-xs-12 col-sm-6 col-md-4 col-md-offset-1" styleName="legend" id="filterInputDescription">
|
||||
@ -39,11 +57,9 @@ class FilterBarCSSIntl extends Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FilterBarCSSIntl.propTypes = {
|
||||
onUserInput: PropTypes.func,
|
||||
filterText: PropTypes.string,
|
||||
intl: intlShape.isRequired
|
||||
};
|
||||
|
||||
export default injectIntl(CSSModules(FilterBarCSSIntl, css));
|
||||
|
@ -1,3 +1,4 @@
|
||||
// NPM imports
|
||||
import React, { Component, PropTypes } from "react";
|
||||
import { Link} from "react-router";
|
||||
import CSSModules from "react-css-modules";
|
||||
@ -9,56 +10,24 @@ import Isotope from "isotope-layout";
|
||||
import Fuse from "fuse.js";
|
||||
import shallowCompare from "react-addons-shallow-compare";
|
||||
|
||||
import FilterBar from "./FilterBar";
|
||||
import Pagination from "./Pagination";
|
||||
// Local imports
|
||||
import { immutableDiff, messagesMap } from "../../utils/";
|
||||
|
||||
// Other components
|
||||
import FilterBar from "./FilterBar";
|
||||
import Pagination from "./Pagination";
|
||||
|
||||
// Translations
|
||||
import commonMessages from "../../locales/messagesDescriptors/common";
|
||||
import messages from "../../locales/messagesDescriptors/grid";
|
||||
|
||||
// Styles
|
||||
import css from "../../styles/elements/Grid.scss";
|
||||
|
||||
// Define translations
|
||||
const gridMessages = defineMessages(messagesMap(Array.concat([], commonMessages, messages)));
|
||||
|
||||
class GridItemCSSIntl extends Component {
|
||||
render () {
|
||||
const {formatMessage} = this.props.intl;
|
||||
|
||||
let nSubItems = this.props.item.get(this.props.subItemsType);
|
||||
if (Immutable.List.isList(nSubItems)) {
|
||||
nSubItems = nSubItems.size;
|
||||
}
|
||||
|
||||
let subItemsLabel = formatMessage(gridMessages[this.props.subItemsLabel], { itemCount: nSubItems });
|
||||
|
||||
const to = "/" + this.props.itemsType + "/" + this.props.item.get("id");
|
||||
const id = "grid-item-" + this.props.itemsType + "/" + this.props.item.get("id");
|
||||
|
||||
const title = formatMessage(gridMessages["app.grid.goTo" + this.props.itemsType.capitalize() + "Page"]);
|
||||
return (
|
||||
<div className="grid-item col-xs-6 col-sm-3" styleName="placeholders" id={id}>
|
||||
<div className="grid-item-content text-center">
|
||||
<Link title={title} to={to}><img src={this.props.item.get("art")} width="200" height="200" className="img-responsive img-circle art" styleName="art" alt={this.props.item.get("name")}/></Link>
|
||||
<h4 className="name" styleName="name">{this.props.item.get("name")}</h4>
|
||||
<span className="sub-items text-muted"><span className="n-sub-items">{nSubItems}</span> <span className="sub-items-type">{subItemsLabel}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
GridItemCSSIntl.propTypes = {
|
||||
item: PropTypes.instanceOf(Immutable.Map).isRequired,
|
||||
itemsType: PropTypes.string.isRequired,
|
||||
itemsLabel: PropTypes.string.isRequired,
|
||||
subItemsType: PropTypes.string.isRequired,
|
||||
subItemsLabel: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired
|
||||
};
|
||||
|
||||
export let GridItem = injectIntl(CSSModules(GridItemCSSIntl, css));
|
||||
|
||||
|
||||
// Constants
|
||||
const ISOTOPE_OPTIONS = { /** Default options for Isotope grid layout. */
|
||||
getSortData: {
|
||||
name: ".name",
|
||||
@ -75,6 +44,55 @@ const ISOTOPE_OPTIONS = { /** Default options for Isotope grid layout. */
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* A single item in the grid, art + text under the art.
|
||||
*/
|
||||
class GridItemCSSIntl extends Component {
|
||||
render () {
|
||||
const {formatMessage} = this.props.intl;
|
||||
|
||||
// Get number of sub-items
|
||||
let nSubItems = this.props.item.get(this.props.subItemsType);
|
||||
if (Immutable.List.isList(nSubItems)) {
|
||||
nSubItems = nSubItems.size;
|
||||
}
|
||||
|
||||
// Define correct sub-items label (plural)
|
||||
let subItemsLabel = formatMessage(
|
||||
gridMessages[this.props.subItemsLabel],
|
||||
{ itemCount: nSubItems }
|
||||
);
|
||||
|
||||
const to = "/" + this.props.itemsType + "/" + this.props.item.get("id");
|
||||
const id = "grid-item-" + this.props.itemsType + "/" + this.props.item.get("id");
|
||||
const title = formatMessage(gridMessages["app.grid.goTo" + this.props.itemsType.capitalize() + "Page"]);
|
||||
|
||||
return (
|
||||
<div className="grid-item col-xs-6 col-sm-3" styleName="placeholders" id={id}>
|
||||
<div className="grid-item-content text-center">
|
||||
<Link title={title} to={to}><img src={this.props.item.get("art")} width="200" height="200" className="img-responsive img-circle art" styleName="art" alt={this.props.item.get("name")}/></Link>
|
||||
<h4 className="name" styleName="name">{this.props.item.get("name")}</h4>
|
||||
<span className="sub-items text-muted"><span className="n-sub-items">{nSubItems}</span> <span className="sub-items-type">{subItemsLabel}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
GridItemCSSIntl.propTypes = {
|
||||
item: PropTypes.instanceOf(Immutable.Map).isRequired,
|
||||
itemsType: PropTypes.string.isRequired,
|
||||
itemsLabel: PropTypes.string.isRequired,
|
||||
subItemsType: PropTypes.string.isRequired,
|
||||
subItemsLabel: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired
|
||||
};
|
||||
export let GridItem = injectIntl(CSSModules(GridItemCSSIntl, css));
|
||||
|
||||
|
||||
/**
|
||||
* A grid, formatted using Isotope.JS
|
||||
*/
|
||||
export class Grid extends Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
@ -82,20 +100,29 @@ export class Grid extends Component {
|
||||
// Init grid data member
|
||||
this.iso = null;
|
||||
|
||||
// Bind this
|
||||
this.createIsotopeContainer = this.createIsotopeContainer.bind(this);
|
||||
this.handleFiltering = this.handleFiltering.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an isotope container if none already exist.
|
||||
*/
|
||||
createIsotopeContainer () {
|
||||
if (this.iso == null) {
|
||||
this.iso = new Isotope(this.refs.grid, ISOTOPE_OPTIONS);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle filtering on the grid.
|
||||
*/
|
||||
handleFiltering (props) {
|
||||
// If no query provided, drop any filter in use
|
||||
if (props.filterText == "") {
|
||||
return this.iso.arrange(ISOTOPE_OPTIONS);
|
||||
}
|
||||
|
||||
// Use Fuse for the filter
|
||||
let result = new Fuse(
|
||||
props.items.toJS(),
|
||||
@ -103,7 +130,8 @@ export class Grid extends Component {
|
||||
"keys": ["name"],
|
||||
"threshold": 0.4,
|
||||
"include": ["score"]
|
||||
}).search(props.filterText);
|
||||
}
|
||||
).search(props.filterText);
|
||||
|
||||
// Apply filter on grid
|
||||
this.iso.arrange({
|
||||
@ -130,10 +158,12 @@ export class Grid extends Component {
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
// Shallow comparison, render is pure
|
||||
return shallowCompare(this, nextProps, nextState);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
// Handle filtering if filterText is changed
|
||||
if (nextProps.filterText !== this.props.filterText) {
|
||||
this.handleFiltering(nextProps);
|
||||
}
|
||||
@ -143,8 +173,7 @@ export class Grid extends Component {
|
||||
// Setup grid
|
||||
this.createIsotopeContainer();
|
||||
// Only arrange if there are elements to arrange
|
||||
const length = this.props.items.length || 0;
|
||||
if (length > 0) {
|
||||
if (this.props.items.size > 0) {
|
||||
this.iso.arrange();
|
||||
}
|
||||
}
|
||||
@ -152,25 +181,31 @@ export class Grid extends Component {
|
||||
componentDidUpdate(prevProps) {
|
||||
// The list of keys seen in the previous render
|
||||
let currentKeys = prevProps.items.map(
|
||||
(n) => "grid-item-" + prevProps.itemsType + "/" + n.get("id"));
|
||||
(n) => "grid-item-" + prevProps.itemsType + "/" + n.get("id")
|
||||
);
|
||||
|
||||
// The latest list of keys that have been rendered
|
||||
const {itemsType} = this.props;
|
||||
let newKeys = this.props.items.map(
|
||||
(n) => "grid-item-" + itemsType + "/" + n.get("id"));
|
||||
(n) => "grid-item-" + itemsType + "/" + n.get("id")
|
||||
);
|
||||
|
||||
// Find which keys are new between the current set of keys and any new children passed to this component
|
||||
// Find which keys are new between the current set of keys and any new
|
||||
// children passed to this component
|
||||
let addKeys = immutableDiff(newKeys, currentKeys);
|
||||
|
||||
// Find which keys have been removed between the current set of keys and any new children passed to this component
|
||||
// Find which keys have been removed between the current set of keys
|
||||
// and any new children passed to this component
|
||||
let removeKeys = immutableDiff(currentKeys, newKeys);
|
||||
|
||||
let iso = this.iso;
|
||||
if (removeKeys.count() > 0) {
|
||||
// Remove removed items
|
||||
if (removeKeys.size > 0) {
|
||||
removeKeys.forEach(removeKey => iso.remove(document.getElementById(removeKey)));
|
||||
iso.arrange();
|
||||
}
|
||||
if (addKeys.count() > 0) {
|
||||
// Add new items
|
||||
if (addKeys.size > 0) {
|
||||
const itemsToAdd = addKeys.map((addKey) => document.getElementById(addKey)).toArray();
|
||||
iso.addItems(itemsToAdd);
|
||||
iso.arrange();
|
||||
@ -187,13 +222,9 @@ export class Grid extends Component {
|
||||
}
|
||||
|
||||
render () {
|
||||
let gridItems = [];
|
||||
const { itemsType, itemsLabel, subItemsType, subItemsLabel } = this.props;
|
||||
this.props.items.forEach(function (item) {
|
||||
gridItems.push(<GridItem item={item} itemsType={itemsType} itemsLabel={itemsLabel} subItemsType={subItemsType} subItemsLabel={subItemsLabel} key={item.get("id")} />);
|
||||
});
|
||||
// Handle loading
|
||||
let loading = null;
|
||||
if (gridItems.length == 0 && this.props.isFetching) {
|
||||
if (this.props.isFetching) {
|
||||
loading = (
|
||||
<div className="row text-center">
|
||||
<p>
|
||||
@ -203,9 +234,16 @@ export class Grid extends Component {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Build grid items
|
||||
let gridItems = [];
|
||||
const { itemsType, itemsLabel, subItemsType, subItemsLabel } = this.props;
|
||||
this.props.items.forEach(function (item) {
|
||||
gridItems.push(<GridItem item={item} itemsType={itemsType} itemsLabel={itemsLabel} subItemsType={subItemsType} subItemsLabel={subItemsLabel} key={item.get("id")} />);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{ loading }
|
||||
<div className="row">
|
||||
<div className="grid" ref="grid">
|
||||
{/* Sizing element */}
|
||||
@ -214,11 +252,11 @@ export class Grid extends Component {
|
||||
{ gridItems }
|
||||
</div>
|
||||
</div>
|
||||
{ loading }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Grid.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
items: PropTypes.instanceOf(Immutable.List).isRequired,
|
||||
@ -229,16 +267,29 @@ Grid.propTypes = {
|
||||
filterText: PropTypes.string
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Full grid with pagination and filtering input.
|
||||
*/
|
||||
export default class FilterablePaginatedGrid extends Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
filterText: ""
|
||||
filterText: "" // No filterText at init
|
||||
};
|
||||
|
||||
// Bind this
|
||||
this.handleUserInput = this.handleUserInput.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Method called whenever the filter input is changed.
|
||||
*
|
||||
* Update the state accordingly.
|
||||
*
|
||||
* @param filterText Content of the filter input.
|
||||
*/
|
||||
handleUserInput (filterText) {
|
||||
this.setState({
|
||||
filterText: filterText
|
||||
|
@ -1,71 +1,90 @@
|
||||
// NPM imports
|
||||
import React, { Component, PropTypes } from "react";
|
||||
import { Link } from "react-router";
|
||||
import CSSModules from "react-css-modules";
|
||||
import { defineMessages, injectIntl, intlShape, FormattedMessage, FormattedHTMLMessage } from "react-intl";
|
||||
|
||||
import { messagesMap } from "../../utils";
|
||||
// Local imports
|
||||
import { computePaginationBounds, filterInt, messagesMap } from "../../utils";
|
||||
|
||||
// Translations
|
||||
import commonMessages from "../../locales/messagesDescriptors/common";
|
||||
import messages from "../../locales/messagesDescriptors/elements/Pagination";
|
||||
|
||||
// Styles
|
||||
import css from "../../styles/elements/Pagination.scss";
|
||||
|
||||
// Define translations
|
||||
const paginationMessages = defineMessages(messagesMap(Array.concat([], commonMessages, messages)));
|
||||
|
||||
|
||||
/**
|
||||
* Pagination button bar
|
||||
*/
|
||||
class PaginationCSSIntl extends Component {
|
||||
computePaginationBounds(currentPage, nPages, maxNumberPagesShown=5) {
|
||||
// Taken from http://stackoverflow.com/a/8608998/2626416
|
||||
let lowerLimit = currentPage;
|
||||
let upperLimit = currentPage;
|
||||
constructor (props) {
|
||||
super (props);
|
||||
|
||||
for (let b = 1; b < maxNumberPagesShown && b < nPages;) {
|
||||
if (lowerLimit > 1 ) {
|
||||
lowerLimit--;
|
||||
b++;
|
||||
}
|
||||
if (b < maxNumberPagesShown && upperLimit < nPages) {
|
||||
upperLimit++;
|
||||
b++;
|
||||
}
|
||||
// Bind this
|
||||
this.goToPage = this.goToPage.bind(this);
|
||||
this.dotsOnClick = this.dotsOnClick.bind(this);
|
||||
this.dotsOnKeyDown = this.dotsOnKeyDown.bind(this);
|
||||
this.cancelModalBox = this.cancelModalBox.bind(this);
|
||||
}
|
||||
|
||||
return {
|
||||
lowerLimit: lowerLimit,
|
||||
upperLimit: upperLimit + 1 // +1 to ease iteration in for with <
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Handle click on the "go to page" button in the modal.
|
||||
*/
|
||||
goToPage(e) {
|
||||
e.preventDefault();
|
||||
|
||||
goToPage(ev) {
|
||||
ev.preventDefault();
|
||||
const pageNumber = parseInt(this.refs.pageInput.value);
|
||||
// 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");
|
||||
if (pageNumber) {
|
||||
this.props.goToPage(pageNumber);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click on the ellipsis dots.
|
||||
*/
|
||||
dotsOnClick() {
|
||||
// Show modal
|
||||
$(this.refs.paginationModal).modal();
|
||||
}
|
||||
|
||||
dotsOnKeyDown(ev) {
|
||||
ev.preventDefault;
|
||||
const code = ev.keyCode || ev.which;
|
||||
/**
|
||||
* Bind key down events on ellipsis dots for a11y.
|
||||
*/
|
||||
dotsOnKeyDown(e) {
|
||||
e.preventDefault;
|
||||
const code = e.keyCode || e.which;
|
||||
if (code == 13 || code == 32) { // Enter or Space key
|
||||
this.dotsOnClick(); // Fire same event as onClick
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click on "cancel" in the modal box.
|
||||
*/
|
||||
cancelModalBox() {
|
||||
// Hide modal
|
||||
$(this.refs.paginationModal).modal("hide");
|
||||
}
|
||||
|
||||
render () {
|
||||
const { formatMessage } = this.props.intl;
|
||||
const { lowerLimit, upperLimit } = this.computePaginationBounds(this.props.currentPage, this.props.nPages);
|
||||
|
||||
// Get bounds
|
||||
const { lowerLimit, upperLimit } = computePaginationBounds(this.props.currentPage, this.props.nPages);
|
||||
// Store buttons
|
||||
let pagesButton = [];
|
||||
let key = 0; // key increment to ensure correct ordering
|
||||
|
||||
// If lower limit is above 1, push 1 and ellipsis
|
||||
if (lowerLimit > 1) {
|
||||
// Push first page
|
||||
pagesButton.push(
|
||||
<li className="page-item" key={key}>
|
||||
<Link className="page-link" title={formatMessage(paginationMessages["app.pagination.goToPageWithoutMarkup"], { pageNumber: 1})} to={this.props.buildLinkToPage(1)}>
|
||||
@ -73,27 +92,28 @@ class PaginationCSSIntl extends Component {
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
key++;
|
||||
key++; // Always increment key after a push
|
||||
if (lowerLimit > 2) {
|
||||
// Eventually push "…"
|
||||
pagesButton.push(
|
||||
<li className="page-item" key={key}>
|
||||
<span tabIndex="0" role="button" onKeyDown={this.dotsOnKeyDown.bind(this)} onClick={this.dotsOnClick.bind(this)}>…</span>
|
||||
<span tabIndex="0" role="button" onKeyDown={this.dotsOnKeyDown} onClick={this.dotsOnClick}>…</span>
|
||||
</li>
|
||||
);
|
||||
key++;
|
||||
}
|
||||
}
|
||||
// Main buttons, between lower and upper limits
|
||||
for (let i = lowerLimit; i < upperLimit; i++) {
|
||||
let className = "page-item";
|
||||
let classNames = ["page-item"];
|
||||
let currentSpan = null;
|
||||
if (this.props.currentPage == i) {
|
||||
className += " active";
|
||||
classNames.push("active");
|
||||
currentSpan = <span className="sr-only">(<FormattedMessage {...paginationMessages["app.pagination.current"]} />)</span>;
|
||||
}
|
||||
const title = formatMessage(paginationMessages["app.pagination.goToPageWithoutMarkup"], { pageNumber: i });
|
||||
pagesButton.push(
|
||||
<li className={className} key={key}>
|
||||
<li className={classNames.join(" ")} key={key}>
|
||||
<Link className="page-link" title={title} to={this.props.buildLinkToPage(i)}>
|
||||
<FormattedHTMLMessage {...paginationMessages["app.pagination.goToPage"]} values={{ pageNumber: i }} />
|
||||
{currentSpan}
|
||||
@ -102,12 +122,13 @@ class PaginationCSSIntl extends Component {
|
||||
);
|
||||
key++;
|
||||
}
|
||||
// If upper limit is below the total number of page, show last page button
|
||||
if (upperLimit < this.props.nPages) {
|
||||
if (upperLimit < this.props.nPages - 1) {
|
||||
// Eventually push "…"
|
||||
pagesButton.push(
|
||||
<li className="page-item" key={key}>
|
||||
<span tabIndex="0" role="button" onKeyDown={this.dotsOnKeyDown.bind(this)} onClick={this.dotsOnClick.bind(this)}>…</span>
|
||||
<span tabIndex="0" role="button" onKeyDown={this.dotsOnKeyDown} onClick={this.dotsOnClick}>…</span>
|
||||
</li>
|
||||
);
|
||||
key++;
|
||||
@ -122,6 +143,8 @@ class PaginationCSSIntl extends Component {
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// If there are actually some buttons, show them
|
||||
if (pagesButton.length > 1) {
|
||||
return (
|
||||
<div>
|
||||
@ -140,15 +163,15 @@ class PaginationCSSIntl extends Component {
|
||||
</h4>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<form onSubmit={this.goToPage.bind(this)}>
|
||||
<form onSubmit={this.goToPage}>
|
||||
<input className="form-control" autoComplete="off" type="number" ref="pageInput" aria-label={formatMessage(paginationMessages["app.pagination.pageToGoTo"])} autoFocus />
|
||||
</form>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-default" onClick={this.cancelModalBox.bind(this)}>
|
||||
<button type="button" className="btn btn-default" onClick={this.cancelModalBox}>
|
||||
<FormattedMessage {...paginationMessages["app.common.cancel"]} />
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={this.goToPage.bind(this)}>
|
||||
<button type="button" className="btn btn-primary" onClick={this.goToPage}>
|
||||
<FormattedMessage {...paginationMessages["app.common.go"]} />
|
||||
</button>
|
||||
</div>
|
||||
@ -161,7 +184,6 @@ class PaginationCSSIntl extends Component {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
PaginationCSSIntl.propTypes = {
|
||||
currentPage: PropTypes.number.isRequired,
|
||||
goToPage: PropTypes.func.isRequired,
|
||||
@ -169,5 +191,4 @@ PaginationCSSIntl.propTypes = {
|
||||
nPages: PropTypes.number.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CSSModules(PaginationCSSIntl, css));
|
||||
|
@ -1,3 +1,4 @@
|
||||
// TODO: This file is to review
|
||||
import React, { Component, PropTypes } from "react";
|
||||
import CSSModules from "react-css-modules";
|
||||
import { defineMessages, injectIntl, intlShape, FormattedMessage } from "react-intl";
|
||||
|
@ -1,21 +1,34 @@
|
||||
// NPM imports
|
||||
import React, { Component, PropTypes } from "react";
|
||||
import { IndexLink, Link} from "react-router";
|
||||
import CSSModules from "react-css-modules";
|
||||
import { defineMessages, injectIntl, intlShape, FormattedMessage } from "react-intl";
|
||||
|
||||
// Local imports
|
||||
import { messagesMap } from "../../utils";
|
||||
|
||||
// Other components
|
||||
/* import WebPlayer from "../../views/WebPlayer"; TODO */
|
||||
|
||||
// Translations
|
||||
import commonMessages from "../../locales/messagesDescriptors/common";
|
||||
import messages from "../../locales/messagesDescriptors/layouts/Sidebar";
|
||||
|
||||
import WebPlayer from "../../views/WebPlayer";
|
||||
|
||||
// Styles
|
||||
import css from "../../styles/layouts/Sidebar.scss";
|
||||
|
||||
// Define translations
|
||||
const sidebarLayoutMessages = defineMessages(messagesMap(Array.concat([], commonMessages, messages)));
|
||||
|
||||
|
||||
/**
|
||||
* Sidebar layout component, putting children next to the sidebar menu.
|
||||
*/
|
||||
class SidebarLayoutIntl extends Component {
|
||||
render () {
|
||||
const { formatMessage } = this.props.intl;
|
||||
|
||||
// Check active links
|
||||
const isActive = {
|
||||
discover: (this.props.location.pathname == "/discover") ? "active" : "link",
|
||||
browse: (this.props.location.pathname == "/browse") ? "active" : "link",
|
||||
@ -24,9 +37,12 @@ class SidebarLayoutIntl extends Component {
|
||||
songs: (this.props.location.pathname == "/songs") ? "active" : "link",
|
||||
search: (this.props.location.pathname == "/search") ? "active" : "link"
|
||||
};
|
||||
|
||||
// Hamburger collapsing function
|
||||
const collapseHamburger = function () {
|
||||
$("#main-navbar").collapse("hide");
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="row">
|
||||
@ -128,17 +144,9 @@ class SidebarLayoutIntl extends Component {
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/search" title={formatMessage(sidebarLayoutMessages["app.sidebarLayout.search"])} styleName={isActive.search} onClick={collapseHamburger}>
|
||||
<span className="glyphicon glyphicon-search" aria-hidden="true"></span>
|
||||
<span className="hidden-md">
|
||||
<FormattedMessage {...sidebarLayoutMessages["app.sidebarLayout.search"]} />
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<WebPlayer />
|
||||
{ /** TODO <WebPlayer /> */ }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -149,11 +157,8 @@ class SidebarLayoutIntl extends Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
SidebarLayoutIntl.propTypes = {
|
||||
children: PropTypes.node,
|
||||
intl: intlShape.isRequired
|
||||
};
|
||||
|
||||
export default injectIntl(CSSModules(SidebarLayoutIntl, css));
|
||||
|
@ -1,5 +1,10 @@
|
||||
// NPM imports
|
||||
import React, { Component } from "react";
|
||||
|
||||
|
||||
/**
|
||||
* Simple layout, meaning just enclosing children in a div.
|
||||
*/
|
||||
export default class SimpleLayout extends Component {
|
||||
render () {
|
||||
return (
|
||||
|
@ -1,12 +1,15 @@
|
||||
/**
|
||||
* Main container at the top of our application components tree.
|
||||
*
|
||||
* Just a div wrapper around children for now.
|
||||
*/
|
||||
import React, { Component, PropTypes } from "react";
|
||||
|
||||
export default class App extends Component {
|
||||
render () {
|
||||
return (
|
||||
<div>
|
||||
{this.props.children && React.cloneElement(this.props.children, {
|
||||
error: this.props.error
|
||||
})}
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,18 +1,31 @@
|
||||
/**
|
||||
* Container wrapping elements neeeding a valid session. Automatically
|
||||
* redirects to login form in case such session does not exist.
|
||||
*/
|
||||
import React, { Component, PropTypes } from "react";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
// TODO: Handle expired session
|
||||
|
||||
export class RequireAuthentication extends Component {
|
||||
componentWillMount () {
|
||||
// Check authentication on mount
|
||||
this.checkAuth(this.props.isAuthenticated);
|
||||
}
|
||||
|
||||
componentWillUpdate (newProps) {
|
||||
// Check authentication on update
|
||||
this.checkAuth(newProps.isAuthenticated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle redirection in case user is not authenticated.
|
||||
*
|
||||
* @param isAuthenticated A boolean stating whether user has a valid
|
||||
* session or not.
|
||||
*/
|
||||
checkAuth (isAuthenticated) {
|
||||
if (!isAuthenticated) {
|
||||
// Redirect to login, redirecting to the actual page after login.
|
||||
this.context.router.replace({
|
||||
pathname: "/login",
|
||||
state: {
|
||||
|
@ -1,3 +1,6 @@
|
||||
/**
|
||||
* Root component to render, setting locale, messages, Router and Store.
|
||||
*/
|
||||
import React, { Component, PropTypes } from "react";
|
||||
import { Provider } from "react-redux";
|
||||
import { Router } from "react-router";
|
||||
|
@ -39,7 +39,6 @@ module.exports = {
|
||||
"app.sidebarLayout.home": "Home", // Home
|
||||
"app.sidebarLayout.logout": "Logout", // Logout
|
||||
"app.sidebarLayout.mainNavigationMenu": "Main navigation menu", // ARIA label for the main navigation menu
|
||||
"app.sidebarLayout.search": "Search", // Search
|
||||
"app.sidebarLayout.settings": "Settings", // Settings
|
||||
"app.sidebarLayout.toggleNavigation": "Toggle navigation", // Screen reader description of toggle navigation button
|
||||
"app.songs.genre": "Genre", // Genre (song)
|
||||
|
@ -39,7 +39,6 @@ module.exports = {
|
||||
"app.sidebarLayout.home": "Accueil", // Home
|
||||
"app.sidebarLayout.logout": "Déconnexion", // Logout
|
||||
"app.sidebarLayout.mainNavigationMenu": "Menu principal", // ARIA label for the main navigation menu
|
||||
"app.sidebarLayout.search": "Rechercher", // Search
|
||||
"app.sidebarLayout.settings": "Préférences", // Settings
|
||||
"app.sidebarLayout.toggleNavigation": "Afficher le menu", // Screen reader description of toggle navigation button
|
||||
"app.songs.genre": "Genre", // Genre (song)
|
||||
|
@ -1,3 +1,4 @@
|
||||
// Export all the existing locales
|
||||
module.exports = {
|
||||
"en-US": require("./en-US"),
|
||||
"fr-FR": require("./fr-FR")
|
||||
|
@ -44,11 +44,6 @@ const messages = [
|
||||
description: "Browse songs",
|
||||
defaultMessage: "Browse songs"
|
||||
},
|
||||
{
|
||||
id: "app.sidebarLayout.search",
|
||||
description: "Search",
|
||||
defaultMessage: "Search"
|
||||
},
|
||||
{
|
||||
id: "app.sidebarLayout.toggleNavigation",
|
||||
description: "Screen reader description of toggle navigation button",
|
||||
|
@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Redux middleware to perform API queries.
|
||||
*
|
||||
* This middleware catches the API requests and replaces them with API
|
||||
* responses.
|
||||
*/
|
||||
import fetch from "isomorphic-fetch";
|
||||
import humps from "humps";
|
||||
import X2JS from "x2js";
|
||||
@ -10,9 +16,19 @@ import { loginUserExpired } from "../actions/auth";
|
||||
export const API_VERSION = 350001; /** API version to use. */
|
||||
export const BASE_API_PATH = "/server/xml.server.php"; /** Base API path after endpoint. */
|
||||
|
||||
// Action key that carries API call info interpreted by this Redux middleware.
|
||||
export const CALL_API = "CALL_API";
|
||||
|
||||
// Error class to represents errors from these actions.
|
||||
class APIError extends Error {}
|
||||
|
||||
|
||||
/**
|
||||
* Check the HTTP status of the response.
|
||||
*
|
||||
* @param response A XHR response object.
|
||||
* @return The response or a rejected Promise if the check failed.
|
||||
*/
|
||||
function _checkHTTPStatus (response) {
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return response;
|
||||
@ -21,10 +37,17 @@ function _checkHTTPStatus (response) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Parse the XML resulting from the API to JS object.
|
||||
*
|
||||
* @param responseText The text from the API response.
|
||||
* @return The response as a JS object or a rejected Promise on error.
|
||||
*/
|
||||
function _parseToJSON (responseText) {
|
||||
let x2js = new X2JS({
|
||||
attributePrefix: "",
|
||||
keepCData: false
|
||||
attributePrefix: "", // No prefix for attributes
|
||||
keepCData: false // Do not store __cdata and toString functions
|
||||
});
|
||||
if (responseText) {
|
||||
return x2js.xml_str2json(responseText).root;
|
||||
@ -35,6 +58,13 @@ function _parseToJSON (responseText) {
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check the errors returned by the API itself, in its response.
|
||||
*
|
||||
* @param jsonData A JS object representing the API response.
|
||||
* @return The input data or a rejected Promise if errors are present.
|
||||
*/
|
||||
function _checkAPIErrors (jsonData) {
|
||||
if (jsonData.error) {
|
||||
return Promise.reject(jsonData.error);
|
||||
@ -48,7 +78,15 @@ function _checkAPIErrors (jsonData) {
|
||||
return jsonData;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Apply some fixes on the API data.
|
||||
*
|
||||
* @param jsonData A JS object representing the API response.
|
||||
* @return A fixed JS object.
|
||||
*/
|
||||
function _uglyFixes (jsonData) {
|
||||
// Fix songs array
|
||||
let _uglyFixesSongs = function (songs) {
|
||||
return songs.map(function (song) {
|
||||
// Fix for cdata left in artist and album
|
||||
@ -58,9 +96,10 @@ function _uglyFixes (jsonData) {
|
||||
});
|
||||
};
|
||||
|
||||
// Fix albums array
|
||||
let _uglyFixesAlbums = function (albums) {
|
||||
return albums.map(function (album) {
|
||||
// TODO
|
||||
// TODO: Should go in Ampache core
|
||||
// Fix for absence of distinction between disks in the same album
|
||||
if (album.disk > 1) {
|
||||
album.name = album.name + " [Disk " + album.disk + "]";
|
||||
@ -75,13 +114,14 @@ function _uglyFixes (jsonData) {
|
||||
album.tracks = [album.tracks];
|
||||
}
|
||||
|
||||
// Fix tracks
|
||||
// Fix tracks array
|
||||
album.tracks = _uglyFixesSongs(album.tracks);
|
||||
}
|
||||
return album;
|
||||
});
|
||||
};
|
||||
|
||||
// Fix artists array
|
||||
let _uglyFixesArtists = function (artists) {
|
||||
return artists.map(function (artist) {
|
||||
// Move albums one node top
|
||||
@ -131,17 +171,15 @@ function _uglyFixes (jsonData) {
|
||||
|
||||
// Fix albums
|
||||
if (jsonData.album) {
|
||||
// Fix albums
|
||||
jsonData.album = _uglyFixesAlbums(jsonData.album);
|
||||
}
|
||||
|
||||
// Fix songs
|
||||
if (jsonData.song) {
|
||||
// Fix songs
|
||||
jsonData.song = _uglyFixesSongs(jsonData.song);
|
||||
}
|
||||
|
||||
// TODO
|
||||
// TODO: Should go in Ampache core
|
||||
// Add sessionExpire information
|
||||
if (!jsonData.sessionExpire) {
|
||||
// Fix for Ampache not returning updated sessionExpire
|
||||
@ -151,17 +189,31 @@ function _uglyFixes (jsonData) {
|
||||
return jsonData;
|
||||
}
|
||||
|
||||
// Fetches an API response and normalizes the result JSON according to schema.
|
||||
// This makes every API response have the same shape, regardless of how nested it was.
|
||||
|
||||
/**
|
||||
* Fetches an API response and normalizes the result.
|
||||
*
|
||||
* @param endpoint Base URL of your Ampache server.
|
||||
* @param action API action name.
|
||||
* @param auth API token to use.
|
||||
* @param username Username to use in the API.
|
||||
* @param extraParams An object of extra parameters to pass to the API.
|
||||
*
|
||||
* @return A fetching Promise.
|
||||
*/
|
||||
function doAPICall (endpoint, action, auth, username, extraParams) {
|
||||
// Translate the API action to real API action
|
||||
const APIAction = extraParams.filter ? action.rstrip("s") : action;
|
||||
// Set base params
|
||||
const baseParams = {
|
||||
version: API_VERSION,
|
||||
action: APIAction,
|
||||
auth: auth,
|
||||
user: username
|
||||
};
|
||||
// Extend with extraParams
|
||||
const params = Object.assign({}, baseParams, extraParams);
|
||||
// Assemble the full URL with endpoint, API path and GET params
|
||||
const fullURL = assembleURLAndParams(endpoint + BASE_API_PATH, params);
|
||||
|
||||
return fetch(fullURL, {
|
||||
@ -175,19 +227,19 @@ function doAPICall (endpoint, action, auth, username, extraParams) {
|
||||
.then(_uglyFixes);
|
||||
}
|
||||
|
||||
// Action key that carries API call info interpreted by this Redux middleware.
|
||||
export const CALL_API = "CALL_API";
|
||||
|
||||
// A Redux middleware that interprets actions with CALL_API info specified.
|
||||
// Performs the call and promises when such actions are dispatched.
|
||||
/**
|
||||
* A Redux middleware that interprets actions with CALL_API info specified.
|
||||
* Performs the call and promises when such actions are dispatched.
|
||||
*/
|
||||
export default store => next => reduxAction => {
|
||||
if (reduxAction.type !== CALL_API) {
|
||||
// Do not apply on every action
|
||||
// Do not apply on other actions
|
||||
return next(reduxAction);
|
||||
}
|
||||
|
||||
// Check payload
|
||||
const { endpoint, action, auth, username, dispatch, extraParams } = reduxAction.payload;
|
||||
|
||||
if (!endpoint || typeof endpoint !== "string") {
|
||||
throw new APIError("Specify a string endpoint URL.");
|
||||
}
|
||||
@ -207,22 +259,27 @@ export default store => next => reduxAction => {
|
||||
throw new APIError("Expected action to dispatch to be functions or null.");
|
||||
}
|
||||
|
||||
// Get the actions to dispatch
|
||||
const [ requestDispatch, successDispatch, failureDispatch ] = dispatch;
|
||||
if (requestDispatch) {
|
||||
// Dispatch request action if needed
|
||||
store.dispatch(requestDispatch());
|
||||
}
|
||||
|
||||
// Run the API call
|
||||
return doAPICall(endpoint, action, auth, username, extraParams).then(
|
||||
response => {
|
||||
if (successDispatch) {
|
||||
// Dispatch success if needed
|
||||
store.dispatch(successDispatch(response));
|
||||
}
|
||||
},
|
||||
error => {
|
||||
if (failureDispatch) {
|
||||
const errorMessage = error.__cdata + " (" + error._code + ")";
|
||||
// Error object from the API
|
||||
// Error object from the API (in the JS object)
|
||||
if (error._code && error.__cdata) {
|
||||
// Format the error message
|
||||
const errorMessage = error.__cdata + " (" + error._code + ")";
|
||||
if (401 == error._code) {
|
||||
// This is an error meaning no valid session was
|
||||
// passed. We must perform a new handshake.
|
||||
|
@ -1,22 +1,28 @@
|
||||
/**
|
||||
* This file defines API related models.
|
||||
*/
|
||||
|
||||
// NPM imports
|
||||
import { Schema, arrayOf } from "normalizr";
|
||||
|
||||
export const artist = new Schema("artist");
|
||||
export const album = new Schema("album");
|
||||
export const track = new Schema("track");
|
||||
export const tag = new Schema("tag");
|
||||
|
||||
artist.define({
|
||||
// Define normalizr schemas for major entities returned by the API
|
||||
export const artist = new Schema("artist"); /** Artist schema */
|
||||
export const album = new Schema("album"); /** Album schema */
|
||||
export const song = new Schema("song"); /** Song schema */
|
||||
|
||||
// Explicit relations between them
|
||||
artist.define({ // Artist has albums and songs (tracks)
|
||||
albums: arrayOf(album),
|
||||
songs: arrayOf(track)
|
||||
songs: arrayOf(song)
|
||||
});
|
||||
|
||||
album.define({
|
||||
album.define({ // Album has artist, tracks and tags
|
||||
artist: artist,
|
||||
tracks: arrayOf(track),
|
||||
tag: arrayOf(tag)
|
||||
tracks: arrayOf(song)
|
||||
});
|
||||
|
||||
track.define({
|
||||
song.define({ // Track has artist and album
|
||||
artist: artist,
|
||||
album: album
|
||||
});
|
||||
|
@ -1,18 +1,27 @@
|
||||
/**
|
||||
* This file defines authentication related models.
|
||||
*/
|
||||
|
||||
// NPM imports
|
||||
import Immutable from "immutable";
|
||||
|
||||
|
||||
/** Record to store token parameters */
|
||||
export const tokenRecord = Immutable.Record({
|
||||
token: null,
|
||||
expires: null
|
||||
token: null, /** Token string */
|
||||
expires: null /** Token expiration date */
|
||||
});
|
||||
|
||||
|
||||
/** Record to store the full auth state */
|
||||
export const stateRecord = new Immutable.Record({
|
||||
token: tokenRecord,
|
||||
username: null,
|
||||
endpoint: null,
|
||||
rememberMe: false,
|
||||
isAuthenticated: false,
|
||||
isAuthenticating: false,
|
||||
error: null,
|
||||
info: null,
|
||||
timerID: null
|
||||
token: new tokenRecord(), /** Auth token */
|
||||
username: null, /** Username */
|
||||
endpoint: null, /** Ampache server base URL */
|
||||
rememberMe: false, /** Whether to remember me or not */
|
||||
isAuthenticated: false, /** Whether authentication is ok or not */
|
||||
isAuthenticating: false, /** Whether authentication is in progress or not */
|
||||
error: null, /** An error string */
|
||||
info: null, /** An info string */
|
||||
timerID: null /** Timer ID for setInterval calls to revive API session */
|
||||
});
|
||||
|
22
app/models/entities.js
Normal file
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";
|
||||
|
||||
/** i18n record for passing errors to be localized from actions to components */
|
||||
export const i18nRecord = new Immutable.Record({
|
||||
id: null,
|
||||
values: new Immutable.Map()
|
||||
id: null, /** Translation message id */
|
||||
values: new Immutable.Map() /** Values to pass to formatMessage */
|
||||
});
|
||||
|
@ -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";
|
||||
|
||||
export const entitiesRecord = new Immutable.Record({
|
||||
artists: new Immutable.Map(),
|
||||
albums: new Immutable.Map(),
|
||||
tracks: new Immutable.Map()
|
||||
});
|
||||
|
||||
/** Record to store the webplayer state. */
|
||||
export const stateRecord = new Immutable.Record({
|
||||
isPlaying: false,
|
||||
isRandom: false,
|
||||
isRepeat: false,
|
||||
isMute: false,
|
||||
currentIndex: 0,
|
||||
playlist: new Immutable.List(),
|
||||
entities: new entitiesRecord()
|
||||
isPlaying: false, /** Whether webplayer is playing */
|
||||
isRandom: false, /** Whether random mode is on */
|
||||
isRepeat: false, /** Whether repeat mode is on */
|
||||
isMute: false, /** Whether sound is muted or not */
|
||||
volume: 100, /** Current volume, between 0 and 100 */
|
||||
currentIndex: 0, /** Current index in the playlist */
|
||||
playlist: new Immutable.List() /** List of songs IDs, references songs in the entities store */
|
||||
});
|
||||
|
@ -1,15 +1,31 @@
|
||||
/**
|
||||
* This implements the auth reducer, storing and updating authentication state.
|
||||
*/
|
||||
|
||||
// NPM imports
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
import { LOGIN_USER_REQUEST, LOGIN_USER_SUCCESS, LOGIN_USER_FAILURE, LOGIN_USER_EXPIRED, LOGOUT_USER } from "../actions";
|
||||
// Local imports
|
||||
import { createReducer } from "../utils";
|
||||
|
||||
// Models
|
||||
import { i18nRecord } from "../models/i18n";
|
||||
import { tokenRecord, stateRecord } from "../models/auth";
|
||||
|
||||
/**
|
||||
* Initial state
|
||||
*/
|
||||
// Actions
|
||||
import {
|
||||
LOGIN_USER_REQUEST,
|
||||
LOGIN_USER_SUCCESS,
|
||||
LOGIN_USER_FAILURE,
|
||||
LOGIN_USER_EXPIRED,
|
||||
LOGOUT_USER } from "../actions";
|
||||
|
||||
|
||||
/**
|
||||
* Initial state, load data from cookies if set
|
||||
*/
|
||||
var initialState = new stateRecord();
|
||||
// Get token
|
||||
const initialToken = Cookies.getJSON("token");
|
||||
if (initialToken) {
|
||||
initialToken.expires = new Date(initialToken.expires);
|
||||
@ -18,6 +34,7 @@ if (initialToken) {
|
||||
new tokenRecord({ token: initialToken.token, expires: new Date(initialToken.expires) })
|
||||
);
|
||||
}
|
||||
// Get username
|
||||
const initialUsername = Cookies.get("username");
|
||||
if (initialUsername) {
|
||||
initialState = initialState.set(
|
||||
@ -25,6 +42,7 @@ if (initialUsername) {
|
||||
initialUsername
|
||||
);
|
||||
}
|
||||
// Get endpoint
|
||||
const initialEndpoint = Cookies.get("endpoint");
|
||||
if (initialEndpoint) {
|
||||
initialState = initialState.set(
|
||||
@ -32,6 +50,7 @@ if (initialEndpoint) {
|
||||
initialEndpoint
|
||||
);
|
||||
}
|
||||
// Set remember me
|
||||
if (initialUsername && initialEndpoint) {
|
||||
initialState = initialState.set(
|
||||
"rememberMe",
|
||||
@ -39,10 +58,10 @@ if (initialUsername && initialEndpoint) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reducers
|
||||
*/
|
||||
|
||||
export default createReducer(initialState, {
|
||||
[LOGIN_USER_REQUEST]: () => {
|
||||
return new stateRecord({
|
||||
|
211
app/reducers/entities.js
Normal file
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 { combineReducers } from "redux";
|
||||
|
||||
// Import all the available reducers
|
||||
import auth from "./auth";
|
||||
import paginate from "./paginate";
|
||||
import entities from "./entities";
|
||||
import paginatedMaker from "./paginated";
|
||||
import webplayer from "./webplayer";
|
||||
|
||||
// Actions
|
||||
import * as ActionTypes from "../actions";
|
||||
|
||||
// Updates the pagination data for different actions.
|
||||
const api = paginate([
|
||||
// Build paginated reducer
|
||||
const paginated = paginatedMaker([
|
||||
ActionTypes.API_REQUEST,
|
||||
ActionTypes.API_SUCCESS,
|
||||
ActionTypes.API_FAILURE
|
||||
]);
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
// Export the combined reducers
|
||||
export default combineReducers({
|
||||
routing,
|
||||
auth,
|
||||
api,
|
||||
entities,
|
||||
paginated,
|
||||
webplayer
|
||||
});
|
||||
|
||||
export default rootReducer;
|
||||
|
@ -1,14 +1,29 @@
|
||||
/**
|
||||
* This implements a wrapper to create reducers for paginated content.
|
||||
*/
|
||||
|
||||
// NPM imports
|
||||
import Immutable from "immutable";
|
||||
|
||||
// Local imports
|
||||
import { createReducer } from "../utils";
|
||||
import { stateRecord } from "../models/paginate";
|
||||
import { INVALIDATE_STORE } from "../actions";
|
||||
|
||||
// Models
|
||||
import { stateRecord } from "../models/paginated";
|
||||
|
||||
// Actions
|
||||
import { CLEAR_RESULTS, INVALIDATE_STORE } from "../actions";
|
||||
|
||||
|
||||
/** Initial state of the reducer */
|
||||
const initialState = new stateRecord();
|
||||
|
||||
// Creates a reducer managing pagination, given the action types to handle,
|
||||
// and a function telling how to extract the key from an action.
|
||||
export default function paginate(types) {
|
||||
|
||||
/**
|
||||
* Creates a reducer managing pagination, given the action types to handle.
|
||||
*/
|
||||
export default function paginated(types) {
|
||||
// Check parameters
|
||||
if (!Array.isArray(types) || types.length !== 3) {
|
||||
throw new Error("Expected types to be an array of three elements.");
|
||||
}
|
||||
@ -18,33 +33,28 @@ export default function paginate(types) {
|
||||
|
||||
const [ requestType, successType, failureType ] = types;
|
||||
|
||||
// Create reducer
|
||||
return createReducer(initialState, {
|
||||
[requestType]: (state) => {
|
||||
return (
|
||||
state
|
||||
.set("isFetching", true)
|
||||
.set("error", null)
|
||||
);
|
||||
return state;
|
||||
},
|
||||
[successType]: (state, payload) => {
|
||||
return (
|
||||
state
|
||||
.set("isFetching", false)
|
||||
.set("type", payload.type)
|
||||
.set("result", Immutable.fromJS(payload.result))
|
||||
.set("entities", Immutable.fromJS(payload.entities))
|
||||
.set("error", null)
|
||||
.set("nPages", payload.nPages)
|
||||
.set("currentPage", payload.currentPage)
|
||||
);
|
||||
},
|
||||
[failureType]: (state, payload) => {
|
||||
return (
|
||||
state
|
||||
.set("isFetching", false)
|
||||
.set("error", payload.error)
|
||||
);
|
||||
[failureType]: (state) => {
|
||||
return state;
|
||||
},
|
||||
[CLEAR_RESULTS]: (state) => {
|
||||
return state.set("result", new Immutable.List());
|
||||
},
|
||||
[INVALIDATE_STORE]: () => {
|
||||
// Reset state on invalidation
|
||||
return new stateRecord();
|
||||
}
|
||||
});
|
@ -1,3 +1,4 @@
|
||||
// TODO: This is a WIP
|
||||
import Immutable from "immutable";
|
||||
|
||||
import {
|
||||
|
@ -1,3 +1,6 @@
|
||||
/**
|
||||
* Routes for the React app.
|
||||
*/
|
||||
import React from "react";
|
||||
import { IndexRoute, Route } from "react-router";
|
||||
|
||||
@ -17,13 +20,13 @@ import ArtistPage from "./views/ArtistPage";
|
||||
import AlbumPage from "./views/AlbumPage";
|
||||
|
||||
export default (
|
||||
<Route path="/" component={App}>
|
||||
<Route path="login" component={SimpleLayout}>
|
||||
<Route path="/" component={App}> // Main container is App
|
||||
<Route path="login" component={SimpleLayout}> // Login is a SimpleLayout
|
||||
<IndexRoute component={LoginPage} />
|
||||
</Route>
|
||||
<Route component={SidebarLayout}>
|
||||
<Route component={SidebarLayout}> // All the rest is a SidebarLayout
|
||||
<Route path="logout" component={LogoutPage} />
|
||||
<Route component={RequireAuthentication}>
|
||||
<Route component={RequireAuthentication}> // And some pages require authentication
|
||||
<Route path="discover" component={DiscoverPage} />
|
||||
<Route path="browse" component={BrowsePage} />
|
||||
<Route path="artists" component={ArtistsPage} />
|
||||
|
@ -7,6 +7,7 @@ import createLogger from "redux-logger";
|
||||
import rootReducer from "../reducers";
|
||||
import apiMiddleware from "../middleware/api";
|
||||
|
||||
// Use history and log everything during dev
|
||||
const historyMiddleware = routerMiddleware(hashHistory);
|
||||
const loggerMiddleware = createLogger();
|
||||
|
||||
|
@ -1,3 +1,6 @@
|
||||
/**
|
||||
* Store configuration
|
||||
*/
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
module.exports = require("./configureStore.production.js");
|
||||
} else {
|
||||
|
@ -6,6 +6,7 @@ import thunkMiddleware from "redux-thunk";
|
||||
import rootReducer from "../reducers";
|
||||
import apiMiddleware from "../middleware/api";
|
||||
|
||||
// Use history
|
||||
const historyMiddleware = routerMiddleware(hashHistory);
|
||||
|
||||
export default function configureStore(preloadedState) {
|
||||
|
@ -1,18 +1,36 @@
|
||||
/**
|
||||
* Album component style.
|
||||
*/
|
||||
|
||||
/** Variables */
|
||||
$rowMarginTop: 30px;
|
||||
$rowMarginBottom: 10px;
|
||||
$artMarginBottom: 10px;
|
||||
|
||||
/* Style for an album row */
|
||||
.row {
|
||||
margin-top: $rowMarginTop;
|
||||
}
|
||||
|
||||
/* Style for album arts */
|
||||
.art {
|
||||
composes: art from "./elements/Grid.scss";
|
||||
display: inline-block;
|
||||
margin-bottom: $artMarginBottom;
|
||||
width: 75%;
|
||||
height: auto;
|
||||
|
||||
/* doiuse-disable viewport-units */
|
||||
|
||||
max-width: 25vw;
|
||||
|
||||
/* doiuse-enable viewport-units */
|
||||
}
|
||||
|
||||
.art:hover {
|
||||
cursor: initial;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Play button is based on the one in Songs list. */
|
||||
.play {
|
||||
composes: play from "./Songs.scss";
|
||||
}
|
||||
|
@ -1,3 +1,10 @@
|
||||
/**
|
||||
* Styles for Artist component.
|
||||
*/
|
||||
|
||||
/** Variables */
|
||||
$artMarginBottom: 10px;
|
||||
|
||||
.name > h1 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@ -7,5 +14,18 @@
|
||||
}
|
||||
|
||||
.art {
|
||||
composes: art from "./elements/Grid.scss";
|
||||
display: inline-block;
|
||||
margin-bottom: $artMarginBottom;
|
||||
width: 75%;
|
||||
height: auto;
|
||||
|
||||
/* doiuse-disable viewport-units */
|
||||
|
||||
max-width: 25vw;
|
||||
|
||||
/* doiuse-enable viewport-units */
|
||||
}
|
||||
|
||||
.art:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Style for Discover component.
|
||||
*/
|
||||
|
||||
// TODO: Fix this style
|
||||
|
||||
.noMarginTop {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
@ -1,3 +1,8 @@
|
||||
/**
|
||||
* Style for Login component.
|
||||
*/
|
||||
|
||||
/** Variables */
|
||||
$titleImage-size: $font-size-h1 + 10px;
|
||||
|
||||
.titleImage {
|
||||
|
@ -1,3 +1,6 @@
|
||||
/**
|
||||
* Style for Songs component.
|
||||
*/
|
||||
.play {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
|
@ -1,3 +1,8 @@
|
||||
/**
|
||||
* Styles for the FilterBar component.
|
||||
*/
|
||||
|
||||
/** Variables */
|
||||
$marginBottom: 34px;
|
||||
|
||||
.filter {
|
||||
|
@ -1,3 +1,8 @@
|
||||
/**
|
||||
* Style for the Grid component.
|
||||
*/
|
||||
|
||||
/** Variables */
|
||||
$marginBottom: 30px;
|
||||
$artMarginBottom: 10px;
|
||||
|
||||
|
@ -1,3 +1,6 @@
|
||||
/**
|
||||
* Styles for the Pagination component.
|
||||
*/
|
||||
.nav {
|
||||
text-align: center;
|
||||
}
|
||||
|
@ -1,3 +1,8 @@
|
||||
/**
|
||||
* Styles for the WebPlayer component.
|
||||
*/
|
||||
|
||||
/** Variables */
|
||||
$controlsMarginTop: 10px;
|
||||
|
||||
.webplayer {
|
||||
|
@ -1,3 +1,8 @@
|
||||
/**
|
||||
* Styles for Sidebar layout component.
|
||||
*/
|
||||
|
||||
/** Variables */
|
||||
$background: #333;
|
||||
$hoverBackground: #222;
|
||||
$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/_mixins";
|
||||
|
||||
$blue: #3e90fa;
|
||||
$orange: #faa83e;
|
||||
$blue: #3e90fa; // Blue color from the logo
|
||||
$orange: #faa83e; // Orange color from the logo
|
||||
|
32
app/utils/ampache.js
Normal file
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) {
|
||||
return a.filter(function (i) {
|
||||
return b.indexOf(i) < 0;
|
||||
|
@ -1,3 +1,7 @@
|
||||
/**
|
||||
* Collection of utility functions and helpers.
|
||||
*/
|
||||
export * from "./ampache";
|
||||
export * from "./immutable";
|
||||
export * from "./locale";
|
||||
export * from "./misc";
|
||||
|
@ -1,10 +1,17 @@
|
||||
/**
|
||||
* Collection of helper functions to deal with localization.
|
||||
*/
|
||||
import { i18nRecord } from "../models/i18n";
|
||||
|
||||
/**
|
||||
* Get the preferred locales from the browser, as an array sorted by preferences.
|
||||
*/
|
||||
export function getBrowserLocales () {
|
||||
let langs;
|
||||
let langs = [];
|
||||
|
||||
if (navigator.languages) {
|
||||
// chrome does not currently set navigator.language correctly https://code.google.com/p/chromium/issues/detail?id=101138
|
||||
// Chrome does not currently set navigator.language correctly
|
||||
// https://code.google.com/p/chromium/issues/detail?id=101138
|
||||
// but it does set the first element of navigator.languages correctly
|
||||
langs = navigator.languages;
|
||||
} else if (navigator.userLanguage) {
|
||||
@ -24,6 +31,10 @@ export function getBrowserLocales () {
|
||||
return locales;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Convert an array of messagesDescriptors to a map.
|
||||
*/
|
||||
export function messagesMap(messagesDescriptorsArray) {
|
||||
let messagesDescriptorsMap = {};
|
||||
|
||||
@ -35,9 +46,25 @@ export function messagesMap(messagesDescriptorsArray) {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Format an error message from the state.
|
||||
*
|
||||
* Error message can be either an i18nRecord, which is to be formatted, or a
|
||||
* raw string. This function performs the check and returns the correctly
|
||||
* formatted string.
|
||||
*
|
||||
* @param errorMessage The error message from the state, either plain
|
||||
* string or i18nRecord.
|
||||
* @param formatMessage react-i18n formatMessage.
|
||||
* @param messages List of messages to use for formatting.
|
||||
*
|
||||
* @return A string for the error.
|
||||
*/
|
||||
export function handleErrorI18nObject(errorMessage, formatMessage, messages) {
|
||||
if (errorMessage instanceof i18nRecord) {
|
||||
// If it is an object, format it and return it
|
||||
return formatMessage(messages[errorMessage.id], errorMessage.values);
|
||||
}
|
||||
// Else, it's a string, just return it
|
||||
return errorMessage;
|
||||
}
|
||||
|
@ -1,7 +1,14 @@
|
||||
/**
|
||||
* Miscellaneous helper functions.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Strict int checking function.
|
||||
*
|
||||
* @param value The value to check for int.
|
||||
* @return Either NaN if the string was not a valid int representation, or the
|
||||
* int.
|
||||
*/
|
||||
export function filterInt (value) {
|
||||
if (/^(\-|\+)?([0-9]+|Infinity)$/.test(value)) {
|
||||
@ -10,6 +17,7 @@ export function filterInt (value) {
|
||||
return NaN;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Helper to format song length.
|
||||
*
|
||||
|
@ -1,3 +1,18 @@
|
||||
/**
|
||||
* Collection of helper functions to deal with pagination.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Helper function to build a pagination object to pass down the component tree.
|
||||
*
|
||||
* @param location react-router location props.
|
||||
* @param currentPage Number of the current page.
|
||||
* @param nPages Total number of pages.
|
||||
* @param goToPageAction Action to dispatch to go to a specific page.
|
||||
*
|
||||
* @return An object containing all the props for the Pagination component.
|
||||
*/
|
||||
export function buildPaginationObject(location, currentPage, nPages, goToPageAction) {
|
||||
const buildLinkToPage = function (pageNumber) {
|
||||
return {
|
||||
@ -12,3 +27,36 @@ export function buildPaginationObject(location, currentPage, nPages, goToPageAct
|
||||
buildLinkToPage: buildLinkToPage
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Helper function to compute the buttons to display.
|
||||
*
|
||||
* Taken from http://stackoverflow.com/a/8608998/2626416
|
||||
*
|
||||
* @param currentPage Number of the current page.
|
||||
* @param nPages Total number of pages.
|
||||
* @param maxNumberPagesShown Maximum number of pages button to show.
|
||||
*
|
||||
* @return An object containing lower limit and upper limit bounds.
|
||||
*/
|
||||
export function computePaginationBounds(currentPage, nPages, maxNumberPagesShown=5) {
|
||||
let lowerLimit = currentPage;
|
||||
let upperLimit = currentPage;
|
||||
|
||||
for (let b = 1; b < maxNumberPagesShown && b < nPages;) {
|
||||
if (lowerLimit > 1 ) {
|
||||
lowerLimit--;
|
||||
b++;
|
||||
}
|
||||
if (b < maxNumberPagesShown && upperLimit < nPages) {
|
||||
upperLimit++;
|
||||
b++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
lowerLimit: lowerLimit,
|
||||
upperLimit: upperLimit + 1 // +1 to ease iteration in for with <
|
||||
};
|
||||
}
|
||||
|
@ -1,3 +1,16 @@
|
||||
/**
|
||||
* Collection of helper functions to deal with reducers.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Utility function to create a reducer.
|
||||
*
|
||||
* @param initialState Initial state of the reducer.
|
||||
* @param reducerMap Map between action types and reducing functions.
|
||||
*
|
||||
* @return A reducer.
|
||||
*/
|
||||
export function createReducer(initialState, reducerMap) {
|
||||
return (state = initialState, action) => {
|
||||
const reducer = reducerMap[action.type];
|
||||
|
@ -1,3 +1,16 @@
|
||||
/**
|
||||
* Collection of helper functions to deal with URLs.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Assemble a base URL and its GET parameters.
|
||||
*
|
||||
* @param endpoint Base URL.
|
||||
* @param params An object of GET params and their values.
|
||||
*
|
||||
* @return A string with the full URL with GET params.
|
||||
*/
|
||||
export function assembleURLAndParams (endpoint, params) {
|
||||
let url = endpoint + "?";
|
||||
Object.keys(params).forEach(
|
||||
@ -11,3 +24,29 @@ export function assembleURLAndParams (endpoint, params) {
|
||||
);
|
||||
return url.rstrip("&");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Clean an endpoint URL.
|
||||
*
|
||||
* Adds the protocol prefix if not specified, remove trailing slash
|
||||
*
|
||||
* @param An URL
|
||||
* @return The cleaned URL
|
||||
*/
|
||||
export function cleanURL (endpoint) {
|
||||
if (
|
||||
!endpoint.startsWith("//") &&
|
||||
!endpoint.startsWith("http://") &&
|
||||
!endpoint.startsWith("https://"))
|
||||
{
|
||||
// Handle endpoints of the form "ampache.example.com"
|
||||
// Append same protocol as currently in use, to avoid mixed content.
|
||||
endpoint = window.location.protocol + "//" + endpoint;
|
||||
}
|
||||
|
||||
// Remove trailing slash
|
||||
endpoint = endpoint.replace(/\/$/, "");
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
@ -1,39 +1,57 @@
|
||||
// NPM imports
|
||||
import React, { Component } from "react";
|
||||
import { bindActionCreators } from "redux";
|
||||
import { connect } from "react-redux";
|
||||
import { defineMessages, injectIntl, intlShape } from "react-intl";
|
||||
import Immutable from "immutable";
|
||||
|
||||
import * as actionCreators from "../actions";
|
||||
// Local imports
|
||||
import { buildPaginationObject, messagesMap, handleErrorI18nObject } from "../utils";
|
||||
|
||||
// Actions
|
||||
import * as actionCreators from "../actions";
|
||||
|
||||
// Components
|
||||
import Albums from "../components/Albums";
|
||||
|
||||
// Translations
|
||||
import APIMessages from "../locales/messagesDescriptors/api";
|
||||
|
||||
// Define translations
|
||||
const albumsMessages = defineMessages(messagesMap(Array.concat([], APIMessages)));
|
||||
|
||||
|
||||
/**
|
||||
* Albums page, grid layout of albums arts.
|
||||
*/
|
||||
class AlbumsPageIntl extends Component {
|
||||
componentWillMount () {
|
||||
// Load the data for current page
|
||||
const currentPage = parseInt(this.props.location.query.page) || 1;
|
||||
// Load the data
|
||||
this.props.actions.loadAlbums({pageNumber: currentPage});
|
||||
this.props.actions.loadPaginatedAlbums({ pageNumber: currentPage });
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
// Load the data if page has changed
|
||||
const currentPage = parseInt(this.props.location.query.page) || 1;
|
||||
const nextPage = parseInt(nextProps.location.query.page) || 1;
|
||||
if (currentPage != nextPage) {
|
||||
// Load the data
|
||||
this.props.actions.loadAlbums({pageNumber: nextPage});
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const pagination = buildPaginationObject(this.props.location, this.props.currentPage, this.props.nPages, this.props.actions.goToPageAction);
|
||||
componentWillUnmount () {
|
||||
// Unload data on page change
|
||||
this.props.actions.clearResults();
|
||||
}
|
||||
|
||||
render () {
|
||||
const {formatMessage} = this.props.intl;
|
||||
|
||||
const pagination = buildPaginationObject(this.props.location, this.props.currentPage, this.props.nPages, this.props.actions.goToPage);
|
||||
|
||||
const error = handleErrorI18nObject(this.props.error, formatMessage, albumsMessages);
|
||||
|
||||
return (
|
||||
<Albums isFetching={this.props.isFetching} error={error} albums={this.props.albumsList} pagination={pagination} />
|
||||
);
|
||||
@ -46,18 +64,17 @@ AlbumsPageIntl.propTypes = {
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
let albumsList = new Immutable.List();
|
||||
let albums = state.api.result.get("album");
|
||||
if (albums) {
|
||||
albumsList = albums.map(
|
||||
id => state.api.entities.getIn(["album", id])
|
||||
if (state.paginated.type == "album" && state.paginated.result.size > 0) {
|
||||
albumsList = state.paginated.result.map(
|
||||
id => state.entities.getIn(["entities", "album", id])
|
||||
);
|
||||
}
|
||||
return {
|
||||
isFetching: state.api.isFetching,
|
||||
error: state.api.error,
|
||||
isFetching: state.entities.isFetching,
|
||||
error: state.entities.error,
|
||||
albumsList: albumsList,
|
||||
currentPage: state.api.currentPage,
|
||||
nPages: state.api.nPages
|
||||
currentPage: state.paginated.currentPage,
|
||||
nPages: state.paginated.nPages
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -1,79 +1,91 @@
|
||||
// NPM imports
|
||||
import React, { Component } from "react";
|
||||
import { bindActionCreators } from "redux";
|
||||
import { connect } from "react-redux";
|
||||
import { defineMessages, injectIntl, intlShape } from "react-intl";
|
||||
import Immutable from "immutable";
|
||||
|
||||
import * as actionCreators from "../actions";
|
||||
// Local imports
|
||||
import { messagesMap, handleErrorI18nObject } from "../utils";
|
||||
|
||||
// Actions
|
||||
import * as actionCreators from "../actions";
|
||||
|
||||
// Components
|
||||
import Artist from "../components/Artist";
|
||||
|
||||
// Translations
|
||||
import APIMessages from "../locales/messagesDescriptors/api";
|
||||
|
||||
// Define translations
|
||||
const artistMessages = defineMessages(messagesMap(Array.concat([], APIMessages)));
|
||||
|
||||
|
||||
/**
|
||||
* Single artist page.
|
||||
*/
|
||||
class ArtistPageIntl extends Component {
|
||||
componentWillMount () {
|
||||
// Load the data
|
||||
this.props.actions.loadArtists({
|
||||
pageNumber: 1,
|
||||
this.props.actions.loadArtist({
|
||||
filter: this.props.params.id,
|
||||
include: ["albums", "songs"]
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.props.actions.decrementRefCount({
|
||||
"artist": [this.props.artist.get("id")]
|
||||
});
|
||||
}
|
||||
|
||||
render () {
|
||||
const {formatMessage} = this.props.intl;
|
||||
|
||||
const error = handleErrorI18nObject(this.props.error, formatMessage, artistMessages);
|
||||
|
||||
return (
|
||||
<Artist playAction={this.props.actions.playTrack} isFetching={this.props.isFetching} error={error} artist={this.props.artist} albums={this.props.albums} songs={this.props.songs} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ArtistPageIntl.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const artists = state.api.entities.get("artist");
|
||||
let artist = new Immutable.Map();
|
||||
let albums = new Immutable.Map();
|
||||
let songs = new Immutable.Map();
|
||||
if (artists) {
|
||||
// Get artist
|
||||
artist = artists.find(
|
||||
item => item.get("id") == ownProps.params.id
|
||||
);
|
||||
let artist = state.entities.getIn(["entities", "artist", ownProps.params.id]);
|
||||
let albums = new Immutable.List();
|
||||
let songs = new Immutable.Map();
|
||||
if (artist) {
|
||||
// Get albums
|
||||
const artistAlbums = artist.get("albums");
|
||||
let artistAlbums = artist.get("albums");
|
||||
if (Immutable.List.isList(artistAlbums)) {
|
||||
albums = new Immutable.Map(
|
||||
artistAlbums.map(
|
||||
id => [id, state.api.entities.getIn(["album", id])]
|
||||
)
|
||||
albums = artistAlbums.map(
|
||||
id => state.entities.getIn(["entities", "album", id])
|
||||
);
|
||||
}
|
||||
// Get songs
|
||||
const artistSongs = artist.get("songs");
|
||||
let artistSongs = artist.get("songs");
|
||||
if (Immutable.List.isList(artistSongs)) {
|
||||
songs = new Immutable.Map(
|
||||
artistSongs.map(
|
||||
id => [id, state.api.entities.getIn(["track", id])]
|
||||
)
|
||||
songs = state.entities.getIn(["entities", "song"]).filter(
|
||||
song => artistSongs.includes(song.get("id"))
|
||||
);
|
||||
}
|
||||
} else {
|
||||
artist = new Immutable.Map();
|
||||
}
|
||||
return {
|
||||
isFetching: state.api.isFetching,
|
||||
error: state.api.error,
|
||||
isFetching: state.entities.isFetching,
|
||||
error: state.entities.error,
|
||||
artist: artist,
|
||||
albums: albums,
|
||||
songs: songs
|
||||
};
|
||||
};
|
||||
|
||||
ArtistPageIntl.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
actions: bindActionCreators(actionCreators, dispatch)
|
||||
});
|
||||
|
@ -1,39 +1,57 @@
|
||||
// NPM imports
|
||||
import React, { Component } from "react";
|
||||
import { bindActionCreators } from "redux";
|
||||
import { connect } from "react-redux";
|
||||
import { defineMessages, injectIntl, intlShape } from "react-intl";
|
||||
import Immutable from "immutable";
|
||||
|
||||
import * as actionCreators from "../actions";
|
||||
// Local imports
|
||||
import { buildPaginationObject, messagesMap, handleErrorI18nObject } from "../utils";
|
||||
|
||||
// Actions
|
||||
import * as actionCreators from "../actions";
|
||||
|
||||
// Components
|
||||
import Artists from "../components/Artists";
|
||||
|
||||
// Translations
|
||||
import APIMessages from "../locales/messagesDescriptors/api";
|
||||
|
||||
// Define translations
|
||||
const artistsMessages = defineMessages(messagesMap(Array.concat([], APIMessages)));
|
||||
|
||||
|
||||
/**
|
||||
* Grid of artists arts.
|
||||
*/
|
||||
class ArtistsPageIntl extends Component {
|
||||
componentWillMount () {
|
||||
// Load the data for the current page
|
||||
const currentPage = parseInt(this.props.location.query.page) || 1;
|
||||
// Load the data
|
||||
this.props.actions.loadArtists({pageNumber: currentPage});
|
||||
this.props.actions.loadPaginatedArtists({pageNumber: currentPage});
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
// Load the data if page has changed
|
||||
const currentPage = parseInt(this.props.location.query.page) || 1;
|
||||
const nextPage = parseInt(nextProps.location.query.page) || 1;
|
||||
if (currentPage != nextPage) {
|
||||
// Load the data
|
||||
this.props.actions.loadArtists({pageNumber: nextPage});
|
||||
this.props.actions.loadPaginatedArtists({pageNumber: nextPage});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
// Unload data on page change
|
||||
this.props.actions.clearResults();
|
||||
}
|
||||
|
||||
render () {
|
||||
const pagination = buildPaginationObject(this.props.location, this.props.currentPage, this.props.nPages, this.props.actions.goToPageAction);
|
||||
|
||||
const {formatMessage} = this.props.intl;
|
||||
|
||||
const pagination = buildPaginationObject(this.props.location, this.props.currentPage, this.props.nPages, this.props.actions.goToPage);
|
||||
|
||||
const error = handleErrorI18nObject(this.props.error, formatMessage, artistsMessages);
|
||||
|
||||
return (
|
||||
<Artists isFetching={this.props.isFetching} error={error} artists={this.props.artistsList} pagination={pagination} />
|
||||
);
|
||||
@ -46,17 +64,17 @@ ArtistsPageIntl.propTypes = {
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
let artistsList = new Immutable.List();
|
||||
if (state.api.result.get("artist")) {
|
||||
artistsList = state.api.result.get("artist").map(
|
||||
id => state.api.entities.getIn(["artist", id])
|
||||
if (state.paginated.type == "artist" && state.paginated.result.size > 0) {
|
||||
artistsList = state.paginated.result.map(
|
||||
id => state.entities.getIn(["entities", "artist", id])
|
||||
);
|
||||
}
|
||||
return {
|
||||
isFetching: state.api.isFetching,
|
||||
error: state.api.error,
|
||||
isFetching: state.entities.isFetching,
|
||||
error: state.entities.error,
|
||||
artistsList: artistsList,
|
||||
currentPage: state.api.currentPage,
|
||||
nPages: state.api.nPages,
|
||||
currentPage: state.paginated.currentPage,
|
||||
nPages: state.paginated.nPages,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -1,7 +1,13 @@
|
||||
// NPM imports
|
||||
import React, { Component } from "react";
|
||||
|
||||
// Other views
|
||||
import ArtistsPage from "./ArtistsPage";
|
||||
|
||||
|
||||
/**
|
||||
* Browse page is an alias for artists page at the moment.
|
||||
*/
|
||||
export default class BrowsePage extends Component {
|
||||
render () {
|
||||
return (
|
||||
|
@ -1,7 +1,12 @@
|
||||
// NPM imports
|
||||
import React, { Component } from "react";
|
||||
|
||||
// Components
|
||||
import Discover from "../components/Discover";
|
||||
|
||||
/**
|
||||
* Discover page
|
||||
*/
|
||||
export default class DiscoverPage extends Component {
|
||||
render () {
|
||||
return (
|
||||
|
@ -1,7 +1,12 @@
|
||||
// NPM imports
|
||||
import React, { Component } from "react";
|
||||
|
||||
// Other views
|
||||
import ArtistsPage from "./ArtistsPage";
|
||||
|
||||
/**
|
||||
* Homepage is an alias for Artists page at the moment.
|
||||
*/
|
||||
export default class HomePage extends Component {
|
||||
render () {
|
||||
return (
|
||||
|
@ -1,15 +1,34 @@
|
||||
// NPM imports
|
||||
import React, { Component, PropTypes } from "react";
|
||||
import { bindActionCreators } from "redux";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
// Actions
|
||||
import * as actionCreators from "../actions";
|
||||
|
||||
// Components
|
||||
import Login from "../components/Login";
|
||||
|
||||
function _getRedirectTo(props) {
|
||||
|
||||
/**
|
||||
* Login page
|
||||
*/
|
||||
export class LoginPage extends Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
|
||||
// Bind this
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this._getRedirectTo = this._getRedirectTo.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get URL to redirect to based on location props.
|
||||
*/
|
||||
_getRedirectTo() {
|
||||
let redirectPathname = "/";
|
||||
let redirectQuery = {};
|
||||
const { location } = props;
|
||||
const { location } = this.props;
|
||||
if (location.state && location.state.nextPathname) {
|
||||
redirectPathname = location.state.nextPathname;
|
||||
}
|
||||
@ -22,28 +41,36 @@ function _getRedirectTo(props) {
|
||||
};
|
||||
}
|
||||
|
||||
export class LoginPage extends Component {
|
||||
componentWillMount () {
|
||||
this.checkAuth(this.props);
|
||||
}
|
||||
// This checks if the user is already connected or not and redirects
|
||||
// them if it is the case.
|
||||
|
||||
checkAuth (propsIn) {
|
||||
const redirectTo = _getRedirectTo(propsIn);
|
||||
if (propsIn.isAuthenticated) {
|
||||
// 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 (propsIn.rememberMe) {
|
||||
this.props.actions.loginUser(propsIn.username, propsIn.token, propsIn.endpoint, true, redirectTo, true);
|
||||
} 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
constructor (props) {
|
||||
super(props);
|
||||
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click on submit button.
|
||||
*/
|
||||
handleSubmit (username, password, endpoint, rememberMe) {
|
||||
const redirectTo = _getRedirectTo(this.props);
|
||||
// Get page to redirect to
|
||||
const redirectTo = this._getRedirectTo();
|
||||
// Trigger login action
|
||||
this.props.actions.loginUser(username, password, endpoint, rememberMe, redirectTo);
|
||||
}
|
||||
|
||||
|
@ -1,11 +1,18 @@
|
||||
// NPM imports
|
||||
import React, { Component } from "react";
|
||||
import { bindActionCreators } from "redux";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
// Actions
|
||||
import * as actionCreators from "../actions";
|
||||
|
||||
|
||||
/**
|
||||
* Logout page
|
||||
*/
|
||||
export class LogoutPage extends Component {
|
||||
componentDidMount () {
|
||||
componentWillMount () {
|
||||
// Logout when component is mounted
|
||||
this.props.actions.logoutAndRedirect();
|
||||
}
|
||||
|
||||
|
@ -1,39 +1,57 @@
|
||||
// NPM imports
|
||||
import React, { Component } from "react";
|
||||
import { bindActionCreators } from "redux";
|
||||
import { connect } from "react-redux";
|
||||
import { defineMessages, injectIntl, intlShape } from "react-intl";
|
||||
import Immutable from "immutable";
|
||||
|
||||
import * as actionCreators from "../actions";
|
||||
// Local imports
|
||||
import { buildPaginationObject, messagesMap, handleErrorI18nObject } from "../utils";
|
||||
|
||||
// Actions
|
||||
import * as actionCreators from "../actions";
|
||||
|
||||
// Components
|
||||
import Songs from "../components/Songs";
|
||||
|
||||
// Translations
|
||||
import APIMessages from "../locales/messagesDescriptors/api";
|
||||
|
||||
// Define translations
|
||||
const songsMessages = defineMessages(messagesMap(Array.concat([], APIMessages)));
|
||||
|
||||
|
||||
/**
|
||||
* Paginated table of available songs
|
||||
*/
|
||||
class SongsPageIntl extends Component {
|
||||
componentWillMount () {
|
||||
// Load the data for current page
|
||||
const currentPage = parseInt(this.props.location.query.page) || 1;
|
||||
// Load the data
|
||||
this.props.actions.loadSongs({pageNumber: currentPage});
|
||||
this.props.actions.loadPaginatedSongs({pageNumber: currentPage});
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
// Load the data if page has changed
|
||||
const currentPage = parseInt(this.props.location.query.page) || 1;
|
||||
const nextPage = parseInt(nextProps.location.query.page) || 1;
|
||||
if (currentPage != nextPage) {
|
||||
// Load the data
|
||||
this.props.actions.loadSongs({pageNumber: nextPage});
|
||||
this.props.actions.loadPaginatedSongs({pageNumber: nextPage});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
// Unload data on page change
|
||||
this.props.actions.clearResults();
|
||||
}
|
||||
|
||||
render () {
|
||||
const pagination = buildPaginationObject(this.props.location, this.props.currentPage, this.props.nPages, this.props.actions.goToPageAction);
|
||||
|
||||
const {formatMessage} = this.props.intl;
|
||||
|
||||
const pagination = buildPaginationObject(this.props.location, this.props.currentPage, this.props.nPages, this.props.actions.goToPage);
|
||||
|
||||
const error = handleErrorI18nObject(this.props.error, formatMessage, songsMessages);
|
||||
|
||||
return (
|
||||
<Songs playAction={this.props.actions.playTrack} isFetching={this.props.isFetching} error={error} songs={this.props.songsList} pagination={pagination} />
|
||||
);
|
||||
@ -46,25 +64,25 @@ SongsPageIntl.propTypes = {
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
let songsList = new Immutable.List();
|
||||
if (state.api.result.get("song")) {
|
||||
songsList = state.api.result.get("song").map(function (id) {
|
||||
let song = state.api.entities.getIn(["track", id]);
|
||||
// Add artist and album infos
|
||||
const artist = state.api.entities.getIn(["artist", song.get("artist")]);
|
||||
const album = state.api.entities.getIn(["album", song.get("album")]);
|
||||
song = song.set("artist", new Immutable.Map({id: artist.get("id"), name: artist.get("name")}));
|
||||
song = song.set("album", new Immutable.Map({id: album.get("id"), name: album.get("name")}));
|
||||
return song;
|
||||
if (state.paginated.type == "song" && state.paginated.result.size > 0) {
|
||||
songsList = state.paginated.result.map(function (id) {
|
||||
let song = state.entities.getIn(["entities", "song", id]);
|
||||
// Add artist and album infos to song
|
||||
const artist = state.entities.getIn(["entities", "artist", song.get("artist")]);
|
||||
const album = state.entities.getIn(["entities", "album", song.get("album")]);
|
||||
return (
|
||||
song
|
||||
.set("artist", new Immutable.Map({id: artist.get("id"), name: artist.get("name")}))
|
||||
.set("album", new Immutable.Map({id: album.get("id"), name: album.get("name")}))
|
||||
);
|
||||
});
|
||||
}
|
||||
return {
|
||||
isFetching: state.api.isFetching,
|
||||
error: state.api.error,
|
||||
artistsList: state.api.entities.get("artist"),
|
||||
albumsList: state.api.entities.get("album"),
|
||||
isFetching: state.entities.isFetching,
|
||||
error: state.entities.error,
|
||||
songsList: songsList,
|
||||
currentPage: state.api.currentPage,
|
||||
nPages: state.api.nPages
|
||||
currentPage: state.paginated.currentPage,
|
||||
nPages: state.paginated.nPages
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
// TODO: This file is not finished
|
||||
import React, { Component } from "react";
|
||||
import { bindActionCreators } from "redux";
|
||||
import { connect } from "react-redux";
|
||||
@ -101,8 +102,7 @@ const mapStateToProps = (state) => ({
|
||||
isRepeat: state.webplayer.isRepeat,
|
||||
isMute: state.webplayer.isMute,
|
||||
currentIndex: state.webplayer.currentIndex,
|
||||
playlist: state.webplayer.playlist,
|
||||
entities: state.webplayer.entities
|
||||
playlist: state.webplayer.playlist
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
|
@ -1 +1,4 @@
|
||||
/**
|
||||
* Special JS entry point to add IE9-dedicated fixes.
|
||||
*/
|
||||
export * from "html5shiv";
|
||||
|
@ -1,12 +1,15 @@
|
||||
set -e
|
||||
|
||||
# Get against which ref to diff
|
||||
if git rev-parse --verify HEAD >/dev/null 2>&1
|
||||
then
|
||||
against=HEAD
|
||||
else
|
||||
# Initial commit
|
||||
# Something weird, initial commit
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# List all the modified CSS and JS files (not in output path)
|
||||
css_js_files=$(git diff-index --name-only $against | grep -e '.\(jsx\?\)\|\(s\?css\)$' | grep -v "^public")
|
||||
|
||||
# Nothing more to do if no JS files was committed
|
||||
@ -15,8 +18,9 @@ then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Else, rebuild as production, run tests and add files
|
||||
echo "Rebuilding dist JavaScript files…"
|
||||
npm test
|
||||
npm run clean
|
||||
npm run build:prod
|
||||
npm test
|
||||
git add public
|
||||
|
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 ReactDOM from "react-dom";
|
||||
import { applyRouterMiddleware, hashHistory } from "react-router";
|
||||
import { syncHistoryWithStore } from "react-router-redux";
|
||||
import useScroll from "react-router-scroll";
|
||||
|
||||
// i18n
|
||||
// Store
|
||||
import configureStore from "./app/store/configureStore";
|
||||
|
||||
// i18n stuff
|
||||
import { addLocaleData } from "react-intl";
|
||||
import en from "react-intl/locale-data/en";
|
||||
import fr from "react-intl/locale-data/fr";
|
||||
|
||||
import configureStore from "./app/store/configureStore";
|
||||
|
||||
import { getBrowserLocales } from "./app/utils";
|
||||
import rawMessages from "./app/locales";
|
||||
|
||||
// Init store and history
|
||||
const store = configureStore();
|
||||
const history = syncHistoryWithStore(hashHistory, store);
|
||||
|
||||
// Get root element
|
||||
export const rootElement = document.getElementById("root");
|
||||
|
||||
// i18n
|
||||
export const onWindowIntl = () => {
|
||||
/**
|
||||
* Main function to be called once window.Intl has been populated.
|
||||
*
|
||||
* Populates the locales messages and perform render.
|
||||
*/
|
||||
export function onWindowIntl () {
|
||||
// Add locales we support
|
||||
addLocaleData([...en, ...fr]);
|
||||
|
||||
// Fetch current preferred locales from the browser
|
||||
const locales = getBrowserLocales();
|
||||
|
||||
var locale = "en-US";
|
||||
var locale = "en-US"; // Safe default
|
||||
// Populate strings with best matching locale
|
||||
var strings = {};
|
||||
for (var i = 0; i < locales.length; ++i) {
|
||||
if (rawMessages[locales[i]]) {
|
||||
locale = locales[i];
|
||||
strings = rawMessages[locale];
|
||||
break;
|
||||
break; // Break at first matching locale
|
||||
}
|
||||
}
|
||||
// Overload strings with default English translation, in case of missing translations
|
||||
strings = Object.assign(rawMessages["en-US"], strings);
|
||||
// Set html lang attribute
|
||||
|
||||
// Dynamically set html lang attribute
|
||||
document.documentElement.lang = locale;
|
||||
|
||||
let render = () => {
|
||||
// Return a rendering function
|
||||
return () => {
|
||||
const Root = require("./app/containers/Root").default;
|
||||
ReactDOM.render(
|
||||
<Root store={store} history={history} render={applyRouterMiddleware(useScroll())} locale={locale} defaultLocale="en-US" messages={strings} />,
|
||||
rootElement
|
||||
);
|
||||
};
|
||||
|
||||
return render;
|
||||
};
|
||||
|
||||
export const Intl = (render) => {
|
||||
/**
|
||||
* Ensure window.Intl exists, or polyfill it.
|
||||
*
|
||||
* @param render Initial rendering function.
|
||||
*/
|
||||
export function Intl (render) {
|
||||
if (!window.Intl) {
|
||||
require.ensure([
|
||||
"intl",
|
||||
|
@ -1,15 +1,21 @@
|
||||
/**
|
||||
* This is the main JS entry point in development build.
|
||||
*/
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
// Load react-a11y for accessibility overview
|
||||
var a11y = require("react-a11y");
|
||||
a11y(React, { ReactDOM: ReactDOM, includeSrcNode: true });
|
||||
|
||||
// Load common index
|
||||
const index = require("./index.all.js");
|
||||
|
||||
// Initial rendering function from common index
|
||||
var render = index.onWindowIntl();
|
||||
if (process.env.NODE_ENV !== "production" && module.hot) {
|
||||
// Support hot reloading of components
|
||||
// and display an overlay for runtime errors
|
||||
if (module.hot) {
|
||||
// If we support hot reloading of components,
|
||||
// display an overlay for runtime errors
|
||||
const renderApp = render;
|
||||
const renderError = (error) => {
|
||||
const RedBox = require("redbox-react").default;
|
||||
@ -18,6 +24,8 @@ if (process.env.NODE_ENV !== "production" && module.hot) {
|
||||
index.rootElement
|
||||
);
|
||||
};
|
||||
|
||||
// Try to render, and display an overlay for runtime errors
|
||||
render = () => {
|
||||
try {
|
||||
renderApp();
|
||||
@ -26,8 +34,11 @@ if (process.env.NODE_ENV !== "production" && module.hot) {
|
||||
renderError(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.hot.accept("./app/containers/Root", () => {
|
||||
setTimeout(render);
|
||||
});
|
||||
}
|
||||
|
||||
// Perform i18n and render
|
||||
index.Intl(render);
|
||||
|
@ -7,7 +7,7 @@
|
||||
|
||||
<meta name="description" content="">
|
||||
<meta name="author" content="">
|
||||
<link rel="icon" href="favicon.ico">
|
||||
<link rel="icon" href="./favicon.ico">
|
||||
|
||||
<title>Ampache music player</title>
|
||||
|
||||
@ -20,8 +20,8 @@
|
||||
|
||||
<!-- IE10 viewport hack for Surface/desktop Windows 8 bug -->
|
||||
<!--[if IE 10]>
|
||||
<link href="./vendor/ie10-viewport-bug-workaround/ie10-viewport-bug-workaround.css" rel="stylesheet">
|
||||
<script src="./vendor/ie10-viewport-bug-workaround/ie10-viewport-bug-workaround.js"></script>
|
||||
<link href="./ie10-viewport-bug-workaround/ie10-viewport-bug-workaround.css" rel="stylesheet">
|
||||
<script src="./ie10-viewport-bug-workaround/ie10-viewport-bug-workaround.js"></script>
|
||||
<![endif]-->
|
||||
</head>
|
||||
|
||||
|
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") {
|
||||
module.exports = require("./index.production.js");
|
||||
} 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");
|
||||
|
||||
// Get the rendering function
|
||||
const render = index.onWindowIntl();
|
||||
|
||||
// Perform i18n and render
|
||||
index.Intl(render);
|
||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
public/favicon.ico
Normal file
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