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:
parent
5a9f540cc0
commit
40f6223bd0
25
TODO
25
TODO
|
@ -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
|
||||
|
|
|
@ -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));
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import { push } from "react-router-redux";
|
||||
|
||||
export function goToPage(pageLocation) {
|
||||
return (dispatch) => {
|
||||
dispatch(push(pageLocation));
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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">
|
||||
<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">
|
||||
<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">
|
||||
<FormattedMessage {...sidebarLayoutMessages["app.common.song"]} values={{itemCount: 42}} />
|
||||
<span className="hidden-md text-capitalize">
|
||||
<FormattedMessage {...sidebarLayoutMessages["app.common.track"]} values={{itemCount: 42}} />
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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…"
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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));
|
||||
|
|
|
@ -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
|
||||
});
|
|
@ -0,0 +1,6 @@
|
|||
import Immutable from "immutable";
|
||||
|
||||
export const i18nRecord = new Immutable.Record({
|
||||
id: null,
|
||||
values: new Immutable.Map()
|
||||
});
|
|
@ -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
|
||||
});
|
|
@ -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
|
||||
})
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
export function immutableDiff (a, b) {
|
||||
return a.filter(function (i) {
|
||||
return b.indexOf(i) < 0;
|
||||
});
|
||||
}
|
|
@ -1,4 +1,6 @@
|
|||
export * from "./immutable";
|
||||
export * from "./locale";
|
||||
export * from "./misc";
|
||||
export * from "./pagination";
|
||||
export * from "./reducers";
|
||||
export * from "./url";
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
|
@ -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));
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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));
|
||||
|
|
15
index.all.js
15
index.all.js
|
@ -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 = () => {
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue