commit 2e1381acc605e94c211549018a61179665a3e260 Author: Phyks (Lucas Verney) Date: Thu Jul 7 23:23:18 2016 +0200 Initial commit diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..d4e5f8d --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["es2015", "react"] +} diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..1dd702d --- /dev/null +++ b/.eslintignore @@ -0,0 +1,4 @@ +app/dist/* +node_modules/* +vendor/* +webpack.config.* diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..0526dde --- /dev/null +++ b/.eslintrc.js @@ -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" + } +}; diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..de519c7 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e1101c --- /dev/null +++ b/README.md @@ -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. diff --git a/TODO b/TODO new file mode 100644 index 0000000..f24b16c --- /dev/null +++ b/TODO @@ -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 diff --git a/app/actions/APIActions.js b/app/actions/APIActions.js new file mode 100644 index 0000000..bc2ad29 --- /dev/null +++ b/app/actions/APIActions.js @@ -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; +} diff --git a/app/actions/auth.js b/app/actions/auth.js new file mode 100644 index 0000000..ceb6ed3 --- /dev/null +++ b/app/actions/auth.js @@ -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} + } + }; +} diff --git a/app/actions/index.js b/app/actions/index.js new file mode 100644 index 0000000..7919dfb --- /dev/null +++ b/app/actions/index.js @@ -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); diff --git a/app/assets/img/ampache-blue.png b/app/assets/img/ampache-blue.png new file mode 100644 index 0000000..8784083 Binary files /dev/null and b/app/assets/img/ampache-blue.png differ diff --git a/app/components/Album.jsx b/app/components/Album.jsx new file mode 100644 index 0000000..6253df3 --- /dev/null +++ b/app/components/Album.jsx @@ -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 ( + + {this.props.track.track} + {this.props.track.name} + {length} + + ); + } +} + +AlbumTrackRow.propTypes = { + track: PropTypes.object.isRequired +}; + + +export class AlbumTracksTable extends Component { + render () { + var rows = []; + this.props.tracks.forEach(function (item) { + rows.push(); + }); + return ( + + + {rows} + +
+ ); + } +} + +AlbumTracksTable.propTypes = { + tracks: PropTypes.array.isRequired +}; + +export class AlbumRow extends Component { + render () { + return ( +
+
+
+

{this.props.album.name}

+
+
+
+
+

{this.props.album.name}

+
+
+ +
+
+
+ ); + } +} + +AlbumRow.propTypes = { + album: PropTypes.object.isRequired +}; + +export default class Album extends Component { + render () { + return ( + + ); + } +} + +Album.propTypes = { + album: PropTypes.object.isRequired +}; diff --git a/app/components/Albums.jsx b/app/components/Albums.jsx new file mode 100644 index 0000000..517def7 --- /dev/null +++ b/app/components/Albums.jsx @@ -0,0 +1,19 @@ +import React, { Component, PropTypes } from "react"; + +import FilterablePaginatedGrid from "./elements/Grid"; + +export default class Albums extends Component { + render () { + return ( + + ); + } +} + +Albums.propTypes = { + albums: PropTypes.array.isRequired, + albumsTotalCount: PropTypes.number.isRequired, + albumsPerPage: PropTypes.number.isRequired, + currentPage: PropTypes.number.isRequired, + location: PropTypes.object.isRequired +}; diff --git a/app/components/Artist.jsx b/app/components/Artist.jsx new file mode 100644 index 0000000..76a5b4c --- /dev/null +++ b/app/components/Artist.jsx @@ -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(); + }); + } + return ( +
+
+
+

{this.props.artist.name}

+
+

{this.props.artist.summary}

+
+
+

{this.props.artist.name}/

+
+
+ { albumsRows } +
+ ); + } +} + +Artist.propTypes = { + artist: PropTypes.object.isRequired +}; diff --git a/app/components/Artists.jsx b/app/components/Artists.jsx new file mode 100644 index 0000000..1d2998c --- /dev/null +++ b/app/components/Artists.jsx @@ -0,0 +1,19 @@ +import React, { Component, PropTypes } from "react"; + +import FilterablePaginatedGrid from "./elements/Grid"; + +export default class Artists extends Component { + render () { + return ( + + ); + } +} + +Artists.propTypes = { + artists: PropTypes.array.isRequired, + artistsTotalCount: PropTypes.number.isRequired, + artistsPerPage: PropTypes.number.isRequired, + currentPage: PropTypes.number.isRequired, + location: PropTypes.object.isRequired +}; diff --git a/app/components/Login.jsx b/app/components/Login.jsx new file mode 100644 index 0000000..b9cebbb --- /dev/null +++ b/app/components/Login.jsx @@ -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 ( +
+ { + this.props.error ? +
+
+ { this.props.error } +
+
+ : null + } + { + this.props.info ? +
+
+ { this.props.info } +
+
+ : null + } +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+ ); + } +} + +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 ( +
+

Ampache

+
+

Welcome back on Ampache, let"s go!

+
+ +
+
+ ); + } +} + +Login.propTypes = { + username: PropTypes.string, + endpoint: PropTypes.string, + rememberMe: PropTypes.bool, + onSubmit: PropTypes.func.isRequired, + isAuthenticating: PropTypes.bool, + error: PropTypes.string, + info: PropTypes.string +}; diff --git a/app/components/Songs.jsx b/app/components/Songs.jsx new file mode 100644 index 0000000..536ae08 --- /dev/null +++ b/app/components/Songs.jsx @@ -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 ( + + + {this.props.song.name} + {this.props.song.artist.name} + {this.props.song.album.name} + {this.props.song.genre} + {length} + + ); + } +} + +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(); + }); + return ( + + + + + + + + + + + + {rows} +
TitleArtistAlbumGenreLength
+ ); + } +} + +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 ( +
+ + + +
+ ); + } +} + +FilterablePaginatedSongsTable.propTypes = { + songs: PropTypes.array.isRequired, + songsTotalCount: PropTypes.number.isRequired, + songsPerPage: PropTypes.number.isRequired, + currentPage: PropTypes.number.isRequired, + location: PropTypes.object.isRequired +}; diff --git a/app/components/elements/FilterBar.jsx b/app/components/elements/FilterBar.jsx new file mode 100644 index 0000000..d2a0b49 --- /dev/null +++ b/app/components/elements/FilterBar.jsx @@ -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 ( +
+

What are we listening to today?

+
+
+
+ +
+
+
+
+ ); + } +} + +FilterBar.propTypes = { + onUserInput: PropTypes.func, + filterText: PropTypes.string +}; diff --git a/app/components/elements/Grid.jsx b/app/components/elements/Grid.jsx new file mode 100644 index 0000000..0d3b73d --- /dev/null +++ b/app/components/elements/Grid.jsx @@ -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 ( +
+
+ {this.props.item.name}/ +

{this.props.item.name}

+ {nSubItems} {subItemsLabel} +
+
+ ); + } +} + +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(); + }); + return ( +
+
+ {/* Sizing element */} +
+ {/* Other items */} + { gridItems } +
+
+ ); + } +} + +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 ( +
+ + + +
+ ); + } +} + +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 +}; diff --git a/app/components/elements/Pagination.jsx b/app/components/elements/Pagination.jsx new file mode 100644 index 0000000..f1d9050 --- /dev/null +++ b/app/components/elements/Pagination.jsx @@ -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( +
  • + 1 +
  • + ); + key++; + if (lowerLimit > 2) { + // Eventually push "…" + pagesButton.push( +
  • + $("#paginationModal").modal() }>… +
  • + ); + key++; + } + } + var i = 0; + for (i = lowerLimit; i < upperLimit; i++) { + var className = "page-item"; + if (this.props.currentPage == i) { + className += " active"; + } + pagesButton.push( +
  • + {i} +
  • + ); + key++; + } + if (i < this.props.nPages) { + if (i < this.props.nPages - 1) { + // Eventually push "…" + pagesButton.push( +
  • + $("#paginationModal").modal() }>… +
  • + ); + key++; + } + // Push last page + pagesButton.push( +
  • + {this.props.nPages} +
  • + ); + } + if (pagesButton.length > 1) { + return ( +
    + + +
    + ); + } + return null; + } +} + +Pagination.propTypes = { + currentPage: PropTypes.number.isRequired, + location: PropTypes.object.isRequired, + nPages: PropTypes.number.isRequired +}; + +export default withRouter(Pagination); diff --git a/app/components/layouts/Sidebar.jsx b/app/components/layouts/Sidebar.jsx new file mode 100644 index 0000000..ceab78b --- /dev/null +++ b/app/components/layouts/Sidebar.jsx @@ -0,0 +1,60 @@ +import React, { Component } from "react"; +import { IndexLink, Link} from "react-router"; + +export default class SidebarLayout extends Component { + render () { + return ( +
    +
    +

    Ampache

    + +
    + +
    + {this.props.children} +
    +
    + ); + } +} diff --git a/app/components/layouts/Simple.jsx b/app/components/layouts/Simple.jsx new file mode 100644 index 0000000..4430524 --- /dev/null +++ b/app/components/layouts/Simple.jsx @@ -0,0 +1,11 @@ +import React, { Component } from "react"; + +export default class SimpleLayout extends Component { + render () { + return ( +
    + {this.props.children} +
    + ); + } +} diff --git a/app/containers/App.jsx b/app/containers/App.jsx new file mode 100644 index 0000000..46f56e7 --- /dev/null +++ b/app/containers/App.jsx @@ -0,0 +1,18 @@ +import React, { Component, PropTypes } from "react"; + +export default class App extends Component { + render () { + return ( +
    + {this.props.children && React.cloneElement(this.props.children, { + error: this.props.error + })} +
    + ); + } +} + +App.propTypes = { + // Injected by React Router + children: PropTypes.node, +}; diff --git a/app/containers/RequireAuthentication.js b/app/containers/RequireAuthentication.js new file mode 100644 index 0000000..f00ffb7 --- /dev/null +++ b/app/containers/RequireAuthentication.js @@ -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 ( +
    + {this.props.isAuthenticated === true + ? this.props.children + : null + } +
    + ); + } +} + +RequireAuthentication.propTypes = { + // Injected by React Router + children: PropTypes.node +}; + +RequireAuthentication.contextTypes = { + router: PropTypes.object.isRequired +}; + +const mapStateToProps = (state) => ({ + isAuthenticated: state.auth.isAuthenticated +}); + +export default connect(mapStateToProps)(RequireAuthentication); diff --git a/app/containers/Root.jsx b/app/containers/Root.jsx new file mode 100644 index 0000000..37699c5 --- /dev/null +++ b/app/containers/Root.jsx @@ -0,0 +1,21 @@ +import React, { Component, PropTypes } from "react"; +import { Provider } from "react-redux"; +import { Router } from "react-router"; + +import routes from "../routes"; + +export default class Root extends Component { + render() { + const { store, history } = this.props; + return ( + + + + ); + } +} + +Root.propTypes = { + store: PropTypes.object.isRequired, + history: PropTypes.object.isRequired +}; diff --git a/app/dist/fix.ie9.js b/app/dist/fix.ie9.js new file mode 100644 index 0000000..7aa52db --- /dev/null +++ b/app/dist/fix.ie9.js @@ -0,0 +1,2 @@ +!function(e){function t(r){if(n[r])return n[r].exports;var a=n[r]={exports:{},id:r,loaded:!1};return e[r].call(a.exports,a,a.exports,t),a.loaded=!0,a.exports}var n={};return t.m=e,t.c=n,t.p="/app/dist/",t(0)}({0:function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r=n(495);Object.keys(r).forEach(function(e){"default"!==e&&Object.defineProperty(t,e,{enumerable:!0,get:function(){return r[e]}})})},495:function(e,t){!function(t,n){function r(e,t){var n=e.createElement("p"),r=e.getElementsByTagName("head")[0]||e.documentElement;return n.innerHTML="x",r.insertBefore(n.lastChild,r.firstChild)}function a(){var e=b.elements;return"string"==typeof e?e.split(" "):e}function o(e,t){var n=b.elements;"string"!=typeof n&&(n=n.join(" ")),"string"!=typeof e&&(e=e.join(" ")),b.elements=n+" "+e,s(t)}function c(e){var t=E[e[v]];return t||(t={},y++,e[v]=y,E[y]=t),t}function i(e,t,r){if(t||(t=n),f)return t.createElement(e);r||(r=c(t));var a;return a=r.cache[e]?r.cache[e].cloneNode():g.test(e)?(r.cache[e]=r.createElem(e)).cloneNode():r.createElem(e),!a.canHaveChildren||p.test(e)||a.tagUrn?a:r.frag.appendChild(a)}function l(e,t){if(e||(e=n),f)return e.createDocumentFragment();t=t||c(e);for(var r=t.frag.cloneNode(),o=0,i=a(),l=i.length;o",d="hidden"in e,f=1==e.childNodes.length||function(){n.createElement("a");var e=n.createDocumentFragment();return"undefined"==typeof e.cloneNode||"undefined"==typeof e.createDocumentFragment||"undefined"==typeof e.createElement}()}catch(t){d=!0,f=!0}}();var b={elements:h.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output picture progress section summary template time video",version:m,shivCSS:h.shivCSS!==!1,supportsUnknownElements:f,shivMethods:h.shivMethods!==!1,type:"default",shivDocument:s,createElement:i,createDocumentFragment:l,addElements:o};t.html5=b,s(n),"object"==typeof e&&e.exports&&(e.exports=b)}("undefined"!=typeof window?window:this,document)}}); +//# sourceMappingURL=fix.ie9.js.map \ No newline at end of file diff --git a/app/dist/fix.ie9.js.map b/app/dist/fix.ie9.js.map new file mode 100644 index 0000000..4d02e73 --- /dev/null +++ b/app/dist/fix.ie9.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["webpack:///fix.ie9.js","webpack:///webpack/bootstrap 9adf20f87cfcad4a7983?2805","webpack:///./fix.ie9.js","webpack:///./~/html5shiv/dist/html5shiv.js"],"names":["modules","__webpack_require__","moduleId","installedModules","exports","module","id","loaded","call","m","c","p","0","Object","defineProperty","value","_html5shiv","keys","forEach","key","enumerable","get","495","window","document","addStyleSheet","ownerDocument","cssText","createElement","parent","getElementsByTagName","documentElement","innerHTML","insertBefore","lastChild","firstChild","getElements","elements","html5","split","addElements","newElements","join","shivDocument","getExpandoData","data","expandoData","expando","expanID","nodeName","supportsUnknownElements","node","cache","cloneNode","saveClones","test","createElem","canHaveChildren","reSkip","tagUrn","frag","appendChild","createDocumentFragment","clone","i","elems","l","length","shivMethods","createFrag","Function","replace","shivCSS","supportsHtml5Styles","hasCSS","version","options","a","childNodes","e","type","this"],"mappings":"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","file":"fix.ie9.js","sourcesContent":["/******/ (function(modules) { // webpackBootstrap\n/******/ \t// The module cache\n/******/ \tvar installedModules = {};\n/******/\n/******/ \t// The require function\n/******/ \tfunction __webpack_require__(moduleId) {\n/******/\n/******/ \t\t// Check if module is in cache\n/******/ \t\tif(installedModules[moduleId])\n/******/ \t\t\treturn installedModules[moduleId].exports;\n/******/\n/******/ \t\t// Create a new module (and put it into the cache)\n/******/ \t\tvar module = installedModules[moduleId] = {\n/******/ \t\t\texports: {},\n/******/ \t\t\tid: moduleId,\n/******/ \t\t\tloaded: false\n/******/ \t\t};\n/******/\n/******/ \t\t// Execute the module function\n/******/ \t\tmodules[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n/******/\n/******/ \t\t// Flag the module as loaded\n/******/ \t\tmodule.loaded = true;\n/******/\n/******/ \t\t// Return the exports of the module\n/******/ \t\treturn module.exports;\n/******/ \t}\n/******/\n/******/\n/******/ \t// expose the modules object (__webpack_modules__)\n/******/ \t__webpack_require__.m = modules;\n/******/\n/******/ \t// expose the module cache\n/******/ \t__webpack_require__.c = installedModules;\n/******/\n/******/ \t// __webpack_public_path__\n/******/ \t__webpack_require__.p = \"/app/dist/\";\n/******/\n/******/ \t// Load entry module and return exports\n/******/ \treturn __webpack_require__(0);\n/******/ })\n/************************************************************************/\n/******/ ({\n\n/***/ 0:\n/***/ function(module, exports, __webpack_require__) {\n\n\t\"use strict\";\n\t\n\tObject.defineProperty(exports, \"__esModule\", {\n\t value: true\n\t});\n\t\n\tvar _html5shiv = __webpack_require__(495);\n\t\n\tObject.keys(_html5shiv).forEach(function (key) {\n\t if (key === \"default\") return;\n\t Object.defineProperty(exports, key, {\n\t enumerable: true,\n\t get: function get() {\n\t return _html5shiv[key];\n\t }\n\t });\n\t});\n\n/***/ },\n\n/***/ 495:\n/***/ function(module, exports) {\n\n\t/**\n\t* @preserve HTML5 Shiv 3.7.3 | @afarkas @jdalton @jon_neal @rem | MIT/GPL2 Licensed\n\t*/\n\t;(function(window, document) {\n\t/*jshint evil:true */\n\t /** version */\n\t var version = '3.7.3-pre';\n\t\n\t /** Preset options */\n\t var options = window.html5 || {};\n\t\n\t /** Used to skip problem elements */\n\t var reSkip = /^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i;\n\t\n\t /** Not all elements can be cloned in IE **/\n\t var saveClones = /^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i;\n\t\n\t /** Detect whether the browser supports default html5 styles */\n\t var supportsHtml5Styles;\n\t\n\t /** Name of the expando, to work with multiple documents or to re-shiv one document */\n\t var expando = '_html5shiv';\n\t\n\t /** The id for the the documents expando */\n\t var expanID = 0;\n\t\n\t /** Cached data for each document */\n\t var expandoData = {};\n\t\n\t /** Detect whether the browser supports unknown elements */\n\t var supportsUnknownElements;\n\t\n\t (function() {\n\t try {\n\t var a = document.createElement('a');\n\t a.innerHTML = '';\n\t //if the hidden property is implemented we can assume, that the browser supports basic HTML5 Styles\n\t supportsHtml5Styles = ('hidden' in a);\n\t\n\t supportsUnknownElements = a.childNodes.length == 1 || (function() {\n\t // assign a false positive if unable to shiv\n\t (document.createElement)('a');\n\t var frag = document.createDocumentFragment();\n\t return (\n\t typeof frag.cloneNode == 'undefined' ||\n\t typeof frag.createDocumentFragment == 'undefined' ||\n\t typeof frag.createElement == 'undefined'\n\t );\n\t }());\n\t } catch(e) {\n\t // assign a false positive if detection fails => unable to shiv\n\t supportsHtml5Styles = true;\n\t supportsUnknownElements = true;\n\t }\n\t\n\t }());\n\t\n\t /*--------------------------------------------------------------------------*/\n\t\n\t /**\n\t * Creates a style sheet with the given CSS text and adds it to the document.\n\t * @private\n\t * @param {Document} ownerDocument The document.\n\t * @param {String} cssText The CSS text.\n\t * @returns {StyleSheet} The style element.\n\t */\n\t function addStyleSheet(ownerDocument, cssText) {\n\t var p = ownerDocument.createElement('p'),\n\t parent = ownerDocument.getElementsByTagName('head')[0] || ownerDocument.documentElement;\n\t\n\t p.innerHTML = 'x';\n\t return parent.insertBefore(p.lastChild, parent.firstChild);\n\t }\n\t\n\t /**\n\t * Returns the value of `html5.elements` as an array.\n\t * @private\n\t * @returns {Array} An array of shived element node names.\n\t */\n\t function getElements() {\n\t var elements = html5.elements;\n\t return typeof elements == 'string' ? elements.split(' ') : elements;\n\t }\n\t\n\t /**\n\t * Extends the built-in list of html5 elements\n\t * @memberOf html5\n\t * @param {String|Array} newElements whitespace separated list or array of new element names to shiv\n\t * @param {Document} ownerDocument The context document.\n\t */\n\t function addElements(newElements, ownerDocument) {\n\t var elements = html5.elements;\n\t if(typeof elements != 'string'){\n\t elements = elements.join(' ');\n\t }\n\t if(typeof newElements != 'string'){\n\t newElements = newElements.join(' ');\n\t }\n\t html5.elements = elements +' '+ newElements;\n\t shivDocument(ownerDocument);\n\t }\n\t\n\t /**\n\t * Returns the data associated to the given document\n\t * @private\n\t * @param {Document} ownerDocument The document.\n\t * @returns {Object} An object of data.\n\t */\n\t function getExpandoData(ownerDocument) {\n\t var data = expandoData[ownerDocument[expando]];\n\t if (!data) {\n\t data = {};\n\t expanID++;\n\t ownerDocument[expando] = expanID;\n\t expandoData[expanID] = data;\n\t }\n\t return data;\n\t }\n\t\n\t /**\n\t * returns a shived element for the given nodeName and document\n\t * @memberOf html5\n\t * @param {String} nodeName name of the element\n\t * @param {Document} ownerDocument The context document.\n\t * @returns {Object} The shived element.\n\t */\n\t function createElement(nodeName, ownerDocument, data){\n\t if (!ownerDocument) {\n\t ownerDocument = document;\n\t }\n\t if(supportsUnknownElements){\n\t return ownerDocument.createElement(nodeName);\n\t }\n\t if (!data) {\n\t data = getExpandoData(ownerDocument);\n\t }\n\t var node;\n\t\n\t if (data.cache[nodeName]) {\n\t node = data.cache[nodeName].cloneNode();\n\t } else if (saveClones.test(nodeName)) {\n\t node = (data.cache[nodeName] = data.createElem(nodeName)).cloneNode();\n\t } else {\n\t node = data.createElem(nodeName);\n\t }\n\t\n\t // Avoid adding some elements to fragments in IE < 9 because\n\t // * Attributes like `name` or `type` cannot be set/changed once an element\n\t // is inserted into a document/fragment\n\t // * Link elements with `src` attributes that are inaccessible, as with\n\t // a 403 response, will cause the tab/window to crash\n\t // * Script elements appended to fragments will execute when their `src`\n\t // or `text` property is set\n\t return node.canHaveChildren && !reSkip.test(nodeName) && !node.tagUrn ? data.frag.appendChild(node) : node;\n\t }\n\t\n\t /**\n\t * returns a shived DocumentFragment for the given document\n\t * @memberOf html5\n\t * @param {Document} ownerDocument The context document.\n\t * @returns {Object} The shived DocumentFragment.\n\t */\n\t function createDocumentFragment(ownerDocument, data){\n\t if (!ownerDocument) {\n\t ownerDocument = document;\n\t }\n\t if(supportsUnknownElements){\n\t return ownerDocument.createDocumentFragment();\n\t }\n\t data = data || getExpandoData(ownerDocument);\n\t var clone = data.frag.cloneNode(),\n\t i = 0,\n\t elems = getElements(),\n\t l = elems.length;\n\t for(;i