Browse Source

Initial commit

gh-pages
Lucas Verney 6 years ago
commit
2e1381acc6
  1. 3
      .babelrc
  2. 4
      .eslintignore
  3. 41
      .eslintrc.js
  4. 1
      .gitignore
  5. 21
      LICENSE
  6. 42
      README.md
  7. 16
      TODO
  8. 77
      app/actions/APIActions.js
  9. 173
      app/actions/auth.js
  10. 18
      app/actions/index.js
  11. BIN
      app/assets/img/ampache-blue.png
  12. 79
      app/components/Album.jsx
  13. 19
      app/components/Albums.jsx
  14. 35
      app/components/Artist.jsx
  15. 19
      app/components/Artists.jsx
  16. 142
      app/components/Login.jsx
  17. 110
      app/components/Songs.jsx
  18. 34
      app/components/elements/FilterBar.jsx
  19. 232
      app/components/elements/Grid.jsx
  20. 138
      app/components/elements/Pagination.jsx
  21. 60
      app/components/layouts/Sidebar.jsx
  22. 11
      app/components/layouts/Simple.jsx
  23. 18
      app/containers/App.jsx
  24. 50
      app/containers/RequireAuthentication.js
  25. 21
      app/containers/Root.jsx
  26. 2
      app/dist/fix.ie9.js
  27. 1
      app/dist/fix.ie9.js.map
  28. 20
      app/dist/index.js
  29. 1
      app/dist/index.js.map
  30. 248
      app/middleware/api.js
  31. 74
      app/reducers/auth.js
  32. 34
      app/reducers/index.js
  33. 46
      app/reducers/paginate.js
  34. 39
      app/routes.js
  35. 19
      app/store/configureStore.js
  36. 153
      app/styles/ampache.css
  37. 587
      app/styles/bootstrap/bootstrap-theme.css
  38. 1
      app/styles/bootstrap/bootstrap-theme.css.map
  39. 6
      app/styles/bootstrap/bootstrap-theme.min.css
  40. 1
      app/styles/bootstrap/bootstrap-theme.min.css.map
  41. 6760
      app/styles/bootstrap/bootstrap.css
  42. 1
      app/styles/bootstrap/bootstrap.css.map
  43. 6
      app/styles/bootstrap/bootstrap.min.css
  44. 1
      app/styles/bootstrap/bootstrap.min.css.map
  45. 15
      app/styles/bootstrap/ie10-viewport-bug-workaround.css
  46. BIN
      app/styles/fonts/glyphicons-halflings-regular.eot
  47. 288
      app/styles/fonts/glyphicons-halflings-regular.svg
  48. BIN
      app/styles/fonts/glyphicons-halflings-regular.ttf
  49. BIN
      app/styles/fonts/glyphicons-halflings-regular.woff
  50. BIN
      app/styles/fonts/glyphicons-halflings-regular.woff2
  51. 5
      app/utils/index.js
  52. 20
      app/utils/jquery.js
  53. 26
      app/utils/misc.js
  54. 9
      app/utils/reducers.js
  55. 16
      app/utils/string.js
  56. 13
      app/utils/url.js
  57. 40
      app/views/AlbumPage.jsx
  58. 43
      app/views/AlbumsPage.jsx
  59. 40
      app/views/ArtistPage.jsx
  60. 43
      app/views/ArtistsPage.jsx
  61. 11
      app/views/BrowsePage.jsx
  62. 11
      app/views/HomePage.jsx
  63. 76
      app/views/LoginPage.jsx
  64. 26
      app/views/LogoutPage.jsx
  65. 43
      app/views/SongsPage.jsx
  66. BIN
      favicon.ico
  67. 1
      fix.ie9.js
  68. 57
      hooks/pre-commit
  69. 43
      index.html
  70. 19
      index.js
  71. 40
      package.json
  72. 7
      vendor/bootstrap/bootstrap.min.js
  73. 23
      vendor/bootstrap/ie10-viewport-bug-workaround.js
  74. 43
      webpack.config.base.js
  75. 6
      webpack.config.development.js
  76. 1
      webpack.config.js
  77. 32
      webpack.config.production.js

3
.babelrc

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

4
.eslintignore

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

41
.eslintrc.js

@ -0,0 +1,41 @@ @@ -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

@ -0,0 +1 @@ @@ -0,0 +1 @@
node_modules

21
LICENSE

@ -0,0 +1,21 @@ @@ -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

@ -0,0 +1,42 @@ @@ -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

@ -0,0 +1,16 @@ @@ -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

@ -0,0 +1,77 @@ @@ -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

@ -0,0 +1,173 @@ @@ -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

@ -0,0 +1,18 @@ @@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

79
app/components/Album.jsx

@ -0,0 +1,79 @@ @@ -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

@ -0,0 +1,19 @@ @@ -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

@ -0,0 +1,35 @@ @@ -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

@ -0,0 +1,19 @@ @@ -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

@ -0,0 +1,142 @@ @@ -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

@ -0,0 +1,110 @@ @@ -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

@ -0,0 +1,34 @@ @@ -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

@ -0,0 +1,232 @@ @@ -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

@ -0,0 +1,138 @@ @@ -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

@ -0,0 +1,60 @@ @@ -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

@ -0,0 +1,11 @@ @@ -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

@ -0,0 +1,18 @@ @@ -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

@ -0,0 +1,50 @@ @@ -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
};
RequireAuthentication.contextTypes = {
router: PropTypes.object.isRequired
};
const mapStateToProps = (state) => ({