Major code review

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

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

View File

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

View File

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

View File

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

5
TODO
View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
// NPM imports
import React, { Component, PropTypes } from "react";
import { Link} from "react-router";
import CSSModules from "react-css-modules";
@ -6,24 +7,36 @@ import FontAwesome from "react-fontawesome";
import Immutable from "immutable";
import Fuse from "fuse.js";
// Local imports
import { formatLength, messagesMap } from "../utils";
// Other components
import DismissibleAlert from "./elements/DismissibleAlert";
import FilterBar from "./elements/FilterBar";
import Pagination from "./elements/Pagination";
import { formatLength, messagesMap } from "../utils";
// Translations
import commonMessages from "../locales/messagesDescriptors/common";
import messages from "../locales/messagesDescriptors/Songs";
// Styles
import css from "../styles/Songs.scss";
// Define translations
const songsMessages = defineMessages(messagesMap(Array.concat([], commonMessages, messages)));
/**
* A single row for a single song in the songs table.
*/
class SongsTableRowCSSIntl extends Component {
render () {
const { formatMessage } = this.props.intl;
const length