Initial commit
This commit is contained in:
commit
2e1381acc6
4
.eslintignore
Normal file
4
.eslintignore
Normal file
@ -0,0 +1,4 @@
|
||||
app/dist/*
|
||||
node_modules/*
|
||||
vendor/*
|
||||
webpack.config.*
|
41
.eslintrc.js
Normal file
41
.eslintrc.js
Normal file
@ -0,0 +1,41 @@
|
||||
module.exports = {
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true
|
||||
},
|
||||
"extends": "eslint:recommended",
|
||||
"installedESLint": true,
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"experimentalObjectRestSpread": true,
|
||||
"jsx": true
|
||||
},
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": [
|
||||
"react"
|
||||
],
|
||||
"rules": {
|
||||
"indent": [
|
||||
"error",
|
||||
4
|
||||
],
|
||||
"linebreak-style": [
|
||||
"error",
|
||||
"unix"
|
||||
],
|
||||
"quotes": [
|
||||
"error",
|
||||
"double"
|
||||
],
|
||||
"semi": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"strict": [
|
||||
"error",
|
||||
],
|
||||
"react/jsx-uses-react": "error",
|
||||
"react/jsx-uses-vars": "error"
|
||||
}
|
||||
};
|
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
node_modules
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016 Phyks(Lucas Verney)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
42
README.md
Normal file
42
README.md
Normal file
@ -0,0 +1,42 @@
|
||||
Ampache React
|
||||
=============
|
||||
|
||||
This is an alternative web interface for
|
||||
[Ampache](https://github.com/ampache/ampache/) built using Ampache XML API and
|
||||
React.
|
||||
|
||||
## Trying it out
|
||||
|
||||
Just drop this repo in a location served by a webserver and head your browser
|
||||
to the correct URL :)
|
||||
|
||||
|
||||
## Support
|
||||
|
||||
The supported browsers should be:
|
||||
|
||||
* `IE >= 9` (previous versions of IE are no longer supported by Microsoft)
|
||||
* Any recent version of any other browser.
|
||||
|
||||
If you experience any issue, please report :)
|
||||
|
||||
|
||||
## Building
|
||||
|
||||
Building of this app relies on `webpack`.
|
||||
|
||||
First do a `npm install` to install all the required dependencies.
|
||||
|
||||
Then, to make a development build, just run `webpack` in the root folder. To
|
||||
make a production build, just run `NODE_ENV=production webpack` in the root
|
||||
folder. All files will be generated in the `app/dist` folder.
|
||||
|
||||
Please use the Git hooks (in `hooks` folder) to automatically make a build
|
||||
before comitting, as commit should always contain an up to date production
|
||||
build.
|
||||
|
||||
## License
|
||||
|
||||
This code is distributed under an MIT license.
|
||||
|
||||
Feel free to contribute and reuse. For more details, see `LICENSE` file.
|
16
TODO
Normal file
16
TODO
Normal file
@ -0,0 +1,16 @@
|
||||
5. Web player
|
||||
6. Homepage
|
||||
7. Settings
|
||||
8. Search
|
||||
9. Discover
|
||||
|
||||
|
||||
## Global UI
|
||||
* What happens when JS is off?
|
||||
=> https://www.allantatter.com/react-js-and-progressive-enhancement/
|
||||
|
||||
## Miscellaneous
|
||||
* See TODOs in the code
|
||||
* https://facebook.github.io/immutable-js/ ?
|
||||
* Web workers?
|
||||
* Accessibility and semantics
|
77
app/actions/APIActions.js
Normal file
77
app/actions/APIActions.js
Normal file
@ -0,0 +1,77 @@
|
||||
import humps from "humps";
|
||||
|
||||
import { CALL_API } from "../middleware/api";
|
||||
import { DEFAULT_LIMIT } from "../reducers/paginate";
|
||||
|
||||
export default function (action, requestType, successType, failureType) {
|
||||
const itemName = action.rstrip("s");
|
||||
const fetchItemsSuccess = function (itemsList, itemsCount) {
|
||||
return {
|
||||
type: successType,
|
||||
payload: {
|
||||
items: itemsList,
|
||||
total: itemsCount
|
||||
}
|
||||
};
|
||||
};
|
||||
const fetchItemsRequest = function () {
|
||||
return {
|
||||
type: requestType,
|
||||
payload: {
|
||||
}
|
||||
};
|
||||
};
|
||||
const fetchItemsFailure = function (error) {
|
||||
return {
|
||||
type: failureType,
|
||||
payload: {
|
||||
error: error
|
||||
}
|
||||
};
|
||||
};
|
||||
const fetchItems = function (endpoint, username, passphrase, filter, offset, include = [], limit=DEFAULT_LIMIT) {
|
||||
var extraParams = {
|
||||
offset: offset,
|
||||
limit: limit
|
||||
};
|
||||
if (filter) {
|
||||
extraParams.filter = filter;
|
||||
}
|
||||
if (include && include.length > 0) {
|
||||
extraParams.include = include;
|
||||
}
|
||||
return {
|
||||
type: CALL_API,
|
||||
payload: {
|
||||
endpoint: endpoint,
|
||||
dispatch: [
|
||||
fetchItemsRequest,
|
||||
jsonData => dispatch => {
|
||||
dispatch(fetchItemsSuccess(jsonData[itemName], jsonData[action]));
|
||||
},
|
||||
fetchItemsFailure
|
||||
],
|
||||
action: action,
|
||||
auth: passphrase,
|
||||
username: username,
|
||||
extraParams: extraParams
|
||||
}
|
||||
};
|
||||
};
|
||||
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));
|
||||
};
|
||||
};
|
||||
|
||||
const camelizedAction = humps.pascalize(action);
|
||||
var returned = {};
|
||||
returned["fetch" + camelizedAction + "Success"] = fetchItemsSuccess;
|
||||
returned["fetch" + camelizedAction + "Request"] = fetchItemsRequest;
|
||||
returned["fetch" + camelizedAction + "Failure"] = fetchItemsFailure;
|
||||
returned["fetch" + camelizedAction] = fetchItems;
|
||||
returned["load" + camelizedAction] = loadItems;
|
||||
return returned;
|
||||
}
|
173
app/actions/auth.js
Normal file
173
app/actions/auth.js
Normal file
@ -0,0 +1,173 @@
|
||||
import { push } from "react-router-redux";
|
||||
import jsSHA from "jssha";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
import { CALL_API } from "../middleware/api";
|
||||
|
||||
export const DEFAULT_SESSION_INTERVAL = 1800 * 1000; // 30 mins default
|
||||
|
||||
function _cleanEndpoint (endpoint) {
|
||||
// Handle endpoints of the form "ampache.example.com"
|
||||
if (
|
||||
!endpoint.startsWith("//") &&
|
||||
!endpoint.startsWith("http://") &&
|
||||
!endpoint.startsWith("https://"))
|
||||
{
|
||||
endpoint = "http://" + endpoint;
|
||||
}
|
||||
// Remove trailing slash and store endpoint
|
||||
endpoint = endpoint.replace(/\/$/, "");
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
function _buildHMAC (password) {
|
||||
// Handle Ampache HMAC generation
|
||||
const time = Math.floor(Date.now() / 1000);
|
||||
|
||||
var shaObj = new jsSHA("SHA-256", "TEXT");
|
||||
shaObj.update(password);
|
||||
const key = shaObj.getHash("HEX");
|
||||
|
||||
shaObj = new jsSHA("SHA-256", "TEXT");
|
||||
shaObj.update(time + key);
|
||||
|
||||
return {
|
||||
time: time,
|
||||
passphrase: shaObj.getHash("HEX")
|
||||
};
|
||||
}
|
||||
|
||||
export function loginKeepAlive(username, token, endpoint) {
|
||||
return {
|
||||
type: CALL_API,
|
||||
payload: {
|
||||
endpoint: endpoint,
|
||||
dispatch: [
|
||||
null,
|
||||
null,
|
||||
error => dispatch => {
|
||||
dispatch(loginUserFailure(error || "Your session expired… =("));
|
||||
}
|
||||
],
|
||||
action: "ping",
|
||||
auth: token,
|
||||
username: username,
|
||||
extraParams: {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const LOGIN_USER_SUCCESS = "LOGIN_USER_SUCCESS";
|
||||
export function loginUserSuccess(username, token, endpoint, rememberMe, timerID) {
|
||||
return {
|
||||
type: LOGIN_USER_SUCCESS,
|
||||
payload: {
|
||||
username: username,
|
||||
token: token,
|
||||
endpoint: endpoint,
|
||||
rememberMe: rememberMe,
|
||||
timerID: timerID
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const LOGIN_USER_FAILURE = "LOGIN_USER_FAILURE";
|
||||
export function loginUserFailure(error) {
|
||||
Cookies.remove("username");
|
||||
Cookies.remove("token");
|
||||
Cookies.remove("endpoint");
|
||||
return {
|
||||
type: LOGIN_USER_FAILURE,
|
||||
payload: {
|
||||
error: error
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const LOGIN_USER_REQUEST = "LOGIN_USER_REQUEST";
|
||||
export function loginUserRequest() {
|
||||
return {
|
||||
type: LOGIN_USER_REQUEST
|
||||
};
|
||||
}
|
||||
|
||||
export const LOGOUT_USER = "LOGOUT_USER";
|
||||
export function logout() {
|
||||
return (dispatch, state) => {
|
||||
const { auth } = state();
|
||||
if (auth.timerID) {
|
||||
clearInterval(auth.timerID);
|
||||
}
|
||||
Cookies.remove("username");
|
||||
Cookies.remove("token");
|
||||
Cookies.remove("endpoint");
|
||||
dispatch({
|
||||
type: LOGOUT_USER
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function logoutAndRedirect() {
|
||||
return (dispatch) => {
|
||||
dispatch(logout());
|
||||
dispatch(push("/login"));
|
||||
};
|
||||
}
|
||||
|
||||
export function loginUser(username, passwordOrToken, endpoint, rememberMe, redirect="/", isToken=false) {
|
||||
endpoint = _cleanEndpoint(endpoint);
|
||||
var time = 0;
|
||||
var passphrase = passwordOrToken;
|
||||
|
||||
if (!isToken) {
|
||||
// Standard password connection
|
||||
const HMAC = _buildHMAC(passwordOrToken);
|
||||
time = HMAC.time;
|
||||
passphrase = HMAC.passphrase;
|
||||
} else {
|
||||
// Remember me connection
|
||||
if (passwordOrToken.expires < new Date()) {
|
||||
// Token has expired
|
||||
return loginUserFailure("Your session expired… =(");
|
||||
}
|
||||
time = Math.floor(Date.now() / 1000);
|
||||
passphrase = passwordOrToken.token;
|
||||
}
|
||||
return {
|
||||
type: CALL_API,
|
||||
payload: {
|
||||
endpoint: endpoint,
|
||||
dispatch: [
|
||||
loginUserRequest,
|
||||
jsonData => dispatch => {
|
||||
if (!jsonData.auth || !jsonData.sessionExpire) {
|
||||
return Promise.reject("API error.");
|
||||
}
|
||||
const token = {
|
||||
token: jsonData.auth,
|
||||
expires: new Date(jsonData.sessionExpire)
|
||||
};
|
||||
// Dispatch success
|
||||
const timerID = setInterval(
|
||||
() => dispatch(loginKeepAlive(username, token.token, endpoint)),
|
||||
DEFAULT_SESSION_INTERVAL
|
||||
);
|
||||
if (rememberMe) {
|
||||
const cookiesOption = { expires: token.expires };
|
||||
Cookies.set("username", username, cookiesOption);
|
||||
Cookies.set("token", token, cookiesOption);
|
||||
Cookies.set("endpoint", endpoint, cookiesOption);
|
||||
}
|
||||
dispatch(loginUserSuccess(username, token, endpoint, rememberMe, timerID));
|
||||
// Redirect
|
||||
dispatch(push(redirect));
|
||||
},
|
||||
loginUserFailure
|
||||
],
|
||||
action: "handshake",
|
||||
auth: passphrase,
|
||||
username: username,
|
||||
extraParams: {timestamp: time}
|
||||
}
|
||||
};
|
||||
}
|
18
app/actions/index.js
Normal file
18
app/actions/index.js
Normal file
@ -0,0 +1,18 @@
|
||||
export * from "./auth";
|
||||
|
||||
import APIAction from "./APIActions";
|
||||
|
||||
export const ARTISTS_SUCCESS = "ARTISTS_SUCCESS";
|
||||
export const ARTISTS_REQUEST = "ARTISTS_REQUEST";
|
||||
export const ARTISTS_FAILURE = "ARTISTS_FAILURE";
|
||||
export var { loadArtists } = APIAction("artists", ARTISTS_REQUEST, ARTISTS_SUCCESS, ARTISTS_FAILURE);
|
||||
|
||||
export const ALBUMS_SUCCESS = "ALBUMS_SUCCESS";
|
||||
export const ALBUMS_REQUEST = "ALBUMS_REQUEST";
|
||||
export const ALBUMS_FAILURE = "ALBUMS_FAILURE";
|
||||
export var { loadAlbums } = APIAction("albums", ALBUMS_REQUEST, ALBUMS_SUCCESS, ALBUMS_FAILURE);
|
||||
|
||||
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);
|
BIN
app/assets/img/ampache-blue.png
Normal file
BIN
app/assets/img/ampache-blue.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 45 KiB |
79
app/components/Album.jsx
Normal file
79
app/components/Album.jsx
Normal file
@ -0,0 +1,79 @@
|
||||
import React, { Component, PropTypes } from "react";
|
||||
|
||||
import { formatLength } from "../utils";
|
||||
|
||||
export class AlbumTrackRow extends Component {
|
||||
render () {
|
||||
const length = formatLength(this.props.track.length);
|
||||
return (
|
||||
<tr>
|
||||
<td>{this.props.track.track}</td>
|
||||
<td>{this.props.track.name}</td>
|
||||
<td>{length}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AlbumTrackRow.propTypes = {
|
||||
track: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
|
||||
export class AlbumTracksTable extends Component {
|
||||
render () {
|
||||
var rows = [];
|
||||
this.props.tracks.forEach(function (item) {
|
||||
rows.push(<AlbumTrackRow track={item} key={item.id} />);
|
||||
});
|
||||
return (
|
||||
<table className="table table-hover songs">
|
||||
<tbody>
|
||||
{rows}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AlbumTracksTable.propTypes = {
|
||||
tracks: PropTypes.array.isRequired
|
||||
};
|
||||
|
||||
export class AlbumRow extends Component {
|
||||
render () {
|
||||
return (
|
||||
<div className="row albumRow">
|
||||
<div className="row">
|
||||
<div className="col-md-offset-2 col-md-10">
|
||||
<h2>{this.props.album.name}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-md-2">
|
||||
<p className="text-center"><img src={this.props.album.art} width="200" height="200" className="img-responsive art" alt={this.props.album.name} /></p>
|
||||
</div>
|
||||
<div className="col-md-10">
|
||||
<AlbumTracksTable tracks={this.props.album.tracks} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AlbumRow.propTypes = {
|
||||
album: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
export default class Album extends Component {
|
||||
render () {
|
||||
return (
|
||||
<AlbumRow album={this.props.album} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Album.propTypes = {
|
||||
album: PropTypes.object.isRequired
|
||||
};
|
19
app/components/Albums.jsx
Normal file
19
app/components/Albums.jsx
Normal file
@ -0,0 +1,19 @@
|
||||
import React, { Component, PropTypes } from "react";
|
||||
|
||||
import FilterablePaginatedGrid from "./elements/Grid";
|
||||
|
||||
export default class Albums extends Component {
|
||||
render () {
|
||||
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" />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Albums.propTypes = {
|
||||
albums: PropTypes.array.isRequired,
|
||||
albumsTotalCount: PropTypes.number.isRequired,
|
||||
albumsPerPage: PropTypes.number.isRequired,
|
||||
currentPage: PropTypes.number.isRequired,
|
||||
location: PropTypes.object.isRequired
|
||||
};
|
35
app/components/Artist.jsx
Normal file
35
app/components/Artist.jsx
Normal file
@ -0,0 +1,35 @@
|
||||
import React, { Component, PropTypes } from "react";
|
||||
|
||||
import { AlbumRow } from "./Album";
|
||||
|
||||
// TODO: Songs without associated album
|
||||
|
||||
export default class Artist extends Component {
|
||||
render () {
|
||||
var albumsRows = [];
|
||||
if (Array.isArray(this.props.artist.albums)) {
|
||||
this.props.artist.albums.forEach(function (item) {
|
||||
albumsRows.push(<AlbumRow album={item} key={item.id} />);
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="row">
|
||||
<div className="col-md-9">
|
||||
<h1 className="text-right">{this.props.artist.name}</h1>
|
||||
<hr/>
|
||||
<p>{this.props.artist.summary}</p>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<p><img src={this.props.artist.art} width="200" height="200" className="img-responsive art" alt={this.props.artist.name}/></p>
|
||||
</div>
|
||||
</div>
|
||||
{ albumsRows }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Artist.propTypes = {
|
||||
artist: PropTypes.object.isRequired
|
||||
};
|
19
app/components/Artists.jsx
Normal file
19
app/components/Artists.jsx
Normal file
@ -0,0 +1,19 @@
|
||||
import React, { Component, PropTypes } from "react";
|
||||
|
||||
import FilterablePaginatedGrid from "./elements/Grid";
|
||||
|
||||
export default class Artists extends Component {
|
||||
render () {
|
||||
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" />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Artists.propTypes = {
|
||||
artists: PropTypes.array.isRequired,
|
||||
artistsTotalCount: PropTypes.number.isRequired,
|
||||
artistsPerPage: PropTypes.number.isRequired,
|
||||
currentPage: PropTypes.number.isRequired,
|
||||
location: PropTypes.object.isRequired
|
||||
};
|
142
app/components/Login.jsx
Normal file
142
app/components/Login.jsx
Normal file
@ -0,0 +1,142 @@
|
||||
import React, { Component, PropTypes } from "react";
|
||||
import $ from "jquery";
|
||||
|
||||
export class LoginForm extends Component {
|
||||
constructor (props) {
|
||||
super(props)
|
||||
|
||||
this.handleSubmit = this.handleSubmit.bind(this)
|
||||
}
|
||||
|
||||
setError (formGroup, error) {
|
||||
if (error) {
|
||||
formGroup.classList.add("has-error");
|
||||
formGroup.classList.remove("has-success");
|
||||
return true;
|
||||
}
|
||||
formGroup.classList.remove("has-error");
|
||||
formGroup.classList.add("has-success");
|
||||
return false;
|
||||
}
|
||||
|
||||
handleSubmit (e) {
|
||||
e.preventDefault();
|
||||
const username = this.refs.username.value.trim();
|
||||
const password = this.refs.password.value.trim();
|
||||
const endpoint = this.refs.endpoint.value.trim();
|
||||
const rememberMe = this.refs.rememberMe.checked;
|
||||
|
||||
var hasError = this.setError(this.refs.usernameFormGroup, !username);
|
||||
hasError |= this.setError(this.refs.passwordFormGroup, !password);
|
||||
hasError |= this.setError(this.refs.endpointFormGroup, !endpoint);
|
||||
|
||||
if (!hasError) {
|
||||
this.props.onSubmit(username, password, endpoint, rememberMe)
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
if (this.props.error) {
|
||||
$(this.refs.loginForm).shake(3, 10, 300);
|
||||
this.setError(this.refs.usernameFormGroup, this.props.error);
|
||||
this.setError(this.refs.passwordFormGroup, this.props.error);
|
||||
this.setError(this.refs.endpointFormGroup, this.props.error);
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
this.props.error ?
|
||||
<div className="row">
|
||||
<div className="alert alert-danger">
|
||||
<span className="glyphicon glyphicon-exclamation-sign"></span> { this.props.error }
|
||||
</div>
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
{
|
||||
this.props.info ?
|
||||
<div className="row">
|
||||
<div className="alert alert-info">
|
||||
{ this.props.info }
|
||||
</div>
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
<div className="row">
|
||||
<form className="col-sm-9 col-sm-offset-1 col-md-6 col-md-offset-3 text-left form-horizontal login" onSubmit={this.handleSubmit} ref="loginForm">
|
||||
<div className="row">
|
||||
<div className="form-group" ref="usernameFormGroup">
|
||||
<div className="col-xs-12">
|
||||
<input type="text" className="form-control" ref="username" placeholder="Username" autoFocus defaultValue={this.props.username} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group" ref="passwordFormGroup">
|
||||
<div className="col-xs-12">
|
||||
<input type="password" className="form-control" ref="password" placeholder="Password" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group" ref="endpointFormGroup">
|
||||
<div className="col-xs-12">
|
||||
<input type="text" className="form-control" ref="endpoint" placeholder="http://ampache.example.com" defaultValue={this.props.endpoint} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<div className="col-xs-12">
|
||||
<div className="row">
|
||||
<div className="col-sm-6 col-xs-12 checkbox">
|
||||
<label>
|
||||
<input type="checkbox" ref="rememberMe" defaultChecked={this.props.rememberMe} /> Remember me
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-sm-6 col-sm-12 submit text-right">
|
||||
<input type="submit" className="btn btn-default" defaultValue="Sign in" disabled={this.props.isAuthenticating} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
LoginForm.propTypes = {
|
||||
username: PropTypes.string,
|
||||
endpoint: PropTypes.string,
|
||||
rememberMe: PropTypes.bool,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
isAuthenticating: PropTypes.bool,
|
||||
error: PropTypes.string,
|
||||
info: PropTypes.string
|
||||
};
|
||||
|
||||
|
||||
export default class Login extends Component {
|
||||
render () {
|
||||
return (
|
||||
<div className="login text-center">
|
||||
<h1><img src="./app/assets/img/ampache-blue.png" alt="A"/>mpache</h1>
|
||||
<hr/>
|
||||
<p>Welcome back on Ampache, let"s go!</p>
|
||||
<div className="col-sm-9 col-sm-offset-2 col-md-6 col-md-offset-3">
|
||||
<LoginForm onSubmit={this.props.onSubmit} username={this.props.username} endpoint={this.props.endpoint} rememberMe={this.props.rememberMe} isAuthenticating={this.props.isAuthenticating} error={this.props.error} info={this.props.info} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Login.propTypes = {
|
||||
username: PropTypes.string,
|
||||
endpoint: PropTypes.string,
|
||||
rememberMe: PropTypes.bool,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
isAuthenticating: PropTypes.bool,
|
||||
error: PropTypes.string,
|
||||
info: PropTypes.string
|
||||
};
|
110
app/components/Songs.jsx
Normal file
110
app/components/Songs.jsx
Normal file
@ -0,0 +1,110 @@
|
||||
import React, { Component, PropTypes } from "react";
|
||||
import { Link} from "react-router";
|
||||
import Fuse from "fuse.js";
|
||||
|
||||
import FilterBar from "./elements/FilterBar";
|
||||
import Pagination from "./elements/Pagination";
|
||||
import { formatLength} from "../utils";
|
||||
|
||||
export class SongsTableRow extends Component {
|
||||
render () {
|
||||
const length = formatLength(this.props.song.length);
|
||||
const linkToArtist = "/artist/" + this.props.song.artist.id;
|
||||
const linkToAlbum = "/album/" + this.props.song.album.id;
|
||||
return (
|
||||
<tr>
|
||||
<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="genre">{this.props.song.genre}</td>
|
||||
<td className="length">{length}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SongsTableRow.propTypes = {
|
||||
song: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
|
||||
export class SongsTable extends Component {
|
||||
render () {
|
||||
var displayedSongs = this.props.songs;
|
||||
if (this.props.filterText) {
|
||||
// Use Fuse for the filter
|
||||
displayedSongs = new Fuse(
|
||||
this.props.songs,
|
||||
{
|
||||
"keys": ["name"],
|
||||
"threshold": 0.4,
|
||||
"include": ["score"]
|
||||
}).search(this.props.filterText);
|
||||
// Keep only items in results
|
||||
displayedSongs = displayedSongs.map(function (item) { return item.item; });
|
||||
}
|
||||
|
||||
var rows = [];
|
||||
displayedSongs.forEach(function (song) {
|
||||
rows.push(<SongsTableRow song={song} key={song.id} />);
|
||||
});
|
||||
return (
|
||||
<table className="table table-hover songs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Title</th>
|
||||
<th>Artist</th>
|
||||
<th>Album</th>
|
||||
<th>Genre</th>
|
||||
<th>Length</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SongsTable.propTypes = {
|
||||
songs: PropTypes.array.isRequired,
|
||||
filterText: PropTypes.string
|
||||
};
|
||||
|
||||
|
||||
export default class FilterablePaginatedSongsTable extends Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
filterText: ""
|
||||
};
|
||||
|
||||
this.handleUserInput = this.handleUserInput.bind(this);
|
||||
}
|
||||
|
||||
handleUserInput (filterText) {
|
||||
this.setState({
|
||||
filterText: filterText.trim()
|
||||
});
|
||||
}
|
||||
|
||||
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} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FilterablePaginatedSongsTable.propTypes = {
|
||||
songs: PropTypes.array.isRequired,
|
||||
songsTotalCount: PropTypes.number.isRequired,
|
||||
songsPerPage: PropTypes.number.isRequired,
|
||||
currentPage: PropTypes.number.isRequired,
|
||||
location: PropTypes.object.isRequired
|
||||
};
|
34
app/components/elements/FilterBar.jsx
Normal file
34
app/components/elements/FilterBar.jsx
Normal file
@ -0,0 +1,34 @@
|
||||
import React, { Component, PropTypes } from "react";
|
||||
|
||||
export default class FilterBar extends Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
}
|
||||
|
||||
handleChange (e) {
|
||||
e.preventDefault();
|
||||
|
||||
this.props.onUserInput(this.refs.filterTextInput.value);
|
||||
}
|
||||
|
||||
render () {
|
||||
return (
|
||||
<div className="filter">
|
||||
<p className="col-xs-12 col-sm-6 col-md-4 col-md-offset-1 filter-legend">What are we listening to today?</p>
|
||||
<div className="col-xs-12 col-sm-6 col-md-4 input-group">
|
||||
<form className="form-inline" onSubmit={this.handleChange}>
|
||||
<div className="form-group">
|
||||
<input type="text" className="form-control filter-input" placeholder="Filter…" aria-label="Filter…" value={this.props.filterText} onChange={this.handleChange} ref="filterTextInput" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FilterBar.propTypes = {
|
||||
onUserInput: PropTypes.func,
|
||||
filterText: PropTypes.string
|
||||
};
|
232
app/components/elements/Grid.jsx
Normal file
232
app/components/elements/Grid.jsx
Normal file
@ -0,0 +1,232 @@
|
||||
import React, { Component, PropTypes } from "react";
|
||||
import { Link} from "react-router";
|
||||
import imagesLoaded from "imagesloaded";
|
||||
import Isotope from "isotope-layout";
|
||||
import Fuse from "fuse.js";
|
||||
import _ from "lodash";
|
||||
import $ from "jquery";
|
||||
|
||||
import FilterBar from "./FilterBar";
|
||||
import Pagination from "./Pagination";
|
||||
|
||||
export class GridItem extends Component {
|
||||
render () {
|
||||
var nSubItems = this.props.item[this.props.subItemsType];
|
||||
if (Array.isArray(nSubItems)) {
|
||||
nSubItems = nSubItems.length;
|
||||
}
|
||||
var subItemsLabel = this.props.subItemsType;
|
||||
if (nSubItems < 2) {
|
||||
subItemsLabel = subItemsLabel.rstrip("s");
|
||||
}
|
||||
const to = "/" + this.props.itemsType.rstrip("s") + "/" + this.props.item.id;
|
||||
const id = "grid-item-" + this.props.item.type + "/" + this.props.item.id;
|
||||
return (
|
||||
<div className="grid-item col-xs-6 col-sm-3 placeholders" id={id}>
|
||||
<div className="grid-item-content placeholder text-center">
|
||||
<Link to={to}><img src={this.props.item.art} width="200" height="200" className="img-responsive art" alt={this.props.item.name}/></Link>
|
||||
<h4 className="name">{this.props.item.name}</h4>
|
||||
<span className="sub-items text-muted"><span className="n-sub-items">{nSubItems}</span> <span className="sub-items-type">{subItemsLabel}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
GridItem.propTypes = {
|
||||
item: PropTypes.object.isRequired,
|
||||
itemsType: PropTypes.string.isRequired,
|
||||
subItemsType: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
|
||||
const ISOTOPE_OPTIONS = { /** Default options for Isotope grid layout. */
|
||||
getSortData: {
|
||||
name: ".name",
|
||||
nSubitems: ".sub-items .n-sub-items"
|
||||
},
|
||||
transitionDuration: 0,
|
||||
sortBy: "name",
|
||||
itemSelector: ".grid-item",
|
||||
percentPosition: true,
|
||||
layoutMode: "fitRows",
|
||||
filter: "*",
|
||||
fitRows: {
|
||||
gutter: 0
|
||||
}
|
||||
};
|
||||
|
||||
export class Grid extends Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
|
||||
// Init grid data member
|
||||
this.iso = null;
|
||||
|
||||
this.handleFiltering = this.handleFiltering.bind(this);
|
||||
}
|
||||
|
||||
createIsotopeContainer () {
|
||||
if (this.iso == null) {
|
||||
this.iso = new Isotope(this.refs.grid, ISOTOPE_OPTIONS);
|
||||
}
|
||||
}
|
||||
|
||||
handleFiltering (props) {
|
||||
// If no query provided, drop any filter in use
|
||||
if (props.filterText == "") {
|
||||
return this.iso.arrange(ISOTOPE_OPTIONS);
|
||||
}
|
||||
// Use Fuse for the filter
|
||||
var result = new Fuse(
|
||||
props.items,
|
||||
{
|
||||
"keys": ["name"],
|
||||
"threshold": 0.4,
|
||||
"include": ["score"]
|
||||
}).search(props.filterText);
|
||||
|
||||
// Apply filter on grid
|
||||
this.iso.arrange({
|
||||
filter: function () {
|
||||
var name = $(this).find(".name").text();
|
||||
return result.find(function (item) { return item.item.name == name; });
|
||||
},
|
||||
transitionDuration: "0.4s",
|
||||
getSortData: {
|
||||
relevance: function (item) {
|
||||
var name = $(item).find(".name").text();
|
||||
return result.reduce(function (p, c) {
|
||||
if (c.item.name == name) {
|
||||
return c.score + p;
|
||||
}
|
||||
return p;
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
sortBy: "relevance"
|
||||
});
|
||||
this.iso.updateSortData();
|
||||
this.iso.arrange();
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return !_.isEqual(this.props, nextProps) || !_.isEqual(this.state, nextState);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (!_.isEqual(nextProps.filterText, this.props.filterText)) {
|
||||
this.handleFiltering(nextProps);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
// Setup grid
|
||||
this.createIsotopeContainer();
|
||||
// Only arrange if there are elements to arrange
|
||||
if (_.get(this, "props.items.length", 0) > 0) {
|
||||
this.iso.arrange();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// The list of keys seen in the previous render
|
||||
let currentKeys = _.map(
|
||||
prevProps.items,
|
||||
(n) => "grid-item-" + n.type + "/" + n.id);
|
||||
|
||||
// The latest list of keys that have been rendered
|
||||
let newKeys = _.map(
|
||||
this.props.items,
|
||||
(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 = _.difference(newKeys, currentKeys);
|
||||
|
||||
// Find which keys have been removed between the current set of keys and any new children passed to this component
|
||||
let removeKeys = _.difference(currentKeys, newKeys);
|
||||
|
||||
if (removeKeys.length > 0) {
|
||||
_.each(removeKeys, removeKey => this.iso.remove(document.getElementById(removeKey)));
|
||||
this.iso.arrange();
|
||||
}
|
||||
if (addKeys.length > 0) {
|
||||
this.iso.addItems(_.map(addKeys, (addKey) => document.getElementById(addKey)));
|
||||
this.iso.arrange();
|
||||
}
|
||||
|
||||
var iso = this.iso;
|
||||
// Layout again after images are loaded
|
||||
imagesLoaded(this.refs.grid).on("progress", function() {
|
||||
// Layout after each image load, fix for responsive grid
|
||||
if (!iso) { // Grid could have been destroyed in the meantime
|
||||
return;
|
||||
}
|
||||
iso.layout();
|
||||
});
|
||||
}
|
||||
|
||||
render () {
|
||||
var gridItems = [];
|
||||
const itemsType = this.props.itemsType;
|
||||
const subItemsType = this.props.subItemsType;
|
||||
this.props.items.forEach(function (item) {
|
||||
gridItems.push(<GridItem item={item} itemsType={itemsType} subItemsType={subItemsType} key={item.id} />);
|
||||
});
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Grid.propTypes = {
|
||||
items: PropTypes.array.isRequired,
|
||||
itemsType: PropTypes.string.isRequired,
|
||||
subItemsType: PropTypes.string.isRequired,
|
||||
filterText: PropTypes.string
|
||||
};
|
||||
|
||||
export default class FilterablePaginatedGrid extends Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
filterText: ""
|
||||
};
|
||||
|
||||
this.handleUserInput = this.handleUserInput.bind(this);
|
||||
}
|
||||
|
||||
handleUserInput (filterText) {
|
||||
this.setState({
|
||||
filterText: filterText.trim()
|
||||
});
|
||||
}
|
||||
|
||||
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} />
|
||||
</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
|
||||
};
|
138
app/components/elements/Pagination.jsx
Normal file
138
app/components/elements/Pagination.jsx
Normal file
@ -0,0 +1,138 @@
|
||||
import React, { Component, PropTypes } from "react";
|
||||
import { Link, withRouter } from "react-router";
|
||||
import $ from "jquery";
|
||||
|
||||
export class Pagination 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;
|
||||
var upperLimit = currentPage;
|
||||
|
||||
for (var b = 1; b < maxNumberPagesShown && b < nPages;) {
|
||||
if (lowerLimit > 1 ) {
|
||||
lowerLimit--;
|
||||
b++;
|
||||
}
|
||||
if (b < maxNumberPagesShown && upperLimit < nPages) {
|
||||
upperLimit++;
|
||||
b++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
lowerLimit: lowerLimit,
|
||||
upperLimit: upperLimit + 1 // +1 to ease iteration in for with <
|
||||
};
|
||||
}
|
||||
|
||||
buildLinkTo(pageNumber) {
|
||||
return {
|
||||
pathname: this.props.location.pathname,
|
||||
query: Object.assign({}, this.props.location.query, { page: pageNumber })
|
||||
};
|
||||
}
|
||||
|
||||
goToPage() {
|
||||
const pageNumber = parseInt(this.refs.pageInput.value);
|
||||
$("#paginationModal").modal("hide");
|
||||
this.props.router.push(this.buildLinkTo(pageNumber));
|
||||
}
|
||||
|
||||
render () {
|
||||
const { lowerLimit, upperLimit } = this.computePaginationBounds(this.props.currentPage, this.props.nPages);
|
||||
var pagesButton = [];
|
||||
var key = 0; // key increment to ensure correct ordering
|
||||
if (lowerLimit > 1) {
|
||||
// Push first page
|
||||
pagesButton.push(
|
||||
<li className="page-item" key={key}>
|
||||
<Link className="page-link" to={this.buildLinkTo(1)}>1</Link>
|
||||
</li>
|
||||
);
|
||||
key++;
|
||||
if (lowerLimit > 2) {
|
||||
// Eventually push "…"
|
||||
pagesButton.push(
|
||||
<li className="page-item" key={key}>
|
||||
<span onClick={() => $("#paginationModal").modal() }>…</span>
|
||||
</li>
|
||||
);
|
||||
key++;
|
||||
}
|
||||
}
|
||||
var i = 0;
|
||||
for (i = lowerLimit; i < upperLimit; i++) {
|
||||
var className = "page-item";
|
||||
if (this.props.currentPage == i) {
|
||||
className += " active";
|
||||
}
|
||||
pagesButton.push(
|
||||
<li className={className} key={key}>
|
||||
<Link className="page-link" to={this.buildLinkTo(i)}>{i}</Link>
|
||||
</li>
|
||||
);
|
||||
key++;
|
||||
}
|
||||
if (i < this.props.nPages) {
|
||||
if (i < this.props.nPages - 1) {
|
||||
// Eventually push "…"
|
||||
pagesButton.push(
|
||||
<li className="page-item" key={key}>
|
||||
<span onClick={() => $("#paginationModal").modal() }>…</span>
|
||||
</li>
|
||||
);
|
||||
key++;
|
||||
}
|
||||
// Push last page
|
||||
pagesButton.push(
|
||||
<li className="page-item" key={key}>
|
||||
<Link className="page-link" to={this.buildLinkTo(this.props.nPages)}>{this.props.nPages}</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
if (pagesButton.length > 1) {
|
||||
return (
|
||||
<div>
|
||||
<nav className="pagination-nav">
|
||||
<ul className="pagination">
|
||||
{ pagesButton }
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="modal fade" id="paginationModal" tabIndex="-1" role="dialog" aria-hidden="false">
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<button type="button" className="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 className="modal-title">Page to go to?</h4>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<form>
|
||||
<input className="form-control" autoComplete="off" type="number" ref="pageInput" />
|
||||
</form>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-default" onClick={ () => $("#paginationModal").modal("hide") }>Cancel</button>
|
||||
<button type="button" className="btn btn-primary" onClick={this.goToPage.bind(this)}>OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Pagination.propTypes = {
|
||||
currentPage: PropTypes.number.isRequired,
|
||||
location: PropTypes.object.isRequired,
|
||||
nPages: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
export default withRouter(Pagination);
|
60
app/components/layouts/Sidebar.jsx
Normal file
60
app/components/layouts/Sidebar.jsx
Normal file
@ -0,0 +1,60 @@
|
||||
import React, { Component } from "react";
|
||||
import { IndexLink, Link} from "react-router";
|
||||
|
||||
export default class SidebarLayout extends Component {
|
||||
render () {
|
||||
return (
|
||||
<div>
|
||||
<div className="col-sm-3 col-md-2 sidebar hidden-xs">
|
||||
<h1 className="text-center"><IndexLink to="/"><img alt="A" src="./app/assets/img/ampache-blue.png"/>mpache</IndexLink></h1>
|
||||
<nav>
|
||||
<div className="navbar text-center icon-navbar">
|
||||
<div className="container-fluid">
|
||||
<ul className="nav navbar-nav icon-navbar-nav">
|
||||
<li aria-hidden="true">
|
||||
<Link to="/" className="glyphicon glyphicon-home"></Link>
|
||||
</li>
|
||||
<li aria-hidden="true">
|
||||
<Link to="/settings" className="glyphicon glyphicon-wrench"></Link>
|
||||
</li>
|
||||
<li aria-hidden="true">
|
||||
<Link to="/logout" className="glyphicon glyphicon-off"></Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="nav nav-sidebar">
|
||||
<li>
|
||||
<Link to="/discover">
|
||||
<span className="glyphicon glyphicon-globe" aria-hidden="true"></span>
|
||||
<span className="hidden-sm"> Discover</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/browse">
|
||||
<span className="glyphicon glyphicon-headphones" aria-hidden="true"></span>
|
||||
<span className="hidden-sm"> Browse</span>
|
||||
</Link>
|
||||
<ul className="nav nav-sidebar text-center">
|
||||
<li><Link to="/artists"><span className="glyphicon glyphicon-user"></span> Artists</Link></li>
|
||||
<li><Link to="/albums"><span className="glyphicon glyphicon-cd" aria-hidden="true"></span> Albums</Link></li>
|
||||
<li><Link to="/songs"><span className="glyphicon glyphicon-music"></span> Songs</Link></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/search">
|
||||
<span className="glyphicon glyphicon-search" aria-hidden="true"></span>
|
||||
<span className="hidden-sm"> Search</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="col-sm-9 col-sm-offset-3 col-md-10 col-md-offset-2 main-panel">
|
||||
{this.props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
11
app/components/layouts/Simple.jsx
Normal file
11
app/components/layouts/Simple.jsx
Normal file
@ -0,0 +1,11 @@
|
||||
import React, { Component } from "react";
|
||||
|
||||
export default class SimpleLayout extends Component {
|
||||
render () {
|
||||
return (
|
||||
<div>
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
18
app/containers/App.jsx
Normal file
18
app/containers/App.jsx
Normal file
@ -0,0 +1,18 @@
|
||||
import React, { Component, PropTypes } from "react";
|
||||
|
||||
export default class App extends Component {
|
||||
render () {
|
||||
return (
|
||||
<div>
|
||||
{this.props.children && React.cloneElement(this.props.children, {
|
||||
error: this.props.error
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
App.propTypes = {
|
||||
// Injected by React Router
|
||||
children: PropTypes.node,
|
||||
};
|
50
app/containers/RequireAuthentication.js
Normal file
50
app/containers/RequireAuthentication.js
Normal file
@ -0,0 +1,50 @@
|
||||
import React, { Component, PropTypes } from "react";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
export class RequireAuthentication extends Component {
|
||||
componentWillMount () {
|
||||
this.checkAuth(this.props.isAuthenticated);
|
||||
}
|
||||
|
||||
componentWillUpdate () {
|
||||
this.checkAuth(this.props.isAuthenticated);
|
||||
}
|
||||
|
||||
checkAuth (isAuthenticated) {
|
||||
if (!isAuthenticated) {
|
||||
this.context.router.replace({
|
||||
pathname: "/login",
|
||||
state: {
|
||||
nextPathname: this.props.location.pathname,
|
||||
nextQuery: this.props.location.query
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
return (
|
||||
<div>
|
||||
{this.props.isAuthenticated === true
|
||||
? this.props.children
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RequireAuthentication.propTypes = {
|
||||
// Injected by React Router
|
||||
children: PropTypes.node
|
||||