├── .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 |
35 | © 2017 - mluberry 36 |
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 |
  1. Recherche
  2. 61 |
  3. {artist.name}
  4. 62 |
  5. {album.name}
  6. 63 |
64 |
65 |

Pistes

66 |

{artist.name} - {album.name}

67 |
68 |
69 |
70 | {album.name} 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 |
  1. Recherche
  2. 62 |
  3. {artist.name}
  4. 63 |
64 |
65 |

Albums

66 |

{artist.name}

67 |
68 |
69 |
70 | {_.map(albums, (album) => 71 |
72 |
73 | 74 | {album.name} 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 |
105 |

106 | {artist.name} 107 |

108 | {this.getArtistGenres(artist)}
109 | {artist.external_urls.spotify} 110 |
111 |
112 | ); 113 | }); 114 | 115 | if (total > limit) { 116 | pagination = ( 117 |
118 | 125 |
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 |
146 |
147 |
148 | 155 |
156 | 157 |
158 |
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 | --------------------------------------------------------------------------------