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 5. Web player
6. Homepage 6. Homepage
7. Settings 7. Settings
@ -6,25 +6,4 @@
9. Discover 9. Discover
# API middleware # API middleware
* https://github.com/reactjs/redux/issues/1824#issuecomment-228609501 * Immutable.js : entities in API
* 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

View File

@ -1,16 +1,19 @@
import humps from "humps"; import humps from "humps";
import { CALL_API } from "../middleware/api"; 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) { export default function (action, requestType, successType, failureType) {
const itemName = action.rstrip("s"); const itemName = action.rstrip("s");
const fetchItemsSuccess = function (itemsList, itemsCount) { const fetchItemsSuccess = function (itemsList, itemsCount, pageNumber) {
const nPages = Math.ceil(itemsCount / DEFAULT_LIMIT);
return { return {
type: successType, type: successType,
payload: { payload: {
items: itemsList, 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 = { var extraParams = {
offset: offset, offset: offset,
limit: limit limit: limit
@ -47,7 +51,7 @@ export default function (action, requestType, successType, failureType) {
dispatch: [ dispatch: [
fetchItemsRequest, fetchItemsRequest,
jsonData => dispatch => { jsonData => dispatch => {
dispatch(fetchItemsSuccess(jsonData[itemName], jsonData[action])); dispatch(fetchItemsSuccess(jsonData[itemName], jsonData[action], pageNumber));
}, },
fetchItemsFailure fetchItemsFailure
], ],
@ -61,8 +65,7 @@ export default function (action, requestType, successType, failureType) {
const loadItems = function({ pageNumber = 1, filter = null, include = [] } = {}) { const loadItems = function({ pageNumber = 1, filter = null, include = [] } = {}) {
return (dispatch, getState) => { return (dispatch, getState) => {
const { auth } = getState(); const { auth } = getState();
const offset = (pageNumber - 1) * DEFAULT_LIMIT; dispatch(fetchItems(auth.endpoint, auth.username, auth.token.token, filter, pageNumber, include));
dispatch(fetchItems(auth.endpoint, auth.username, auth.token.token, filter, offset, include));
}; };
}; };

View File

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

View File

@ -16,3 +16,5 @@ export const SONGS_SUCCESS = "SONGS_SUCCESS";
export const SONGS_REQUEST = "SONGS_REQUEST"; export const SONGS_REQUEST = "SONGS_REQUEST";
export const SONGS_FAILURE = "SONGS_FAILURE"; export const SONGS_FAILURE = "SONGS_FAILURE";
export var { loadSongs } = APIAction("songs", SONGS_REQUEST, SONGS_SUCCESS, 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 React, { Component, PropTypes } from "react";
import Immutable from "immutable";
import FilterablePaginatedGrid from "./elements/Grid"; import FilterablePaginatedGrid from "./elements/Grid";
export default class Albums extends Component { export default class Albums extends Component {
render () { render () {
const grid = {
isFetching: this.props.isFetching,
items: this.props.albums,
itemsLabel: "app.common.album",
subItemsType: "tracks",
subItemsLabel: "app.common.track"
};
return ( 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 = {
albums: PropTypes.array.isRequired, isFetching: PropTypes.bool.isRequired,
albumsTotalCount: PropTypes.number.isRequired, albums: PropTypes.instanceOf(Immutable.List).isRequired,
albumsPerPage: PropTypes.number.isRequired, pagination: PropTypes.object.isRequired,
currentPage: PropTypes.number.isRequired,
location: PropTypes.object.isRequired
}; };

View File

@ -1,19 +1,27 @@
import React, { Component, PropTypes } from "react"; import React, { Component, PropTypes } from "react";
import Immutable from "immutable";
import FilterablePaginatedGrid from "./elements/Grid"; import FilterablePaginatedGrid from "./elements/Grid";
export default class Artists extends Component { class Artists extends Component {
render () { render () {
const grid = {
isFetching: this.props.isFetching,
items: this.props.artists,
itemsLabel: "app.common.artist",
subItemsType: "albums",
subItemsLabel: "app.common.album"
};
return ( 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 = {
artists: PropTypes.array.isRequired, isFetching: PropTypes.bool.isRequired,
artistsTotalCount: PropTypes.number.isRequired, artists: PropTypes.instanceOf(Immutable.List).isRequired,
artistsPerPage: PropTypes.number.isRequired, pagination: PropTypes.object.isRequired,
currentPage: PropTypes.number.isRequired,
location: 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 CSSModules from "react-css-modules";
import { defineMessages, injectIntl, intlShape, FormattedMessage } from "react-intl"; import { defineMessages, injectIntl, intlShape, FormattedMessage } from "react-intl";
import { i18nRecord } from "../models/i18n";
import { messagesMap } from "../utils"; import { messagesMap } from "../utils";
import APIMessages from "../locales/messagesDescriptors/api";
import messages from "../locales/messagesDescriptors/Login"; import messages from "../locales/messagesDescriptors/Login";
import css from "../styles/Login.scss"; import css from "../styles/Login.scss";
const loginMessages = defineMessages(messagesMap(messages)); const loginMessages = defineMessages(messagesMap(Array.concat([], APIMessages, messages)));
class LoginFormCSSIntl extends Component { class LoginFormCSSIntl extends Component {
constructor (props) { constructor (props) {
@ -58,13 +60,17 @@ class LoginFormCSSIntl extends Component {
render () { render () {
const {formatMessage} = this.props.intl; const {formatMessage} = this.props.intl;
var infoMessage = ""; var infoMessage = this.props.info;
if (typeof this.props.info === "object") { if (this.props.info && this.props.info instanceof i18nRecord) {
infoMessage = ( infoMessage = (
<FormattedMessage {...loginMessages[this.props.info.id]} values={ this.props.info.values} /> <FormattedMessage {...loginMessages[this.props.info.id]} values={ this.props.info.values} />
); );
} 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 ( return (
<div> <div>
@ -73,7 +79,7 @@ class LoginFormCSSIntl extends Component {
<div className="row"> <div className="row">
<div className="alert alert-danger" id="loginFormError"> <div className="alert alert-danger" id="loginFormError">
<p> <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> </p>
</div> </div>
</div> </div>
@ -135,8 +141,8 @@ LoginFormCSSIntl.propTypes = {
rememberMe: PropTypes.bool, rememberMe: PropTypes.bool,
onSubmit: PropTypes.func.isRequired, onSubmit: PropTypes.func.isRequired,
isAuthenticating: PropTypes.bool, isAuthenticating: PropTypes.bool,
error: PropTypes.string, error: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(i18nRecord)]),
info: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), info: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(i18nRecord)]),
intl: intlShape.isRequired, intl: intlShape.isRequired,
}; };

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import React, { Component, PropTypes } from "react"; import React, { Component, PropTypes } from "react";
import { Link, withRouter } from "react-router"; import { Link } from "react-router";
import CSSModules from "react-css-modules"; import CSSModules from "react-css-modules";
import { defineMessages, injectIntl, intlShape, FormattedMessage, FormattedHTMLMessage } from "react-intl"; import { defineMessages, injectIntl, intlShape, FormattedMessage, FormattedHTMLMessage } from "react-intl";
@ -12,11 +12,6 @@ import css from "../../styles/elements/Pagination.scss";
const paginationMessages = defineMessages(messagesMap(Array.concat([], commonMessages, messages))); const paginationMessages = defineMessages(messagesMap(Array.concat([], commonMessages, messages)));
class PaginationCSSIntl extends Component { class PaginationCSSIntl extends Component {
constructor(props) {
super(props);
this.buildLinkTo.bind(this);
}
computePaginationBounds(currentPage, nPages, maxNumberPagesShown=5) { computePaginationBounds(currentPage, nPages, maxNumberPagesShown=5) {
// Taken from http://stackoverflow.com/a/8608998/2626416 // Taken from http://stackoverflow.com/a/8608998/2626416
var lowerLimit = currentPage; var lowerLimit = currentPage;
@ -39,18 +34,12 @@ class PaginationCSSIntl extends Component {
}; };
} }
buildLinkTo(pageNumber) { goToPage(ev) {
return { ev.preventDefault();
pathname: this.props.location.pathname,
query: Object.assign({}, this.props.location.query, { page: pageNumber })
};
}
goToPage() {
const pageNumber = parseInt(this.refs.pageInput.value); const pageNumber = parseInt(this.refs.pageInput.value);
$(this.refs.paginationModal).modal("hide"); $(this.refs.paginationModal).modal("hide");
if (pageNumber) { if (pageNumber) {
this.props.router.push(this.buildLinkTo(pageNumber)); this.props.goToPage(pageNumber);
} }
} }
@ -79,7 +68,7 @@ class PaginationCSSIntl extends Component {
// Push first page // Push first page
pagesButton.push( pagesButton.push(
<li className="page-item" key={key}> <li className="page-item" key={key}>
<Link className="page-link" title={formatMessage(paginationMessages["app.pagination.goToPageWithoutMarkup"], { pageNumber: 1})} to={this.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 }} /> <FormattedHTMLMessage {...paginationMessages["app.pagination.goToPage"]} values={{ pageNumber: 1 }} />
</Link> </Link>
</li> </li>
@ -106,7 +95,7 @@ class PaginationCSSIntl extends Component {
const title = formatMessage(paginationMessages["app.pagination.goToPageWithoutMarkup"], { pageNumber: i }); const title = formatMessage(paginationMessages["app.pagination.goToPageWithoutMarkup"], { pageNumber: i });
pagesButton.push( pagesButton.push(
<li className={className} key={key}> <li className={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 }} /> <FormattedHTMLMessage {...paginationMessages["app.pagination.goToPage"]} values={{ pageNumber: i }} />
{currentSpan} {currentSpan}
</Link> </Link>
@ -128,7 +117,7 @@ class PaginationCSSIntl extends Component {
// Push last page // Push last page
pagesButton.push( pagesButton.push(
<li className="page-item" key={key}> <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 }} /> <FormattedHTMLMessage {...paginationMessages["app.pagination.goToPage"]} values={{ pageNumber: this.props.nPages }} />
</Link> </Link>
</li> </li>
@ -152,8 +141,8 @@ class PaginationCSSIntl extends Component {
</h4> </h4>
</div> </div>
<div className="modal-body"> <div className="modal-body">
<form> <form onSubmit={this.goToPage.bind(this)}>
<input className="form-control" autoComplete="off" type="number" ref="pageInput" aria-label={formatMessage(paginationMessages["app.pagination.pageToGoTo"])} /> <input className="form-control" autoComplete="off" type="number" ref="pageInput" aria-label={formatMessage(paginationMessages["app.pagination.pageToGoTo"])} autoFocus />
</form> </form>
</div> </div>
<div className="modal-footer"> <div className="modal-footer">
@ -176,9 +165,10 @@ class PaginationCSSIntl extends Component {
PaginationCSSIntl.propTypes = { PaginationCSSIntl.propTypes = {
currentPage: PropTypes.number.isRequired, currentPage: PropTypes.number.isRequired,
location: PropTypes.object.isRequired, goToPage: PropTypes.func.isRequired,
buildLinkToPage: PropTypes.func.isRequired,
nPages: PropTypes.number.isRequired, nPages: PropTypes.number.isRequired,
intl: intlShape.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> <li>
<Link to="/artists" title={formatMessage(sidebarLayoutMessages["app.sidebarLayout.browseArtists"])} styleName={isActive.artists}> <Link to="/artists" title={formatMessage(sidebarLayoutMessages["app.sidebarLayout.browseArtists"])} styleName={isActive.artists}>
<span className="glyphicon glyphicon-user" aria-hidden="true"></span> <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}} /> <FormattedMessage {...sidebarLayoutMessages["app.common.artist"]} values={{itemCount: 42}} />
</span> </span>
<span className="hidden-md"> <span className="hidden-md text-capitalize">
&nbsp;<FormattedMessage {...sidebarLayoutMessages["app.common.artist"]} values={{itemCount: 42}} /> &nbsp;<FormattedMessage {...sidebarLayoutMessages["app.common.artist"]} values={{itemCount: 42}} />
</span> </span>
</Link> </Link>
@ -93,8 +93,10 @@ class SidebarLayoutIntl extends Component {
<li> <li>
<Link to="/albums" title={formatMessage(sidebarLayoutMessages["app.sidebarLayout.browseAlbums"])} styleName={isActive.albums}> <Link to="/albums" title={formatMessage(sidebarLayoutMessages["app.sidebarLayout.browseAlbums"])} styleName={isActive.albums}>
<span className="glyphicon glyphicon-cd" aria-hidden="true"></span> <span className="glyphicon glyphicon-cd" aria-hidden="true"></span>
<span className="sr-only"><FormattedMessage {...sidebarLayoutMessages["app.common.album"]} values={{itemCount: 42}} /></span> <span className="sr-only text-capitalize">
<span className="hidden-md"> <FormattedMessage {...sidebarLayoutMessages["app.common.album"]} values={{itemCount: 42}} />
</span>
<span className="hidden-md text-capitalize">
&nbsp;<FormattedMessage {...sidebarLayoutMessages["app.common.album"]} values={{itemCount: 42}} /> &nbsp;<FormattedMessage {...sidebarLayoutMessages["app.common.album"]} values={{itemCount: 42}} />
</span> </span>
</Link> </Link>
@ -102,11 +104,11 @@ class SidebarLayoutIntl extends Component {
<li> <li>
<Link to="/songs" title={formatMessage(sidebarLayoutMessages["app.sidebarLayout.browseSongs"])} styleName={isActive.songs}> <Link to="/songs" title={formatMessage(sidebarLayoutMessages["app.sidebarLayout.browseSongs"])} styleName={isActive.songs}>
<span className="glyphicon glyphicon-music" aria-hidden="true"></span> <span className="glyphicon glyphicon-music" aria-hidden="true"></span>
<span className="sr-only"> <span className="sr-only text-capitalize">
<FormattedMessage {...sidebarLayoutMessages["app.common.song"]} values={{itemCount: 42}} /> <FormattedMessage {...sidebarLayoutMessages["app.common.track"]} values={{itemCount: 42}} />
</span> </span>
<span className="hidden-md"> <span className="hidden-md text-capitalize">
&nbsp;<FormattedMessage {...sidebarLayoutMessages["app.common.song"]} values={{itemCount: 42}} /> &nbsp;<FormattedMessage {...sidebarLayoutMessages["app.common.track"]} values={{itemCount: 42}} />
</span> </span>
</Link> </Link>
</li> </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 //# 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 = { module.exports = {
"app.common.album": "{itemCount, plural, one {Album} other {Albums}}", // Album "app.api.invalidResponse": "Invalid response text.", // Invalid response from the API
"app.common.artist": "{itemCount, plural, one {Artist} other {Artists}}", // Artist "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.cancel": "Cancel", // Cancel
"app.common.close": "Close", // Close "app.common.close": "Close", // Close
"app.common.go": "Go", // Go "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.filter": "Filter…", // Filtering input placeholder
"app.filter.whatAreWeListeningToToday": "What are we listening to today?", // Description for the filter bar "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.byebye": "See you soon!", // Info message on successful logout
"app.login.connecting": "Connecting…", // Info message while trying to connect "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.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.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.password": "Password", // Password input placeholder
"app.login.rememberMe": "Remember me", // Remember me checkbox label "app.login.rememberMe": "Remember me", // Remember me checkbox label

View File

@ -1,15 +1,22 @@
module.exports = { module.exports = {
"app.common.album": "{itemCount, plural, one {Album} other {Albums}}", // Albums "app.api.invalidResponse": "Réponse invalide reçue.", // Invalid response from the API
"app.common.artist": "{itemCount, plural, one {Artiste} other {Artistes}}", // Artists "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.cancel": "Annuler", // Cancel
"app.common.close": "Fermer", // Close "app.common.close": "Fermer", // Close
"app.common.go": "Aller", // Go "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.filter": "Filtrer…", // Filtering input placeholder
"app.filter.whatAreWeListeningToToday": "Que voulez-vous écouter aujourd'hui\u00a0?", // Description for the filter bar "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.byebye": "À bientôt\u00a0!", // Info message on successful logout
"app.login.connecting": "Connexion…", // Info message while trying to connect "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.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.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.password": "Mot de passe", // Password input placeholder
"app.login.rememberMe": "Se souvenir", // Remember me checkbox label "app.login.rememberMe": "Se souvenir", // Remember me checkbox label

View File

@ -45,6 +45,11 @@ const messages = [
id: "app.login.byebye", id: "app.login.byebye",
defaultMessage: "See you soon!", defaultMessage: "See you soon!",
description: "Info message on successful logout" 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", id: "app.common.artist",
description: "Artist", description: "Artist",
defaultMessage: "{itemCount, plural, one {Artist} other {Artists}}" defaultMessage: "{itemCount, plural, one {artist} other {artists}}"
}, },
{ {
id: "app.common.album", id: "app.common.album",
description: "Album", description: "Album",
defaultMessage: "{itemCount, plural, one {Album} other {Albums}}" defaultMessage: "{itemCount, plural, one {album} other {albums}}"
}, },
{ {
id: "app.common.song", id: "app.common.track",
description: "Song", description: "Track",
defaultMessage: "{itemCount, plural, one {Song} other {Songs}}" 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 X2JS from "x2js";
import { assembleURLAndParams } from "../utils"; import { assembleURLAndParams } from "../utils";
import { i18nRecord } from "../models/i18n";
export const API_VERSION = 350001; /** API version to use. */ export const API_VERSION = 350001; /** API version to use. */
export const BASE_API_PATH = "/server/xml.server.php"; /** Base API path after endpoint. */ export const BASE_API_PATH = "/server/xml.server.php"; /** Base API path after endpoint. */
@ -26,7 +27,10 @@ function _parseToJSON (responseText) {
if (responseText) { if (responseText) {
return x2js.xml_str2json(responseText).root; 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) { function _checkAPIErrors (jsonData) {
@ -34,7 +38,10 @@ function _checkAPIErrors (jsonData) {
return Promise.reject(jsonData.error.cdata + " (" + jsonData.error.code + ")"); return Promise.reject(jsonData.error.cdata + " (" + jsonData.error.code + ")");
} else if (!jsonData) { } else if (!jsonData) {
// No data returned // No data returned
return Promise.reject("Empty response"); return Promise.reject(new i18nRecord({
id: "app.api.emptyResponse",
values: {}
}));
} }
return jsonData; return jsonData;
} }
@ -52,6 +59,8 @@ function _uglyFixes (endpoint, token) {
var _uglyFixesSongs = function (songs) { var _uglyFixesSongs = function (songs) {
for (var i = 0; i < songs.length; i++) { for (var i = 0; i < songs.length; i++) {
// Add song type
songs[i].type = "track";
// Fix for name becoming title in songs objects // Fix for name becoming title in songs objects
songs[i].name = songs[i].title; songs[i].name = songs[i].title;
// Fix for length being time in songs objects // Fix for length being time in songs objects
@ -66,6 +75,9 @@ function _uglyFixes (endpoint, token) {
var _uglyFixesAlbums = function (albums) { var _uglyFixesAlbums = function (albums) {
for (var i = 0; i < albums.length; i++) { 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 // Fix for absence of distinction between disks in the same album
if (albums[i].disk > 1) { if (albums[i].disk > 1) {
albums[i].name = albums[i].name + " [Disk " + albums[i].disk + "]"; albums[i].name = albums[i].name + " [Disk " + albums[i].disk + "]";
@ -117,6 +129,9 @@ function _uglyFixes (endpoint, token) {
if (jsonData.artist) { if (jsonData.artist) {
for (var i = 0; i < jsonData.artist.length; i++) { for (var i = 0; i < jsonData.artist.length; i++) {
// Add artist type
jsonData.artist[i].type = "artist";
// Fix for artists art not included // 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; 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(_checkHTTPStatus)
.then (response => response.text()) .then (response => response.text())
.then(_parseToJSON) .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. // Action key that carries API call info interpreted by this Redux middleware.
@ -239,7 +254,7 @@ export default store => next => reduxAction => {
}, },
error => { error => {
if (failureDispatch) { if (failureDispatch) {
if (typeof error !== "string") { if (error instanceof Error) {
error = error.message; error = error.message;
} }
store.dispatch(failureDispatch(error)); 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 {LOGIN_USER_REQUEST, LOGIN_USER_SUCCESS, LOGIN_USER_FAILURE, LOGOUT_USER} from "../actions";
import { createReducer } from "../utils"; 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) { if (initialToken) {
initialToken.expires = new Date(initialToken.expires); 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 || { * Reducers
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
};
export default createReducer(initialState, { export default createReducer(initialState, {
[LOGIN_USER_REQUEST]: (state) => { [LOGIN_USER_REQUEST]: () => {
return Object.assign({}, state, { return new stateRecord({
isAuthenticating: true, isAuthenticating: true,
info: { info: new i18nRecord({
id: "app.login.connecting", id: "app.login.connecting",
values: {} values: {}
}, })
error: "",
timerID: null
}); });
}, },
[LOGIN_USER_SUCCESS]: (state, payload) => { [LOGIN_USER_SUCCESS]: (state, payload) => {
return Object.assign({}, state, { return new stateRecord({
isAuthenticating: false, "isAuthenticated": true,
isAuthenticated: true, "token": new tokenRecord(payload.token),
token: payload.token, "username": payload.username,
username: payload.username, "endpoint": payload.endpoint,
endpoint: payload.endpoint, "rememberMe": payload.rememberMe,
rememberMe: payload.rememberMe, "info": new i18nRecord({
info: {
id: "app.login.success", id: "app.login.success",
values: { username: payload.username} values: {username: payload.username}
}, }),
error: "", "timerID": payload.timerID
timerID: payload.timerID
}); });
}, },
[LOGIN_USER_FAILURE]: (state, payload) => { [LOGIN_USER_FAILURE]: (state, payload) => {
return Object.assign({}, state, { return new stateRecord({
isAuthenticating: false, "error": payload.error
isAuthenticated: false,
token: initialState.token,
username: "",
endpoint: "",
rememberMe: false,
info: "",
error: payload.error,
timerID: 0
}); });
}, },
[LOGOUT_USER]: (state) => { [LOGOUT_USER]: () => {
return Object.assign({}, state, { return new stateRecord({
isAuthenticated: false, info: new i18nRecord({
token: initialState.token,
username: "",
endpoint: "",
rememberMe: false,
info: {
id: "app.login.byebye", id: "app.login.byebye",
values: {} values: {}
}, })
error: "",
timerID: 0
}); });
} }
}); });

View File

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

View File

@ -1,23 +1,25 @@
export function getBrowserLocale () { export function getBrowserLocales () {
var lang; var langs;
if (navigator.languages) { if (navigator.languages) {
// chrome does not currently set navigator.language correctly https://code.google.com/p/chromium/issues/detail?id=101138 // chrome does not currently set navigator.language correctly https://code.google.com/p/chromium/issues/detail?id=101138
// but it does set the first element of navigator.languages correctly // but it does set the first element of navigator.languages correctly
lang = navigator.languages[0]; langs = navigator.languages;
} else if (navigator.userLanguage) { } else if (navigator.userLanguage) {
// IE only // IE only
lang = navigator.userLanguage; langs = [navigator.userLanguage];
} else { } else {
// as of this writing the latest version of firefox + safari set this correctly // 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 // Some browsers does not return uppercase for second part
var locale = lang.split("-"); var locales = langs.map(function (lang) {
locale = locale[1] ? `${locale[0]}-${locale[1].toUpperCase()}` : lang; var locale = lang.split("-");
return locale[1] ? `${locale[0]}-${locale[1].toUpperCase()}` : lang;
});
return locale; return locales;
} }
export function messagesMap(messagesDescriptorsArray) { 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 { bindActionCreators } from "redux";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { defineMessages, injectIntl, intlShape } from "react-intl";
import * as actionCreators from "../actions"; 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"; 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 () { componentWillMount () {
const currentPage = parseInt(this.props.location.query.page) || 1; const currentPage = parseInt(this.props.location.query.page) || 1;
// Load the data // Load the data
@ -24,20 +30,41 @@ export class AlbumsPage extends Component {
} }
render () { 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 ( 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) => ({ const mapStateToProps = (state) => ({
isFetching: state.pagination.albums.isFetching,
error: state.pagination.albums.error,
albumsList: state.pagination.albums.items, albumsList: state.pagination.albums.items,
albumsCount: state.pagination.albums.total currentPage: state.pagination.albums.currentPage,
nPages: state.pagination.albums.nPages
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
actions: bindActionCreators(actionCreators, 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 { bindActionCreators } from "redux";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { defineMessages, injectIntl, intlShape } from "react-intl";
import * as actionCreators from "../actions"; 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"; 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 () { componentWillMount () {
const currentPage = parseInt(this.props.location.query.page) || 1; const currentPage = parseInt(this.props.location.query.page) || 1;
// Load the data // Load the data
@ -24,20 +30,40 @@ export class ArtistsPage extends Component {
} }
render () { 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 ( 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) => ({ const mapStateToProps = (state) => ({
isFetching: state.pagination.artists.isFetching,
artistsList: state.pagination.artists.items, artistsList: state.pagination.artists.items,
artistsCount: state.pagination.artists.total currentPage: state.pagination.artists.currentPage,
nPages: state.pagination.artists.nPages
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
actions: bindActionCreators(actionCreators, 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 { bindActionCreators } from "redux";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { defineMessages, injectIntl, intlShape } from "react-intl";
import * as actionCreators from "../actions"; 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"; 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 () { componentWillMount () {
const currentPage = parseInt(this.props.location.query.page) || 1; const currentPage = parseInt(this.props.location.query.page) || 1;
// Load the data // Load the data
@ -24,20 +30,40 @@ export class SongsPage extends Component {
} }
render () { 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 ( 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) => ({ const mapStateToProps = (state) => ({
isFetching: state.pagination.songs.isFetching,
songsList: state.pagination.songs.items, songsList: state.pagination.songs.items,
songsCount: state.pagination.songs.total currentPage: state.pagination.songs.currentPage,
nPages: state.pagination.songs.nPages
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
actions: bindActionCreators(actionCreators, 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 configureStore from "./app/store/configureStore";
import { getBrowserLocale } from "./app/utils"; import { getBrowserLocales } from "./app/utils";
import rawMessages from "./app/locales"; import rawMessages from "./app/locales";
const store = configureStore(); const store = configureStore();
@ -22,8 +22,17 @@ export const rootElement = document.getElementById("root");
// i18n // i18n
export const onWindowIntl = () => { export const onWindowIntl = () => {
addLocaleData([...en, ...fr]); addLocaleData([...en, ...fr]);
const locale = getBrowserLocale(); // TODO: Get navigator.languages as array and match const locales = getBrowserLocales();
var strings = rawMessages[locale] ? rawMessages[locale] : rawMessages["en-US"];
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); strings = Object.assign(rawMessages["en-US"], strings);
let render = () => { let render = () => {

View File

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