Initial commit

This commit is contained in:
Lucas Verney 2016-07-07 23:23:18 +02:00
commit 2e1381acc6
77 changed files with 10361 additions and 0 deletions

3
.babelrc Normal file
View File

@ -0,0 +1,3 @@
{
"presets": ["es2015", "react"]
}

4
.eslintignore Normal file
View File

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

41
.eslintrc.js Normal file
View 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
View File

@ -0,0 +1 @@
node_modules

21
LICENSE Normal file
View 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
View 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
View 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
View 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
View 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
View 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);

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

79
app/components/Album.jsx Normal file
View 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
View 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
View 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
};

View 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
View 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
View 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
};

View 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
};

View 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
};

View 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);

View 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>
);
}
}

View 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
View 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,
};

View 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