├── .editorconfig ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── api └── index.js ├── assets ├── README.md ├── spotify.svg └── twitter.svg ├── components ├── Footer.vue ├── Header.vue ├── NowPlaying.vue ├── Progress.vue └── README.md ├── docker-compose.yml ├── layouts ├── README.md └── default.vue ├── middleware └── README.md ├── nuxt.config.js ├── package.json ├── pages ├── README.md ├── auth.vue └── index.vue ├── plugins └── README.md ├── static ├── README.md └── favicon.png └── store ├── README.md └── index.js /.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 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # parcel-bundler cache (https://parceljs.org/) 63 | .cache 64 | 65 | # next.js build output 66 | .next 67 | 68 | # nuxt.js build output 69 | .nuxt 70 | 71 | # Nuxt generate 72 | dist 73 | 74 | # vuepress build output 75 | .vuepress/dist 76 | 77 | # Serverless directories 78 | .serverless 79 | 80 | # IDE 81 | .idea 82 | 83 | # Service worker 84 | sw.* 85 | 86 | .DS_Store -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Cher Scarlett 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cher Is Listening 2 | 3 | > A Spotify Now Playing App 4 | 5 | [Demo](http://cherislistening.herokuapp.com/) 6 | 7 | [Tutorial](https://www.smashingmagazine.com/2019/03/spotify-app-vue-nuxt-javascript/) 8 | 9 | ## Build Setup 10 | 11 | ```bash 12 | # install dependencies 13 | $ npm install 14 | 15 | # serve with hot reload at localhost:3000 16 | $ npm run dev 17 | 18 | # build for production and launch server 19 | $ npm run build 20 | $ npm start 21 | 22 | # generate static project 23 | $ npm run generate 24 | ``` 25 | 26 | For detailed explanation on how things work, checkout [Nuxt.js docs](https://nuxtjs.org). 27 | -------------------------------------------------------------------------------- /api/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import redis from 'async-redis' 3 | import axios from 'axios' 4 | 5 | require('dotenv').config() 6 | 7 | const app = express() 8 | app.use(express.json()) 9 | 10 | // Redis 11 | 12 | function connectToRedis() { 13 | const redisClient = redis.createClient(process.env.REDIS_URL) 14 | redisClient.on('connect', () => { 15 | console.log('\n🎉 Redis client connected 🎉\n') 16 | }) 17 | redisClient.on('error', err => { 18 | console.error(`\n🚨 Redis client could not connect: ${err} 🚨\n`) 19 | }) 20 | return redisClient 21 | } 22 | 23 | // Express app 24 | 25 | app.all('/spotify/data/:key', async ({ params: { key }, query }, res) => { 26 | try { 27 | if (key === ('refresh_token' || 'access_token')) 28 | throw { error: '🔒 Cannot get protected stores. 🔒' } 29 | const { value } = query 30 | const reply = await callStorage(...storageArgs(key, { value })) 31 | res.send({ [key]: reply }) 32 | } catch (err) { 33 | console.error(`\n🚨 There was an error at /api/spotify/data: ${err} 🚨\n`) 34 | res.send(err) 35 | } 36 | }) 37 | 38 | function storageArgs(key, props) { 39 | const { expires, body, value } = props 40 | const val = Boolean(body) ? JSON.stringify(body) : value 41 | return [ 42 | Boolean(val) ? 'set' : 'get', 43 | key, 44 | val, 45 | Boolean(expires) ? 'EX' : null, 46 | expires 47 | ].filter(arg => Boolean(arg)) 48 | } 49 | 50 | async function callStorage(method, ...args) { 51 | const redisClient = connectToRedis() 52 | const response = await redisClient[method](...args) 53 | redisClient.quit() 54 | return response 55 | } 56 | 57 | app.get('/spotify/callback', async ({ query: { code } }, res) => { 58 | try { 59 | const { data } = await getSpotifyToken({ 60 | code, 61 | grant_type: 'authorization_code' 62 | }) 63 | const { access_token, refresh_token, expires_in } = data 64 | const { 65 | data: { id } 66 | } = await getUserData(access_token) 67 | 68 | if (id !== process.env.SPOTIFY_USER_ID) 69 | throw "🤖 You aren't the droid we're looking for. 🤖" 70 | 71 | callStorage(...storageArgs('is_connected', { value: true })) 72 | callStorage(...storageArgs('refresh_token', { value: refresh_token })) 73 | callStorage( 74 | ...storageArgs('access_token', { 75 | value: access_token, 76 | expires: expires_in 77 | }) 78 | ) 79 | 80 | const success = '🎉 Welcome Back 🎉' 81 | res.redirect(`/auth?success=${success}`) 82 | } catch (err) { 83 | console.error( 84 | `\n🚨 There was an error at /api/spotify/callback: ${err} 🚨\n` 85 | ) 86 | res.redirect(`/auth?message=${err}`) 87 | } 88 | }) 89 | 90 | const getSpotifyToken = (props = {}) => 91 | axios({ 92 | method: 'post', 93 | url: 'https://accounts.spotify.com/api/token', 94 | params: { 95 | client_id: process.env.SPOTIFY_CLIENT_ID, 96 | client_secret: process.env.SPOTIFY_CLIENT_SECRET, 97 | redirect_uri: `${process.env.CLIENT_URL}/api/spotify/callback`, 98 | ...props 99 | }, 100 | headers: { 101 | 'Content-Type': 'application/x-www-form-urlencoded' 102 | } 103 | }) 104 | 105 | const spotifyBaseUrl = 'https://api.spotify.com/v1/' 106 | 107 | const getUserData = access_token => 108 | axios.get(`${spotifyBaseUrl}me`, { 109 | headers: { 110 | withCredentials: true, 111 | Authorization: `Bearer ${access_token}` 112 | } 113 | }) 114 | 115 | async function getAccessToken() { 116 | const redisClient = connectToRedis() 117 | const accessTokenObj = { value: await redisClient.get('access_token') } 118 | if (!Boolean(accessTokenObj.value)) { 119 | const refresh_token = await redisClient.get('refresh_token') 120 | const { 121 | data: { access_token, expires_in } 122 | } = await getSpotifyToken({ 123 | refresh_token, 124 | grant_type: 'refresh_token' 125 | }) 126 | Object.assign(accessTokenObj, { 127 | value: access_token, 128 | expires: expires_in 129 | }) 130 | callStorage(...storageArgs('access_token', { ...accessTokenObj })) 131 | } 132 | redisClient.quit() 133 | return accessTokenObj.value 134 | } 135 | 136 | app.get('/spotify/now-playing/', async (req, res) => { 137 | try { 138 | const access_token = await getAccessToken() 139 | const response = await axios.get( 140 | `${spotifyBaseUrl}me/player/currently-playing?market=US`, 141 | { 142 | headers: { 143 | withCredentials: true, 144 | Authorization: `Bearer ${access_token}` 145 | } 146 | } 147 | ) 148 | const { data } = response 149 | setLastPlayed(access_token, data) 150 | const reply = await callStorage('get', 'last_played') 151 | res.send({ 152 | item: JSON.parse(reply), 153 | is_playing: Boolean(data.is_playing), 154 | progress_ms: data.progress_ms || 0 155 | }) 156 | } catch (err) { 157 | console.error(err) 158 | res.send({ error: err.message }) 159 | } 160 | }) 161 | 162 | async function setLastPlayed(access_token, item) { 163 | if (!Boolean(item)) { 164 | const { data } = await axios.get( 165 | `${spotifyBaseUrl}me/player/recently-played?market=US`, 166 | { 167 | headers: { 168 | withCredentials: true, 169 | Authorization: `Bearer ${access_token}` 170 | } 171 | } 172 | ) 173 | postStoredTrack(data.items[0].track) 174 | } else { 175 | postStoredTrack(item.item) 176 | } 177 | } 178 | 179 | function postStoredTrack(props) { 180 | callStorage( 181 | ...storageArgs('last_played', { 182 | body: props 183 | }) 184 | ) 185 | } 186 | 187 | module.exports = { 188 | path: '/api/', 189 | handler: app 190 | } 191 | -------------------------------------------------------------------------------- /assets/README.md: -------------------------------------------------------------------------------- 1 | # ASSETS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your un-compiled assets such as LESS, SASS, or JavaScript. 6 | 7 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#webpacked). 8 | -------------------------------------------------------------------------------- /assets/spotify.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /assets/twitter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /components/Footer.vue: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /components/Header.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{$nuxt.layout && $nuxt.layout.authorName}} {{ $nuxt.layout && $nuxt.layout.titleShort }} 5 | 6 | 7 | 8 | 9 | 10 | 15 | 16 | 53 | -------------------------------------------------------------------------------- /components/NowPlaying.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | {{name}} 10 | {{artistsList}} 11 | 12 | {{$nuxt.layout && $nuxt.layout.authorName}} {{ status }}. 13 | Listen? 14 | 15 | 16 | 17 | 18 | 19 | 20 | 121 | 122 | -------------------------------------------------------------------------------- /components/Progress.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 29 | 30 | 31 | 32 | 33 | 38 | 39 | 82 | -------------------------------------------------------------------------------- /components/README.md: -------------------------------------------------------------------------------- 1 | # COMPONENTS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | The components directory contains your Vue.js Components. 6 | 7 | _Nuxt.js doesn't supercharge these components._ 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | nuxt: 5 | image: node:latest 6 | command: npm run dev 7 | working_dir: /app 8 | ports: 9 | - 3000: 10 | environment: 11 | - NUXT_HOST=0.0.0.0 12 | - NUXT_PORT=3000 13 | - REDIS_URL=redis://redis:6379 14 | - CLIENT_URL=http://localhost:3000 15 | volumes: 16 | - .:/app 17 | depends_on: 18 | - redis 19 | networks: 20 | default: 21 | internal: 22 | 23 | redis: 24 | image: redis:latest 25 | ports: 26 | - 6379:6379 27 | networks: 28 | internal: 29 | 30 | networks: 31 | default: 32 | internal: 33 | internal: true 34 | -------------------------------------------------------------------------------- /layouts/README.md: -------------------------------------------------------------------------------- 1 | # LAYOUTS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your Application Layouts. 6 | 7 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/views#layouts). 8 | -------------------------------------------------------------------------------- /layouts/default.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 46 | 47 | 176 | -------------------------------------------------------------------------------- /middleware/README.md: -------------------------------------------------------------------------------- 1 | # MIDDLEWARE 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your application middleware. 6 | Middleware let you define custom functions that can be run before rendering either a page or a group of pages. 7 | 8 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing#middleware). 9 | -------------------------------------------------------------------------------- /nuxt.config.js: -------------------------------------------------------------------------------- 1 | const pkg = require('./package') 2 | 3 | module.exports = { 4 | mode: 'universal', 5 | 6 | /* 7 | ** Headers of the page 8 | */ 9 | head: { 10 | title: 'Now Playing on Spotify', 11 | meta: [ 12 | { charset: 'utf-8' }, 13 | { name: 'viewport', content: 'width=device-width, initial-scale=1' }, 14 | { hid: 'description', name: 'description', content: pkg.description } 15 | ], 16 | link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.png' }] 17 | }, 18 | 19 | /* 20 | ** Customize the progress-bar color 21 | */ 22 | loading: { color: '#fff' }, 23 | 24 | /* 25 | ** Global CSS 26 | */ 27 | css: [], 28 | 29 | /* 30 | ** Plugins to load before mounting the App 31 | */ 32 | plugins: [], 33 | 34 | /* 35 | ** Nuxt.js modules 36 | */ 37 | modules: [ 38 | // Doc: https://axios.nuxtjs.org/usage 39 | '@nuxtjs/axios', 40 | '@nuxtjs/dotenv' 41 | ], 42 | /* 43 | ** Axios module configuration 44 | */ 45 | axios: { 46 | // See https://github.com/nuxt-community/axios-module#options 47 | }, 48 | 49 | /* 50 | ** Build configuration 51 | */ 52 | build: { 53 | watch: ['api'], 54 | /* 55 | ** You can extend webpack config here 56 | */ 57 | extend(config, ctx) {} 58 | }, 59 | serverMiddleware: ['~/api'], 60 | env: { 61 | spotifyId: process.env.SPOTIFY_CLIENT_ID, 62 | clientUrl: process.env.CLIENT_URL 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cherislistening", 3 | "version": "1.0.0", 4 | "description": "A Spotify Now Playing App", 5 | "author": "Cher Scarlett", 6 | "private": true, 7 | "scripts": { 8 | "dev": "nodemon --watch api --exec \"nuxt\"", 9 | "build": "nuxt build", 10 | "start": "nuxt start", 11 | "generate": "nuxt generate", 12 | "heroku-postbuild": "npm run build" 13 | }, 14 | "dependencies": { 15 | "@nuxtjs/axios": "^5.3.6", 16 | "@nuxtjs/dotenv": "^1.3.0", 17 | "async-redis": "^1.1.5", 18 | "cross-env": "^5.2.0", 19 | "express": "^4.18.1", 20 | "nuxt": "^2.3.4" 21 | }, 22 | "devDependencies": { 23 | "eslint-config-prettier": "^3.1.0", 24 | "eslint-plugin-prettier": "2.6.2", 25 | "nodemon": "^1.18.9", 26 | "prettier": "1.14.3" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /pages/README.md: -------------------------------------------------------------------------------- 1 | # PAGES 2 | 3 | This directory contains your Application Views and Routes. 4 | The framework reads all the `*.vue` files inside this directory and creates the router of your application. 5 | 6 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing). 7 | -------------------------------------------------------------------------------- /pages/auth.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Close 9 | {{message}} 10 | 11 | 12 | 13 | 14 | 53 | 54 | 104 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 😭 {{ $nuxt.layout && $nuxt.layout.authorName }} hasn't connected yet. 😭 6 | Nudge her 9 | 10 | 11 | 12 | 13 | 41 | 42 | 59 | -------------------------------------------------------------------------------- /plugins/README.md: -------------------------------------------------------------------------------- 1 | # PLUGINS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains Javascript plugins that you want to run before mounting the root Vue.js application. 6 | 7 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/plugins). 8 | -------------------------------------------------------------------------------- /static/README.md: -------------------------------------------------------------------------------- 1 | # STATIC 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your static files. 6 | Each file inside this directory is mapped to `/`. 7 | 8 | Example: `/static/robots.txt` is mapped as `/robots.txt`. 9 | 10 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#static). 11 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cherscarlett/cherislistening/e0d1f7355a4e370e1e0bf5117651385f7adb4b4f/static/favicon.png -------------------------------------------------------------------------------- /store/README.md: -------------------------------------------------------------------------------- 1 | # STORE 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your Vuex Store files. 6 | Vuex Store option is implemented in the Nuxt.js framework. 7 | 8 | Creating a file in this directory automatically activates the option in the framework. 9 | 10 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/vuex-store). 11 | -------------------------------------------------------------------------------- /store/index.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | const clientUrl = process.env.CLIENT_URL 4 | 5 | export const state = () => ({ 6 | isConnected: false, 7 | message: null, 8 | nowPlaying: {}, 9 | recentlyPlayed: {}, 10 | trackProgress: 0, 11 | isPlaying: false 12 | }) 13 | 14 | export const mutations = { 15 | connectionChange(state, isConnected) { 16 | state.isConnected = isConnected 17 | }, 18 | updateMessage(state, message) { 19 | state.message = message 20 | }, 21 | nowPlayingChange(state, nowPlaying) { 22 | state.nowPlaying = nowPlaying 23 | }, 24 | isPlayingChange(state, isPlaying) { 25 | state.isPlaying = isPlaying 26 | }, 27 | progressChange(state, { progress, duration }) { 28 | state.trackProgress = (progress / duration) * 100 29 | }, 30 | recentlyPlayedChange(state, recentlyPlayed) { 31 | state.recentlyPlayed = recentlyPlayed 32 | } 33 | } 34 | export const actions = { 35 | async nuxtServerInit({ commit }) { 36 | try { 37 | const redisUrl = `${clientUrl}/api/spotify/data/` 38 | const { 39 | data: { is_connected } 40 | } = await axios.get(`${redisUrl}is_connected`) 41 | 42 | commit('connectionChange', is_connected) 43 | 44 | if (Boolean(is_connected)) { 45 | const { 46 | data: { item, is_playing } 47 | } = await axios.get(`${clientUrl}/api/spotify/now-playing`) 48 | commit('nowPlayingChange', item) 49 | commit('isPlayingChange', is_playing) 50 | } 51 | } catch (err) { 52 | console.error(err) 53 | } 54 | }, 55 | updateProgress: ({ commit, state }, props) => { 56 | commit('progressChange', props) 57 | return state.trackProgress 58 | }, 59 | updateTrack: ({ commit, state }, nowPlaying) => { 60 | commit('nowPlayingChange', nowPlaying) 61 | return state.nowPlaying 62 | }, 63 | updateStatus: ({ commit, state }, isPlaying) => { 64 | commit('isPlayingChange', isPlaying) 65 | return state.isPlaying 66 | }, 67 | updateConnection: ({ commit, state }, isConnected) => { 68 | commit('connectionChange', isConnected) 69 | return state.isConnected 70 | } 71 | } 72 | --------------------------------------------------------------------------------
{{artistsList}}
12 | {{$nuxt.layout && $nuxt.layout.authorName}} {{ status }}. 13 | Listen? 14 |
5 | 😭 {{ $nuxt.layout && $nuxt.layout.authorName }} hasn't connected yet. 😭 6 | Nudge her 9 |