├── .editorconfig
├── .eslintrc.js
├── .gitignore
├── README.md
├── components
└── layout.js
├── nodemon.json
├── package.json
├── pages
├── album.js
├── artist.js
├── index.js
└── search.js
├── server.js
├── server
└── routes
│ └── apiRoutes.js
└── static
└── styles.css
/.editorconfig:
--------------------------------------------------------------------------------
1 | # editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
12 | [*.md]
13 | trim_trailing_whitespace = false
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "extends": [
3 | "eslint:recommended",
4 | "plugin:import/errors",
5 | "plugin:import/warnings"
6 | ],
7 | "parserOptions": {
8 | "ecmaVersion": 6,
9 | "sourceType": "module"
10 | },
11 | "env": {
12 | "es6": true,
13 | "browser": true,
14 | "node": true,
15 | "jquery": true
16 | },
17 | "rules": {
18 | "quotes": 0,
19 | "no-console": 1,
20 | "no-debugger": 1,
21 | "no-var": 1,
22 | "semi": [1, "always"],
23 | "no-trailing-spaces": 0,
24 | "eol-last": 0,
25 | "no-unused-vars": 0,
26 | "no-underscore-dangle": 0,
27 | "no-alert": 0,
28 | "no-lone-blocks": 0
29 | }
30 | };
31 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | node_modules
3 |
4 | # logs
5 | npm-debug.log
6 |
7 | # Next build
8 | .next
9 |
10 | # Nuxt generate
11 | dist
12 |
13 | .DS_Store
14 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Next.js + Express
2 |
3 | > Next.js project using Spotify API ([See demo](https://next-express-klelutfcnm.now.sh/)).
4 |
5 | ## Build Setup
6 |
7 | ``` bash
8 | # Dependencies
9 | $ npm install
10 |
11 | # Serve at at localhost:3000
12 | $ npm run dev
13 | ```
14 |
15 | ## Docs
16 |
17 | * [Next.js](https://zeit.co/blog/next)
18 | * [Now](https://zeit.co/now)
19 | * [React](https://facebook.github.io/react/)
20 | * [Express](http://expressjs.com/)
21 |
--------------------------------------------------------------------------------
/components/layout.js:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import Head from 'next/head';
3 |
4 | export default ({ children, title = 'Next.js / Express App' }) => (
5 |
6 |
7 |
{ title }
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
30 |
31 | { children }
32 |
33 |
34 |
37 |
38 |
39 |
40 |
41 |
42 | );
43 |
--------------------------------------------------------------------------------
/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "verbose": true,
3 | "ignore": ["node_modules", ".next"],
4 | "watch": [
5 | "server/routes/apiRoutes.js",
6 | "server.js"
7 | ],
8 | "ext": "js json"
9 | }
10 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next-express",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "server.js",
6 | "scripts": {
7 | "dev": "nodemon server.js",
8 | "build": "next build",
9 | "start": "NODE_ENV=production node server.js"
10 | },
11 | "author": "",
12 | "license": "ISC",
13 | "dependencies": {
14 | "async": "2.1.4",
15 | "axios": "0.15.3",
16 | "body-parser": "1.15.2",
17 | "compression": "1.6.2",
18 | "express": "^4.14.0",
19 | "express-session": "1.14.2",
20 | "jquery": "^3.1.1",
21 | "lodash": "^4.17.4",
22 | "moment": "^2.17.1",
23 | "next": "^2.0.0-beta",
24 | "path-match": "^1.2.4",
25 | "react-bootstrap": "^0.30.7",
26 | "react-if": "^2.1.0",
27 | "react-router": "^3.0.2",
28 | "superagent": "^3.4.0",
29 | "url": "^0.11.0"
30 | },
31 | "devDependencies": {
32 | "eslint": "3.13.1",
33 | "eslint-config-semistandard": "7.0.0",
34 | "eslint-config-standard": "6.2.1",
35 | "eslint-plugin-import": "2.2.0",
36 | "eslint-plugin-promise": "3.4.0",
37 | "eslint-plugin-standard": "2.0.1",
38 | "nodemon": "1.11.0"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/pages/album.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { If, Then, Else } from 'react-if';
3 | import Link from 'next/link';
4 |
5 | import axios from 'axios';
6 | import _ from 'lodash';
7 | import moment from 'moment';
8 |
9 | import Layout from '../components/layout';
10 |
11 | class AlbumPage extends React.Component {
12 | static getInitialProps ({ query: { id } }) {
13 | return { id };
14 | }
15 | constructor(props) {
16 | super(props);
17 | this.state = {
18 | loading: true,
19 | artist: null,
20 | album: null,
21 | };
22 | this.fetchData = this.fetchData.bind(this);
23 | this.getImage = this.getImage.bind(this);
24 | this.asDuration = this.asDuration.bind(this);
25 | }
26 | componentDidMount() {
27 | this.fetchData();
28 | }
29 | fetchData() {
30 | this.setState({loading: true}, () => {
31 | axios.get('/api/album/' + this.props.id)
32 | .then((response) => {
33 | this.setState({
34 | artist: response.data.artists[0],
35 | album: response.data,
36 | loading: false
37 | });
38 | })
39 | .catch((error) => {
40 | this.setState({loading: false});
41 | });
42 | });
43 | }
44 | getImage() {
45 | return _.get(this.state.album, 'images.0.url', 'http://placehold.it/640x640');
46 | }
47 | asDuration(milliseconds) {
48 | const min = moment.duration(milliseconds).get('minutes');
49 | const sec = moment.duration(milliseconds).get('seconds');
50 | return _.padStart(min, 2, '0') + ':' + _.padStart(sec, 2, '0');
51 | }
52 | renderTracksList() {
53 | const { loading, artist, album } = this.state;
54 | if (loading) {
55 | return (Chargement...
);
56 | } else {
57 | return (
58 |
59 |
60 | - Recherche
61 | - {artist.name}
62 | - {album.name}
63 |
64 |
65 |
Pistes
66 | {artist.name} - {album.name}
67 |
68 |
69 |
70 |
})
71 |
72 |
73 |
74 | {_.map(album.tracks.items, (track) =>
75 | -
76 | {track.track_number}. {track.name} {this.asDuration(track.duration_ms)}
77 |
78 | )}
79 |
80 |
81 |
82 |
83 | );
84 | }
85 | }
86 | render() {
87 | return (
88 |
89 |
90 | { this.renderTracksList() }
91 |
92 |
93 | );
94 | }
95 | }
96 |
97 | export default AlbumPage;
98 |
--------------------------------------------------------------------------------
/pages/artist.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { If, Then, Else } from 'react-if';
3 | import Link from 'next/link';
4 |
5 | import axios from 'axios';
6 | import _ from 'lodash';
7 |
8 | import Layout from '../components/layout';
9 |
10 | class ArtistPage extends React.Component {
11 | static getInitialProps ({ query: { id } }) {
12 | return { id };
13 | }
14 | constructor(props) {
15 | super(props);
16 | this.state = {
17 | loading: true,
18 | artist: {},
19 | albums: [],
20 | limit: 20,
21 | offset: 0,
22 | total: 0
23 | };
24 | this.fetchData = this.fetchData.bind(this);
25 | }
26 | componentDidMount() {
27 | this.fetchData();
28 | }
29 | fetchData() {
30 | this.setState({loading: true}, () => {
31 | axios.get('/api/artist/' + this.props.id, {
32 | params: {
33 | limit: this.state.limit,
34 | offset: this.state.offset
35 | }
36 | })
37 | .then((response) => {
38 | const { artist, albums } = response.data;
39 | this.setState({
40 | artist: artist,
41 | albums: albums.items,
42 | limit: albums.limit,
43 | offset: albums.offset,
44 | total: albums.total,
45 | loading: false
46 | });
47 | })
48 | .catch((error) => {
49 | this.setState({loading: false});
50 | });
51 | });
52 | }
53 | renderAlbumsList() {
54 | const { loading, artist, albums, limit, total } = this.state;
55 | if (loading) {
56 | return (Chargement...
);
57 | } else {
58 | return (
59 |
60 |
61 | - Recherche
62 | - {artist.name}
63 |
64 |
65 |
Albums
66 | {artist.name}
67 |
68 |
69 |
70 | {_.map(albums, (album) =>
71 |
72 |
73 |
74 |

75 |
76 |
77 |
{album.name}
78 |
79 |
80 |
81 | )}
82 |
83 |
84 |
85 | );
86 | }
87 | }
88 | render() {
89 | return (
90 |
91 |
92 | { this.renderAlbumsList() }
93 |
94 |
95 | );
96 | }
97 | }
98 |
99 | export default ArtistPage;
100 |
--------------------------------------------------------------------------------
/pages/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Link from 'next/link';
3 |
4 | import Layout from '../components/layout';
5 |
6 | export default () => {
7 | return (
8 |
9 |
10 |
11 |
Next.js + Express
12 |
A simple app using Spotify API
13 |
14 | Use it !
15 |
16 |
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/pages/search.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { If, Then, Else } from 'react-if';
3 | import { Pagination } from 'react-bootstrap';
4 | import Link from 'next/link';
5 |
6 | import axios from 'axios';
7 | import _ from 'lodash';
8 |
9 | import Layout from '../components/layout';
10 |
11 | class SearchPage extends React.Component {
12 | constructor(props) {
13 | super(props);
14 | this.state = {
15 | loading: false,
16 | empty: false,
17 | query: '',
18 | artists: [],
19 | limit: 5,
20 | page: 1,
21 | total: -1,
22 | };
23 | this.handleChange = this.handleChange.bind(this);
24 | this.handleSelect = this.handleSelect.bind(this);
25 | this.handleSubmit = this.handleSubmit.bind(this);
26 | this.fetchData = this.fetchData.bind(this);
27 | this.getArtistThumbnail = this.getArtistThumbnail.bind(this);
28 | this.getArtistGenres = this.getArtistGenres.bind(this);
29 | this.renderResultsList = this.renderResultsList.bind(this);
30 | }
31 | handleChange(event) {
32 | let obj = {};
33 | obj[event.target.name] = event.target.value;
34 | this.setState(obj);
35 | }
36 | handleSelect(page) {
37 | this.setState({page: page}, this.fetchData);
38 | }
39 | handleSubmit(event) {
40 | event.stopPropagation();
41 | event.preventDefault();
42 | this.setState({page: 1}, this.fetchData);
43 | }
44 | fetchData() {
45 | if (this.state.query.length > 3) {
46 | this.setState({loading: true}, () => {
47 | axios.get('/api/search', {
48 | params: {
49 | query: this.state.query,
50 | limit: this.state.limit,
51 | offset: this.state.page - 1
52 | }
53 | })
54 | .then((response) => {
55 | const { artists } = response.data;
56 | this.setState({
57 | artists: artists.items,
58 | limit: artists.limit,
59 | page: artists.offset + 1,
60 | total: artists.total,
61 | empty: artists.total === 0,
62 | loading: false
63 | });
64 | })
65 | .catch((error) => {
66 | this.setState({loading: false});
67 | });
68 | });
69 | }
70 | }
71 | getArtistThumbnail(artist) {
72 | if (!_.has(artist, 'images')) {
73 | return 'http://placehold.it/64x64';
74 | }
75 | return _.get(_.last(artist.images), 'url', 'http://placehold.it/64x64');
76 | }
77 | getArtistGenres(artist) {
78 | return _.join(artist.genres, ', ');
79 | }
80 | renderResultsList() {
81 | const { loading, empty, query, artists, limit, page, total } = this.state;
82 | if (loading || empty) {
83 | let alert;
84 | if (loading) {
85 | alert = (Recherche en cours...
);
86 | } else if (empty) {
87 | alert = (Aucun résultat correspondant à votre recherche { query }.
);
88 | }
89 | return (
90 |
91 | { alert }
92 |
93 | );
94 | }
95 | let pagination = '';
96 | const list = _.map(artists, (artist) => {
97 | return (
98 |
99 |
100 |
101 |
})
102 |
103 |
104 |
111 |
112 | );
113 | });
114 |
115 | if (total > limit) {
116 | pagination = (
117 |
126 | );
127 | }
128 |
129 | return (
130 |
131 | { list }
132 | { pagination }
133 |
134 | );
135 | }
136 | render() {
137 | return (
138 |
139 |
140 |
141 |
Artistes
142 |
143 |
144 |
Rechercher un artiste Spotify
145 |
159 |
160 |
161 | { this.renderResultsList() }
162 |
163 | );
164 | }
165 | }
166 |
167 | export default SearchPage;
168 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const bodyParser = require('body-parser');
3 | const session = require('express-session');
4 | const path = require('path');
5 |
6 | const dev = process.env.NODE_ENV !== 'production';
7 | const next = require('next');
8 | const pathMatch = require('path-match');
9 | const app = next({ dev });
10 | const handle = app.getRequestHandler();
11 | const { parse } = require('url');
12 |
13 | const apiRoutes = require('./server/routes/apiRoutes.js');
14 |
15 | app.prepare().then(() => {
16 | const server = express();
17 |
18 | server.use(bodyParser.json());
19 | server.use(session({
20 | secret: 'super-secret-key',
21 | resave: false,
22 | saveUninitialized: false,
23 | cookie: { maxAge: 60000 }
24 | }));
25 |
26 | server.use('/api', apiRoutes);
27 |
28 | // Server-side
29 | const route = pathMatch();
30 |
31 | server.get('/search', (req, res) => {
32 | return app.render(req, res, '/search', req.query);
33 | });
34 |
35 | server.get('/artist/:id', (req, res) => {
36 | const params = route('/artist/:id')(parse(req.url).pathname);
37 | return app.render(req, res, '/artist', params);
38 | });
39 |
40 | server.get('/album/:id', (req, res) => {
41 | const params = route('/album/:id')(parse(req.url).pathname);
42 | return app.render(req, res, '/album', params);
43 | });
44 |
45 | server.get('*', (req, res) => {
46 | return handle(req, res);
47 | });
48 |
49 | /* eslint-disable no-console */
50 | server.listen(3000, (err) => {
51 | if (err) throw err;
52 | console.log('Server ready on http://localhost:3000');
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/server/routes/apiRoutes.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const router = express.Router();
3 |
4 | const request = require('superagent');
5 | const async = require('async');
6 |
7 | router.get('/search', (req, res) => {
8 | request
9 | .get('https://api.spotify.com/v1/search')
10 | .set('Content-Type', 'application/json')
11 | .query({
12 | type: 'artist',
13 | q: req.query.query,
14 | limit: req.query.limit,
15 | offset: req.query.offset
16 | })
17 | .end((err, response) => {
18 | if (err) {
19 | return res.status(500).json(err);
20 | }
21 | res.status(200).json(response.body);
22 | });
23 | });
24 |
25 | router.get('/artist/:id', (req, res) => {
26 | async.auto({
27 | artist: function(callback) {
28 | request
29 | .get('https://api.spotify.com/v1/artists/' + req.params.id)
30 | .end((err, response) => callback(err, response.body));
31 | },
32 | albums: ['artist', (results, callback) => {
33 | request
34 | .get('https://api.spotify.com/v1/artists/' + req.params.id + '/albums')
35 | .query({
36 | album_type: 'album',
37 | limit: req.query.limit,
38 | offset: req.query.offset
39 | })
40 | .end((err, response) => {
41 | callback(err, {
42 | 'items': response.body.items,
43 | 'limit': response.body.limit,
44 | 'offset': response.body.offset,
45 | 'total': response.body.total
46 | });
47 | });
48 | }]
49 | }, (err, results) => {
50 | if (err) {
51 | return res.status(500).json(err);
52 | }
53 | res.status(200).json(results);
54 | });
55 | });
56 |
57 | router.get('/album/:id', (req, res) => {
58 | request
59 | .get('https://api.spotify.com/v1/albums/' + req.params.id)
60 | .end((err, response) => {
61 | if (err) {
62 | return res.status(500).json(err);
63 | }
64 | res.status(200).json(response.body);
65 | });
66 | });
67 |
68 | module.exports = router;
69 |
--------------------------------------------------------------------------------
/static/styles.css:
--------------------------------------------------------------------------------
1 | body {
2 | padding-top: 70px;
3 | }
4 | .table .btn-link {
5 | padding: 0px 12px;
6 | }
7 | .panel-footer .pagination {
8 | margin: 0;
9 | }
10 | .footer {
11 | padding: 15px 0;
12 | margin-top: 50px;
13 | border-top: 1px solid #ddd;
14 | }
15 |
--------------------------------------------------------------------------------