Browse Source

Initial commit

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

+ 3
- 0
.babelrc View File

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

+ 4
- 0
.eslintignore View File

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

+ 41
- 0
.eslintrc.js View File

@@ -0,0 +1,41 @@
1
+module.exports = {
2
+    "env": {
3
+        "browser": true,
4
+        "es6": true
5
+    },
6
+    "extends": "eslint:recommended",
7
+    "installedESLint": true,
8
+    "parserOptions": {
9
+        "ecmaFeatures": {
10
+            "experimentalObjectRestSpread": true,
11
+            "jsx": true
12
+        },
13
+        "sourceType": "module"
14
+    },
15
+    "plugins": [
16
+        "react"
17
+    ],
18
+    "rules": {
19
+        "indent": [
20
+            "error",
21
+            4
22
+        ],
23
+        "linebreak-style": [
24
+            "error",
25
+            "unix"
26
+        ],
27
+        "quotes": [
28
+            "error",
29
+            "double"
30
+        ],
31
+        "semi": [
32
+            "error",
33
+            "always"
34
+        ],
35
+        "strict": [
36
+            "error",
37
+        ],
38
+        "react/jsx-uses-react": "error",
39
+        "react/jsx-uses-vars": "error"
40
+    }
41
+};

+ 1
- 0
.gitignore View File

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

+ 21
- 0
LICENSE View File

@@ -0,0 +1,21 @@
1
+The MIT License (MIT)
2
+
3
+Copyright (c) 2016 Phyks(Lucas Verney)
4
+
5
+Permission is hereby granted, free of charge, to any person obtaining a copy
6
+of this software and associated documentation files (the "Software"), to deal
7
+in the Software without restriction, including without limitation the rights
8
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+copies of the Software, and to permit persons to whom the Software is
10
+furnished to do so, subject to the following conditions:
11
+
12
+The above copyright notice and this permission notice shall be included in all
13
+copies or substantial portions of the Software.
14
+
15
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+SOFTWARE.

+ 42
- 0
README.md View File

@@ -0,0 +1,42 @@
1
+Ampache React
2
+=============
3
+
4
+This is an alternative web interface for
5
+[Ampache](https://github.com/ampache/ampache/) built using Ampache XML API and
6
+React.
7
+
8
+## Trying it out
9
+
10
+Just drop this repo in a location served by a webserver and head your browser
11
+to the correct URL :)
12
+
13
+
14
+## Support
15
+
16
+The supported browsers should be:
17
+
18
+* `IE >= 9` (previous versions of IE are no longer supported by Microsoft)
19
+* Any recent version of any other browser.
20
+
21
+If you experience any issue, please report :)
22
+
23
+
24
+## Building
25
+
26
+Building of this app relies on `webpack`.
27
+
28
+First do a `npm install` to install all the required dependencies.
29
+
30
+Then, to make a development build, just run `webpack` in the root folder. To
31
+make a production build, just run `NODE_ENV=production webpack` in the root
32
+folder. All files will be generated in the `app/dist` folder.
33
+
34
+Please use the Git hooks (in `hooks` folder) to automatically make a build
35
+before comitting, as commit should always contain an up to date production
36
+build.
37
+
38
+## License
39
+
40
+This code is distributed under an MIT license.
41
+
42
+Feel free to contribute and reuse. For more details, see `LICENSE` file.

+ 16
- 0
TODO View File

@@ -0,0 +1,16 @@
1
+5. Web player
2
+6. Homepage
3
+7. Settings
4
+8. Search
5
+9. Discover
6
+
7
+
8
+## Global UI
9
+    * What happens when JS is off?
10
+        => https://www.allantatter.com/react-js-and-progressive-enhancement/
11
+
12
+## Miscellaneous
13
+    * See TODOs in the code
14
+    * https://facebook.github.io/immutable-js/ ?
15
+    * Web workers?
16
+    * Accessibility and semantics

+ 77
- 0
app/actions/APIActions.js View File

@@ -0,0 +1,77 @@
1
+import humps from "humps";
2
+
3
+import { CALL_API } from "../middleware/api";
4
+import { DEFAULT_LIMIT } from "../reducers/paginate";
5
+
6
+export default function (action, requestType, successType, failureType) {
7
+    const itemName = action.rstrip("s");
8
+    const fetchItemsSuccess = function (itemsList, itemsCount) {
9
+        return {
10
+            type: successType,
11
+            payload: {
12
+                items: itemsList,
13
+                total: itemsCount
14
+            }
15
+        };
16
+    };
17
+    const fetchItemsRequest = function () {
18
+        return {
19
+            type: requestType,
20
+            payload: {
21
+            }
22
+        };
23
+    };
24
+    const fetchItemsFailure = function (error) {
25
+        return {
26
+            type: failureType,
27
+            payload: {
28
+                error: error
29
+            }
30
+        };
31
+    };
32
+    const fetchItems = function (endpoint, username, passphrase, filter, offset, include = [], limit=DEFAULT_LIMIT) {
33
+        var extraParams = {
34
+            offset: offset,
35
+            limit: limit
36
+        };
37
+        if (filter) {
38
+            extraParams.filter = filter;
39
+        }
40
+        if (include && include.length > 0) {
41
+            extraParams.include = include;
42
+        }
43
+        return {
44
+            type: CALL_API,
45
+            payload: {
46
+                endpoint: endpoint,
47
+                dispatch: [
48
+                    fetchItemsRequest,
49
+                    jsonData => dispatch => {
50
+                        dispatch(fetchItemsSuccess(jsonData[itemName], jsonData[action]));
51
+                    },
52
+                    fetchItemsFailure
53
+                ],
54
+                action: action,
55
+                auth: passphrase,
56
+                username: username,
57
+                extraParams: extraParams
58
+            }
59
+        };
60
+    };
61
+    const loadItems = function({ pageNumber = 1, filter = null, include = [] } = {}) {
62
+        return (dispatch, getState) => {
63
+            const { auth } = getState();
64
+            const offset = (pageNumber - 1) * DEFAULT_LIMIT;
65
+            dispatch(fetchItems(auth.endpoint, auth.username, auth.token.token, filter, offset, include));
66
+        };
67
+    };
68
+
69
+    const camelizedAction = humps.pascalize(action);
70
+    var returned = {};
71
+    returned["fetch" + camelizedAction + "Success"] = fetchItemsSuccess;
72
+    returned["fetch" + camelizedAction + "Request"] = fetchItemsRequest;
73
+    returned["fetch" + camelizedAction + "Failure"] = fetchItemsFailure;
74
+    returned["fetch" + camelizedAction] = fetchItems;
75
+    returned["load" + camelizedAction] = loadItems;
76
+    return returned;
77
+}

+ 173
- 0
app/actions/auth.js View File

@@ -0,0 +1,173 @@
1
+import { push } from "react-router-redux";
2
+import jsSHA from "jssha";
3
+import Cookies from "js-cookie";
4
+
5
+import { CALL_API } from "../middleware/api";
6
+
7
+export const DEFAULT_SESSION_INTERVAL = 1800 * 1000;  // 30 mins default
8
+
9
+function _cleanEndpoint (endpoint) {
10
+    // Handle endpoints of the form "ampache.example.com"
11
+    if (
12
+        !endpoint.startsWith("//") &&
13
+            !endpoint.startsWith("http://") &&
14
+        !endpoint.startsWith("https://"))
15
+    {
16
+        endpoint = "http://" + endpoint;
17
+    }
18
+    // Remove trailing slash and store endpoint
19
+    endpoint = endpoint.replace(/\/$/, "");
20
+    return endpoint;
21
+}
22
+
23
+function _buildHMAC (password) {
24
+    // Handle Ampache HMAC generation
25
+    const time = Math.floor(Date.now() / 1000);
26
+
27
+    var shaObj = new jsSHA("SHA-256", "TEXT");
28
+    shaObj.update(password);
29
+    const key = shaObj.getHash("HEX");
30
+
31
+    shaObj = new jsSHA("SHA-256", "TEXT");
32
+    shaObj.update(time + key);
33
+
34
+    return {
35
+        time: time,
36
+        passphrase: shaObj.getHash("HEX")
37
+    };
38
+}
39
+
40
+export function loginKeepAlive(username, token, endpoint) {
41
+    return {
42
+        type: CALL_API,
43
+        payload: {
44
+            endpoint: endpoint,
45
+            dispatch: [
46
+                null,
47
+                null,
48
+                error => dispatch => {
49
+                    dispatch(loginUserFailure(error || "Your session expired… =("));
50
+                }
51
+            ],
52
+            action: "ping",
53
+            auth: token,
54
+            username: username,
55
+            extraParams: {}
56
+        }
57
+    };
58
+}
59
+
60
+export const LOGIN_USER_SUCCESS = "LOGIN_USER_SUCCESS";
61
+export function loginUserSuccess(username, token, endpoint, rememberMe, timerID) {
62
+    return {
63
+        type: LOGIN_USER_SUCCESS,
64
+        payload: {
65
+            username: username,
66
+            token: token,
67
+            endpoint: endpoint,
68
+            rememberMe: rememberMe,
69
+            timerID: timerID
70
+        }
71
+    };
72
+}
73
+
74
+export const LOGIN_USER_FAILURE = "LOGIN_USER_FAILURE";
75
+export function loginUserFailure(error) {
76
+    Cookies.remove("username");
77
+    Cookies.remove("token");
78
+    Cookies.remove("endpoint");
79
+    return {
80
+        type: LOGIN_USER_FAILURE,
81
+        payload: {
82
+            error: error
83
+        }
84
+    };
85
+}
86
+
87
+export const LOGIN_USER_REQUEST = "LOGIN_USER_REQUEST";
88
+export function loginUserRequest() {
89
+    return {
90
+        type: LOGIN_USER_REQUEST
91
+    };
92
+}
93
+
94
+export const LOGOUT_USER = "LOGOUT_USER";
95
+export function logout() {
96
+    return (dispatch, state) => {
97
+        const { auth } = state();
98
+        if (auth.timerID) {
99
+            clearInterval(auth.timerID);
100
+        }
101
+        Cookies.remove("username");
102
+        Cookies.remove("token");
103
+        Cookies.remove("endpoint");
104
+        dispatch({
105
+            type: LOGOUT_USER
106
+        });
107
+    };
108
+}
109
+
110
+export function logoutAndRedirect() {
111
+    return (dispatch) => {
112
+        dispatch(logout());
113
+        dispatch(push("/login"));
114
+    };
115
+}
116
+
117
+export function loginUser(username, passwordOrToken, endpoint, rememberMe, redirect="/", isToken=false) {
118
+    endpoint = _cleanEndpoint(endpoint);
119
+    var time = 0;
120
+    var passphrase = passwordOrToken;
121
+
122
+    if (!isToken) {
123
+        // Standard password connection
124
+        const HMAC = _buildHMAC(passwordOrToken);
125
+        time = HMAC.time;
126
+        passphrase = HMAC.passphrase;
127
+    } else {
128
+        // Remember me connection
129
+        if (passwordOrToken.expires < new Date()) {
130
+            // Token has expired
131
+            return loginUserFailure("Your session expired… =(");
132
+        }
133
+        time = Math.floor(Date.now() / 1000);
134
+        passphrase = passwordOrToken.token;
135
+    }
136
+    return {
137
+        type: CALL_API,
138
+        payload: {
139
+            endpoint: endpoint,
140
+            dispatch: [
141
+                loginUserRequest,
142
+                jsonData => dispatch => {
143
+                    if (!jsonData.auth || !jsonData.sessionExpire) {
144
+                        return Promise.reject("API error.");
145
+                    }
146
+                    const token = {
147
+                        token: jsonData.auth,
148
+                        expires: new Date(jsonData.sessionExpire)
149
+                    };
150
+                    // Dispatch success
151
+                    const timerID = setInterval(
152
+                        () => dispatch(loginKeepAlive(username, token.token, endpoint)),
153
+                        DEFAULT_SESSION_INTERVAL
154
+                    );
155
+                    if (rememberMe) {
156
+                        const cookiesOption = { expires: token.expires };
157
+                        Cookies.set("username", username, cookiesOption);
158
+                        Cookies.set("token", token, cookiesOption);
159
+                        Cookies.set("endpoint", endpoint, cookiesOption);
160
+                    }
161
+                    dispatch(loginUserSuccess(username, token, endpoint, rememberMe, timerID));
162
+                    // Redirect
163
+                    dispatch(push(redirect));
164
+                },
165
+                loginUserFailure
166
+            ],
167
+            action: "handshake",
168
+            auth: passphrase,
169
+            username: username,
170
+            extraParams: {timestamp: time}
171
+        }
172
+    };
173
+}

+ 18
- 0
app/actions/index.js View File

@@ -0,0 +1,18 @@
1
+export * from "./auth";
2
+
3
+import APIAction from "./APIActions";
4
+
5
+export const ARTISTS_SUCCESS = "ARTISTS_SUCCESS";
6
+export const ARTISTS_REQUEST = "ARTISTS_REQUEST";
7
+export const ARTISTS_FAILURE = "ARTISTS_FAILURE";
8
+export var { loadArtists } = APIAction("artists", ARTISTS_REQUEST, ARTISTS_SUCCESS, ARTISTS_FAILURE);
9
+
10
+export const ALBUMS_SUCCESS = "ALBUMS_SUCCESS";
11
+export const ALBUMS_REQUEST = "ALBUMS_REQUEST";
12
+export const ALBUMS_FAILURE = "ALBUMS_FAILURE";
13
+export var { loadAlbums } = APIAction("albums", ALBUMS_REQUEST, ALBUMS_SUCCESS, ALBUMS_FAILURE);
14
+
15
+export const SONGS_SUCCESS = "SONGS_SUCCESS";
16
+export const SONGS_REQUEST = "SONGS_REQUEST";
17
+export const SONGS_FAILURE = "SONGS_FAILURE";
18
+export var { loadSongs } = APIAction("songs", SONGS_REQUEST, SONGS_SUCCESS, SONGS_FAILURE);

BIN
app/assets/img/ampache-blue.png View File


+ 79
- 0
app/components/Album.jsx View File

@@ -0,0 +1,79 @@
1
+import React, { Component, PropTypes } from "react";
2
+
3
+import { formatLength } from "../utils";
4
+
5
+export class AlbumTrackRow extends Component {
6
+    render () {
7
+        const length = formatLength(this.props.track.length);
8
+        return (
9
+            <tr>
10
+                <td>{this.props.track.track}</td>
11
+                <td>{this.props.track.name}</td>
12
+                <td>{length}</td>
13
+            </tr>
14
+        );
15
+    }
16
+}
17
+
18
+AlbumTrackRow.propTypes = {
19
+    track: PropTypes.object.isRequired
20
+};
21
+
22
+
23
+export class AlbumTracksTable extends Component {
24
+    render () {
25
+        var rows = [];
26
+        this.props.tracks.forEach(function (item) {
27
+            rows.push(<AlbumTrackRow track={item} key={item.id} />);
28
+        });
29
+        return (
30
+            <table className="table table-hover songs">
31
+                <tbody>
32
+                    {rows}
33
+                </tbody>
34
+            </table>
35
+        );
36
+    }
37
+}
38
+
39
+AlbumTracksTable.propTypes = {
40
+    tracks: PropTypes.array.isRequired
41
+};
42
+
43
+export class AlbumRow extends Component {
44
+    render () {
45
+        return (
46
+            <div className="row albumRow">
47
+                <div className="row">
48
+                    <div className="col-md-offset-2 col-md-10">
49
+                        <h2>{this.props.album.name}</h2>
50
+                    </div>
51
+                </div>
52
+                <div className="row">
53
+                    <div className="col-md-2">
54
+                        <p className="text-center"><img src={this.props.album.art} width="200" height="200" className="img-responsive art" alt={this.props.album.name} /></p>
55
+                    </div>
56
+                    <div className="col-md-10">
57
+                        <AlbumTracksTable tracks={this.props.album.tracks} />
58
+                    </div>
59
+                </div>
60
+            </div>
61
+        );
62
+    }
63
+}
64
+
65
+AlbumRow.propTypes = {
66
+    album: PropTypes.object.isRequired
67
+};
68
+
69
+export default class Album extends Component {
70
+    render () {
71
+        return (
72
+            <AlbumRow album={this.props.album} />
73
+        );
74
+    }
75
+}
76
+
77
+Album.propTypes = {
78
+    album: PropTypes.object.isRequired
79
+};

+ 19
- 0
app/components/Albums.jsx View File

@@ -0,0 +1,19 @@
1
+import React, { Component, PropTypes } from "react";
2
+
3
+import FilterablePaginatedGrid from "./elements/Grid";
4
+
5
+export default class Albums extends Component {
6
+    render () {
7
+        return (
8
+            <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" />
9
+        );
10
+    }
11
+}
12
+
13
+Albums.propTypes = {
14
+    albums: PropTypes.array.isRequired,
15
+    albumsTotalCount: PropTypes.number.isRequired,
16
+    albumsPerPage: PropTypes.number.isRequired,
17
+    currentPage: PropTypes.number.isRequired,
18
+    location: PropTypes.object.isRequired
19
+};

+ 35
- 0
app/components/Artist.jsx View File

@@ -0,0 +1,35 @@
1
+import React, { Component, PropTypes } from "react";
2
+
3
+import { AlbumRow } from "./Album";
4
+
5
+// TODO: Songs without associated album
6
+
7
+export default class Artist extends Component {
8
+    render () {
9
+        var albumsRows = [];
10
+        if (Array.isArray(this.props.artist.albums)) {
11
+            this.props.artist.albums.forEach(function (item) {
12
+                albumsRows.push(<AlbumRow album={item} key={item.id} />);
13
+            });
14
+        }
15
+        return (
16
+            <div>
17
+                <div className="row">
18
+                    <div className="col-md-9">
19
+                        <h1 className="text-right">{this.props.artist.name}</h1>
20
+                        <hr/>
21
+                        <p>{this.props.artist.summary}</p>
22
+                    </div>
23
+                    <div className="col-md-3">
24
+                        <p><img src={this.props.artist.art} width="200" height="200" className="img-responsive art" alt={this.props.artist.name}/></p>
25
+                    </div>
26
+                </div>
27
+                { albumsRows }
28
+            </div>
29
+        );
30
+    }
31
+}
32
+
33
+Artist.propTypes = {
34
+    artist: PropTypes.object.isRequired
35
+};

+ 19
- 0
app/components/Artists.jsx View File

@@ -0,0 +1,19 @@
1
+import React, { Component, PropTypes } from "react";
2
+
3
+import FilterablePaginatedGrid from "./elements/Grid";
4
+
5
+export default class Artists extends Component {
6
+    render () {
7
+        return (
8
+            <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" />
9
+        );
10
+    }
11
+}
12
+
13
+Artists.propTypes = {
14
+    artists: PropTypes.array.isRequired,
15
+    artistsTotalCount: PropTypes.number.isRequired,
16
+    artistsPerPage: PropTypes.number.isRequired,
17
+    currentPage: PropTypes.number.isRequired,
18
+    location: PropTypes.object.isRequired
19
+};

+ 142
- 0
app/components/Login.jsx View File

@@ -0,0 +1,142 @@
1
+import React, { Component, PropTypes } from "react";
2
+import $ from "jquery";
3
+
4
+export class LoginForm extends Component {
5
+    constructor (props) {
6
+        super(props)
7
+
8
+        this.handleSubmit = this.handleSubmit.bind(this)
9
+    }
10
+
11
+    setError (formGroup, error) {
12
+        if (error) {
13
+            formGroup.classList.add("has-error");
14
+            formGroup.classList.remove("has-success");
15
+            return true;
16
+        }
17
+        formGroup.classList.remove("has-error");
18
+        formGroup.classList.add("has-success");
19
+        return false;
20
+    }
21
+
22
+    handleSubmit (e) {
23
+        e.preventDefault();
24
+        const username = this.refs.username.value.trim();
25
+        const password = this.refs.password.value.trim();
26
+        const endpoint = this.refs.endpoint.value.trim();
27
+        const rememberMe = this.refs.rememberMe.checked;
28
+
29
+        var hasError = this.setError(this.refs.usernameFormGroup, !username);
30
+        hasError |= this.setError(this.refs.passwordFormGroup, !password);
31
+        hasError |= this.setError(this.refs.endpointFormGroup, !endpoint);
32
+
33
+        if (!hasError) {
34
+            this.props.onSubmit(username, password, endpoint, rememberMe)
35
+        }
36
+    }
37
+
38
+    componentDidUpdate (prevProps) {
39
+        if (this.props.error) {
40
+            $(this.refs.loginForm).shake(3, 10, 300);
41
+            this.setError(this.refs.usernameFormGroup, this.props.error);
42
+            this.setError(this.refs.passwordFormGroup, this.props.error);
43
+            this.setError(this.refs.endpointFormGroup, this.props.error);
44
+        }
45
+    }
46
+
47
+    render () {
48
+        return (
49
+            <div>
50
+                {
51
+                    this.props.error ?
52
+                        <div className="row">
53
+                            <div className="alert alert-danger">
54
+                                <span className="glyphicon glyphicon-exclamation-sign"></span> { this.props.error }
55
+                            </div>
56
+                        </div>
57
+                        : null
58
+                }
59
+                {
60
+                    this.props.info ?
61
+                        <div className="row">
62
+                            <div className="alert alert-info">
63
+                                { this.props.info }
64
+                            </div>
65
+                        </div>
66
+                        : null
67
+                }
68
+                <div className="row">
69
+                    <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">
70
+                        <div className="row">
71
+                            <div className="form-group" ref="usernameFormGroup">
72
+                                <div className="col-xs-12">
73
+                                    <input type="text" className="form-control" ref="username" placeholder="Username" autoFocus defaultValue={this.props.username} />
74
+                                </div>
75
+                            </div>
76
+                            <div className="form-group" ref="passwordFormGroup">
77
+                                <div className="col-xs-12">
78
+                                    <input type="password" className="form-control" ref="password" placeholder="Password" />
79
+                                </div>
80
+                            </div>
81
+                            <div className="form-group" ref="endpointFormGroup">
82
+                                <div className="col-xs-12">
83
+                                    <input type="text" className="form-control" ref="endpoint" placeholder="http://ampache.example.com" defaultValue={this.props.endpoint} />
84
+                                </div>
85
+                            </div>
86
+                            <div className="form-group">
87
+                                <div className="col-xs-12">
88
+                                    <div className="row">
89
+                                        <div className="col-sm-6 col-xs-12 checkbox">
90
+                                            <label>
91
+                                                <input type="checkbox" ref="rememberMe" defaultChecked={this.props.rememberMe} /> Remember me
92
+                                            </label>
93
+                                        </div>
94
+                                        <div className="col-sm-6 col-sm-12 submit text-right">
95
+                                            <input type="submit" className="btn btn-default" defaultValue="Sign in" disabled={this.props.isAuthenticating} />
96
+                                        </div>
97
+                                    </div>
98
+                                </div>
99
+                            </div>
100
+                        </div>
101
+                    </form>
102
+                </div>
103
+            </div>
104
+        );
105
+    }
106
+}
107
+
108
+LoginForm.propTypes = {
109
+    username: PropTypes.string,
110
+    endpoint: PropTypes.string,
111
+    rememberMe: PropTypes.bool,
112
+    onSubmit: PropTypes.func.isRequired,
113
+    isAuthenticating: PropTypes.bool,
114
+    error: PropTypes.string,
115
+    info: PropTypes.string
116
+};
117
+
118
+
119
+export default class Login extends Component {
120
+    render () {
121
+        return (
122
+            <div className="login text-center">
123
+                <h1><img src="./app/assets/img/ampache-blue.png" alt="A"/>mpache</h1>
124
+                <hr/>
125
+                <p>Welcome back on Ampache, let"s go!</p>
126
+                <div className="col-sm-9 col-sm-offset-2 col-md-6 col-md-offset-3">
127
+                    <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} />
128
+                </div>
129
+            </div>
130
+        );
131
+    }
132
+}
133
+
134
+Login.propTypes = {
135
+    username: PropTypes.string,
136
+    endpoint: PropTypes.string,
137
+    rememberMe: PropTypes.bool,
138
+    onSubmit: PropTypes.func.isRequired,
139
+    isAuthenticating: PropTypes.bool,
140
+    error: PropTypes.string,
141
+    info: PropTypes.string
142
+};

+ 110
- 0
app/components/Songs.jsx View File

@@ -0,0 +1,110 @@
1
+import React, { Component, PropTypes } from "react";
2
+import { Link} from "react-router";
3
+import Fuse from "fuse.js";
4
+
5
+import FilterBar from "./elements/FilterBar";
6
+import Pagination from "./elements/Pagination";
7
+import { formatLength} from "../utils";
8
+
9
+export class SongsTableRow extends Component {
10
+    render () {
11
+        const length = formatLength(this.props.song.length);
12
+        const linkToArtist = "/artist/" + this.props.song.artist.id;
13
+        const linkToAlbum = "/album/" + this.props.song.album.id;
14
+        return (
15
+            <tr>
16
+                <td></td>
17
+                <td className="title">{this.props.song.name}</td>
18
+                <td className="artist"><Link to={linkToArtist}>{this.props.song.artist.name}</Link></td>
19
+                <td className="artist"><Link to={linkToAlbum}>{this.props.song.album.name}</Link></td>
20
+                <td className="genre">{this.props.song.genre}</td>
21
+                <td className="length">{length}</td>
22
+            </tr>
23
+        );
24
+    }
25
+}
26
+
27
+SongsTableRow.propTypes = {
28
+    song: PropTypes.object.isRequired
29
+};
30
+
31
+
32
+export class SongsTable extends Component {
33
+    render () {
34
+        var displayedSongs = this.props.songs;
35
+        if (this.props.filterText) {
36
+            // Use Fuse for the filter
37
+            displayedSongs = new Fuse(
38
+                this.props.songs,
39
+                {
40
+                    "keys": ["name"],
41
+                    "threshold": 0.4,
42
+                    "include": ["score"]
43
+                }).search(this.props.filterText);
44
+            // Keep only items in results
45
+            displayedSongs = displayedSongs.map(function (item) { return item.item; });
46
+        }
47
+
48
+        var rows = [];
49
+        displayedSongs.forEach(function (song) {
50
+            rows.push(<SongsTableRow song={song} key={song.id} />);
51
+        });
52
+        return (
53
+            <table className="table table-hover songs">
54
+                <thead>
55
+                    <tr>
56
+                        <th></th>
57
+                        <th>Title</th>
58
+                        <th>Artist</th>
59
+                        <th>Album</th>
60
+                        <th>Genre</th>
61
+                        <th>Length</th>
62
+                    </tr>
63
+                </thead>
64
+                <tbody>{rows}</tbody>
65
+            </table>
66
+        );
67
+    }
68
+}
69
+
70
+SongsTable.propTypes = {
71
+    songs: PropTypes.array.isRequired,
72
+    filterText: PropTypes.string
73
+};
74
+
75
+
76
+export default class FilterablePaginatedSongsTable extends Component {
77
+    constructor (props) {
78
+        super(props);
79
+        this.state = {
80
+            filterText: ""
81
+        };
82
+
83
+        this.handleUserInput = this.handleUserInput.bind(this);
84
+    }
85
+
86
+    handleUserInput (filterText) {
87
+        this.setState({
88
+            filterText: filterText.trim()
89
+        });
90
+    }
91
+
92
+    render () {
93
+        const nPages = Math.ceil(this.props.songsTotalCount / this.props.songsPerPage);
94
+        return (
95
+            <div>
96
+                <FilterBar filterText={this.state.filterText} onUserInput={this.handleUserInput} />
97
+                <SongsTable songs={this.props.songs} filterText={this.state.filterText} />
98
+                <Pagination nPages={nPages} currentPage={this.props.currentPage} location={this.props.location} />
99
+            </div>
100
+        );
101
+    }
102
+}
103
+
104
+FilterablePaginatedSongsTable.propTypes = {
105
+    songs: PropTypes.array.isRequired,
106
+    songsTotalCount: PropTypes.number.isRequired,
107
+    songsPerPage: PropTypes.number.isRequired,
108
+    currentPage: PropTypes.number.isRequired,
109
+    location: PropTypes.object.isRequired
110
+};

+ 34
- 0
app/components/elements/FilterBar.jsx View File

@@ -0,0 +1,34 @@
1
+import React, { Component, PropTypes } from "react";
2
+
3
+export default class FilterBar extends Component {
4
+    constructor (props) {
5
+        super(props);
6
+        this.handleChange = this.handleChange.bind(this);
7
+    }
8
+
9
+    handleChange (e) {
10
+        e.preventDefault();
11
+
12
+        this.props.onUserInput(this.refs.filterTextInput.value);
13
+    }
14
+
15
+    render () {
16
+        return (
17
+            <div className="filter">
18
+                <p className="col-xs-12 col-sm-6 col-md-4 col-md-offset-1 filter-legend">What are we listening to today?</p>
19
+                <div className="col-xs-12 col-sm-6 col-md-4 input-group">
20
+                    <form className="form-inline" onSubmit={this.handleChange}>
21
+                        <div className="form-group">
22
+                            <input type="text" className="form-control filter-input" placeholder="Filter…" aria-label="Filter…" value={this.props.filterText} onChange={this.handleChange} ref="filterTextInput" />
23
+                        </div>
24
+                    </form>
25
+                </div>
26
+            </div>
27
+        );
28
+    }
29
+}
30
+
31
+FilterBar.propTypes = {
32
+    onUserInput: PropTypes.func,
33
+    filterText: PropTypes.string
34
+};

+ 232
- 0
app/components/elements/Grid.jsx View File

@@ -0,0 +1,232 @@
1
+import React, { Component, PropTypes } from "react";
2
+import { Link} from "react-router";
3
+import imagesLoaded from "imagesloaded";
4
+import Isotope from "isotope-layout";
5
+import Fuse from "fuse.js";
6
+import _ from "lodash";
7
+import $ from "jquery";
8
+
9
+import FilterBar from "./FilterBar";
10
+import Pagination from "./Pagination";
11
+
12
+export class GridItem extends Component {
13
+    render () {
14
+        var nSubItems = this.props.item[this.props.subItemsType];
15
+        if (Array.isArray(nSubItems)) {
16
+            nSubItems = nSubItems.length;
17
+        }
18
+        var subItemsLabel = this.props.subItemsType;
19
+        if (nSubItems < 2) {
20
+            subItemsLabel = subItemsLabel.rstrip("s");
21
+        }
22
+        const to = "/" + this.props.itemsType.rstrip("s") + "/" + this.props.item.id;
23
+        const id = "grid-item-" + this.props.item.type + "/" + this.props.item.id;
24
+        return (
25
+            <div className="grid-item col-xs-6 col-sm-3 placeholders" id={id}>
26
+                <div className="grid-item-content placeholder text-center">
27
+                    <Link to={to}><img src={this.props.item.art} width="200" height="200" className="img-responsive art" alt={this.props.item.name}/></Link>
28
+                    <h4 className="name">{this.props.item.name}</h4>
29
+                    <span className="sub-items text-muted"><span className="n-sub-items">{nSubItems}</span> <span className="sub-items-type">{subItemsLabel}</span></span>
30
+                </div>
31
+            </div>
32
+        );
33
+    }
34
+}
35
+
36
+GridItem.propTypes = {
37
+    item: PropTypes.object.isRequired,
38
+    itemsType: PropTypes.string.isRequired,
39
+    subItemsType: PropTypes.string.isRequired
40
+};
41
+
42
+
43
+const ISOTOPE_OPTIONS = {  /** Default options for Isotope grid layout. */
44
+    getSortData: {
45
+        name: ".name",
46
+        nSubitems: ".sub-items .n-sub-items"
47
+    },
48
+    transitionDuration: 0,
49
+    sortBy: "name",
50
+    itemSelector: ".grid-item",
51
+    percentPosition: true,
52
+    layoutMode: "fitRows",
53
+    filter: "*",
54
+    fitRows: {
55
+        gutter: 0
56
+    }
57
+};
58
+
59
+export class Grid extends Component {
60
+    constructor (props) {
61
+        super(props);
62
+
63
+        // Init grid data member
64
+        this.iso = null;
65
+
66
+        this.handleFiltering = this.handleFiltering.bind(this);
67
+    }
68
+
69
+    createIsotopeContainer () {
70
+        if (this.iso == null) {
71
+            this.iso = new Isotope(this.refs.grid, ISOTOPE_OPTIONS);
72
+        }
73
+    }
74
+
75
+    handleFiltering (props) {
76
+        // If no query provided, drop any filter in use
77
+        if (props.filterText == "") {
78
+            return this.iso.arrange(ISOTOPE_OPTIONS);
79
+        }
80
+        // Use Fuse for the filter
81
+        var result = new Fuse(
82
+            props.items,
83
+            {
84
+                "keys": ["name"],
85
+                "threshold": 0.4,
86
+                "include": ["score"]
87
+            }).search(props.filterText);
88
+
89
+        // Apply filter on grid
90
+        this.iso.arrange({
91
+            filter: function () {
92
+                var name = $(this).find(".name").text();
93
+                return result.find(function (item) { return item.item.name == name; });
94
+            },
95
+            transitionDuration: "0.4s",
96
+            getSortData: {
97
+                relevance: function (item) {
98
+                    var name = $(item).find(".name").text();
99
+                    return result.reduce(function (p, c) {
100
+                        if (c.item.name == name) {
101
+                            return c.score + p;
102
+                        }
103
+                        return p;
104
+                    }, 0);
105
+                }
106
+            },
107
+            sortBy: "relevance"
108
+        });
109
+        this.iso.updateSortData();
110
+        this.iso.arrange();
111
+    }
112
+
113
+    shouldComponentUpdate(nextProps, nextState) {
114
+        return !_.isEqual(this.props, nextProps) || !_.isEqual(this.state, nextState);
115
+    }
116
+
117
+    componentWillReceiveProps(nextProps) {
118
+        if (!_.isEqual(nextProps.filterText, this.props.filterText)) {
119
+            this.handleFiltering(nextProps);
120
+        }
121
+    }
122
+
123
+    componentDidMount () {
124
+        // Setup grid
125
+        this.createIsotopeContainer();
126
+        // Only arrange if there are elements to arrange
127
+        if (_.get(this, "props.items.length", 0) > 0) {
128
+            this.iso.arrange();
129
+        }
130
+    }
131
+
132
+    componentDidUpdate(prevProps) {
133
+        // The list of keys seen in the previous render
134
+        let currentKeys = _.map(
135
+            prevProps.items,
136
+            (n) => "grid-item-" + n.type + "/" + n.id);
137
+
138
+        // The latest list of keys that have been rendered
139
+        let newKeys = _.map(
140
+            this.props.items,
141
+            (n) => "grid-item-" + n.type + "/" + n.id);
142
+
143
+        // Find which keys are new between the current set of keys and any new children passed to this component
144
+        let addKeys = _.difference(newKeys, currentKeys);
145
+
146
+        // Find which keys have been removed between the current set of keys and any new children passed to this component
147
+        let removeKeys = _.difference(currentKeys, newKeys);
148
+
149
+        if (removeKeys.length > 0) {
150
+            _.each(removeKeys, removeKey => this.iso.remove(document.getElementById(removeKey)));
151
+            this.iso.arrange();
152
+        }
153
+        if (addKeys.length > 0) {
154
+            this.iso.addItems(_.map(addKeys, (addKey) => document.getElementById(addKey)));
155
+            this.iso.arrange();
156
+        }
157
+
158
+        var iso = this.iso;
159
+        // Layout again after images are loaded
160
+        imagesLoaded(this.refs.grid).on("progress", function() {
161
+            // Layout after each image load, fix for responsive grid
162
+            if (!iso) {  // Grid could have been destroyed in the meantime
163
+                return;
164
+            }
165
+            iso.layout();
166
+        });
167
+    }
168
+
169
+    render () {
170
+        var gridItems = [];
171
+        const itemsType = this.props.itemsType;
172
+        const subItemsType = this.props.subItemsType;
173
+        this.props.items.forEach(function (item) {
174
+            gridItems.push(<GridItem item={item} itemsType={itemsType} subItemsType={subItemsType} key={item.id} />);
175
+        });
176
+        return (
177
+            <div className="row">
178
+                <div className="grid" ref="grid">
179
+                    {/* Sizing element */}
180
+                    <div className="grid-sizer col-xs-6 col-sm-3"></div>
181
+                    {/* Other items */}
182
+                    { gridItems }
183
+                </div>
184
+            </div>
185
+        );
186
+    }
187
+}
188
+
189
+Grid.propTypes = {
190
+    items: PropTypes.array.isRequired,
191
+    itemsType: PropTypes.string.isRequired,
192
+    subItemsType: PropTypes.string.isRequired,
193
+    filterText: PropTypes.string
194
+};
195
+
196
+export default class FilterablePaginatedGrid extends Component {
197
+    constructor (props) {
198
+        super(props);
199
+        this.state = {
200
+            filterText: ""
201
+        };
202
+
203
+        this.handleUserInput = this.handleUserInput.bind(this);
204
+    }
205
+
206
+    handleUserInput (filterText) {
207
+        this.setState({
208
+            filterText: filterText.trim()
209
+        });
210
+    }
211
+
212
+    render () {
213
+        const nPages = Math.ceil(this.props.itemsTotalCount / this.props.itemsPerPage);
214
+        return (
215
+            <div>
216
+                <FilterBar filterText={this.state.filterText} onUserInput={this.handleUserInput} />
217
+                <Grid items={this.props.items} itemsType={this.props.itemsType} subItemsType={this.props.subItemsType} filterText={this.state.filterText} />
218
+                <Pagination nPages={nPages} currentPage={this.props.currentPage} location={this.props.location} />
219
+            </div>
220
+        );
221
+    }
222
+}
223
+
224
+FilterablePaginatedGrid.propTypes = {
225
+    items: PropTypes.array.isRequired,
226
+    itemsTotalCount: PropTypes.number.isRequired,
227
+    itemsPerPage: PropTypes.number.isRequired,
228
+    currentPage: PropTypes.number.isRequired,
229
+    location: PropTypes.object.isRequired,
230
+    itemsType: PropTypes.string.isRequired,
231
+    subItemsType: PropTypes.string.isRequired
232
+};

+ 138
- 0
app/components/elements/Pagination.jsx View File

@@ -0,0 +1,138 @@
1
+import React, { Component, PropTypes } from "react";
2
+import { Link, withRouter } from "react-router";
3
+import $ from "jquery";
4
+
5
+export class Pagination extends Component {
6
+    constructor(props) {
7
+        super(props);
8
+        this.buildLinkTo.bind(this);
9
+    }
10
+
11
+    computePaginationBounds(currentPage, nPages, maxNumberPagesShown=5) {
12
+        // Taken from http://stackoverflow.com/a/8608998/2626416
13
+        var lowerLimit = currentPage;
14
+        var upperLimit = currentPage;
15
+
16
+        for (var b = 1; b < maxNumberPagesShown && b < nPages;) {
17
+            if (lowerLimit > 1 ) {
18
+                lowerLimit--;
19
+                b++;
20
+            }
21
+            if (b < maxNumberPagesShown && upperLimit < nPages) {
22
+                upperLimit++;
23
+                b++;
24
+            }
25
+        }
26
+
27
+        return {
28
+            lowerLimit: lowerLimit,
29
+            upperLimit: upperLimit + 1  // +1 to ease iteration in for with <
30
+        };
31
+    }
32
+
33
+    buildLinkTo(pageNumber) {
34
+        return {
35
+            pathname: this.props.location.pathname,
36
+            query: Object.assign({}, this.props.location.query, { page: pageNumber })
37
+        };
38
+    }
39
+
40
+    goToPage() {
41
+        const pageNumber = parseInt(this.refs.pageInput.value);
42
+        $("#paginationModal").modal("hide");
43
+        this.props.router.push(this.buildLinkTo(pageNumber));
44
+    }
45
+
46
+    render () {
47
+        const { lowerLimit, upperLimit } = this.computePaginationBounds(this.props.currentPage, this.props.nPages);
48
+        var pagesButton = [];
49
+        var key = 0;  // key increment to ensure correct ordering
50
+        if (lowerLimit > 1) {
51
+            // Push first page
52
+            pagesButton.push(
53
+                <li className="page-item" key={key}>
54
+                    <Link className="page-link" to={this.buildLinkTo(1)}>1</Link>
55
+                </li>
56
+            );
57
+            key++;
58
+            if (lowerLimit > 2) {
59
+                // Eventually push "…"
60
+                pagesButton.push(
61
+                    <li className="page-item" key={key}>
62
+                        <span onClick={() => $("#paginationModal").modal() }>…</span>
63
+                    </li>
64
+                );
65
+                key++;
66
+            }
67
+        }
68
+        var i = 0;
69
+        for (i = lowerLimit; i < upperLimit; i++) {
70
+            var className = "page-item";
71
+            if (this.props.currentPage == i) {
72
+                className += " active";
73
+            }
74
+            pagesButton.push(
75
+                <li className={className} key={key}>
76
+                    <Link className="page-link" to={this.buildLinkTo(i)}>{i}</Link>
77
+                </li>
78
+            );
79
+            key++;
80
+        }
81
+        if (i < this.props.nPages) {
82
+            if (i < this.props.nPages - 1) {
83
+                // Eventually push "…"
84
+                pagesButton.push(
85
+                    <li className="page-item" key={key}>
86
+                        <span onClick={() => $("#paginationModal").modal() }>…</span>
87
+                    </li>
88
+                );
89
+                key++;
90
+            }
91
+            // Push last page
92
+            pagesButton.push(
93
+                <li className="page-item" key={key}>
94
+                    <Link className="page-link" to={this.buildLinkTo(this.props.nPages)}>{this.props.nPages}</Link>
95
+                </li>
96
+            );
97
+        }
98
+        if (pagesButton.length > 1) {
99
+            return (
100
+                <div>
101
+                    <nav className="pagination-nav">
102
+                        <ul className="pagination">
103
+                            { pagesButton }
104
+                        </ul>
105
+                    </nav>
106
+                    <div className="modal fade" id="paginationModal" tabIndex="-1" role="dialog" aria-hidden="false">
107
+                        <div className="modal-dialog">
108
+                            <div className="modal-content">
109
+                                <div className="modal-header">
110
+                                    <button type="button" className="close" data-dismiss="modal" aria-hidden="true">×</button>
111
+                                    <h4 className="modal-title">Page to go to?</h4>
112
+                                </div>
113
+                                <div className="modal-body">
114
+                                    <form>
115
+                                        <input className="form-control" autoComplete="off" type="number" ref="pageInput" />
116
+                                    </form>
117
+                                </div>
118
+                                <div className="modal-footer">
119
+                                    <button type="button" className="btn btn-default" onClick={ () => $("#paginationModal").modal("hide") }>Cancel</button>
120
+                                    <button type="button" className="btn btn-primary" onClick={this.goToPage.bind(this)}>OK</button>
121
+                                </div>
122
+                            </div>
123
+                        </div>
124
+                    </div>
125
+                </div>
126
+            );
127
+        }
128
+        return null;
129
+    }
130
+}
131
+
132
+Pagination.propTypes = {
133
+    currentPage: PropTypes.number.isRequired,
134
+    location: PropTypes.object.isRequired,
135
+    nPages: PropTypes.number.isRequired
136
+};
137
+
138
+export default withRouter(Pagination);

+ 60
- 0
app/components/layouts/Sidebar.jsx View File

@@ -0,0 +1,60 @@
1
+import React, { Component } from "react";
2
+import { IndexLink, Link} from "react-router";
3
+
4
+export default class SidebarLayout extends Component {
5
+    render () {
6
+        return (
7
+            <div>
8
+                <div className="col-sm-3 col-md-2 sidebar hidden-xs">
9
+                    <h1 className="text-center"><IndexLink to="/"><img alt="A" src="./app/assets/img/ampache-blue.png"/>mpache</IndexLink></h1>
10
+                    <nav>
11
+                        <div className="navbar text-center icon-navbar">
12
+                            <div className="container-fluid">
13
+                                <ul className="nav navbar-nav icon-navbar-nav">
14
+                                    <li aria-hidden="true">
15
+                                        <Link to="/" className="glyphicon glyphicon-home"></Link>
16
+                                    </li>
17
+                                    <li aria-hidden="true">
18
+                                        <Link to="/settings" className="glyphicon glyphicon-wrench"></Link>
19
+                                    </li>
20
+                                    <li aria-hidden="true">
21
+                                        <Link to="/logout" className="glyphicon glyphicon-off"></Link>
22
+                                    </li>
23
+                                </ul>
24
+                            </div>
25
+                        </div>
26
+                        <ul className="nav nav-sidebar">
27
+                            <li>
28
+                                <Link to="/discover">
29
+                                    <span className="glyphicon glyphicon-globe" aria-hidden="true"></span>
30
+                                    <span className="hidden-sm"> Discover</span>
31
+                                </Link>
32
+                            </li>
33
+                            <li>
34
+                                <Link to="/browse">
35
+                                    <span className="glyphicon glyphicon-headphones" aria-hidden="true"></span>
36
+                                    <span className="hidden-sm"> Browse</span>
37
+                                </Link>
38
+                                <ul className="nav nav-sidebar text-center">
39
+                                    <li><Link to="/artists"><span className="glyphicon glyphicon-user"></span> Artists</Link></li>
40
+                                    <li><Link to="/albums"><span className="glyphicon glyphicon-cd" aria-hidden="true"></span> Albums</Link></li>
41
+                                    <li><Link to="/songs"><span className="glyphicon glyphicon-music"></span> Songs</Link></li>
42
+                                </ul>
43
+                            </li>
44
+                            <li>
45
+                                <Link to="/search">
46
+                                    <span className="glyphicon glyphicon-search" aria-hidden="true"></span>
47
+                                    <span className="hidden-sm"> Search</span>
48
+                                </Link>
49
+                            </li>
50
+                        </ul>
51
+                    </nav>
52
+                </div>
53
+
54
+                <div className="col-sm-9 col-sm-offset-3 col-md-10 col-md-offset-2 main-panel">
55
+                    {this.props.children}
56
+                </div>
57
+            </div>
58
+        );
59
+    }
60
+}

+ 11
- 0
app/components/layouts/Simple.jsx View File

@@ -0,0 +1,11 @@
1
+import React, { Component } from "react";
2
+
3
+export default class SimpleLayout extends Component {
4
+    render () {
5
+        return (
6
+            <div>
7
+                {this.props.children}
8
+            </div>
9
+        );
10
+    }
11
+}

+ 18
- 0
app/containers/App.jsx View File

@@ -0,0 +1,18 @@
1
+import React, { Component, PropTypes } from "react";
2
+
3
+export default class App extends Component {
4
+    render () {
5
+        return (
6
+            <div>
7
+                {this.props.children && React.cloneElement(this.props.children, {
8
+                    error: this.props.error
9
+                })}
10
+            </div>
11
+        );
12
+    }
13
+}
14
+
15
+App.propTypes = {
16
+    // Injected by React Router
17
+    children: PropTypes.node,
18
+};

+ 50
- 0
app/containers/RequireAuthentication.js View File

@@ -0,0 +1,50 @@
1
+import React, { Component, PropTypes } from "react";
2
+import { connect } from "react-redux";
3
+
4
+export class RequireAuthentication extends Component {
5
+    componentWillMount () {
6
+        this.checkAuth(this.props.isAuthenticated);
7
+    }
8
+
9
+    componentWillUpdate () {
10
+        this.checkAuth(this.props.isAuthenticated);
11
+    }
12
+
13
+    checkAuth (isAuthenticated) {
14
+        if (!isAuthenticated) {
15
+            this.context.router.replace({
16
+                pathname: "/login",
17
+                state: {
18
+                    nextPathname: this.props.location.pathname,
19
+                    nextQuery: this.props.location.query
20
+                }
21
+            });
22
+        }
23
+    }
24
+
25
+    render () {
26
+        return (
27
+            <div>
28
+            {this.props.isAuthenticated === true
29
+                ? this.props.children
30
+                : null
31
+            }
32
+            </div>
33
+        );
34
+    }
35
+}
36
+
37
+RequireAuthentication.propTypes = {
38
+    // Injected by React Router
39
+    children: PropTypes.node
40
+};
41
+
42
+RequireAuthentication.contextTypes = {
43
+    router: PropTypes.object.isRequired
44
+};
45
+
46
+const mapStateToProps = (state) => ({
47
+    isAuthenticated: state.auth.isAuthenticated
48
+});
49
+
50
+export default connect(mapStateToProps)(RequireAuthentication);

+ 21
- 0
app/containers/Root.jsx View File

@@ -0,0 +1,21 @@
1
+import React, { Component, PropTypes } from "react";
2
+import { Provider } from "react-redux";
3
+import { Router } from "react-router";
4
+
5
+import routes from "../routes";
6
+
7
+export default class Root extends Component {
8
+    render() {
9
+        const { store, history } = this.props;
10
+        return (
11
+            <Provider store={store}>
12
+                <Router history={history} routes={routes} />
13
+            </Provider>
14
+        );
15
+    }
16
+}
17
+
18
+Root.propTypes = {
19
+    store: PropTypes.object.isRequired,
20
+    history: PropTypes.object.isRequired
21
+};

+ 2
- 0
app/dist/fix.ie9.js View File

@@ -0,0 +1,2 @@
1
+!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<style>"+t+"</style>",r.insertBefore(n.lastChild,r.firstChild)}function a(){var e=b.elements;return"string"==typeof e?e.split(" "):e}function o(e,t){var n=b.elements;"string"!=typeof n&&(n=n.join(" ")),"string"!=typeof e&&(e=e.join(" ")),b.elements=n+" "+e,s(t)}function c(e){var t=E[e[v]];return t||(t={},y++,e[v]=y,E[y]=t),t}function i(e,t,r){if(t||(t=n),f)return t.createElement(e);r||(r=c(t));var a;return a=r.cache[e]?r.cache[e].cloneNode():g.test(e)?(r.cache[e]=r.createElem(e)).cloneNode():r.createElem(e),!a.canHaveChildren||p.test(e)||a.tagUrn?a:r.frag.appendChild(a)}function l(e,t){if(e||(e=n),f)return e.createDocumentFragment();t=t||c(e);for(var r=t.frag.cloneNode(),o=0,i=a(),l=i.length;o<l;o++)r.createElement(i[o]);return r}function u(e,t){t.cache||(t.cache={},t.createElem=e.createElement,t.createFrag=e.createDocumentFragment,t.frag=t.createFrag()),e.createElement=function(n){return b.shivMethods?i(n,e,t):t.createElem(n)},e.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+a().join().replace(/[\w\-:]+/g,function(e){return t.createElem(e),t.frag.createElement(e),'c("'+e+'")'})+");return n}")(b,t.frag)}function s(e){e||(e=n);var t=c(e);return!b.shivCSS||d||t.hasCSS||(t.hasCSS=!!r(e,"article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}mark{background:#FF0;color:#000}template{display:none}")),f||u(e,t),e}var d,f,m="3.7.3-pre",h=t.html5||{},p=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,g=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,v="_html5shiv",y=0,E={};!function(){try{var e=n.createElement("a");e.innerHTML="<xyz></xyz>",d="hidden"in e,f=1==e.childNodes.length||function(){n.createElement("a");var e=n.createDocumentFragment();return"undefined"==typeof e.cloneNode||"undefined"==typeof e.createDocumentFragment||"undefined"==typeof e.createElement}()}catch(t){d=!0,f=!0}}();var b={elements:h.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output picture progress section summary template time video",version:m,shivCSS:h.shivCSS!==!1,supportsUnknownElements:f,shivMethods:h.shivMethods!==!1,type:"default",shivDocument:s,createElement:i,createDocumentFragment:l,addElements:o};t.html5=b,s(n),"object"==typeof e&&e.exports&&(e.exports=b)}("undefined"!=typeof window?window:this,document)}});
2
+//# sourceMappingURL=fix.ie9.js.map

+ 1
- 0
app/dist/fix.ie9.js.map
File diff suppressed because it is too large
View File


+ 20
- 0
app/dist/index.js
File diff suppressed because it is too large
View File


+ 1
- 0
app/dist/index.js.map
File diff suppressed because it is too large
View File


+ 248
- 0
app/middleware/api.js View File

@@ -0,0 +1,248 @@
1
+// TODO: Refactor using normalizr
2
+import "babel-polyfill";
3
+import fetch from "isomorphic-fetch";
4
+import humps from "humps";
5
+import X2JS from "x2js";
6
+
7
+import { assembleURLAndParams } from "../utils";
8
+
9
+export const API_VERSION = 350001;  /** API version to use. */
10
+export const BASE_API_PATH = "/server/xml.server.php";  /** Base API path after endpoint. */
11
+
12
+// Error class to represents errors from these actions.
13
+class APIError extends Error {}
14
+
15
+function _checkHTTPStatus (response) {
16
+    if (response.status >= 200 && response.status < 300) {
17
+        return response;
18
+    } else {
19
+        return Promise.reject(response.statusText);
20
+    }
21
+}
22
+
23
+function _parseToJSON (responseText) {
24
+    var x2js = new X2JS({
25
+        attributePrefix: "",
26
+        keepCData: false
27
+    });
28
+    if (responseText) {
29
+        return x2js.xml_str2json(responseText).root;
30
+    }
31
+    return Promise.reject("Invalid response text.");
32
+}
33
+
34
+function _checkAPIErrors (jsonData) {
35
+    if (jsonData.error) {
36
+        return Promise.reject(jsonData.error.cdata + " (" + jsonData.error.code + ")");
37
+    } else if (!jsonData) {
38
+        // No data returned
39
+        return Promise.reject("Empty response");
40
+    }
41
+    return jsonData;
42
+}
43
+
44
+function _uglyFixes (endpoint, token) {
45
+    if (typeof _uglyFixes.artistsCount === "undefined" ) {
46
+        _uglyFixes.artistsCount = 0;
47
+    }
48
+    if (typeof _uglyFixes.albumsCount === "undefined" ) {
49
+        _uglyFixes.albumsCount = 0;
50
+    }
51
+    if (typeof _uglyFixes.songsCount === "undefined" ) {
52
+        _uglyFixes.songsCount = 0;
53
+    }
54
+
55
+    var _uglyFixesSongs = function (songs) {
56
+        for (var i = 0; i < songs.length; i++) {
57
+            // Fix for name becoming title in songs objects
58
+            songs[i].name = songs[i].title;
59
+            // Fix for length being time in songs objects
60
+            songs[i].length = songs[i].time;
61
+
62
+            // Fix for cdata left in artist and album
63
+            songs[i].artist.name = songs[i].artist.cdata;
64
+            songs[i].album.name = songs[i].album.cdata;
65
+        }
66
+        return songs;
67
+    };
68
+
69
+    var _uglyFixesAlbums = function (albums) {
70
+        for (var i = 0; i < albums.length; i++) {
71
+            // Fix for absence of distinction between disks in the same album
72
+            if (albums[i].disk > 1) {
73
+                albums[i].name = albums[i].name + " [Disk " + albums[i].disk + "]";
74
+            }
75
+
76
+            // Move songs one node top
77
+            if (albums[i].tracks.song) {
78
+                albums[i].tracks = albums[i].tracks.song;
79
+
80
+                // Ensure tracks is an array
81
+                if (!Array.isArray(albums[i].tracks)) {
82
+                    albums[i].tracks = [albums[i].tracks];
83
+                }
84
+
85
+                // Fix tracks
86
+                albums[i].tracks = _uglyFixesSongs(albums[i].tracks);
87
+            }
88
+        }
89
+        return albums;
90
+    };
91
+
92
+    return jsonData => {
93
+        // Camelize
94
+        jsonData = humps.camelizeKeys(jsonData);
95
+
96
+        // Ensure items are always wrapped in an array
97
+        if (jsonData.artist && !Array.isArray(jsonData.artist)) {
98
+            jsonData.artist = [jsonData.artist];
99
+        }
100
+        if (jsonData.album && !Array.isArray(jsonData.album)) {
101
+            jsonData.album = [jsonData.album];
102
+        }
103
+        if (jsonData.song && !Array.isArray(jsonData.song)) {
104
+            jsonData.song = [jsonData.song];
105
+        }
106
+
107
+        // Keep track of artists count
108
+        if (jsonData.artists) {
109
+            _uglyFixes.artistsCount = parseInt(jsonData.artists);
110
+        }
111
+        // Keep track of albums count
112
+        if (jsonData.albums) {
113
+            _uglyFixes.albumsCount = parseInt(jsonData.albums);
114
+        }
115
+        // Keep track of songs count
116
+        if (jsonData.songs) {
117
+            _uglyFixes.songsCount = parseInt(jsonData.songs);
118
+        }
119
+
120
+        if (jsonData.artist) {
121
+            for (var i = 0; i < jsonData.artist.length; i++) {
122
+                // Fix for artists art not included
123
+                jsonData.artist[i].art = endpoint.replace("/server/xml.server.php", "") + "/image.php?object_id=" + jsonData.artist[i].id + "&object_type=artist&auth=" + token;
124
+
125
+                // Move albums one node top
126
+                if (jsonData.artist[i].albums.album) {
127
+                    jsonData.artist[i].albums = jsonData.artist[i].albums.album;
128
+
129
+                    // Ensure albums are an array
130
+                    if (!Array.isArray(jsonData.artist[i].albums)) {
131
+                        jsonData.artist[i].albums = [jsonData.artist[i].albums];
132
+                    }
133
+
134
+                    // Fix albums
135
+                    jsonData.artist[i].albums = _uglyFixesAlbums(jsonData.artist[i].albums);
136
+                }
137
+
138
+                // Move songs one node top
139
+                if (jsonData.artist[i].songs.song) {
140
+                    jsonData.artist[i].songs = jsonData.artist[i].songs.song;
141
+
142
+                    // Ensure songs are an array
143
+                    if (!Array.isArray(jsonData.artist[i].songs)) {
144
+                        jsonData.artist[i].songs = [jsonData.artist[i].songs];
145
+                    }
146
+
147
+                    // Fix songs
148
+                    jsonData.artist[i].songs = _uglyFixesSongs(jsonData.artist[i].songs);
149
+                }
150
+            }
151
+            // Store the total number of items
152
+            jsonData.artists = _uglyFixes.artistsCount;
153
+        }
154
+        if (jsonData.album) {
155
+            // Fix albums
156
+            jsonData.album = _uglyFixesAlbums(jsonData.album);
157
+            // Store the total number of items
158
+            jsonData.albums = _uglyFixes.albumsCount;
159
+        }
160
+        if (jsonData.song) {
161
+            // Fix songs
162
+            jsonData.song = _uglyFixesSongs(jsonData.song);
163
+            // Store the total number of items
164
+            jsonData.songs = _uglyFixes.songsCount;
165
+        }
166
+
167
+        if (!jsonData.sessionExpire) {
168
+            // Fix for Ampache not returning updated sessionExpire
169
+            jsonData.sessionExpire = (new Date(Date.now() + 3600 * 1000)).toJSON();
170
+        }
171
+
172
+        return jsonData;
173
+    };
174
+}
175
+
176
+// Fetches an API response and normalizes the result JSON according to schema.
177
+// This makes every API response have the same shape, regardless of how nested it was.
178
+function doAPICall (endpoint, action, auth, username, extraParams) {
179
+    const APIAction = extraParams.filter ? action.rstrip("s") : action;
180
+    const baseParams = {
181
+        version: API_VERSION,
182
+        action: APIAction,
183
+        auth: auth,
184
+        user: username
185
+    };
186
+    const params = Object.assign({}, baseParams, extraParams);
187
+    const fullURL = assembleURLAndParams(endpoint + BASE_API_PATH, params);
188
+
189
+    return fetch(fullURL, {
190
+        method: "get",
191
+    })
192
+        .then(_checkHTTPStatus)
193
+        .then (response => response.text())
194
+        .then(_parseToJSON)
195
+        .then(_uglyFixes(endpoint, auth))
196
+        .then(_checkAPIErrors);
197
+}
198
+
199
+// Action key that carries API call info interpreted by this Redux middleware.
200
+export const CALL_API = "CALL_API";
201
+
202
+// A Redux middleware that interprets actions with CALL_API info specified.
203
+// Performs the call and promises when such actions are dispatched.
204
+export default store => next => reduxAction => {
205
+    if (reduxAction.type !== CALL_API) {
206
+        // Do not apply on every action
207
+        return next(reduxAction);
208
+    }
209
+
210
+    const { endpoint, action, auth, username, dispatch, extraParams } = reduxAction.payload;
211
+
212
+    if (!endpoint || typeof endpoint !== "string") {
213
+        throw new APIError("Specify a string endpoint URL.");
214
+    }
215
+    if (!action) {
216
+        throw new APIError("Specify one of the supported API actions.");
217
+    }
218
+    if (!auth) {
219
+        throw new APIError("Specify an auth token.");
220
+    }
221
+    if (!username) {
222
+        throw new APIError("Specify a username.");
223
+    }
224
+    if (!Array.isArray(dispatch) || dispatch.length !== 3) {
225
+        throw new APIError("Expected an array of three action dispatch.");
226
+    }
227
+    if (!dispatch.every(type => typeof type === "function" || type === null)) {
228
+        throw new APIError("Expected action to dispatch to be functions or null.");
229
+    }
230
+
231
+    const [ requestDispatch, successDispatch, failureDispatch ] = dispatch;
232
+    if (requestDispatch) {
233
+        store.dispatch(requestDispatch());
234
+    }
235
+
236
+    return doAPICall(endpoint, action, auth, username, extraParams).then(
237
+        response => {
238
+            if (successDispatch) {
239
+                store.dispatch(successDispatch(response));
240
+            }
241
+        },
242
+        error => {
243
+            if (failureDispatch) {
244
+                store.dispatch(failureDispatch(error));
245
+            }
246
+        }
247
+    );
248
+};

+ 74
- 0
app/reducers/auth.js View File

@@ -0,0 +1,74 @@
1
+import Cookies from "js-cookie";
2
+
3
+import {LOGIN_USER_REQUEST, LOGIN_USER_SUCCESS, LOGIN_USER_FAILURE, LOGOUT_USER} from "../actions";
4
+import { createReducer } from "../utils";
5
+
6
+var initialToken = Cookies.getJSON("token");
7
+if (initialToken) {
8
+    initialToken.expires = new Date(initialToken.expires);
9
+}
10
+
11
+const initialState = {
12
+    token: initialToken || {
13
+        token: "",
14
+        expires: null
15
+    },
16
+    username: Cookies.get("username"),
17
+    endpoint: Cookies.get("endpoint"),
18
+    rememberMe: Boolean(Cookies.get("username") && Cookies.get("endpoint")),
19
+    isAuthenticated: false,
20
+    isAuthenticating: false,
21
+    error: "",
22
+    info: "",
23
+    timerID: null
24
+};
25
+
26
+export default createReducer(initialState, {
27
+    [LOGIN_USER_REQUEST]: (state) => {
28
+        return Object.assign({}, state, {
29
+            isAuthenticating: true,
30
+            info: "Connecting…",
31
+            error: "",
32
+            timerID: null
33
+        });
34
+    },
35
+    [LOGIN_USER_SUCCESS]: (state, payload) => {
36
+        return Object.assign({}, state, {
37
+            isAuthenticating: false,
38
+            isAuthenticated: true,
39
+            token: payload.token,
40
+            username: payload.username,
41
+            endpoint: payload.endpoint,
42
+            rememberMe: payload.rememberMe,
43
+            info: "Successfully logged in as " + payload.username + "!",
44
+            error: "",
45
+            timerID: payload.timerID
46
+        });
47
+
48
+    },
49
+    [LOGIN_USER_FAILURE]: (state, payload) => {
50
+        return Object.assign({}, state, {
51
+            isAuthenticating: false,
52
+            isAuthenticated: false,
53
+            token: initialState.token,
54
+            username: "",
55
+            endpoint: "",
56
+            rememberMe: false,
57
+            info: "",
58
+            error: payload.error,
59
+            timerID: 0
60
+        });
61
+    },
62
+    [LOGOUT_USER]: (state) => {
63
+        return Object.assign({}, state, {
64
+            isAuthenticated: false,
65
+            token: initialState.token,
66
+            username: "",
67
+            endpoint: "",
68
+            rememberMe: false,
69
+            info: "See you soon!",
70
+            error: "",
71
+            timerID: 0
72
+        });
73
+    }
74
+});

+ 34
- 0
app/reducers/index.js View File

@@ -0,0 +1,34 @@
1
+import { routerReducer as routing } from "react-router-redux";
2
+import { combineReducers } from "redux";
3
+
4
+import auth from "./auth";
5
+import paginate from "./paginate";
6
+
7
+import * as ActionTypes from "../actions";
8
+
9
+// Updates the pagination data for different actions.
10
+const pagination = combineReducers({
11
+    artists: paginate([
12
+        ActionTypes.ARTISTS_REQUEST,
13
+        ActionTypes.ARTISTS_SUCCESS,
14
+        ActionTypes.ARTISTS_FAILURE
15
+    ]),
16
+    albums: paginate([
17
+        ActionTypes.ALBUMS_REQUEST,
18
+        ActionTypes.ALBUMS_SUCCESS,
19
+        ActionTypes.ALBUMS_FAILURE
20
+    ]),
21
+    songs: paginate([
22
+        ActionTypes.SONGS_REQUEST,
23
+        ActionTypes.SONGS_SUCCESS,
24
+        ActionTypes.SONGS_FAILURE
25
+    ])
26
+});
27
+
28
+const rootReducer = combineReducers({
29
+    routing,
30
+    auth,
31
+    pagination
32
+});
33
+
34
+export default rootReducer;

+ 46
- 0
app/reducers/paginate.js View File

@@ -0,0 +1,46 @@
1
+import { createReducer } from "../utils";
2
+
3
+export const DEFAULT_LIMIT = 30;  /** Default max number of elements to retrieve. */
4
+
5
+const initialState = {
6
+    isFetching: false,
7
+    items: [],
8
+    total: 0,
9
+    error: ""
10
+};
11
+
12
+// Creates a reducer managing pagination, given the action types to handle,
13
+// and a function telling how to extract the key from an action.
14
+export default function paginate(types) {
15
+    if (!Array.isArray(types) || types.length !== 3) {
16
+        throw new Error("Expected types to be an array of three elements.");
17
+    }
18
+    if (!types.every(t => typeof t === "string")) {
19
+        throw new Error("Expected types to be strings.");
20
+    }
21
+
22
+    const [ requestType, successType, failureType ] = types;
23
+
24
+    return createReducer(initialState, {
25
+        [requestType]: (state) => {
26
+            return Object.assign({}, state, {
27
+                isFetching: true,
28
+                error: "",
29
+            });
30
+        },
31
+        [successType]: (state, payload) => {
32
+            return Object.assign({}, state, {
33
+                isFetching: false,
34
+                items: payload.items,
35
+                total: payload.total,
36
+                error: ""
37
+            });
38
+        },
39
+        [failureType]: (state, payload) => {
40
+            return Object.assign({}, state, {
41
+                isFetching: false,
42
+                error: payload.error
43
+            });
44
+        }
45
+    });
46
+}

+ 39
- 0
app/routes.js View File

@@ -0,0 +1,39 @@
1
+import React from "react";
2
+import { IndexRoute, Route } from "react-router";
3
+
4
+import RequireAuthentication from "./containers/RequireAuthentication";
5
+import App from "./containers/App";
6
+import SimpleLayout from "./components/layouts/Simple";
7
+import SidebarLayout from "./components/layouts/Sidebar";
8
+import BrowsePage from "./views/BrowsePage";
9
+import HomePage from "./views/HomePage";
10
+import LoginPage from "./views/LoginPage";
11
+import LogoutPage from "./views/LogoutPage";
12
+import ArtistsPage from "./views/ArtistsPage";
13
+import AlbumsPage from "./views/AlbumsPage";
14
+import SongsPage from "./views/SongsPage";
15
+import ArtistPage from "./views/ArtistPage";
16
+import AlbumPage from "./views/AlbumPage";
17
+
18
+export default (
19
+    <Route path="/" component={App}>
20
+        <Route path="login" component={SimpleLayout}>
21
+            <IndexRoute component={LoginPage} />
22
+        </Route>
23
+        <Route component={SidebarLayout}>
24
+            <Route path="logout" component={LogoutPage} />
25
+            <Route component={RequireAuthentication}>
26
+                {/*
27
+                  <Route path="discover" component={DiscoverPage} />
28
+                */}
29
+                <Route path="browse" component={BrowsePage} />
30
+                <Route path="artists" component={ArtistsPage} />
31
+                <Route path="artist/:id" component={ArtistPage} />
32
+                <Route path="albums" component={AlbumsPage} />
33
+                <Route path="album/:id" component={AlbumPage} />
34
+                <Route path="songs" component={SongsPage} />
35
+                <IndexRoute component={HomePage} />
36
+            </Route>
37
+        </Route>
38
+    </Route>
39
+);

+ 19
- 0
app/store/configureStore.js View File

@@ -0,0 +1,19 @@
1
+import { createStore, applyMiddleware } from "redux";
2
+import { hashHistory } from "react-router";
3
+import { routerMiddleware } from "react-router-redux";
4
+import thunkMiddleware from "redux-thunk";
5
+import createLogger from "redux-logger";
6
+
7
+import rootReducer from "../reducers";
8
+import apiMiddleware from "../middleware/api";
9
+
10
+const historyMiddleware = routerMiddleware(hashHistory);
11
+const loggerMiddleware = createLogger();
12
+
13
+export default function configureStore(preloadedState) {
14
+    return createStore(
15
+        rootReducer,
16
+        preloadedState,
17
+        applyMiddleware(thunkMiddleware, apiMiddleware, historyMiddleware, loggerMiddleware)
18
+    );
19
+}

+ 153
- 0
app/styles/ampache.css View File

@@ -0,0 +1,153 @@
1
+/*
2
+ * Sidebar
3
+ */
4
+.sidebar {
5
+    position: fixed;
6
+    top: 0;
7
+    bottom: 0;
8
+    left: 0;
9
+    z-index: 1000;
10
+    display: block;
11
+    padding: 20px;
12
+    overflow-x: hidden;
13
+    overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
14
+    background-color: #333;
15
+    color: white;
16
+    border-right: 1px solid #eee;
17
+}
18
+/* Sidebar elements */
19
+.sidebar a {
20
+    color: white;
21
+}
22
+.sidebar h1 {
23
+    margin: 0;
24
+    margin-bottom: 20px;
25
+}
26
+.sidebar h1 img {
27
+    height: 46px;
28
+}
29
+.sidebar h1 a {
30
+    text-decoration: none;
31
+}
32
+/* Sidebar navigation */
33
+.nav-sidebar {
34
+    margin-right: -21px; /* 20px padding + 1px border */
35
+    margin-bottom: 20px;
36
+    margin-left: -20px;
37
+}
38
+.nav-sidebar > li > a {
39
+    padding-right: 20px;
40
+    padding-left: 20px;
41
+}
42
+.nav-sidebar .nav-sidebar {
43
+    margin-bottom: 0;  /* No margin bottom for nested nav-sidebar. */
44
+}
45
+.nav-sidebar > .active > a,
46
+.nav-sidebar > .active > a:hover,
47
+.nav-sidebar > .active > a:focus,
48
+.nav>li>a:hover {
49
+    color: #fff;
50
+    background-color: #222;
51
+}
52
+.icon-navbar {
53
+    background-color: #555;
54
+    font-size: 1.25em;
55
+}
56
+.icon-navbar-nav {
57
+  display: inline-block;
58
+  float: none;
59
+  vertical-align: top;
60
+  text-align: center;
61
+}
62
+
63
+
64
+/*
65
+ * Main content
66
+ */
67
+.main-panel {
68
+    padding: 20px;
69
+}
70
+@media (min-width: 768px) {
71
+    .main-panel {
72
+        padding-right: 40px;
73
+        padding-left: 40px;
74
+    }
75
+}
76
+
77
+
78
+/*
79
+ * Filtering field
80
+ */
81
+div.filter {
82
+    margin-bottom: 34px;
83
+}
84
+.filter-legend {
85
+    text-align: right;
86
+    line-height: 34px;
87
+}
88
+@media (max-width: 767px) {
89
+    .filter-legend {
90
+        text-align: center;
91
+    }
92
+}
93
+@media (min-width: 767px) {
94
+    .filter .form-group {
95
+        width: 75%;
96
+    }
97
+}
98
+
99
+
100
+/*
101
+ * Placeholder dashboard ideas
102
+ */
103
+.placeholders {
104
+    margin-bottom: 30px;
105
+    text-align: center;
106
+}
107
+.placeholders h4 {
108
+    margin-bottom: 0;
109
+}
110
+.placeholder img:hover {
111
+    transform: scale(1.1);
112
+    cursor: pointer;
113
+}
114
+
115
+
116
+/**
117
+ * Pager
118
+ */
119
+.pagination-nav {
120
+    text-align: center;
121
+}
122
+.pagination>li>span {
123
+    cursor: pointer;
124
+}
125
+
126
+/**
127
+ * Login screen
128
+ */
129
+.login h1 img {
130
+    height: 46px;
131
+}
132
+@media (max-width: 767px) {
133
+    .login .submit {
134
+        text-align: center;
135
+    }
136
+}
137
+
138
+/**
139
+ * Misc
140
+ */
141
+
142
+.art {
143
+    display: inline-block;
144
+    border-radius: 50%;
145
+    margin-bottom: .5em;
146
+    width: 75%;
147
+    height: auto;
148
+}
149
+
150
+
151
+.albumRow {
152
+    margin-top: 30px;
153
+}

+ 587
- 0
app/styles/bootstrap/bootstrap-theme.css View File

@@ -0,0 +1,587 @@
1
+/*!
2
+ * Bootstrap v3.3.6 (http://getbootstrap.com)
3
+ * Copyright 2011-2015 Twitter, Inc.
4
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
5
+ */
6
+.btn-default,
7
+.btn-primary,
8
+.btn-success,
9
+.btn-info,
10
+.btn-warning,
11
+.btn-danger {
12
+  text-shadow: 0 -1px 0 rgba(0, 0, 0, .2);
13
+  -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075);
14
+          box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075);
15
+}
16
+.btn-default:active,
17
+.btn-primary:active,
18
+.btn-success:active,
19
+.btn-info:active,
20
+.btn-warning:active,
21
+.btn-danger:active,
22
+.btn-default.active,
23
+.btn-primary.active,
24
+.btn-success.active,
25
+.btn-info.active,
26
+.btn-warning.active,
27
+.btn-danger.active {
28
+  -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
29
+          box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
30
+}
31
+.btn-default.disabled,
32
+.btn-primary.disabled,
33
+.btn-success.disabled,
34
+.btn-info.disabled,
35
+.btn-warning.disabled,
36
+.btn-danger.disabled,
37
+.btn-default[disabled],
38
+.btn-primary[disabled],
39
+.btn-success[disabled],
40
+.btn-info[disabled],
41
+.btn-warning[disabled],
42
+.btn-danger[disabled],
43
+fieldset[disabled] .btn-default,
44
+fieldset[disabled] .btn-primary,
45
+fieldset[disabled] .btn-success,
46
+fieldset[disabled] .btn-info,
47
+fieldset[disabled] .btn-warning,
48
+fieldset[disabled] .btn-danger {
49
+  -webkit-box-shadow: none;
50
+          box-shadow: none;
51
+}
52
+.btn-default .badge,
53
+.btn-primary .badge,
54
+.btn-success .badge,
55
+.btn-info .badge,
56
+.btn-warning .badge,
57
+.btn-danger .badge {
58
+  text-shadow: none;
59
+}
60
+.btn:active,
61
+.btn.active {
62
+  background-image: none;
63
+}
64
+.btn-default {
65
+  text-shadow: 0 1px 0 #fff;
66
+  background-image: -webkit-linear-gradient(top, #fff 0%, #e0e0e0 100%);
67
+  background-image:      -o-linear-gradient(top, #fff 0%, #e0e0e0 100%);
68
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#e0e0e0));
69
+  background-image:         linear-gradient(to bottom, #fff 0%, #e0e0e0 100%);
70
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);
71
+  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
72
+  background-repeat: repeat-x;
73
+  border-color: #dbdbdb;
74
+  border-color: #ccc;
75
+}
76
+.btn-default:hover,
77
+.btn-default:focus {
78
+  background-color: #e0e0e0;
79
+  background-position: 0 -15px;
80
+}
81
+.btn-default:active,
82
+.btn-default.active {
83
+  background-color: #e0e0e0;
84
+  border-color: #dbdbdb;
85
+}
86
+.btn-default.disabled,
87
+.btn-default[disabled],
88
+fieldset[disabled] .btn-default,
89
+.btn-default.disabled:hover,
90
+.btn-default[disabled]:hover,
91
+fieldset[disabled] .btn-default:hover,
92
+.btn-default.disabled:focus,
93
+.btn-default[disabled]:focus,
94
+fieldset[disabled] .btn-default:focus,
95
+.btn-default.disabled.focus,
96
+.btn-default[disabled].focus,
97
+fieldset[disabled] .btn-default.focus,
98
+.btn-default.disabled:active,
99
+.btn-default[disabled]:active,
100
+fieldset[disabled] .btn-default:active,
101
+.btn-default.disabled.active,
102
+.btn-default[disabled].active,
103
+fieldset[disabled] .btn-default.active {
104
+  background-color: #e0e0e0;
105
+  background-image: none;
106
+}
107
+.btn-primary {
108
+  background-image: -webkit-linear-gradient(top, #337ab7 0%, #265a88 100%);
109
+  background-image:      -o-linear-gradient(top, #337ab7 0%, #265a88 100%);
110
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#265a88));
111
+  background-image:         linear-gradient(to bottom, #337ab7 0%, #265a88 100%);
112
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);
113
+  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
114
+  background-repeat: repeat-x;
115
+  border-color: #245580;
116
+}
117
+.btn-primary:hover,
118
+.btn-primary:focus {
119
+  background-color: #265a88;
120
+  background-position: 0 -15px;
121
+}
122
+.btn-primary:active,
123
+.btn-primary.active {
124
+  background-color: #265a88;
125
+  border-color: #245580;
126
+}
127
+.btn-primary.disabled,
128
+.btn-primary[disabled],
129
+fieldset[disabled] .btn-primary,
130
+.btn-primary.disabled:hover,
131
+.btn-primary[disabled]:hover,
132
+fieldset[disabled] .btn-primary:hover,
133
+.btn-primary.disabled:focus,
134
+.btn-primary[disabled]:focus,
135
+fieldset[disabled] .btn-primary:focus,
136
+.btn-primary.disabled.focus,
137
+.btn-primary[disabled].focus,
138
+fieldset[disabled] .btn-primary.focus,
139
+.btn-primary.disabled:active,
140
+.btn-primary[disabled]:active,
141
+fieldset[disabled] .btn-primary:active,
142
+.btn-primary.disabled.active,
143
+.btn-primary[disabled].active,
144
+fieldset[disabled] .btn-primary.active {
145
+  background-color: #265a88;
146
+  background-image: none;
147
+}
148
+.btn-success {
149
+  background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%);
150
+  background-image:      -o-linear-gradient(top, #5cb85c 0%, #419641 100%);
151
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#419641));
152
+  background-image:         linear-gradient(to bottom, #5cb85c 0%, #419641 100%);
153
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);
154
+  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
155
+  background-repeat: repeat-x;
156
+  border-color: #3e8f3e;
157
+}
158
+.btn-success:hover,
159
+.btn-success:focus {
160
+  background-color: #419641;
161
+  background-position: 0 -15px;
162
+}
163
+.btn-success:active,
164
+.btn-success.active {
165
+  background-color: #419641;
166
+  border-color: #3e8f3e;
167
+}
168
+.btn-success.disabled,
169
+.btn-success[disabled],
170
+fieldset[disabled] .btn-success,
171
+.btn-success.disabled:hover,
172
+.btn-success[disabled]:hover,
173
+fieldset[disabled] .btn-success:hover,
174
+.btn-success.disabled:focus,
175
+.btn-success[disabled]:focus,
176
+fieldset[disabled] .btn-success:focus,
177
+.btn-success.disabled.focus,
178
+.btn-success[disabled].focus,
179
+fieldset[disabled] .btn-success.focus,
180
+.btn-success.disabled:active,
181
+.btn-success[disabled]:active,
182
+fieldset[disabled] .btn-success:active,
183
+.btn-success.disabled.active,
184
+.btn-success[disabled].active,
185
+fieldset[disabled] .btn-success.active {
186
+  background-color: #419641;
187
+  background-image: none;
188
+}
189
+.btn-info {
190
+  background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);
191
+  background-image:      -o-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);
192
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#2aabd2));
193
+  background-image:         linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%);
194
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);
195
+  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
196
+  background-repeat: repeat-x;
197
+  border-color: #28a4c9;
198
+}
199
+.btn-info:hover,
200
+.btn-info:focus {
201
+  background-color: #2aabd2;
202
+  background-position: 0 -15px;
203
+}
204
+.btn-info:active,
205
+.btn-info.active {
206
+  background-color: #2aabd2;
207
+  border-color: #28a4c9;
208
+}
209
+.btn-info.disabled,
210
+.btn-info[disabled],
211
+fieldset[disabled] .btn-info,
212
+.btn-info.disabled:hover,
213
+.btn-info[disabled]:hover,
214
+fieldset[disabled] .btn-info:hover,
215
+.btn-info.disabled:focus,
216
+.btn-info[disabled]:focus,
217
+fieldset[disabled] .btn-info:focus,
218
+.btn-info.disabled.focus,
219
+.btn-info[disabled].focus,
220
+fieldset[disabled] .btn-info.focus,
221
+.btn-info.disabled:active,
222
+.btn-info[disabled]:active,
223
+fieldset[disabled] .btn-info:active,
224
+.btn-info.disabled.active,
225
+.btn-info[disabled].active,
226
+fieldset[disabled] .btn-info.active {
227
+  background-color: #2aabd2;
228
+  background-image: none;
229
+}
230
+.btn-warning {
231
+  background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);
232
+  background-image:      -o-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);
233
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#eb9316));
234
+  background-image:         linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%);
235
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);
236
+  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
237
+  background-repeat: repeat-x;
238
+  border-color: #e38d13;
239
+}
240
+.btn-warning:hover,
241
+.btn-warning:focus {
242
+  background-color: #eb9316;
243
+  background-position: 0 -15px;
244
+}
245
+.btn-warning:active,
246
+.btn-warning.active {
247
+  background-color: #eb9316;
248
+  border-color: #e38d13;
249
+}
250
+.btn-warning.disabled,
251
+.btn-warning[disabled],
252
+fieldset[disabled] .btn-warning,
253
+.btn-warning.disabled:hover,
254
+.btn-warning[disabled]:hover,
255
+fieldset[disabled] .btn-warning:hover,
256
+.btn-warning.disabled:focus,
257
+.btn-warning[disabled]:focus,
258
+fieldset[disabled] .btn-warning:focus,
259
+.btn-warning.disabled.focus,
260
+.btn-warning[disabled].focus,
261
+fieldset[disabled] .btn-warning.focus,
262
+.btn-warning.disabled:active,
263
+.btn-warning[disabled]:active,
264
+fieldset[disabled] .btn-warning:active,
265
+.btn-warning.disabled.active,
266
+.btn-warning[disabled].active,
267
+fieldset[disabled] .btn-warning.active {
268
+  background-color: #eb9316;
269
+  background-image: none;
270
+}
271
+.btn-danger {
272
+  background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%);
273
+  background-image:      -o-linear-gradient(top, #d9534f 0%, #c12e2a 100%);
274
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c12e2a));
275
+  background-image:         linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%);
276
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);
277
+  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
278
+  background-repeat: repeat-x;
279
+  border-color: #b92c28;
280
+}
281
+.btn-danger:hover,
282
+.btn-danger:focus {
283
+  background-color: #c12e2a;
284
+  background-position: 0 -15px;
285
+}
286
+.btn-danger:active,
287
+.btn-danger.active {
288
+  background-color: #c12e2a;
289
+  border-color: #b92c28;
290
+}
291
+.btn-danger.disabled,
292
+.btn-danger[disabled],
293
+fieldset[disabled] .btn-danger,
294
+.btn-danger.disabled:hover,
295
+.btn-danger[disabled]:hover,
296
+fieldset[disabled] .btn-danger:hover,
297
+.btn-danger.disabled:focus,
298
+.btn-danger[disabled]:focus,
299
+fieldset[disabled] .btn-danger:focus,
300
+.btn-danger.disabled.focus,
301
+.btn-danger[disabled].focus,
302
+fieldset[disabled] .btn-danger.focus,
303
+.btn-danger.disabled:active,
304
+.btn-danger[disabled]:active,
305
+fieldset[disabled] .btn-danger:active,
306
+.btn-danger.disabled.active,
307
+.btn-danger[disabled].active,
308
+fieldset[disabled] .btn-danger.active {
309
+  background-color: #c12e2a;
310
+  background-image: none;
311
+}
312
+.thumbnail,
313
+.img-thumbnail {
314
+  -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
315
+          box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
316
+}
317
+.dropdown-menu > li > a:hover,
318
+.dropdown-menu > li > a:focus {
319
+  background-color: #e8e8e8;
320
+  background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
321
+  background-image:      -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
322
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8));
323
+  background-image:         linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);
324
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);
325
+  background-repeat: repeat-x;
326
+}
327
+.dropdown-menu > .active > a,
328
+.dropdown-menu > .active > a:hover,
329
+.dropdown-menu > .active > a:focus {
330
+  background-color: #2e6da4;
331
+  background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
332
+  background-image:      -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
333
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
334
+  background-image:         linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
335
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
336
+  background-repeat: repeat-x;
337
+}
338
+.navbar-default {
339
+  background-image: -webkit-linear-gradient(top, #fff 0%, #f8f8f8 100%);
340
+  background-image:      -o-linear-gradient(top, #fff 0%, #f8f8f8 100%);
341
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#f8f8f8));
342
+  background-image:         linear-gradient(to bottom, #fff 0%, #f8f8f8 100%);
343
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);
344
+  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
345
+  background-repeat: repeat-x;
346
+  border-radius: 4px;
347
+  -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075);
348
+          box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075);
349
+}
350
+.navbar-default .navbar-nav > .open > a,
351
+.navbar-default .navbar-nav > .active > a {
352
+  background-image: -webkit-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);
353
+  background-image:      -o-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);
354
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#dbdbdb), to(#e2e2e2));
355
+  background-image:         linear-gradient(to bottom, #dbdbdb 0%, #e2e2e2 100%);
356
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);
357
+  background-repeat: repeat-x;
358
+  -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075);
359
+          box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075);
360
+}
361
+.navbar-brand,
362
+.navbar-nav > li > a {
363
+  text-shadow: 0 1px 0 rgba(255, 255, 255, .25);
364
+}
365
+.navbar-inverse {
366
+  background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222 100%);
367
+  background-image:      -o-linear-gradient(top, #3c3c3c 0%, #222 100%);
368
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#3c3c3c), to(#222));
369
+  background-image:         linear-gradient(to bottom, #3c3c3c 0%, #222 100%);
370
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);
371
+  filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
372
+  background-repeat: repeat-x;
373
+  border-radius: 4px;
374
+}
375
+.navbar-inverse .navbar-nav > .open > a,
376
+.navbar-inverse .navbar-nav > .active > a {
377
+  background-image: -webkit-linear-gradient(top, #080808 0%, #0f0f0f 100%);
378
+  background-image:      -o-linear-gradient(top, #080808 0%, #0f0f0f 100%);
379
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#080808), to(#0f0f0f));
380
+  background-image:         linear-gradient(to bottom, #080808 0%, #0f0f0f 100%);
381
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);
382
+  background-repeat: repeat-x;
383
+  -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25);
384
+          box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25);
385
+}
386
+.navbar-inverse .navbar-brand,
387
+.navbar-inverse .navbar-nav > li > a {
388
+  text-shadow: 0 -1px 0 rgba(0, 0, 0, .25);
389
+}
390
+.navbar-static-top,
391
+.navbar-fixed-top,
392
+.navbar-fixed-bottom {
393
+  border-radius: 0;
394
+}
395
+@media (max-width: 767px) {
396
+  .navbar .navbar-nav .open .dropdown-menu > .active > a,
397
+  .navbar .navbar-nav .open .dropdown-menu > .active > a:hover,
398
+  .navbar .navbar-nav .open .dropdown-menu > .active > a:focus {
399
+    color: #fff;
400
+    background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
401
+    background-image:      -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);
402
+    background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4));
403
+    background-image:         linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);
404
+    filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);
405
+    background-repeat: repeat-x;
406
+  }
407
+}
408
+.alert {
409
+  text-shadow: 0 1px 0 rgba(255, 255, 255, .2);
410
+  -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05);
411
+          box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05);
412
+}
413
+.alert-success {
414
+  background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);
415
+  background-image:      -o-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);
416
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#c8e5bc));
417
+  background-image:         linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%);
418
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);
419
+  background-repeat: repeat-x;
420
+  border-color: #b2dba1;
421
+}
422
+.alert-info {
423
+  background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%);
424
+  background-image:      -o-linear-gradient(top, #d9edf7 0%, #b9def0 100%);
425
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#b9def0));
426
+  background-image:         linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%);
427
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);
428
+  background-repeat: repeat-x;
429
+  border-color: #9acfea;
430
+}
431
+.alert-warning {
432
+  background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);
433
+  background-image:      -o-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);
434
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#f8efc0));
435
+  background-image:         linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%);
436
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);
437
+  background-repeat: repeat-x;
438
+  border-color: #f5e79e;
439
+}
440
+.alert-danger {
441
+  background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);
442
+  background-image:      -o-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);
443
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#e7c3c3));
444
+  background-image:         linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%);
445
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);
446
+  background-repeat: repeat-x;
447
+  border-color: #dca7a7;
448
+}
449
+.progress {
450
+  background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);
451
+  background-image:      -o-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);
452
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#ebebeb), to(#f5f5f5));
453
+  background-image:         linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%);
454
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);
455
+  background-repeat: repeat-x;
456
+}
457
+.progress-bar {
458
+  background-image: -webkit-linear-gradient(top, #337ab7 0%, #286090 100%);
459
+  background-image:      -o-linear-gradient(top, #337ab7 0%, #286090 100%);
460
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#286090));
461
+  background-image:         linear-gradient(to bottom, #337ab7 0%, #286090 100%);
462
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);
463
+  background-repeat: repeat-x;
464
+}
465
+.progress-bar-success {
466
+  background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%);
467
+  background-image:      -o-linear-gradient(top, #5cb85c 0%, #449d44 100%);
468
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#449d44));
469
+  background-image:         linear-gradient(to bottom, #5cb85c 0%, #449d44 100%);
470
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);
471
+  background-repeat: repeat-x;
472
+}
473
+.progress-bar-info {
474
+  background-image: -webkit-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);
475
+  background-image:      -o-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);
476
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#31b0d5));
477
+  background-image:         linear-gradient(to bottom, #5bc0de 0%, #31b0d5 100%);
478
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);
479
+  background-repeat: repeat-x;
480
+}
481
+.progress-bar-warning {
482
+  background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);
483
+  background-image:      -o-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);
484
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#ec971f));
485
+  background-image:         linear-gradient(to bottom, #f0ad4e 0%, #ec971f 100%);
486
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);
487
+  background-repeat: repeat-x;
488
+}
489
+.progress-bar-danger {
490
+  background-image: -webkit-linear-gradient(top, #d9534f 0%, #c9302c 100%);
491
+  background-image:      -o-linear-gradient(top, #d9534f 0%, #c9302c 100%);
492
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c9302c));
493
+  background-image:         linear-gradient(to bottom, #d9534f 0%, #c9302c 100%);
494
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);
495
+  background-repeat: repeat-x;
496
+}
497
+.progress-bar-striped {
498
+  background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
499
+  background-image:      -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
500
+  background-image:         linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);
501
+}
502
+.list-group {
503
+  border-radius: 4px;
504
+  -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
505
+          box-shadow: 0 1px 2px rgba(0, 0, 0, .075);
506
+}
507
+.list-group-item.active,
508
+.list-group-item.active:hover,
509
+.list-group-item.active:focus {
510
+  text-shadow: 0 -1px 0 #286090;
511
+  background-image: -webkit-linear-gradient(top, #337ab7 0%, #2b669a 100%);
512
+  background-image:      -o-linear-gradient(top, #337ab7 0%, #2b669a 100%);
513
+  background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2b669a));
514
+  background-image:         linear-gradient(to bottom, #337ab7 0%, #2b669a 100%);
515
+  filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);
516
+  background-repeat: repeat-x;
517
+  border-color: #2b669a;
518
+}
519
+.list-group-item.active .badge,
520
+.list-group-item.active:hover .badge,
521
+.list-group-item.active:focus .badge {
522
+  text-shadow: none;
523
+}
524
+.panel {
525
+  -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .05);
526
+          box-shadow: 0 1px 2px rgba(0, 0, 0, .05);
527
+}
528
+.panel-default > .panel-heading {
529
+  background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);
530
+  background-image:      -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);