Use Immutable, enhance i18n, pagination in the store

* Keep track of pagination in the store
* Use Immutable in reducers
* Finish i18n, every available strings are now translated in English and French
* Add a loading indicator
* Premises of API error handling
* Better locale negotiation
This commit is contained in:
Lucas Verney 2016-08-01 00:26:52 +02:00
parent 5a9f540cc0
commit 40f6223bd0
39 changed files with 535 additions and 287 deletions

25
TODO
View File

@ -1,4 +1,4 @@
4. Refactor API + Handle failures
4. Refactor API
5. Web player
6. Homepage
7. Settings
@ -6,25 +6,4 @@
9. Discover
# API middleware
* https://github.com/reactjs/redux/issues/1824#issuecomment-228609501
* https://medium.com/@adamrackis/querying-a-redux-store-37db8c7f3b0f#.eezt3dano
* https://github.com/reactjs/redux/issues/644
* https://github.com/peterpme/redux-crud-api-middleware/blob/master/README.md
* https://github.com/madou/armory-front/tree/master/src/app/reducers
* https://github.com/erikras/multireducer
* Immutable.js (?)
## Global UI
* Filter does not work on github.io
* What happens when JS is off?
=> https://www.allantatter.com/react-js-and-progressive-enhancement/
## Miscellaneous
* Webpack chunks?
* Stocker pagination dans le store
* error / info: null
* refondre reducers
* Immutable.js : entities in API

View File

@ -1,16 +1,19 @@
import humps from "humps";
import { CALL_API } from "../middleware/api";
import { DEFAULT_LIMIT } from "../reducers/paginate";
export const DEFAULT_LIMIT = 30; /** Default max number of elements to retrieve. */
export default function (action, requestType, successType, failureType) {
const itemName = action.rstrip("s");
const fetchItemsSuccess = function (itemsList, itemsCount) {
const fetchItemsSuccess = function (itemsList, itemsCount, pageNumber) {
const nPages = Math.ceil(itemsCount / DEFAULT_LIMIT);
return {
type: successType,
payload: {
items: itemsList,
total: itemsCount
nPages: nPages,
currentPage: pageNumber
}
};
};
@ -29,7 +32,8 @@ export default function (action, requestType, successType, failureType) {
}
};
};
const fetchItems = function (endpoint, username, passphrase, filter, offset, include = [], limit=DEFAULT_LIMIT) {
const fetchItems = function (endpoint, username, passphrase, filter, pageNumber, include = [], limit=DEFAULT_LIMIT) {
const offset = (pageNumber - 1) * DEFAULT_LIMIT;
var extraParams = {
offset: offset,
limit: limit
@ -47,7 +51,7 @@ export default function (action, requestType, successType, failureType) {
dispatch: [
fetchItemsRequest,
jsonData => dispatch => {
dispatch(fetchItemsSuccess(jsonData[itemName], jsonData[action]));
dispatch(fetchItemsSuccess(jsonData[itemName], jsonData[action], pageNumber));
},
fetchItemsFailure
],
@ -61,8 +65,7 @@ export default function (action, requestType, successType, failureType) {
const loadItems = function({ pageNumber = 1, filter = null, include = [] } = {}) {
return (dispatch, getState) => {
const { auth } = getState();
const offset = (pageNumber - 1) * DEFAULT_LIMIT;
dispatch(fetchItems(auth.endpoint, auth.username, auth.token.token, filter, offset, include));
dispatch(fetchItems(auth.endpoint, auth.username, auth.token.token, filter, pageNumber, include));
};
};

View File

@ -4,6 +4,8 @@ import Cookies from "js-cookie";
import { CALL_API } from "../middleware/api";
import { i18nRecord } from "../models/i18n";
export const DEFAULT_SESSION_INTERVAL = 1800 * 1000; // 30 mins default
function _cleanEndpoint (endpoint) {
@ -46,7 +48,7 @@ export function loginKeepAlive(username, token, endpoint) {
null,
null,
error => dispatch => {
dispatch(loginUserFailure(error || "Your session expired… =("));
dispatch(loginUserFailure(error || new i18nRecord({ id: "app.login.expired", values: {}})));
}
],
action: "ping",
@ -141,7 +143,7 @@ export function loginUser(username, passwordOrToken, endpoint, rememberMe, redir
loginUserRequest,
jsonData => dispatch => {
if (!jsonData.auth || !jsonData.sessionExpire) {
return Promise.reject("API error.");
return Promise.reject(new i18nRecord({ id: "app.api.error", values: {} }));
}
const token = {
token: jsonData.auth,

View File

@ -16,3 +16,5 @@ export const SONGS_SUCCESS = "SONGS_SUCCESS";
export const SONGS_REQUEST = "SONGS_REQUEST";
export const SONGS_FAILURE = "SONGS_FAILURE";
export var { loadSongs } = APIAction("songs", SONGS_REQUEST, SONGS_SUCCESS, SONGS_FAILURE);
export * from "./paginate";

7
app/actions/paginate.js Normal file
View File

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

View File

@ -1,19 +1,25 @@
import React, { Component, PropTypes } from "react";
import Immutable from "immutable";
import FilterablePaginatedGrid from "./elements/Grid";
export default class Albums extends Component {
render () {
const grid = {
isFetching: this.props.isFetching,
items: this.props.albums,
itemsLabel: "app.common.album",
subItemsType: "tracks",
subItemsLabel: "app.common.track"
};
return (
<FilterablePaginatedGrid items={this.props.albums} itemsTotalCount={this.props.albumsTotalCount} itemsPerPage={this.props.albumsPerPage} currentPage={this.props.currentPage} location={this.props.location} itemsType="albums" subItemsType="tracks" />
<FilterablePaginatedGrid grid={grid} pagination={this.props.pagination} />
);
}
}
Albums.propTypes = {
albums: PropTypes.array.isRequired,
albumsTotalCount: PropTypes.number.isRequired,
albumsPerPage: PropTypes.number.isRequired,
currentPage: PropTypes.number.isRequired,
location: PropTypes.object.isRequired
isFetching: PropTypes.bool.isRequired,
albums: PropTypes.instanceOf(Immutable.List).isRequired,
pagination: PropTypes.object.isRequired,
};

View File

@ -1,19 +1,27 @@
import React, { Component, PropTypes } from "react";
import Immutable from "immutable";
import FilterablePaginatedGrid from "./elements/Grid";
export default class Artists extends Component {
class Artists extends Component {
render () {
const grid = {
isFetching: this.props.isFetching,
items: this.props.artists,
itemsLabel: "app.common.artist",
subItemsType: "albums",
subItemsLabel: "app.common.album"
};
return (
<FilterablePaginatedGrid items={this.props.artists} itemsTotalCount={this.props.artistsTotalCount} itemsPerPage={this.props.artistsPerPage} currentPage={this.props.currentPage} location={this.props.location} itemsType="artists" subItemsType="albums" />
<FilterablePaginatedGrid grid={grid} pagination={this.props.pagination} />
);
}
}
Artists.propTypes = {
artists: PropTypes.array.isRequired,
artistsTotalCount: PropTypes.number.isRequired,
artistsPerPage: PropTypes.number.isRequired,
currentPage: PropTypes.number.isRequired,
location: PropTypes.object.isRequired
isFetching: PropTypes.bool.isRequired,
artists: PropTypes.instanceOf(Immutable.List).isRequired,
pagination: PropTypes.object.isRequired,
};
export default Artists;

View File

@ -2,12 +2,14 @@ import React, { Component, PropTypes } from "react";
import CSSModules from "react-css-modules";
import { defineMessages, injectIntl, intlShape, FormattedMessage } from "react-intl";
import { i18nRecord } from "../models/i18n";
import { messagesMap } from "../utils";
import APIMessages from "../locales/messagesDescriptors/api";
import messages from "../locales/messagesDescriptors/Login";
import css from "../styles/Login.scss";
const loginMessages = defineMessages(messagesMap(messages));
const loginMessages = defineMessages(messagesMap(Array.concat([], APIMessages, messages)));
class LoginFormCSSIntl extends Component {
constructor (props) {
@ -58,13 +60,17 @@ class LoginFormCSSIntl extends Component {
render () {
const {formatMessage} = this.props.intl;
var infoMessage = "";
if (typeof this.props.info === "object") {
var 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} />
);
} else {
infoMessage = this.props.info;
}
var 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>
@ -73,7 +79,7 @@ class LoginFormCSSIntl extends Component {
<div className="row">
<div className="alert alert-danger" id="loginFormError">
<p>
<span className="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> { this.props.error }
<span className="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> { errorMessage }
</p>
</div>
</div>
@ -135,8 +141,8 @@ LoginFormCSSIntl.propTypes = {
rememberMe: PropTypes.bool,
onSubmit: PropTypes.func.isRequired,
isAuthenticating: PropTypes.bool,
error: PropTypes.string,
info: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
error: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(i18nRecord)]),
info: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(i18nRecord)]),
intl: intlShape.isRequired,
};

View File

@ -2,6 +2,7 @@ import React, { Component, PropTypes } from "react";
import { Link} from "react-router";
import CSSModules from "react-css-modules";
import { defineMessages, FormattedMessage } from "react-intl";
import Immutable from "immutable";
import Fuse from "fuse.js";
import FilterBar from "./elements/FilterBar";
@ -25,7 +26,7 @@ export class SongsTableRow extends Component {
<td></td>
<td className="title">{this.props.song.name}</td>
<td className="artist"><Link to={linkToArtist}>{this.props.song.artist.name}</Link></td>
<td className="artist"><Link to={linkToAlbum}>{this.props.song.album.name}</Link></td>
<td className="album"><Link to={linkToAlbum}>{this.props.song.album.name}</Link></td>
<td className="genre">{this.props.song.genre}</td>
<td className="length">{length}</td>
</tr>
@ -58,6 +59,15 @@ class SongsTableCSS extends Component {
displayedSongs.forEach(function (song) {
rows.push(<SongsTableRow song={song} key={song.id} />);
});
var loading = null;
if (rows.length == 0 && this.props.isFetching) {
// If we are fetching and there is nothing to show
loading = (
<p className="text-center">
<FormattedMessage {...songsMessages["app.common.loading"]} />
</p>
);
}
return (
<div className="table-responsive">
<table className="table table-hover" styleName="songs">
@ -67,10 +77,10 @@ class SongsTableCSS extends Component {
<th>
<FormattedMessage {...songsMessages["app.songs.title"]} />
</th>
<th>
<th className="text-capitalize">
<FormattedMessage {...songsMessages["app.common.artist"]} values={{itemCount: 1}} />
</th>
<th>
<th className="text-capitalize">
<FormattedMessage {...songsMessages["app.common.album"]} values={{itemCount: 1}} />
</th>
<th>
@ -83,13 +93,14 @@ class SongsTableCSS extends Component {
</thead>
<tbody>{rows}</tbody>
</table>
{loading}
</div>
);
}
}
SongsTableCSS.propTypes = {
songs: PropTypes.array.isRequired,
songs: PropTypes.instanceOf(Immutable.List).isRequired,
filterText: PropTypes.string
};
@ -113,21 +124,18 @@ export default class FilterablePaginatedSongsTable extends Component {
}
render () {
const nPages = Math.ceil(this.props.songsTotalCount / this.props.songsPerPage);
return (
<div>
<FilterBar filterText={this.state.filterText} onUserInput={this.handleUserInput} />
<SongsTable songs={this.props.songs} filterText={this.state.filterText} />
<Pagination nPages={nPages} currentPage={this.props.currentPage} location={this.props.location} />
<SongsTable isFetching={this.props.isFetching} songs={this.props.songs} filterText={this.state.filterText} />
<Pagination {...this.props.pagination} />
</div>
);
}
}
FilterablePaginatedSongsTable.propTypes = {
songs: PropTypes.array.isRequired,
songsTotalCount: PropTypes.number.isRequired,
songsPerPage: PropTypes.number.isRequired,
currentPage: PropTypes.number.isRequired,
location: PropTypes.object.isRequired
isFetching: PropTypes.bool.isRequired,
songs: PropTypes.instanceOf(Immutable.List).isRequired,
pagination: PropTypes.object.isRequired
};

View File

@ -1,6 +1,8 @@
import React, { Component, PropTypes } from "react";
import { Link} from "react-router";
import CSSModules from "react-css-modules";
import { defineMessages, injectIntl, intlShape, FormattedMessage } from "react-intl";
import Immutable from "immutable";
import imagesLoaded from "imagesloaded";
import Isotope from "isotope-layout";
import Fuse from "fuse.js";
@ -8,27 +10,30 @@ import shallowCompare from "react-addons-shallow-compare";
import FilterBar from "./FilterBar";
import Pagination from "./Pagination";
import { immutableDiff, messagesMap } from "../../utils/";
import commonMessages from "../../locales/messagesDescriptors/common";
import messages from "../../locales/messagesDescriptors/grid";
import css from "../../styles/elements/Grid.scss";
class GridItemCSS extends Component {
const gridMessages = defineMessages(messagesMap(Array.concat([], commonMessages, messages)));
class GridItemCSSIntl extends Component {
render () {
const {formatMessage} = this.props.intl;
var nSubItems = this.props.item[this.props.subItemsType];
if (Array.isArray(nSubItems)) {
nSubItems = nSubItems.length;
}
// TODO: i18n
var subItemsLabel = this.props.subItemsType;
if (nSubItems < 2) {
subItemsLabel = subItemsLabel.rstrip("s");
}
var subItemsLabel = formatMessage(gridMessages[this.props.subItemsLabel], { itemCount: nSubItems });
const to = "/" + this.props.itemsType.rstrip("s") + "/" + this.props.item.id;
const to = "/" + this.props.item.type + "/" + this.props.item.id;
const id = "grid-item-" + this.props.item.type + "/" + this.props.item.id;
// TODO: i18n
const title = "Go to " + this.props.itemsType.rstrip("s") + " page";
const title = formatMessage(gridMessages["app.grid.goTo" + this.props.item.type.capitalize() + "Page"]);
return (
<div className="grid-item col-xs-6 col-sm-3" styleName="placeholders" id={id}>
<div className="grid-item-content text-center">
@ -41,13 +46,15 @@ class GridItemCSS extends Component {
}
}
GridItemCSS.propTypes = {
GridItemCSSIntl.propTypes = {
item: PropTypes.object.isRequired,
itemsType: PropTypes.string.isRequired,
subItemsType: PropTypes.string.isRequired
itemsLabel: PropTypes.string.isRequired,
subItemsType: PropTypes.string.isRequired,
subItemsLabel: PropTypes.string.isRequired,
intl: intlShape.isRequired
};
export let GridItem = CSSModules(GridItemCSS, css);
export let GridItem = injectIntl(CSSModules(GridItemCSSIntl, css));
const ISOTOPE_OPTIONS = { /** Default options for Isotope grid layout. */
@ -150,17 +157,17 @@ export class Grid extends Component {
(n) => "grid-item-" + n.type + "/" + n.id);
// Find which keys are new between the current set of keys and any new children passed to this component
let addKeys = newKeys.diff(currentKeys);
let addKeys = immutableDiff(newKeys, currentKeys);
// Find which keys have been removed between the current set of keys and any new children passed to this component
let removeKeys = currentKeys.diff(newKeys);
let removeKeys = immutableDiff(currentKeys, newKeys);
if (removeKeys.length > 0) {
if (removeKeys.count() > 0) {
removeKeys.forEach(removeKey => this.iso.remove(document.getElementById(removeKey)));
this.iso.arrange();
}
if (addKeys.length > 0) {
const itemsToAdd = addKeys.map((addKey) => document.getElementById(addKey));
if (addKeys.count() > 0) {
const itemsToAdd = addKeys.map((addKey) => document.getElementById(addKey)).toArray();
this.iso.addItems(itemsToAdd);
this.iso.arrange();
}
@ -178,18 +185,32 @@ export class Grid extends Component {
render () {
var gridItems = [];
const itemsType = this.props.itemsType;
const itemsLabel = this.props.itemsLabel;
const subItemsType = this.props.subItemsType;
const subItemsLabel = this.props.subItemsLabel;
this.props.items.forEach(function (item) {
gridItems.push(<GridItem item={item} itemsType={itemsType} subItemsType={subItemsType} key={item.id} />);
gridItems.push(<GridItem item={item} itemsLabel={itemsLabel} subItemsType={subItemsType} subItemsLabel={subItemsLabel} key={item.id} />);
});
var loading = null;
if (gridItems.length == 0 && this.props.isFetching) {
loading = (
<div className="row text-center">
<p>
<FormattedMessage {...gridMessages["app.common.loading"]} />
</p>
</div>
);
}
return (
<div className="row">
<div className="grid" ref="grid">
{/* Sizing element */}
<div className="grid-sizer col-xs-6 col-sm-3"></div>
{/* Other items */}
{ gridItems }
<div>
{ loading }
<div className="row">
<div className="grid" ref="grid">
{/* Sizing element */}
<div className="grid-sizer col-xs-6 col-sm-3"></div>
{/* Other items */}
{ gridItems }
</div>
</div>
</div>
);
@ -197,8 +218,9 @@ export class Grid extends Component {
}
Grid.propTypes = {
items: PropTypes.array.isRequired,
itemsType: PropTypes.string.isRequired,
isFetching: PropTypes.bool.isRequired,
items: PropTypes.instanceOf(Immutable.List).isRequired,
itemsLabel: PropTypes.string.isRequired,
subItemsType: PropTypes.string.isRequired,
filterText: PropTypes.string
};
@ -220,23 +242,17 @@ export default class FilterablePaginatedGrid extends Component {
}
render () {
const nPages = Math.ceil(this.props.itemsTotalCount / this.props.itemsPerPage);
return (
<div>
<FilterBar filterText={this.state.filterText} onUserInput={this.handleUserInput} />
<Grid items={this.props.items} itemsType={this.props.itemsType} subItemsType={this.props.subItemsType} filterText={this.state.filterText} />
<Pagination nPages={nPages} currentPage={this.props.currentPage} location={this.props.location} />
<Grid filterText={this.state.filterText} {...this.props.grid} />
<Pagination {...this.props.pagination} />
</div>
);
}
}
FilterablePaginatedGrid.propTypes = {
items: PropTypes.array.isRequired,
itemsTotalCount: PropTypes.number.isRequired,
itemsPerPage: PropTypes.number.isRequired,
currentPage: PropTypes.number.isRequired,
location: PropTypes.object.isRequired,
itemsType: PropTypes.string.isRequired,
subItemsType: PropTypes.string.isRequired
grid: PropTypes.object.isRequired,
pagination: PropTypes.object.isRequired
};

View File

@ -1,5 +1,5 @@
import React, { Component, PropTypes } from "react";
import { Link, withRouter } from "react-router";
import { Link } from "react-router";
import CSSModules from "react-css-modules";
import { defineMessages, injectIntl, intlShape, FormattedMessage, FormattedHTMLMessage } from "react-intl";
@ -12,11 +12,6 @@ import css from "../../styles/elements/Pagination.scss";
const paginationMessages = defineMessages(messagesMap(Array.concat([], commonMessages, messages)));
class PaginationCSSIntl extends Component {
constructor(props) {
super(props);
this.buildLinkTo.bind(this);
}
computePaginationBounds(currentPage, nPages, maxNumberPagesShown=5) {
// Taken from http://stackoverflow.com/a/8608998/2626416
var lowerLimit = currentPage;
@ -39,18 +34,12 @@ class PaginationCSSIntl extends Component {
};
}
buildLinkTo(pageNumber) {
return {
pathname: this.props.location.pathname,
query: Object.assign({}, this.props.location.query, { page: pageNumber })
};
}
goToPage() {
goToPage(ev) {
ev.preventDefault();
const pageNumber = parseInt(this.refs.pageInput.value);
$(this.refs.paginationModal).modal("hide");
if (pageNumber) {
this.props.router.push(this.buildLinkTo(pageNumber));
this.props.goToPage(pageNumber);
}
}
@ -79,7 +68,7 @@ class PaginationCSSIntl extends Component {
// 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.buildLinkTo(1)}>
<Link className="page-link" title={formatMessage(paginationMessages["app.pagination.goToPageWithoutMarkup"], { pageNumber: 1})} to={this.props.buildLinkToPage(1)}>
<FormattedHTMLMessage {...paginationMessages["app.pagination.goToPage"]} values={{ pageNumber: 1 }} />
</Link>
</li>
@ -106,7 +95,7 @@ class PaginationCSSIntl extends Component {
const title = formatMessage(paginationMessages["app.pagination.goToPageWithoutMarkup"], { pageNumber: i });
pagesButton.push(
<li className={className} key={key}>
<Link className="page-link" title={title} to={this.buildLinkTo(i)}>
<Link className="page-link" title={title} to={this.props.buildLinkToPage(i)}>
<FormattedHTMLMessage {...paginationMessages["app.pagination.goToPage"]} values={{ pageNumber: i }} />
{currentSpan}
</Link>
@ -128,7 +117,7 @@ class PaginationCSSIntl extends Component {
// Push last page
pagesButton.push(
<li className="page-item" key={key}>
<Link className="page-link" title={title} to={this.buildLinkTo(this.props.nPages)}>
<Link className="page-link" title={title} to={this.props.buildLinkToPage(this.props.nPages)}>
<FormattedHTMLMessage {...paginationMessages["app.pagination.goToPage"]} values={{ pageNumber: this.props.nPages }} />
</Link>
</li>
@ -152,8 +141,8 @@ class PaginationCSSIntl extends Component {
</h4>
</div>
<div className="modal-body">
<form>
<input className="form-control" autoComplete="off" type="number" ref="pageInput" aria-label={formatMessage(paginationMessages["app.pagination.pageToGoTo"])} />
<form onSubmit={this.goToPage.bind(this)}>
<input className="form-control" autoComplete="off" type="number" ref="pageInput" aria-label={formatMessage(paginationMessages["app.pagination.pageToGoTo"])} autoFocus />
</form>
</div>
<div className="modal-footer">
@ -176,9 +165,10 @@ class PaginationCSSIntl extends Component {
PaginationCSSIntl.propTypes = {
currentPage: PropTypes.number.isRequired,
location: PropTypes.object.isRequired,
goToPage: PropTypes.func.isRequired,
buildLinkToPage: PropTypes.func.isRequired,
nPages: PropTypes.number.isRequired,
intl: intlShape.isRequired,
};
export default withRouter(injectIntl(CSSModules(PaginationCSSIntl, css)));
export default injectIntl(CSSModules(PaginationCSSIntl, css));

View File

@ -82,10 +82,10 @@ class SidebarLayoutIntl extends Component {
<li>
<Link to="/artists" title={formatMessage(sidebarLayoutMessages["app.sidebarLayout.browseArtists"])} styleName={isActive.artists}>
<span className="glyphicon glyphicon-user" aria-hidden="true"></span>
<span className="sr-only">
<span className="sr-only text-capitalize">
<FormattedMessage {...sidebarLayoutMessages["app.common.artist"]} values={{itemCount: 42}} />
</span>
<span className="hidden-md">
<span className="hidden-md text-capitalize">
&nbsp;<FormattedMessage {...sidebarLayoutMessages["app.common.artist"]} values={{itemCount: 42}} />
</span>
</Link>
@ -93,8 +93,10 @@ class SidebarLayoutIntl extends Component {
<li>
<Link to="/albums" title={formatMessage(sidebarLayoutMessages["app.sidebarLayout.browseAlbums"])} styleName={isActive.albums}>
<span className="glyphicon glyphicon-cd" aria-hidden="true"></span>
<span className="sr-only"><FormattedMessage {...sidebarLayoutMessages["app.common.album"]} values={{itemCount: 42}} /></span>
<span className="hidden-md">
<span className="sr-only text-capitalize">
<FormattedMessage {...sidebarLayoutMessages["app.common.album"]} values={{itemCount: 42}} />
</span>
<span className="hidden-md text-capitalize">
&nbsp;<FormattedMessage {...sidebarLayoutMessages["app.common.album"]} values={{itemCount: 42}} />
</span>
</Link>
@ -102,11 +104,11 @@ class SidebarLayoutIntl extends Component {
<li>
<Link to="/songs" title={formatMessage(sidebarLayoutMessages["app.sidebarLayout.browseSongs"])} styleName={isActive.songs}>
<span className="glyphicon glyphicon-music" aria-hidden="true"></span>
<span className="sr-only">
<FormattedMessage {...sidebarLayoutMessages["app.common.song"]} values={{itemCount: 42}} />
<span className="sr-only text-capitalize">
<FormattedMessage {...sidebarLayoutMessages["app.common.track"]} values={{itemCount: 42}} />
</span>
<span className="hidden-md">
&nbsp;<FormattedMessage {...sidebarLayoutMessages["app.common.song"]} values={{itemCount: 42}} />
<span className="hidden-md text-capitalize">
&nbsp;<FormattedMessage {...sidebarLayoutMessages["app.common.track"]} values={{itemCount: 42}} />
</span>
</Link>
</li>

6
app/dist/1.1.js vendored

File diff suppressed because one or more lines are too long

2
app/dist/1.1.js.map vendored

File diff suppressed because one or more lines are too long

2
app/dist/fix.ie9.js vendored
View File

@ -1,2 +1,2 @@
!function(e){function t(r){if(n[r])return n[r].exports;var a=n[r]={exports:{},id:r,loaded:!1};return e[r].call(a.exports,a,a.exports,t),a.loaded=!0,a.exports}var n={};return t.m=e,t.c=n,t.p="./",t(0)}({0:function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r=n(590);Object.keys(r).forEach(function(e){"default"!==e&&Object.defineProperty(t,e,{enumerable:!0,get:function(){return r[e]}})})},590:function(e,t){!function(t,n){function r(e,t){var n=e.createElement("p"),r=e.getElementsByTagName("head")[0]||e.documentElement;return n.innerHTML="x<style>"+t+"</style>",r.insertBefore(n.lastChild,r.firstChild)}function a(){var e=b.elements;return"string"==typeof e?e.split(" "):e}function o(e,t){var n=b.elements;"string"!=typeof n&&(n=n.join(" ")),"string"!=typeof e&&(e=e.join(" ")),b.elements=n+" "+e,s(t)}function c(e){var t=E[e[v]];return t||(t={},y++,e[v]=y,E[y]=t),t}function i(e,t,r){if(t||(t=n),f)return t.createElement(e);r||(r=c(t));var a;return a=r.cache[e]?r.cache[e].cloneNode():g.test(e)?(r.cache[e]=r.createElem(e)).cloneNode():r.createElem(e),!a.canHaveChildren||p.test(e)||a.tagUrn?a:r.frag.appendChild(a)}function l(e,t){if(e||(e=n),f)return e.createDocumentFragment();t=t||c(e);for(var r=t.frag.cloneNode(),o=0,i=a(),l=i.length;o<l;o++)r.createElement(i[o]);return r}function u(e,t){t.cache||(t.cache={},t.createElem=e.createElement,t.createFrag=e.createDocumentFragment,t.frag=t.createFrag()),e.createElement=function(n){return b.shivMethods?i(n,e,t):t.createElem(n)},e.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+a().join().replace(/[\w\-:]+/g,function(e){return t.createElem(e),t.frag.createElement(e),'c("'+e+'")'})+");return n}")(b,t.frag)}function s(e){e||(e=n);var t=c(e);return!b.shivCSS||d||t.hasCSS||(t.hasCSS=!!r(e,"article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}mark{background:#FF0;color:#000}template{display:none}")),f||u(e,t),e}var d,f,m="3.7.3-pre",h=t.html5||{},p=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,g=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,v="_html5shiv",y=0,E={};!function(){try{var e=n.createElement("a");e.innerHTML="<xyz></xyz>",d="hidden"in e,f=1==e.childNodes.length||function(){n.createElement("a");var e=n.createDocumentFragment();return"undefined"==typeof e.cloneNode||"undefined"==typeof e.createDocumentFragment||"undefined"==typeof e.createElement}()}catch(t){d=!0,f=!0}}();var b={elements:h.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output picture progress section summary template time video",version:m,shivCSS:h.shivCSS!==!1,supportsUnknownElements:f,shivMethods:h.shivMethods!==!1,type:"default",shivDocument:s,createElement:i,createDocumentFragment:l,addElements:o};t.html5=b,s(n),"object"==typeof e&&e.exports&&(e.exports=b)}("undefined"!=typeof window?window:this,document)}});
!function(e){function t(r){if(n[r])return n[r].exports;var a=n[r]={exports:{},id:r,loaded:!1};return e[r].call(a.exports,a,a.exports,t),a.loaded=!0,a.exports}var n={};return t.m=e,t.c=n,t.p="./",t(0)}({0:function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r=n(599);Object.keys(r).forEach(function(e){"default"!==e&&Object.defineProperty(t,e,{enumerable:!0,get:function(){return r[e]}})})},599:function(e,t){!function(t,n){function r(e,t){var n=e.createElement("p"),r=e.getElementsByTagName("head")[0]||e.documentElement;return n.innerHTML="x<style>"+t+"</style>",r.insertBefore(n.lastChild,r.firstChild)}function a(){var e=b.elements;return"string"==typeof e?e.split(" "):e}function o(e,t){var n=b.elements;"string"!=typeof n&&(n=n.join(" ")),"string"!=typeof e&&(e=e.join(" ")),b.elements=n+" "+e,s(t)}function c(e){var t=E[e[v]];return t||(t={},y++,e[v]=y,E[y]=t),t}function i(e,t,r){if(t||(t=n),f)return t.createElement(e);r||(r=c(t));var a;return a=r.cache[e]?r.cache[e].cloneNode():g.test(e)?(r.cache[e]=r.createElem(e)).cloneNode():r.createElem(e),!a.canHaveChildren||p.test(e)||a.tagUrn?a:r.frag.appendChild(a)}function l(e,t){if(e||(e=n),f)return e.createDocumentFragment();t=t||c(e);for(var r=t.frag.cloneNode(),o=0,i=a(),l=i.length;o<l;o++)r.createElement(i[o]);return r}function u(e,t){t.cache||(t.cache={},t.createElem=e.createElement,t.createFrag=e.createDocumentFragment,t.frag=t.createFrag()),e.createElement=function(n){return b.shivMethods?i(n,e,t):t.createElem(n)},e.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+a().join().replace(/[\w\-:]+/g,function(e){return t.createElem(e),t.frag.createElement(e),'c("'+e+'")'})+");return n}")(b,t.frag)}function s(e){e||(e=n);var t=c(e);return!b.shivCSS||d||t.hasCSS||(t.hasCSS=!!r(e,"article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}mark{background:#FF0;color:#000}template{display:none}")),f||u(e,t),e}var d,f,m="3.7.3-pre",h=t.html5||{},p=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,g=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,v="_html5shiv",y=0,E={};!function(){try{var e=n.createElement("a");e.innerHTML="<xyz></xyz>",d="hidden"in e,f=1==e.childNodes.length||function(){n.createElement("a");var e=n.createDocumentFragment();return"undefined"==typeof e.cloneNode||"undefined"==typeof e.createDocumentFragment||"undefined"==typeof e.createElement}()}catch(t){d=!0,f=!0}}();var b={elements:h.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output picture progress section summary template time video",version:m,shivCSS:h.shivCSS!==!1,supportsUnknownElements:f,shivMethods:h.shivMethods!==!1,type:"default",shivDocument:s,createElement:i,createDocumentFragment:l,addElements:o};t.html5=b,s(n),"object"==typeof e&&e.exports&&(e.exports=b)}("undefined"!=typeof window?window:this,document)}});
//# sourceMappingURL=fix.ie9.js.map

File diff suppressed because one or more lines are too long

46
app/dist/index.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,15 +1,22 @@
module.exports = {
"app.common.album": "{itemCount, plural, one {Album} other {Albums}}", // Album
"app.common.artist": "{itemCount, plural, one {Artist} other {Artists}}", // Artist
"app.api.invalidResponse": "Invalid response text.", // Invalid response from the API
"app.api.emptyResponse": "Empty response text.", // Empty response from the API
"app.api.error": "Unknown API error.", // An unknown error occurred from the API
"app.common.album": "{itemCount, plural, one {album} other {albums}}", // Album
"app.common.artist": "{itemCount, plural, one {artist} other {artists}}", // Artist
"app.common.cancel": "Cancel", // Cancel
"app.common.close": "Close", // Close
"app.common.go": "Go", // Go
"app.common.song": "{itemCount, plural, one {Song} other {Songs}}", // Song
"app.common.loading": "Loading…", // Loading indicator
"app.common.track": "{itemCount, plural, one {track} other {tracks}}", // Track
"app.filter.filter": "Filter…", // Filtering input placeholder
"app.filter.whatAreWeListeningToToday": "What are we listening to today?", // Description for the filter bar
"app.grid.goToArtistPage": "Go to artist page", // Artist thumbnail link title
"app.grid.goToAlbumPage": "Go to album page", // Album thumbnail link title
"app.login.byebye": "See you soon!", // Info message on successful logout
"app.login.connecting": "Connecting…", // Info message while trying to connect
"app.login.endpointInputAriaLabel": "URL of your Ampache instance (e.g. http://ampache.example.com)", // ARIA label for the endpoint input
"app.login.expired": "Your session expired… =(", // Error message on expired session
"app.login.greeting": "Welcome back on Ampache, let's go!", // Greeting to welcome the user to the app
"app.login.password": "Password", // Password input placeholder
"app.login.rememberMe": "Remember me", // Remember me checkbox label

View File

@ -1,15 +1,22 @@
module.exports = {
"app.common.album": "{itemCount, plural, one {Album} other {Albums}}", // Albums
"app.common.artist": "{itemCount, plural, one {Artiste} other {Artistes}}", // Artists
"app.api.invalidResponse": "Réponse invalide reçue.", // Invalid response from the API
"app.api.emptyResponse": "Réponse vide reçue.", // Empty response from the API
"app.api.error": "Erreur inconnue.", // An unknown error occurred from the API
"app.common.album": "{itemCount, plural, one {album} other {albums}}", // Albums
"app.common.artist": "{itemCount, plural, one {artiste} other {artistes}}", // Artists
"app.common.cancel": "Annuler", // Cancel
"app.common.close": "Fermer", // Close
"app.common.go": "Aller", // Go
"app.common.song": "{itemCount, plural, one {Piste} other {Pistes}}", // Song
"app.common.loading": "Chargement…", // Loading indicator
"app.common.track": "{itemCount, plural, one {piste} other {pistes}}", // Track
"app.filter.filter": "Filtrer…", // Filtering input placeholder
"app.filter.whatAreWeListeningToToday": "Que voulez-vous écouter aujourd'hui\u00a0?", // Description for the filter bar
"app.grid.goToArtistPage": "Aller à la page de l'artiste", // Artist thumbnail link title
"app.grid.goToAlbumPage": "Aller à la page de l'album", // Album thumbnail link title
"app.login.byebye": "À bientôt\u00a0!", // Info message on successful logout
"app.login.connecting": "Connexion…", // Info message while trying to connect
"app.login.endpointInputAriaLabel": "URL de votre Ampache (e.g. http://ampache.example.com)", // ARIA label for the endpoint input
"app.login.expired": "Session expirée… =(", // Error message on expired session
"app.login.greeting": "Bon retour sur Ampache, c'est parti\u00a0!", // Greeting to welcome the user to the app
"app.login.password": "Mot de passe", // Password input placeholder
"app.login.rememberMe": "Se souvenir", // Remember me checkbox label

View File

@ -45,6 +45,11 @@ const messages = [
id: "app.login.byebye",
defaultMessage: "See you soon!",
description: "Info message on successful logout"
},
{
id: "app.login.expired",
defaultMessage: "Your session expired… =(",
description: "Error message on expired session"
}
];

View File

@ -0,0 +1,19 @@
const messages = [
{
id: "app.api.invalidResponse",
defaultMessage: "Invalid response text.",
description: "Invalid response from the API"
},
{
id: "app.api.emptyResponse",
defaultMessage: "Empty response text.",
description: "Empty response from the API"
},
{
id: "app.api.error",
defaultMessage: "Unknown API error.",
description: "An unknown error occurred from the API"
}
];
export default messages;

View File

@ -17,17 +17,22 @@ const messages = [
{
id: "app.common.artist",
description: "Artist",
defaultMessage: "{itemCount, plural, one {Artist} other {Artists}}"
defaultMessage: "{itemCount, plural, one {artist} other {artists}}"
},
{
id: "app.common.album",
description: "Album",
defaultMessage: "{itemCount, plural, one {Album} other {Albums}}"
defaultMessage: "{itemCount, plural, one {album} other {albums}}"
},
{
id: "app.common.song",
description: "Song",
defaultMessage: "{itemCount, plural, one {Song} other {Songs}}"
id: "app.common.track",
description: "Track",
defaultMessage: "{itemCount, plural, one {track} other {tracks}}"
},
{
id: "app.common.loading",
description: "Loading indicator",
defaultMessage: "Loading…"
},
];

View File

@ -0,0 +1,14 @@
const messages = [
{
id: "app.grid.goToArtistPage",
defaultMessage: "Go to artist page",
description: "Artist thumbnail link title"
},
{
id: "app.grid.goToAlbumPage",
defaultMessage: "Go to album page",
description: "Album thumbnail link title"
}
];
export default messages;

View File

@ -3,6 +3,7 @@ import humps from "humps";
import X2JS from "x2js";
import { assembleURLAndParams } from "../utils";
import { i18nRecord } from "../models/i18n";
export const API_VERSION = 350001; /** API version to use. */
export const BASE_API_PATH = "/server/xml.server.php"; /** Base API path after endpoint. */
@ -26,7 +27,10 @@ function _parseToJSON (responseText) {
if (responseText) {
return x2js.xml_str2json(responseText).root;
}
return Promise.reject("Invalid response text.");
return Promise.reject(new i18nRecord({
id: "app.api.invalidResponse",
values: {}
}));
}
function _checkAPIErrors (jsonData) {
@ -34,7 +38,10 @@ function _checkAPIErrors (jsonData) {
return Promise.reject(jsonData.error.cdata + " (" + jsonData.error.code + ")");
} else if (!jsonData) {
// No data returned
return Promise.reject("Empty response");
return Promise.reject(new i18nRecord({
id: "app.api.emptyResponse",
values: {}
}));
}
return jsonData;
}
@ -52,6 +59,8 @@ function _uglyFixes (endpoint, token) {
var _uglyFixesSongs = function (songs) {
for (var i = 0; i < songs.length; i++) {
// Add song type
songs[i].type = "track";
// Fix for name becoming title in songs objects
songs[i].name = songs[i].title;
// Fix for length being time in songs objects
@ -66,6 +75,9 @@ function _uglyFixes (endpoint, token) {
var _uglyFixesAlbums = function (albums) {
for (var i = 0; i < albums.length; i++) {
// Add album type
albums[i].type = "album";
// Fix for absence of distinction between disks in the same album
if (albums[i].disk > 1) {
albums[i].name = albums[i].name + " [Disk " + albums[i].disk + "]";
@ -117,6 +129,9 @@ function _uglyFixes (endpoint, token) {
if (jsonData.artist) {
for (var i = 0; i < jsonData.artist.length; i++) {
// Add artist type
jsonData.artist[i].type = "artist";
// Fix for artists art not included
jsonData.artist[i].art = endpoint.replace("/server/xml.server.php", "") + "/image.php?object_id=" + jsonData.artist[i].id + "&object_type=artist&auth=" + token;
@ -190,8 +205,8 @@ function doAPICall (endpoint, action, auth, username, extraParams) {
.then(_checkHTTPStatus)
.then (response => response.text())
.then(_parseToJSON)
.then(_uglyFixes(endpoint, auth))
.then(_checkAPIErrors);
.then(_checkAPIErrors)
.then(_uglyFixes(endpoint, auth));
}
// Action key that carries API call info interpreted by this Redux middleware.
@ -239,7 +254,7 @@ export default store => next => reduxAction => {
},
error => {
if (failureDispatch) {
if (typeof error !== "string") {
if (error instanceof Error) {
error = error.message;
}
store.dispatch(failureDispatch(error));

18
app/models/auth.js Normal file
View File

@ -0,0 +1,18 @@
import Immutable from "immutable";
export const tokenRecord = Immutable.Record({
token: null,
expires: null
});
export const stateRecord = new Immutable.Record({
token: tokenRecord,
username: null,
endpoint: null,
rememberMe: false,
isAuthenticated: false,
isAuthenticating: false,
error: null,
info: null,
timerID: null
});

6
app/models/i18n.js Normal file
View File

@ -0,0 +1,6 @@
import Immutable from "immutable";
export const i18nRecord = new Immutable.Record({
id: null,
values: new Immutable.Map()
});

9
app/models/paginate.js Normal file
View File

@ -0,0 +1,9 @@
import Immutable from "immutable";
export const stateRecord = new Immutable.Record({
isFetching: false,
items: new Immutable.List(),
error: null,
currentPage: 1,
nPages: 1
});

View File

@ -2,82 +2,82 @@ import Cookies from "js-cookie";
import {LOGIN_USER_REQUEST, LOGIN_USER_SUCCESS, LOGIN_USER_FAILURE, LOGOUT_USER} from "../actions";
import { createReducer } from "../utils";
import { i18nRecord } from "../models/i18n";
import { tokenRecord, stateRecord } from "../models/auth";
var initialToken = Cookies.getJSON("token");
/**
* Initial state
*/
var initialState = new stateRecord();
const initialToken = Cookies.getJSON("token");
if (initialToken) {
initialToken.expires = new Date(initialToken.expires);
initialState = initialState.set(
"token",
new tokenRecord({ token: initialToken.token, expires: new Date(initialToken.expires) })
);
}
const initialUsername = Cookies.get("username");
if (initialUsername) {
initialState = initialState.set(
"username",
initialUsername
);
}
const initialEndpoint = Cookies.get("endpoint");
if (initialEndpoint) {
initialState = initialState.set(
"endpoint",
initialEndpoint
);
}
if (initialUsername && initialEndpoint) {
initialState = initialState.set(
"rememberMe",
true
);
}
const initialState = {
token: initialToken || {
token: "",
expires: null
},
username: Cookies.get("username"),
endpoint: Cookies.get("endpoint"),
rememberMe: Boolean(Cookies.get("username") && Cookies.get("endpoint")),
isAuthenticated: false,
isAuthenticating: false,
error: "",
info: "",
timerID: null
};
/**
* Reducers
*/
export default createReducer(initialState, {
[LOGIN_USER_REQUEST]: (state) => {
return Object.assign({}, state, {
[LOGIN_USER_REQUEST]: () => {
return new stateRecord({
isAuthenticating: true,
info: {
info: new i18nRecord({
id: "app.login.connecting",
values: {}
},
error: "",
timerID: null
})
});
},
[LOGIN_USER_SUCCESS]: (state, payload) => {
return Object.assign({}, state, {
isAuthenticating: false,
isAuthenticated: true,
token: payload.token,
username: payload.username,
endpoint: payload.endpoint,
rememberMe: payload.rememberMe,
info: {
return new stateRecord({
"isAuthenticated": true,
"token": new tokenRecord(payload.token),
"username": payload.username,
"endpoint": payload.endpoint,
"rememberMe": payload.rememberMe,
"info": new i18nRecord({
id: "app.login.success",
values: { username: payload.username}
},
error: "",
timerID: payload.timerID
values: {username: payload.username}
}),
"timerID": payload.timerID
});
},
[LOGIN_USER_FAILURE]: (state, payload) => {
return Object.assign({}, state, {
isAuthenticating: false,
isAuthenticated: false,
token: initialState.token,
username: "",
endpoint: "",
rememberMe: false,
info: "",
error: payload.error,
timerID: 0
return new stateRecord({
"error": payload.error
});
},
[LOGOUT_USER]: (state) => {
return Object.assign({}, state, {
isAuthenticated: false,
token: initialState.token,
username: "",
endpoint: "",
rememberMe: false,
info: {
[LOGOUT_USER]: () => {
return new stateRecord({
info: new i18nRecord({
id: "app.login.byebye",
values: {}
},
error: "",
timerID: 0
})
});
}
});

View File

@ -1,13 +1,9 @@
import Immutable from "immutable";
import { createReducer } from "../utils";
import { stateRecord } from "../models/paginate";
export const DEFAULT_LIMIT = 30; /** Default max number of elements to retrieve. */
const initialState = {
isFetching: false,
items: [],
total: 0,
error: ""
};
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.
@ -23,24 +19,28 @@ export default function paginate(types) {
return createReducer(initialState, {
[requestType]: (state) => {
return Object.assign({}, state, {
isFetching: true,
error: "",
});
return (
state
.set("isFetching", true)
.set("error", null)
);
},
[successType]: (state, payload) => {
return Object.assign({}, state, {
isFetching: false,
items: payload.items,
total: payload.total,
error: ""
});
return (
state
.set("isFetching", false)
.set("items", new Immutable.List(payload.items))
.set("error", null)
.set("nPages", payload.nPages)
.set("currentPage", payload.currentPage)
);
},
[failureType]: (state, payload) => {
return Object.assign({}, state, {
isFetching: false,
error: payload.error
});
return (
state
.set("isFetching", false)
.set("error", payload.error)
);
}
});
}

5
app/utils/immutable.js Normal file
View File

@ -0,0 +1,5 @@
export function immutableDiff (a, b) {
return a.filter(function (i) {
return b.indexOf(i) < 0;
});
}

View File

@ -1,4 +1,6 @@
export * from "./immutable";
export * from "./locale";
export * from "./misc";
export * from "./pagination";
export * from "./reducers";
export * from "./url";

View File

@ -1,23 +1,25 @@
export function getBrowserLocale () {
var lang;
export function getBrowserLocales () {
var langs;
if (navigator.languages) {
// 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
lang = navigator.languages[0];
langs = navigator.languages;
} else if (navigator.userLanguage) {
// IE only
lang = navigator.userLanguage;
langs = [navigator.userLanguage];
} else {
// as of this writing the latest version of firefox + safari set this correctly
lang = navigator.language;
langs = [navigator.language];
}
// Some browsers does not return uppercase for second part
var locale = lang.split("-");
locale = locale[1] ? `${locale[0]}-${locale[1].toUpperCase()}` : lang;
var locales = langs.map(function (lang) {
var locale = lang.split("-");
return locale[1] ? `${locale[0]}-${locale[1].toUpperCase()}` : lang;
});
return locale;
return locales;
}
export function messagesMap(messagesDescriptorsArray) {

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

@ -0,0 +1,14 @@
export function buildPaginationObject(location, currentPage, nPages, goToPageAction) {
const buildLinkToPage = function (pageNumber) {
return {
pathname: location.pathname,
query: Object.assign({}, location.query, { page: pageNumber })
};
};
return {
currentPage: currentPage,
nPages: nPages,
goToPage: pageNumber => goToPageAction(buildLinkToPage(pageNumber)),
buildLinkToPage: buildLinkToPage
};
}

View File

@ -1,13 +1,19 @@
import React, { Component } from "react";
import React, { Component, PropTypes } from "react";
import { bindActionCreators } from "redux";
import { connect } from "react-redux";
import { defineMessages, injectIntl, intlShape } from "react-intl";
import * as actionCreators from "../actions";
import { DEFAULT_LIMIT } from "../reducers/paginate";
import { i18nRecord } from "../models/i18n";
import { buildPaginationObject, messagesMap } from "../utils";
import Albums from "../components/Albums";
export class AlbumsPage extends Component {
import APIMessages from "../locales/messagesDescriptors/api";
const albumsMessages = defineMessages(messagesMap(APIMessages));
class AlbumsPageIntl extends Component {
componentWillMount () {
const currentPage = parseInt(this.props.location.query.page) || 1;
// Load the data
@ -24,20 +30,41 @@ export class AlbumsPage extends Component {
}
render () {
const currentPage = parseInt(this.props.location.query.page) || 1;
const {formatMessage} = this.props.intl;
if (this.props.error) {
var errorMessage = this.props.error;
if (this.props.error instanceof i18nRecord) {
errorMessage = formatMessage(albumsMessages[this.props.error.id], this.props.error.values);
}
alert(errorMessage);
this.context.router.replace("/");
return null;
}
const pagination = buildPaginationObject(this.props.location, this.props.currentPage, this.props.nPages, this.props.actions.goToPageAction);
return (
<Albums albums={this.props.albumsList} albumsTotalCount={this.props.albumsCount} albumsPerPage={DEFAULT_LIMIT} currentPage={currentPage} location={this.props.location} />
<Albums isFetching={this.props.isFetching} albums={this.props.albumsList} pagination={pagination} />
);
}
}
AlbumsPageIntl.contextTypes = {
router: PropTypes.object.isRequired
};
AlbumsPageIntl.propTypes = {
intl: intlShape.isRequired,
};
const mapStateToProps = (state) => ({
isFetching: state.pagination.albums.isFetching,
error: state.pagination.albums.error,
albumsList: state.pagination.albums.items,
albumsCount: state.pagination.albums.total
currentPage: state.pagination.albums.currentPage,
nPages: state.pagination.albums.nPages
});
const mapDispatchToProps = (dispatch) => ({
actions: bindActionCreators(actionCreators, dispatch)
});
export default connect(mapStateToProps, mapDispatchToProps)(AlbumsPage);
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(AlbumsPageIntl));

View File

@ -1,13 +1,19 @@
import React, { Component } from "react";
import React, { Component, PropTypes } from "react";
import { bindActionCreators } from "redux";
import { connect } from "react-redux";
import { defineMessages, injectIntl, intlShape } from "react-intl";
import * as actionCreators from "../actions";
import { DEFAULT_LIMIT } from "../reducers/paginate";
import { i18nRecord } from "../models/i18n";
import { buildPaginationObject, messagesMap } from "../utils";
import Artists from "../components/Artists";
export class ArtistsPage extends Component {
import APIMessages from "../locales/messagesDescriptors/api";
const artistsMessages = defineMessages(messagesMap(APIMessages));
class ArtistsPageIntl extends Component {
componentWillMount () {
const currentPage = parseInt(this.props.location.query.page) || 1;
// Load the data
@ -24,20 +30,40 @@ export class ArtistsPage extends Component {
}
render () {
const currentPage = parseInt(this.props.location.query.page) || 1;
const {formatMessage} = this.props.intl;
if (this.props.error) {
var errorMessage = this.props.error;
if (this.props.error instanceof i18nRecord) {
errorMessage = formatMessage(artistsMessages[this.props.error.id], this.props.error.values);
}
alert(errorMessage);
this.context.router.replace("/");
return null;
}
const pagination = buildPaginationObject(this.props.location, this.props.currentPage, this.props.nPages, this.props.actions.goToPageAction);
return (
<Artists artists={this.props.artistsList} artistsTotalCount={this.props.artistsCount} artistsPerPage={DEFAULT_LIMIT} currentPage={currentPage} location={this.props.location} />
<Artists isFetching={this.props.isFetching} artists={this.props.artistsList} pagination={pagination} />
);
}
}
ArtistsPageIntl.contextTypes = {
router: PropTypes.object.isRequired
};
ArtistsPageIntl.propTypes = {
intl: intlShape.isRequired,
};
const mapStateToProps = (state) => ({
isFetching: state.pagination.artists.isFetching,
artistsList: state.pagination.artists.items,
artistsCount: state.pagination.artists.total
currentPage: state.pagination.artists.currentPage,
nPages: state.pagination.artists.nPages
});
const mapDispatchToProps = (dispatch) => ({
actions: bindActionCreators(actionCreators, dispatch)
});
export default connect(mapStateToProps, mapDispatchToProps)(ArtistsPage);
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ArtistsPageIntl));

View File

@ -1,13 +1,19 @@
import React, { Component } from "react";
import React, { Component, PropTypes } from "react";
import { bindActionCreators } from "redux";
import { connect } from "react-redux";
import { defineMessages, injectIntl, intlShape } from "react-intl";
import * as actionCreators from "../actions";
import { DEFAULT_LIMIT } from "../reducers/paginate";
import { i18nRecord } from "../models/i18n";
import { buildPaginationObject, messagesMap } from "../utils";
import Songs from "../components/Songs";
export class SongsPage extends Component {
import APIMessages from "../locales/messagesDescriptors/api";
const songsMessages = defineMessages(messagesMap(APIMessages));
class SongsPageIntl extends Component {
componentWillMount () {
const currentPage = parseInt(this.props.location.query.page) || 1;
// Load the data
@ -24,20 +30,40 @@ export class SongsPage extends Component {
}
render () {
const currentPage = parseInt(this.props.location.query.page) || 1;
const {formatMessage} = this.props.intl;
if (this.props.error) {
var errorMessage = this.props.error;
if (this.props.error instanceof i18nRecord) {
errorMessage = formatMessage(songsMessages[this.props.error.id], this.props.error.values);
}
alert(errorMessage);
this.context.router.replace("/");
return null;
}
const pagination = buildPaginationObject(this.props.location, this.props.currentPage, this.props.nPages, this.props.actions.goToPageAction);
return (
<Songs songs={this.props.songsList} songsTotalCount={this.props.songsCount} songsPerPage={DEFAULT_LIMIT} currentPage={currentPage} location={this.props.location} />
<Songs isFetching={this.props.isFetching} songs={this.props.songsList} pagination={pagination} />
);
}
}
SongsPageIntl.contextTypes = {
router: PropTypes.object.isRequired
};
SongsPageIntl.propTypes = {
intl: intlShape.isRequired,
};
const mapStateToProps = (state) => ({
isFetching: state.pagination.songs.isFetching,
songsList: state.pagination.songs.items,
songsCount: state.pagination.songs.total
currentPage: state.pagination.songs.currentPage,
nPages: state.pagination.songs.nPages
});
const mapDispatchToProps = (dispatch) => ({
actions: bindActionCreators(actionCreators, dispatch)
});
export default connect(mapStateToProps, mapDispatchToProps)(SongsPage);
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SongsPageIntl));

View File

@ -11,7 +11,7 @@ import fr from "react-intl/locale-data/fr";
import configureStore from "./app/store/configureStore";
import { getBrowserLocale } from "./app/utils";
import { getBrowserLocales } from "./app/utils";
import rawMessages from "./app/locales";
const store = configureStore();
@ -22,8 +22,17 @@ export const rootElement = document.getElementById("root");
// i18n
export const onWindowIntl = () => {
addLocaleData([...en, ...fr]);
const locale = getBrowserLocale(); // TODO: Get navigator.languages as array and match
var strings = rawMessages[locale] ? rawMessages[locale] : rawMessages["en-US"];
const locales = getBrowserLocales();
var locale = "en-US";
var strings = {};
for (var i = 0; i < locales.length; ++i) {
if (rawMessages[locales[i]]) {
locale = locales[i];
strings = rawMessages[locale];
break;
}
}
strings = Object.assign(rawMessages["en-US"], strings);
let render = () => {

View File

@ -26,6 +26,7 @@
"html5shiv": "^3.7.3",
"humps": "^1.1.0",
"imagesloaded": "^4.1.0",
"immutable": "^3.8.1",
"intl": "^1.2.4",
"isomorphic-fetch": "^2.2.1",
"isotope-layout": "^3.0.1",