diff --git a/.circleci/config.yml b/.circleci/config.yml index d282729..918c063 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -4,6 +4,7 @@ jobs: working_directory: ~/origin-spacebox docker: - image: circleci/node:8.11.0 + - image: mongo:4.0.3 steps: - checkout - run: @@ -16,10 +17,12 @@ jobs: - run: name: Mocha Test Suite command: 'npm run test' + deploy-job: working_directory: ~/origin-spacebox docker: - image: circleci/node:8.11.0 + - image: mongo:4.0.3 steps: - checkout - run: diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3ee22e5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# http://editorconfig.org + +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.eslintignore b/.eslintignore old mode 100755 new mode 100644 index d636ff1..1248ed9 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,4 @@ +/client/ /node_modules /build .vscode diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..a6e5297 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": "loopback" +} \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100755 index 08a27e6..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "airbnb", - "env": { - "browser": true, - "node":true - } -} \ No newline at end of file diff --git a/.gitignore b/.gitignore index b885285..f654410 100755 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,7 @@ package-lock.json npm-debug.log* yarn-debug.log* yarn-error.log* -.env \ No newline at end of file +.env +Mockup.sketch +dist + diff --git a/.yo-rc.json b/.yo-rc.json new file mode 100644 index 0000000..02f3fc1 --- /dev/null +++ b/.yo-rc.json @@ -0,0 +1,3 @@ +{ + "generator-loopback": {} +} \ No newline at end of file diff --git a/README.md b/README.md old mode 100755 new mode 100644 diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..dd00c9e --- /dev/null +++ b/client/README.md @@ -0,0 +1,3 @@ +## Client + +This is the place for your application front-end files. diff --git a/public/index.html b/client/index.html similarity index 100% rename from public/index.html rename to client/index.html diff --git a/public/moon.jpeg b/client/moon.jpeg similarity index 100% rename from public/moon.jpeg rename to client/moon.jpeg diff --git a/common/models/queue.js b/common/models/queue.js new file mode 100644 index 0000000..9897796 --- /dev/null +++ b/common/models/queue.js @@ -0,0 +1,137 @@ +'use strict'; +const { getPlaylist, updatePlaylist } = require('../../server/utils/playlist'); +const { addToDefaultSongs } = require('../../server/utils/adminQueue'); +const { playCurrentSong, pauseCurrentSong } = require('../../server/utils/player'); + +module.exports = function (Queue) { + Queue.getPlaylist = function (id, cb) { + getPlaylist(id) + .then((tracks) => cb(null, tracks)) + .catch(err => cb(err)); + } + + Queue.remoteMethod('getPlaylist', { + description: 'Gets current playlist from Spotify', + accepts: { + arg: 'id', + type: 'string' + }, + http: { + path: '/getPlaylist', + verb: 'get' + }, + returns: { + arg: 'data', + type: 'array', + root: true + }, + }); + + Queue.updatePlaylist = function (id, songID, cb) { + updatePlaylist(id, songID) + .then((queue) => { + Queue.replaceOrCreate(queue, cb); + }) + .catch(err => cb(err)); + } + + Queue.remoteMethod('updatePlaylist', { + description: 'Adds new song to playlist and removes current playing song from playlist. Adds default songs if needed.', + accepts: [{ + arg: 'id', + type: 'string' + }, + { + arg: 'songID', + type: 'string', + required: false + }], + http: { + path: '/updatePlaylist', + verb: 'put' + }, + returns: { + arg: 'data', + type: 'array', + root: true + }, + }); + + Queue.addToDefaultSongs = function (id, uri, cb) { + addToDefaultSongs(id, uri) + .then((queue) => { + Queue.replaceOrCreate(queue, cb); + }) + .catch(err => cb(err)); + }; + + Queue.remoteMethod('addToDefaultSongs', { + description: 'Adds new song to default song array', + accepts: [ + { + arg: 'id', + type: 'string' + }, + { + arg: 'uri', + type: 'string' + }], + http: { + path: '/addToDefaultSongs', + verb: 'post' + }, + returns: { + arg: 'data', + type: 'array', + root: true + }, + }); + + Queue.pauseCurrentSong = function (id, cb) { + pauseCurrentSong(id) + .then(response => cb(null, response)) + .catch(err => cb(err)); + }; + + Queue.remoteMethod('pauseCurrentSong', { + description: 'Pauses currently playing song', + accepts: { + arg: 'id', + type: 'string' + }, + http: { + path: '/pauseCurrentSong', + verb: 'get' + }, + returns: { + arg: 'data', + type: 'array', + root: true + }, + }); + + Queue.playCurrentSong = function (id, cb) { + playCurrentSong(id) + .then(response => cb(null, response)) + .catch(err => cb(err)); + }; + + Queue.remoteMethod('playCurrentSong', { + description: 'Starts/resumes currently playing song', + accepts: { + arg: 'id', + type: 'string' + }, + http: { + path: '/playCurrentSong', + verb: 'get' + }, + returns: { + arg: 'data', + type: 'array', + root: true + }, + }); + +}; + diff --git a/common/models/queue.json b/common/models/queue.json new file mode 100644 index 0000000..f754893 --- /dev/null +++ b/common/models/queue.json @@ -0,0 +1,42 @@ +{ + "name": "Queue", + "base": "PersistedModel", + "idInjection": true, + "options": { + "validateUpsert": true + }, + "properties": { + "defaultSongs": { + "type": [ + "object" + ], + "required": true + } + }, + "validations": [], + "relations": { + "default": { + "type": "referencesMany", + "model": "Song", + "foreignKey": "defaultSongs", + "options": { + "nestRemoting": true + } + }, + "songs": { + "type": "referencesMany", + "model": "Song", + "foreignKey": "", + "options": { + "nestRemoting": true + } + }, + "user": { + "type": "belongsTo", + "model": "user", + "foreignKey": "" + } + }, + "acls": [], + "methods": {} +} diff --git a/common/models/song.js b/common/models/song.js new file mode 100644 index 0000000..8d1992e --- /dev/null +++ b/common/models/song.js @@ -0,0 +1,29 @@ +'use strict'; +const { getSong } = require('../../server/utils/song'); +const { getMoreMusicFromSpotify} = require('../../server/utils/getMoreFromSpotify'); + +module.exports = function(Song) { + Song.getTrackData = function(songUri, userID, cb) { + getSong(songUri, userID) + .then(song => cb(null, song)) + .catch(err => cb(err)); + } + + Song.remoteMethod('getTrackData', { + accepts: [{arg: 'songUri', type: 'string'},{arg: 'userID', type:'string'}], + returns: {arg: 'song', type: 'object'} + }); + + Song.getMoreFromSpotify = function(userId, query, types, cb) { + getMoreMusicFromSpotify(userId, query, types) + .then((songs) => cb(null, songs)) + .catch(err => cb(err)); + }; + + Song.remoteMethod('getMoreFromSpotify', { + description: 'Searchs for spotify songs', + accepts: [{arg: 'userId', type: 'string'}, {arg: 'query', type: 'string'}, {arg: 'types', type: 'array'}], + http: {path: '/getMoreFromSpotify', verb: 'get'}, + returns: {arg: 'data', type: 'array', root: true}, + }); +}; diff --git a/common/models/song.json b/common/models/song.json new file mode 100644 index 0000000..6597c32 --- /dev/null +++ b/common/models/song.json @@ -0,0 +1,38 @@ +{ + "name": "Song", + "base": "PersistedModel", + "idInjection": true, + "options": { + "validateUpsert": true + }, + "properties": { + "name": { + "type": "string", + "required": true + }, + "duration": { + "type": "number", + "required": true + }, + "artist": { + "type": "string", + "required": true + }, + "uri": { + "type": "string", + "required": true + }, + "albumCover": { + "type": "string", + "required": true + }, + "spotifyId": { + "type": "string", + "required": true + } + }, + "validations": [], + "relations": {}, + "acls": [], + "methods": {} +} diff --git a/common/models/user.js b/common/models/user.js new file mode 100644 index 0000000..6214935 --- /dev/null +++ b/common/models/user.js @@ -0,0 +1,4 @@ +'use strict'; + +module.exports = function(User) { +}; diff --git a/common/models/user.json b/common/models/user.json new file mode 100644 index 0000000..1cfc199 --- /dev/null +++ b/common/models/user.json @@ -0,0 +1,63 @@ +{ + "name": "user", + "plural": "users", + "base": "User", + "idInjection": true, + "options": { + "validateUpsert": true + }, + "properties": { + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "email": { + "type": "string", + "required": true + }, + "username": { + "type": "string", + "required": true + }, + "password": { + "type": "string", + "required": true + }, + "accessToken": { + "type": "string", + "required": false + }, + "spotifyID": { + "type": "string", + "required": false + }, + "playlistID": { + "type": "string", + "required": false + }, + "spotifyRefreshToken": { + "type": "string", + "required": false + }, + "spotifyAccessToken": { + "type": "string", + "required": false + }, + "spotifyAccessToken": { + "type": "string", + "required": false + } + }, + "validations": [], + "relations": { + "queue": { + "type": "hasOne", + "model": "Queue", + "foreignKey": "" + } + }, + "acls": [], + "methods": {} +} diff --git a/package.json b/package.json old mode 100755 new mode 100644 index c261e16..bc4707a --- a/package.json +++ b/package.json @@ -1,18 +1,19 @@ { "name": "origin-spacebox", "version": "1.0.0", - "private": true, + "main": "server/server.js", + "engines": { + "node": ">=4" + }, "description": "Origin Code Academy's Jukebox", "scripts": { "watch": "webpack -w", - "server": "nodemon server", - "build": "webpack -p", - "start": "node server", + "server": "nodemon server/server", + "build": "webpack", + "start": "node server/server", "dev": "concurrently \"webpack -w\" \"nodemon server\"", - "test": "mocha tests" + "test": "npm run build && mocha tests/*.spec.js --exit" }, - "author": "", - "license": "ISC", "dependencies": { "@babel/core": "^7.0.0-beta.39", "@babel/plugin-proposal-class-properties": "^7.0.0-beta.39", @@ -20,28 +21,47 @@ "@babel/preset-react": "^7.0.0-beta.39", "axios": "^0.17.1", "babel-loader": "^8.0.0-beta.0", - "chai": "^4.1.2", + "chai": "^4.2.0", "chai-http": "^4.0.0", - "dotenv": "^6.0.0", + "compression": "^1.0.3", + "cors": "^2.5.2", + "dotenv": "^6.1.0", "ejs": "^2.5.7", - "express": "^4.15.2", + "express": "^4.16.4", "file-loader": "^1.1.11", + "helmet": "^3.10.0", "http": "0.0.0", + "loopback": "^3.23.2", + "loopback-boot": "^2.6.5", + "loopback-component-explorer": "^6.2.0", + "loopback-connector-mongodb": "^3.9.1", + "loopback-datasource-juggler": "^4.1.1", "mocha": "^5.2.0", + "nightmare": "^3.0.1", "path": "^0.12.7", "react": "^16.4.1", - "react-dom": "^16.2.0", - "socket.io": "^2.0.4", + "react-dom": "^16.6.0", + "react-redux": "^5.1.0", + "react-router": "^4.3.1", + "react-router-dom": "^4.3.1", + "redux": "^4.0.1", + "redux-promise-middleware": "^5.1.1", + "serve-favicon": "^2.0.1", + "socket.io": "^2.1.1", "socket.io-client": "^2.0.4", - "spotify-web-api-node": "^3.1.1", - "url-loader": "^1.0.1" + "spotify-web-api-node": "^4.0.0", + "strong-error-handler": "^3.0.0", + "url-loader": "^1.0.1", + "webpack": "^3.10.0" }, "devDependencies": { "babel-eslint": "^8.2.3", "babel-plugin-transform-class-properties": "^6.24.1", "concurrently": "^3.5.1", "css-loader": "^0.28.9", + "eslint": "^3.19.0", "eslint-config-airbnb": "^16.1.0", + "eslint-config-loopback": "^8.0.0", "eslint-plugin-import": "^2.12.0", "eslint-plugin-jsx-a11y": "^6.0.3", "eslint-plugin-react": "^7.9.1", @@ -52,5 +72,10 @@ "sass-loader": "^6.0.6", "style-loader": "^0.20.1", "webpack": "^3.10.0" - } + }, + "repository": { + "type": "", + "url": "" + }, + "license": "ISC" } diff --git a/server/boot/authentication.js b/server/boot/authentication.js new file mode 100644 index 0000000..7fd9c55 --- /dev/null +++ b/server/boot/authentication.js @@ -0,0 +1,6 @@ +'use strict'; + +module.exports = function enableAuthentication(server) { + // enable authentication + // server.enableAuth(); +}; diff --git a/server/boot/create-admin.js b/server/boot/create-admin.js new file mode 100644 index 0000000..e47e508 --- /dev/null +++ b/server/boot/create-admin.js @@ -0,0 +1,48 @@ +'use-strict'; + +module.exports = app => { + const {user, Role, RoleMapping} = app.models; + user.findOrCreate( + { + where: { + 'username': process.env.DEFAULT_ADMIN_USERNAME, + }, + }, + { + 'username': process.env.DEFAULT_ADMIN_USERNAME, + 'email': process.env.DEFAULT_ADMIN_EMAIL, + 'password': process.env.DEFAULT_ADMIN_PASSWORD, + }, + (err, user) => { + if (err) console.log(err); + Role.findOrCreate( + { + where: { + 'name': 'admin', + }, + }, + { + 'name': 'admin', + }, + (err) => { + if (err) console.log(err); + RoleMapping.findOrCreate( + { + where: { + principalType: 'admin', + principalId: user.id, + }, + }, + { + principalType: 'admin', + principalId: user.id, + }, + (err) => { + if (err) console.log(err); + } + ); + } + ); + } + ); +}; diff --git a/server/boot/root.js b/server/boot/root.js new file mode 100644 index 0000000..c548aab --- /dev/null +++ b/server/boot/root.js @@ -0,0 +1,6 @@ +'use strict'; + +module.exports = function(server) { + var router = server.loopback.Router(); + server.use(router); +}; diff --git a/server/boot/spotify.js b/server/boot/spotify.js new file mode 100644 index 0000000..12cab07 --- /dev/null +++ b/server/boot/spotify.js @@ -0,0 +1,62 @@ +'use strict'; +const SpotifyWebApi = require('spotify-web-api-node'); + +module.exports = function(server) { + var router = server.loopback.Router(); + + router.get('/spotify', (req, res) => { + + const spotifyApi = new SpotifyWebApi({ + clientId: process.env.SPOTIFY_CLIENT_ID, + clientSecret: process.env.SPOTIFY_CLIENT_SECRET, + redirectUri: `${process.env.SITE_URL}/auth`, + }); + + const scopes = [ + 'playlist-modify-public', + 'playlist-read-private', + 'playlist-modify-private', + 'streaming', + 'app-remote-control', + 'user-modify-playback-state', + 'user-read-currently-playing', + 'user-read-playback-state' + ]; + + res.redirect(spotifyApi.createAuthorizeURL(scopes, 'spacebox')) + }); + + router.get('/auth', (req, res) => { + const { code, state } = req.query; + + if (state === 'spacebox' && code) { + const spotifyApi = new SpotifyWebApi({ + clientId: process.env.SPOTIFY_CLIENT_ID, + clientSecret: process.env.SPOTIFY_CLIENT_SECRET, + redirectUri: `${process.env.SITE_URL}/auth`, + }); + + spotifyApi.authorizationCodeGrant(code) + .then(({ body: { 'access_token': accessToken, 'refresh_token': refreshToken } }) => { + + const UserModel = server.models.user; + + return UserModel.findOrCreate({ where: { username: process.env.ORIGIN_USERNAME } }, { + "email": process.env.ORIGIN_EMAIL, + "username": process.env.ORIGIN_USERNAME, + "password": process.env.ORIGIN_PASSWORD, + "spotifyAccessToken": accessToken, + "spotifyRefreshToken": refreshToken + }); + }) + .then(() => res.redirect('/')) + .catch((err) => res.send(err)); + } + else { + res.redirect('/'); + } + }) + + + server.use(router); +}; \ No newline at end of file diff --git a/server/component-config.json b/server/component-config.json new file mode 100644 index 0000000..ae873f5 --- /dev/null +++ b/server/component-config.json @@ -0,0 +1,6 @@ +{ + "loopback-component-explorer": { + "mountPath": "/explorer", + "generateOperationScopedModels": true + } +} diff --git a/server/config.json b/server/config.json new file mode 100644 index 0000000..d371cd2 --- /dev/null +++ b/server/config.json @@ -0,0 +1,22 @@ +{ + "restApiRoot": "/api", + "host": "0.0.0.0", + "port": 3000, + "remoting": { + "context": false, + "rest": { + "handleErrors": false, + "normalizeHttpPath": false, + "xml": false + }, + "json": { + "strict": false, + "limit": "100kb" + }, + "urlencoded": { + "extended": true, + "limit": "100kb" + }, + "cors": false + } +} diff --git a/server/datasources.json b/server/datasources.json new file mode 100644 index 0000000..3ab206c --- /dev/null +++ b/server/datasources.json @@ -0,0 +1,16 @@ +{ + "db": { + "name": "db", + "connector": "memory" + }, + "MongoDB": { + "host": "", + "port": 0, + "url": "", + "database": "", + "password": "", + "name": "MongoDB", + "user": "", + "connector": "mongodb" + } +} diff --git a/server/datasources.production.js b/server/datasources.production.js new file mode 100644 index 0000000..643d4c0 --- /dev/null +++ b/server/datasources.production.js @@ -0,0 +1,7 @@ +module.exports = { + 'MongoDB': { + 'name': 'MongoDB', + 'connector': 'mongodb', + 'url': process.env.MONGODB_URI, + }, +}; diff --git a/server/default.js b/server/default.js deleted file mode 100644 index cbc5cc8..0000000 --- a/server/default.js +++ /dev/null @@ -1,65 +0,0 @@ -module.exports = [{ - id: '299vmLW2iaQxe8y9HLWNiH', - name: 'just ask', - artist: 'weird inside', - albumCover: 'https://i.scdn.co/image/6b440d0d81145350b4bf87c7a77d679701dd4f22', - duration: 173846, - uri: 'spotify:track:299vmLW2iaQxe8y9HLWNiH' - }, - { - id: '5HE2u1IOizZwM4kWMF5Jpb', - name: 'Sunset', - artist: 'Coubo', - albumCover: 'https://i.scdn.co/image/2362328b20a356c1d23897d38531b1b93844c788', - duration: 213361, - uri: 'spotify:track:5HE2u1IOizZwM4kWMF5Jpb' - }, - { - id: '4uZ6QSZJPzCD4d7Jtscwes', - name: 'Minutes', - artist: 'Freddie Joachim', - albumCover: 'https://i.scdn.co/image/786f2d9d6117cb1f7262e835c43850237444259a', - duration: 227186, - uri: 'spotify:track:4uZ6QSZJPzCD4d7Jtscwes' - }, - { - id: '2GPKo5nrX2uwB8eSCebIIk', - name: 'sincerely, yours', - artist: 'Nohidea', - albumCover: 'https://i.scdn.co/image/96b8069a0d544d1111f65bb37da3fb05f7b59b54', - duration: 139684, - uri: 'spotify:track:2GPKo5nrX2uwB8eSCebIIk' - }, - { - id: '0ej5zbkF48ZiPZOubIX1e1', - name: 'Days To Come - Instrumental', - artist: 'Bonobo', - albumCover: 'https://i.scdn.co/image/fe00d367ac66e49eae8a8783bb9c36779ccee613', - duration: 230400, - uri: 'spotify:track:0ej5zbkF48ZiPZOubIX1e1' - }, - { - id: '3m9X1ZYQPKKZXZpykC2jBf', - name: 'Whispers', - artist: 'Freddie Joachim', - albumCover: 'https://i.scdn.co/image/aea57c1c3d68e1dfb5c01aaeacd2bad0c0a2927c', - duration: 133799, - uri: 'spotify:track:3m9X1ZYQPKKZXZpykC2jBf' - }, - { - albumCover: "https://i.scdn.co/image/1cf857b33913c2237ab017d33a49631b372e9fe6", - artist: "D Numbers", - duration: 313413, - id: "5Hs9xwUmu8xoU31dydcyTd", - name: "Xylem Up", - uri: "spotify:track:5Hs9xwUmu8xoU31dydcyTd", - }, - { - albumCover: "https://i.scdn.co/image/c21c1e4c58342abb6f88dfe1b291c718f6a70e43", - artist: "Maxence Cyrin", - duration: 165160, - id: "4jNQkWhuzqrbqQuqanFFJ6", - name: "Where Is My Mind", - uri: "spotify:track:4jNQkWhuzqrbqQuqanFFJ6" - } -] \ No newline at end of file diff --git a/server/middleware.development.json b/server/middleware.development.json new file mode 100644 index 0000000..071c11a --- /dev/null +++ b/server/middleware.development.json @@ -0,0 +1,10 @@ +{ + "final:after": { + "strong-error-handler": { + "params": { + "debug": true, + "log": true + } + } + } +} diff --git a/server/middleware.json b/server/middleware.json new file mode 100644 index 0000000..5dc1eb2 --- /dev/null +++ b/server/middleware.json @@ -0,0 +1,59 @@ +{ + "initial:before": { + "loopback#favicon": {} + }, + "initial": { + "compression": {}, + "cors": { + "params": { + "origin": true, + "credentials": true, + "maxAge": 86400 + } + }, + "helmet#xssFilter": {}, + "helmet#frameguard": { + "params": { + "action": "deny" + } + }, + "helmet#hsts": { + "params": { + "maxAge": 0, + "includeSubdomains": true + } + }, + "helmet#hidePoweredBy": {}, + "helmet#ieNoOpen": {}, + "helmet#noSniff": {}, + "helmet#noCache": { + "enabled": false + } + }, + "session": {}, + "auth": {}, + "parse": {}, + "routes": { + "loopback#rest": { + "paths": [ + "${restApiRoot}" + ] + } + }, + "files": { + "loopback#static": [ + { + "params": "$!../dist" + }, + { + "params": "$!../client" + } + ] + }, + "final": { + "loopback#urlNotFound": {} + }, + "final:after": { + "strong-error-handler": {} + } +} diff --git a/server/model-config.json b/server/model-config.json new file mode 100644 index 0000000..601cfc8 --- /dev/null +++ b/server/model-config.json @@ -0,0 +1,54 @@ +{ + "_meta": { + "sources": [ + "loopback/common/models", + "loopback/server/models", + "../common/models", + "./models" + ], + "mixins": [ + "loopback/common/mixins", + "loopback/server/mixins", + "../common/mixins", + "./mixins" + ] + }, + "AccessToken": { + "dataSource": "MongoDB", + "public": false, + "relations": { + "user": { + "type": "belongsTo", + "model": "user", + "foreignKey": "userId" + } + } + }, + "ACL": { + "dataSource": "MongoDB", + "public": false + }, + "RoleMapping": { + "dataSource": "MongoDB", + "public": true, + "options": { + "strictObjectIDCoercion": true + } + }, + "Role": { + "dataSource": "MongoDB", + "public": true + }, + "Queue": { + "dataSource": "MongoDB", + "public": true + }, + "Song": { + "dataSource": "MongoDB", + "public": true + }, + "user": { + "dataSource": "MongoDB", + "public": true + } +} diff --git a/server/server.js b/server/server.js old mode 100755 new mode 100644 index 740cbe3..7ceac15 --- a/server/server.js +++ b/server/server.js @@ -1,203 +1,33 @@ -const express = require('express'); -const Server = require('socket.io'); -const app = express(); -const server = require('http').Server(app); -const io = new Server(server); +'use strict'; -var SpotifyWebApi = require('spotify-web-api-node'); -const defaultSongs = require('./default'); +var loopback = require('loopback'); +var boot = require('loopback-boot'); require('dotenv').config(); - -/** - * A global array of Spotify track objects - * @type {Array<{}>} - */ -var songs = []; - -/** - * Last song played - */ -var lastPlayed = {}; - -let lastTime = new Date(); -let accessToken = null; -let refreshToken = process.env.SPOTIFY_REFRESH_TOKEN; -const spotifyApi = new SpotifyWebApi({ - clientId: process.env.SPOTIFY_CLIENT_ID, - clientSecret: process.env.SPOTIFY_CLIENT_SECRET, - redirectUri: process.env.SITE_URL || 'http://localhost:8080', - refreshToken -}); - -/** - * checks if we have a valid access token and if not refreshes token - * @param {{}} req users request - * @param {{}} res servers response obj - * @param {Function} cb callback function that is invoked after retrieving access token - */ -const checkToken = (req, res, cb) => { - const currentTime = new Date(); - - if (lastTime < currentTime || !accessToken) { - spotifyApi.refreshAccessToken() - .then(data => { - lastTime = new Date(); - lastTime.setSeconds(data.body.expires_in); - accessToken = data.body['access_token']; - spotifyApi.setAccessToken(data.body['access_token']); - cb(); - }) - .catch(err => console.log(err)) - } else { - cb(); - } -} - -/** - * takes a track object and formats it - * @param {{}} track the track object - * @returns {{ id: String, name: String, artist: String, albumCover: String, duration: Number, uri: String }} formatted track - */ -const formatSong = track => ({ - id: track.id, - name: track.name, - artist: track.artists[0].name, - albumCover: track.album.images[0].url, - duration: track.duration_ms, - uri: track.uri -}) - -/** - * retrieves playlist from Spotify - * @returns {{url: String, image: String, tracks: Array<{}>}} playlist information and tracks - */ -const getPlaylist = () => { - return spotifyApi.getPlaylist(process.env.SPOTIFY_USER, process.env.SPOTIFY_PLAYLIST) - .then(data => { - if (data.body.tracks.items.length === 0) { - songs = []; - return songs; - } - const playlistInfo = { - url: data.body.external_urls.spotify, - image: data.body.images[0].url, - tracks: data.body.tracks.items.map(i => formatSong(i.track)) +var app = module.exports = loopback(); +app.start = function () { + // start the web server + return app.listen(function () { + app.emit('started'); + var baseUrl = app.get('url').replace(/\/$/, ''); + console.log('Web server listening at: %s', baseUrl); + if (app.get('loopback-component-explorer')) { + var explorerPath = app.get('loopback-component-explorer').mountPath; + console.log('Browse your REST API at %s%s', baseUrl, explorerPath); } - songs = playlistInfo.tracks - return songs; - }) - .catch(err => console.log(err)); -} - -/** - * checking to see if URI exists in songs array - * @param {String} uri spotify track identifier - * @return {Boolean} false if there is no duplicate - */ -const isDup = uri => songs.some(song => song.uri === uri); - -app.use((req, res, next) => { - res.header('Access-Control-Allow-Origin', '*'); - res.header('Access-Control-Allow-Methods', 'GET,POST'); - next(); + }); +}; + +// Bootstrap the application, configure models, datasources and middleware. +// Sub-apps like REST API are mounted via boot scripts. +boot(app, __dirname, function (err) { + if (err) throw err; + // start the server if `$ node server.js` + if (require.main === module) + app.io = require('socket.io')(app.start()); + app.io.on('connection', function(socket) { + socket.on('room', (room) => { + socket.join(room); + app.io.in(room).emit('update', []); + }); + }); }); -app.use(express.json()); -app.use('/', express.static('build')); -app.use('/', express.static('public')); - -app.get('/', (req, res) => res.render('index', { songs })); -app.get('/board', (req, res) => res.json(songs)); - -const updatePlaylist = async (songToAdd = null) => { - const songs = await getPlaylist(); - return spotifyApi.getMyCurrentPlayingTrack() - .then(response => { - const songCurrentlyPlaying = response.body.item; - const isJukeboxOn = response.body.is_playing; - - if (songs[0].uri === songCurrentlyPlaying.uri) { - lastPlayed = songs[0]; - songs.shift(); - } - else if (songs[0].uri !== songCurrentlyPlaying.uri && lastPlayed.uri !== songCurrentlyPlaying.uri) { - // Make song list match currently playing - while (songs.length && songs[0].uri !== songCurrentlyPlaying.uri) - songs.shift(); - } - // If new songs to add - if (songToAdd) songs.push(songToAdd); - - // If last song and no longer playing ? add default songs. - if (songs.length === 1) { - songs.push(...defaultSongs.filter(s => s.uri !== songCurrentlyPlaying.uri)); - } - - let tracks = [...songs].map(s => s.uri); - - return spotifyApi.replaceTracksInPlaylist(process.env.SPOTIFY_USER, process.env.SPOTIFY_PLAYLIST, tracks) - .then(() => { - if (!isJukeboxOn) { - setTimeout(() => { - spotifyApi.play({ - context_uri: `spotify:user:${process.env.SPOTIFY_USER}:playlist:${process.env.SPOTIFY_PLAYLIST}`, - offset: { - position: 1 - } - }) - .catch(err => console.log(err)) - }, 5000); - } - - if (lastPlayed.uri === songCurrentlyPlaying.uri) songs.unshift(lastPlayed); - io.emit('update', songs); - return songs; - }) - .catch(err => ({ error: 'We couldn\'t add your song for some reason. Try again!', err})); - }) - .catch(err => ({ error: 'SPACEBOX is turned off. Tell an instructor!', err})) -} - -app.post('/api/request', (req, res) => { - if (isDup(req.body.uri)) { - res.json({ error: 'Duplicate song!' }) - } - else { - spotifyApi.getTrack(req.body.uri.slice(14)) - .then(track => { - const trackData = formatSong(track.body); - updatePlaylist(trackData) - .then(songs => res.send(songs)) - }) - .catch(err => res.json({ error: 'Track doesn\'t exist! Try spotify:track:{SONG_ID}' })); - } -}); - -//Custom routes -app.get('/404', (req, res) => res.json({ message: 'Nothing is here. But thanks for checking!' })); - -app.use('/api', checkToken); -app.get('/api/artist/:artist', (req, res) => { - spotifyApi.searchArtists(req.params.artist) - .then(data => { - const items = data.body.artists.items; - if (!items.length) res.send('Stop it'); - res.send(items[0]) - }) - .catch(err => console.log(err)); -}) - -app.get('/api/playlist', async (req, res) => { - const songs = await updatePlaylist(); - res.send(songs); -}); - -app.delete('/api/request', (req, res, next) => { - spotifyApi.removeTracksFromPlaylist(process.env.SPOTIFY_USER, process.env.SPOTIFY_PLAYLIST, req.body.tracks) - .then((response) => { - io.emit('update', songs); - res.send(response) - }) - .catch(err => console.log(err)) -}); - -module.exports = { server, checkToken }; \ No newline at end of file diff --git a/server/utils/adminQueue.js b/server/utils/adminQueue.js new file mode 100644 index 0000000..3d34605 --- /dev/null +++ b/server/utils/adminQueue.js @@ -0,0 +1,59 @@ +const app = require('../server'); +const {getSong} = require('../../server/utils/song'); + +function addToDefaultSongs(id, uri) { + return new Promise((resolve, reject) => { + const {Song, Queue} = app.models; + Song.find({where: {uri: uri}}) + .then(song => { + if (song[0]) { + let songId = song[0].id; + Queue.findById(id) + .then(queue => { + let defaultSongs = queue.defaultSongs; + defaultSongs.push(songId); + let updatedQueue = { + 'defaultSongs': defaultSongs, + 'id': id, + 'songIds': queue.songIds, + 'userId': queue.userId, + }; + resolve(updatedQueue); + }) + .catch(err => reject(err)); + } else { + Queue.findById(id) + .then(queue => { + let userId = queue.userId; + let defaultSongs = queue.defaultSongs; + getSong(uri, userId) + .then(song => { + Song.find({where: {uri: uri}}) + .then(song => { + let songId = song[0].id; + Queue.findById(id) + .then(queue => { + let defaultSongs = queue.defaultSongs; + defaultSongs.push(songId); + let updatedQueue = { + 'defaultSongs': defaultSongs, + 'id': id, + 'songIds': queue.songIds, + 'userId': queue.userId, + }; + resolve(updatedQueue); + }) + .catch(err => reject(err)); + }) + .catch(err => reject(err)); + }) + .catch(err => reject(err)); + }) + .catch(err => reject(err)); + } + }) + .catch(err => reject(err)); + }); +} + +module.exports = {addToDefaultSongs}; diff --git a/server/utils/getMoreFromSpotify.js b/server/utils/getMoreFromSpotify.js new file mode 100644 index 0000000..f4a1e34 --- /dev/null +++ b/server/utils/getMoreFromSpotify.js @@ -0,0 +1,25 @@ +var SpotifyWebApi = require('spotify-web-api-node'); +const { getAccessToken } = require('../../server/utils/playlist') + +const app = require('../server'); + +function getMoreMusicFromSpotify(userId, query, types) { + return new Promise((resolve, reject) => { + getAccessToken(userId) + .then(accessToken => { + const spotifyApi = new SpotifyWebApi({ accessToken }); + spotifyApi.search(query, types) + .then(res => { + let trackData = res.body.tracks.items.map(({ name, uri, album }) => ({ + name, + uri, + artist: album.artists[0].name + }) ) + resolve(trackData); + } + ) + .catch(err => console.log('we caught an error in get more music', err)) + }) +} +)} +module.exports = { getMoreMusicFromSpotify } \ No newline at end of file diff --git a/server/utils/player.js b/server/utils/player.js new file mode 100644 index 0000000..ef1ef66 --- /dev/null +++ b/server/utils/player.js @@ -0,0 +1,75 @@ +const app = require('../server'); +const SpotifyWebApi = require('spotify-web-api-node'); +const { getAccessToken } = require('../../server/utils/playlist'); + +function playCurrentSong(id) { + const { Queue, User } = app.models; + return new Promise((resolve, reject) => { + Queue.findById(id) + .then(queue => { + var userID = queue.userId; + // takes the userID from the queue and gets spotifyID and playlistID from that user + User.findById(userID) + .then((user) => { + var spotifyID = user.spotifyID + var playlistID = user.playlistID + getAccessToken(user.id) + .then(accessToken => { + const spotifyApi = new SpotifyWebApi({ accessToken }); + spotifyApi.getMyCurrentPlayingTrack() + .then(response => { + const songCurrentlyPlaying = response.body.item; + const isJukeboxOn = response.body.is_playing; + spotifyApi.play({ + context_uri: `spotify:playlist:${playlistID}`, + offset: { + position: 0 + } + }) + .then(() => resolve({ message: "Jukebox is on!" })) + .catch(err => reject(err)); + }) + .catch(err => reject(err)); + }) + .catch(err => reject(err)); + }) + .catch(err => reject(err)); + }) + .catch(err => reject(err)); + }) +} + +function pauseCurrentSong(id) { + const { Queue, User } = app.models; + return new Promise((resolve, reject) => { + Queue.findById(id) + .then(queue => { + var userID = queue.userId; + User.findById(userID) + .then((user) => { + var playlistID = user.playlistID + getAccessToken(user.id) + .then(accessToken => { + const spotifyApi = new SpotifyWebApi({ accessToken }); + spotifyApi.getMyCurrentPlayingTrack() + .then(() => { + spotifyApi.pause({ + context_uri: `spotify:playlist:${playlistID}`, + offset: { + position: 0 + } + }) + .then(() => resolve({ message: "Jukebox is on!" })) + .catch(err => reject(err)); + }) + .catch(err => reject(err)); + }) + .catch(err => reject(err)); + }) + .catch(err => reject(err)); + }) + .catch(err => reject(err)); + }) +} + +module.exports = { playCurrentSong, pauseCurrentSong }; diff --git a/server/utils/playlist.js b/server/utils/playlist.js new file mode 100644 index 0000000..197b2a6 --- /dev/null +++ b/server/utils/playlist.js @@ -0,0 +1,322 @@ +const app = require('../server') +const SpotifyWebApi = require('spotify-web-api-node'); + +function getAccessToken(userID = null) { + const spotifyApi = new SpotifyWebApi({ + clientId: process.env.SPOTIFY_CLIENT_ID, + clientSecret: process.env.SPOTIFY_CLIENT_SECRET, + redirectUri: process.env.SITE_URL || 'http://localhost:3000/auth', + }); + + return new Promise((resolve, reject) => { + if (!userID) { + reject('No user id provided'); + return false; + } + const { User } = app.models; + User.findById(userID) + .then((user) => { + if (!user.spotifyRefreshToken) { + reject('No refresh token'); + return false; + } + // get their refresh token and add accessToken + spotifyApi.setRefreshToken(user.spotifyRefreshToken); + spotifyApi.refreshAccessToken() + .then(({ body: { 'access_token': accessToken } }) => { + resolve(accessToken); + }) + .catch(err => reject(err)); + }) + }) +} + +/** + * + * @param {String} id ID for queue you're searching for + * @returns {Promise} Resolves to array of objects with tracks on the playlist + */ +function getPlaylist(id) { + return new Promise((resolve, reject) => { + const { Queue, User } = app.models; + // finds the correct queue based on the queue ID that you put in + Queue.findById(id, { fields: { userId: true } }) + .then((queue) => { + const userID = queue.userId; + // takes the userID from the queue and gets spotifyID and playlistID from that user + User.findById(userID) + .then((user) => { + const spotifyID = user.spotifyID + const playlistID = user.playlistID + getAccessToken(user.id) + .then(accessToken => { + const spotifyApi = new SpotifyWebApi({ accessToken }); + spotifyApi.getPlaylist(spotifyID, playlistID) + .then(playlist => { + const formatSong = track => ({ + id: track.id, + name: track.name, + artist: track.artists[0].name, + albumCover: track.album.images[0].url, + duration: track.duration_ms, + uri: track.uri + }) + const tracks = playlist.body.tracks.items.map(i => formatSong(i.track)) + const output = { + tracks: tracks, + userID: userID, + spotifyID: spotifyID, + playlistID: playlistID + } + resolve(output) + }) + .catch(err => reject(err)); + }) + .catch(err => reject(err)) + }) + }) + .catch(err => reject(err)); + }) +} + +function removeCurrentlyPlaying(songs, songCurrentlyPlaying, queueId) { + return new Promise((resolve, reject) => { + const { Queue, Song } = app.models; + // update database so the queue matches song track from spotify + Queue.findById(queueId) + .then((queue) => { + // last played is first song in the queue + if (!queue.songIds.length) { + return resolve({ + songs, + lastPlayed: undefined + }) + } + + let lastPlayed = queue.songIds[0]; + Song.findById(lastPlayed) + .then((songObject) => { + let lastPlayedObject = songObject + if (songs[0].uri === songCurrentlyPlaying.uri) { + // update the queue so it matches songs(from spotify) + let songURIs = songs.map(s => s.uri) + getSongIds(songURIs) + .then((songIds) => { + const songIdArray = songIds + let newQueue = { + "defaultSongs": queue.defaultSongs, + "id": queue.id, + "songIds": songIdArray, + "userId": queue.userId + } + Queue.replaceOrCreate(newQueue) + // update lastplayed here (Do I still need to do this? duplicating the default above) + Queue.findById(queue.id) + .then((queue) => { + lastPlayed = queue.songIds[0] + // remove first song from spotify + songs.shift(); + return resolve({ + songs, + lastPlayed + }) + }) + }) + .catch(err => ({ error: '', err })) + } + else if (songs[0].uri !== songCurrentlyPlaying.uri && lastPlayedObject.uri !== songCurrentlyPlaying.uri) { + // Make song list match currently playing + while (songs.length && songs[0].uri !== songCurrentlyPlaying.uri) songs.shift(); + // update queue so it matches songs + let songURIs = [...songs].map(s => s.uri) + getSongIds(songURIs) + .then((songIds) => { + const songIdArray = songIds + let newQueue = { + "defaultSongs": queue.defaultSongs, + "id": queue.id, + "songIds": songIdArray, + "userId": queue.userId + } + Queue.replaceOrCreate(newQueue) + // update last playded it be song[0] + lastPlayed = newQueue.songIds[0] + // shift queue one more time so it doesn't have currently playing song + songs.shift() + songURIs = [...songs].map(s => s.uri) + getSongIds(songURIs) + .then((songIds) => { + const songIdArray = songIds + let newQueue = { + "defaultSongs": queue.defaultSongs, + "id": queue.id, + "songIds": songIdArray, + "userId": queue.userId + } + Queue.replaceOrCreate(newQueue) + return resolve({ + songs, + lastPlayed + }) + }) + .catch(err => reject(err)) + }) + .catch(err => reject(err)) + } + else { + return resolve({ + songs, + lastPlayed + }) + } + }) + .catch(err => reject(err)) + }) + .catch(err => ({ error: 'couldnt find queue id', err })) + }) + .catch(err => reject(err)) +} + +function addNewSong(songID, songs) { + return new Promise((resolve, reject) => { + const { Song } = app.models; + if (songID) { + Song.findById(songID) + .then((song) => { + if (songs.every((track) => track.uri !== song.uri)) { + songs.push(song) + resolve(songs) + } + else reject({ message: 'Duplicate song' }) + }) + .catch(err => reject(err)) + } + else resolve(songs) + }) +} + +function getSongIds(songURIs) { + return new Promise((resolve, reject) => { + const { Song } = app.models; + Song.find({}) + .then((songs) => { + let songIds = songURIs.map((uri) => { + return songs.filter((song) => song.uri == uri) + .map((song) => song.id) + }) + songIds = songIds.map((id) => id[0]) + resolve(songIds) + }) + .catch(err => ({ error: 'Could not find matching song id', err })) + }) + .catch(err => ({ error: 'Could not get song ids', err })) +} + +function addDefaultSongsAndGetURIs(songs, id) { + return new Promise((resolve, reject) => { + const { Queue } = app.models; + let songURIs = songs.map(s => s.uri) + getSongIds(songURIs) + .then((songIds) => { + const justSongIds = songIds + Queue.findById(id) + .then((queue) => { + if (songs.length === 1) { + queue.default((err, defaultSongs) => { + let defaultSongIds = defaultSongs.map((song) => song.id) + let combinedSongIds = justSongIds.concat(defaultSongIds) + let defaultSongURIs = defaultSongs.map((song) => song.uri) + let combinedSongURIs = songURIs.concat(defaultSongURIs) + resolve({ + songIds: combinedSongIds, + songURIs: combinedSongURIs + }) + }) + } + else { + resolve({ + songIds: justSongIds, + songURIs: songURIs + }) + } + }) + .catch(err => ({ error: 'could not complete add default songs and get URIs function', err })) + }) + .catch((err) => ({ error: 'could not complete getSongIds function within add default songs function', err })) + }) +} + +function updatePlaylist(id, songID = null) { + const { Queue, Song } = app.models; + return new Promise((resolve, reject) => { + getPlaylist(id) + .then((response) => { + let userID = response.userID + let spotifyID = response.spotifyID + let playlistID = response.playlistID + let tracks = response.tracks + getAccessToken(userID) + .then(accessToken => { + const spotifyApi = new SpotifyWebApi({ accessToken }); + spotifyApi.getMyCurrentPlayingTrack() + .then((response) => { + // copying current playlist into a new array that we will mutate called songs + let songs = [...tracks] + const songCurrentlyPlaying = response.body.item; + removeCurrentlyPlaying(songs, songCurrentlyPlaying, id) + .then((response) => { + songs = response.songs + lastPlayed = response.lastPlayed + addNewSong(songID, songs) + .then((songs) => { + addDefaultSongsAndGetURIs(songs, id) + .then((response) => { + let songURIs = response.songURIs; + let songIds = response.songIds; + return spotifyApi.replaceTracksInPlaylist(spotifyID, playlistID, songURIs) + .then(async () => { + if (lastPlayed) { + await Song.findById(lastPlayed) + .then((lastPlayedSongObject) => { + if (lastPlayedSongObject.uri === songCurrentlyPlaying.uri) { + songIds.unshift(lastPlayed) + } + }) + .catch(err => reject(err)) + } + Queue.findById(id) + .then((singleQueue) => { + let queue = { + "defaultSongs": singleQueue.defaultSongs, + "id": id, + "songIds": songIds, + "userId": userID + } + Song.find({}) + .then((songs) => { + let fullSongs = songIds.map((id) => { + return songs.filter((song) => song.id.toString() == id) + }) + fullSongs = fullSongs.map((id) => id[0]) + app.io.in(id).emit('update', fullSongs) + resolve(queue) + }) + .catch(err => ({ error: 'could not get full song object', err })) + }) + .catch(err => reject(err)) + }) + }) + .catch(err => reject(err)) + }) + .catch(err => reject(err)) + }) + }) + .catch(err => ({ error: 'SPACEBOX is turned off. Tell an instructor!', err })) + }) + .catch(err => reject(err)) + }) + .catch(err => reject(err)) + }) +} + +module.exports = { getPlaylist, updatePlaylist, getAccessToken, removeCurrentlyPlaying, addNewSong, addDefaultSongsAndGetURIs } diff --git a/server/utils/song.js b/server/utils/song.js new file mode 100644 index 0000000..35fd8b8 --- /dev/null +++ b/server/utils/song.js @@ -0,0 +1,72 @@ +var SpotifyWebApi = require('spotify-web-api-node'); +const { getAccessToken } = require('../../server/utils/playlist'); +const app = require('../server') + + /** + * takes a track object and formats it + * @param {{}} track the track object + * @returns {{ id: String, name: String, artist: String, albumCover: String, duration: Number, uri: String }} formatted track + */ + const formatSong = track => ({ + name: track.name, + duration: track.duration_ms, + artist: track.album.artists[0].name, + uri:track.uri, + albumCover: track.album.images[0].url, + spotifyId:track.album.id + }) + +function getSong(songUri, userID) { + return new Promise((resolve, reject) => { + if (songUri == undefined) { + resolve('Bad URI'); + return false; + } + if (songUri === '' || songUri === []) { + resolve('No song URI'); + return false; + } + if (userID == undefined) { + resolve('Bad userID'); + return false; + } + if (userID === '' || userID === []) { + resolve('No userID'); + return false; + } + let trackData; + const {Song} = app.models; + Song + .find({ where: { uri: songUri } }) + .then(song => { + //the following checks if the song does not exist in Song model + //if no song, go to api and get song data + if(song==false){ + getAccessToken(userID) + .then(accessToken => { + const spotifyApi = new SpotifyWebApi({ accessToken }); + + spotifyApi.getTrack(songUri.slice(14)) //returns a response object {} + .then(res => { + trackData = formatSong(res.body); //trackData is an object of a song + Song.create(trackData) + .then((songData) => resolve(songData)) + .catch(err => console.log('HERE IS THE ERROR DETAILS:', err)); + }) + .catch(err => console.log('HERE IS THE ERROR DETAILS:', err)); + }) + .catch(err => console.log(err, " - THIS IS ERROR DETAILS")) + + }else{ + + ////if song exists then return it + resolve(song) + } + }) + }) +} + +module.exports = { + formatSong, + getSong +} diff --git a/src/App.jsx b/src/App.jsx new file mode 100755 index 0000000..b8596af --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,25 @@ +import React, { Component } from 'react'; +import { + HashRouter as Router, + Route, Switch +} from 'react-router-dom'; + +import HomeContainer from './containers/HomeContainer'; +import AdminContainer from './containers/AdminContainer'; +import LoginContainer from './containers/Logincontainer'; +import RoomContainer from './containers/RoomContainer'; + +export default class Routes extends Component { + render() { + return ( + + + + + + + + + ) + } +}; diff --git a/src/components/CurrentSong/CurrentSong.jsx b/src/components/CurrentSong/CurrentSong.jsx new file mode 100644 index 0000000..efecc03 --- /dev/null +++ b/src/components/CurrentSong/CurrentSong.jsx @@ -0,0 +1,31 @@ +import React, { Component } from 'react'; +import { startPlayback, pausePlayback, togglePlaying } from './SongActions'; + +export default class CurrentSong extends Component { + constructor(props) { + super(props); + + this.handleBtn = this.handleBtn.bind(this); + } + + handleBtn() { + const { playing, dispatch, queueId } = this.props; + dispatch(togglePlaying(playing)); + playing ? dispatch(pausePlayback(queueId)) : dispatch(startPlayback(queueId)); + } + + render() { + + const { playing } = this.props; + + return ( +
+
+ { + (playing) ?
:
+ } +
+
+ ) + } +} diff --git a/src/components/CurrentSong/SongActions.js b/src/components/CurrentSong/SongActions.js new file mode 100644 index 0000000..644b14b --- /dev/null +++ b/src/components/CurrentSong/SongActions.js @@ -0,0 +1,16 @@ +const axios = require('axios'); + +export const togglePlaying = (playing) => ({ + type: 'TOGGLE_PLAYBACK', + payload: !playing +}); + +export const startPlayback = (queueId) => ({ + type: 'START_PLAYBACK', + payload: axios.get(`/api/Queues/playCurrentSong?id=${queueId}`).then(response => response.data.message) +}); + +export const pausePlayback = (queueId) => ({ + type: 'PAUSE_PLAYBACK', + payload: axios.get(`/api/Queues/pauseCurrentSong?id=${queueId}`) +}); diff --git a/src/components/CurrentSong/SongReducer.js b/src/components/CurrentSong/SongReducer.js new file mode 100644 index 0000000..2e691bc --- /dev/null +++ b/src/components/CurrentSong/SongReducer.js @@ -0,0 +1,54 @@ +const initialState = { + song: '', + artist: '', + album: '', + length: 0, + playing: false, + status: '' +}; + +export default function SongReducer(state = initialState, action) { + const { type, payload } = action; + switch (type) { + case 'UPDATE_SONG_INFO_FULFILLED': { + return { + ...state, + song: payload.item.name, + artist: payload.item.album.artists.name, + album: payload.item.album.name, + length: item.duration_ms, + playing: false, + status: '' // Used to test for API call + } + } + case 'TOGGLE_PLAYBACK': { + return { + ...state, + playing: payload + } + } + case 'START_PLAYBACK_FULFILLED': { + return { + ...state, + playing: true, + status: payload + } + } + case 'START_PLAYBACK_REJECTED': { + return { + ...state, + playing: true, + status: payload + } + } + case 'PAUSE_PLAYBACK_FULFILLED': { + return { + ...state, + playing: false + } + } + default: { + return state; + } + } +} diff --git a/src/components/CurrentSong/index.js b/src/components/CurrentSong/index.js new file mode 100644 index 0000000..aaa7eaa --- /dev/null +++ b/src/components/CurrentSong/index.js @@ -0,0 +1,11 @@ +import { connect } from 'react-redux'; +import CurrentSong from './CurrentSong'; + +function mapStoreToProps(store) { + return { + playing: store.Song.playing, + queueId: store.DQueue.queueId + }; +} + +export default connect(mapStoreToProps)(CurrentSong); diff --git a/src/components/DefaultQueue/DQueueActions.js b/src/components/DefaultQueue/DQueueActions.js new file mode 100644 index 0000000..9291c52 --- /dev/null +++ b/src/components/DefaultQueue/DQueueActions.js @@ -0,0 +1,39 @@ +const axios = require('axios'); + +export function getDefaultQueue(userID) { + return { + type: 'GET_DEFAULT_QUEUE_ID', + payload: axios.get(`/api/users/${userID}/queue`) + .then(response => response.data.id) + } +} + +export function getDefaultSongs(queueId) { + return { + type: 'GET_DEFAULT_SONGS', + payload: axios.get(`/api/Queues/${queueId}/default`) + .then(response => response.data) + } +} + +export function deleteDefaultSong(songId, queueId) { + return { + type: 'DELETE_DEFAULT_SONG', + payload: axios.delete(`/api/Queues/${queueId}/default/${songId}`) + } +} + +export function handleAddInput(event) { + return { + type: 'GET_INPUT_VALUE', + payload: event + } +} + +export function addDefaultSong(inputValue, queueId) { + return { + type: 'ADD_DEFAULT_SONG', + payload: axios.post(`/api/Queues/addToDefaultSongs`, {id: queueId, uri: inputValue}) + .then(response => response.data.defaultSongs) + } +} diff --git a/src/components/DefaultQueue/DQueueReducer.js b/src/components/DefaultQueue/DQueueReducer.js new file mode 100644 index 0000000..b2cfec4 --- /dev/null +++ b/src/components/DefaultQueue/DQueueReducer.js @@ -0,0 +1,39 @@ +const initialState = { + queueId: '', + defaultSongs: [], + inputValue: '' +}; + +export default function dQueueReducer(state = initialState, action) { + const { type, payload } = action; + + switch (type) { + case 'GET_DEFAULT_QUEUE_ID_FULFILLED': { + return { + ...state, + queueId: payload + } + } + case 'GET_DEFAULT_SONGS_FULFILLED': { + return { + ...state, + defaultSongs: payload + } + } + case 'GET_INPUT_VALUE': { + return { + ...state, + inputValue: payload + } + } + case 'ADD_DEFAULT_SONG_FULFILLED': { + return { + ...state, + defaultSongs: payload + } + } + default: { + return state; + } + } +} diff --git a/src/components/DefaultQueue/DefaultQueue.jsx b/src/components/DefaultQueue/DefaultQueue.jsx new file mode 100644 index 0000000..b75d43c --- /dev/null +++ b/src/components/DefaultQueue/DefaultQueue.jsx @@ -0,0 +1,67 @@ +import React, { Component } from 'react'; +import { getDefaultQueue, getDefaultSongs, deleteDefaultSong, handleAddInput, addDefaultSong } from './DQueueActions' + +export default class DefaultQueue extends Component { + constructor(props) { + super(props); + + this.getDefaultSongs = this.getDefaultSongs.bind(this); + this.handleAddInput = this.handleAddInput.bind(this); + this.deleteDefaultSong = this.deleteDefaultSong.bind(this); + this.addDefaultSong = this.addDefaultSong.bind(this); + } + + componentDidMount() { + const { dispatch, userID } = this.props; + dispatch(getDefaultQueue(userID)) + } + + getDefaultSongs() { + const { dispatch, queueId } = this.props; + dispatch(getDefaultSongs(queueId)) + } + + deleteDefaultSong(event) { + const { dispatch, queueId } = this.props; + dispatch(deleteDefaultSong(event.target.name, queueId)) + } + + handleAddInput(event) { + const { dispatch } = this.props; + dispatch(handleAddInput(event.target.value)) + } + + addDefaultSong() { + const { dispatch, inputValue, queueId } = this.props; + dispatch(addDefaultSong(inputValue, queueId)) + } + + render() { + const { defaultSongs } = this.props; + return ( +
+

Comfort Music

+ + + + +
+ ) + } +} diff --git a/src/components/DefaultQueue/index.js b/src/components/DefaultQueue/index.js new file mode 100644 index 0000000..ce373ed --- /dev/null +++ b/src/components/DefaultQueue/index.js @@ -0,0 +1,13 @@ +import { connect } from 'react-redux'; +import DefaultQueue from './DefaultQueue'; + +function mapStoreToProps(store) { + return { + queueId: store.DQueue.queueId, + defaultSongs: store.DQueue.defaultSongs, + inputValue: store.DQueue.inputValue, + userID: store.Login.userID + } +} + +export default connect(mapStoreToProps)(DefaultQueue); diff --git a/src/components/HomePage/HomePage.jsx b/src/components/HomePage/HomePage.jsx new file mode 100644 index 0000000..bc5bed5 --- /dev/null +++ b/src/components/HomePage/HomePage.jsx @@ -0,0 +1,47 @@ +import React, { Component } from 'react'; +import Search from '../Search'; +import { getSongs } from '../SongQueue/SQueueActions' + +class HomePage extends Component { + constructor() { + super(); + } + + componentDidMount() { + const { dispatch, queueId } = this.props; + dispatch(getSongs(queueId)); + } + + render() { + const { songs } = this.props + return ( +
+

SPACEBOX

+
+ +
+
+ {songs.map((song, index) => { + if (index < 3) { + return ( +
+ +
+ {index === 0 &&

Recently Playing

} +

{song.name}

+

{song.artist}

+
+
+ ) + } + return ( +
  • {song.name}
  • + ) + })} +
    +
    + ); + } +} + +export default HomePage; diff --git a/src/components/HomePage/index.js b/src/components/HomePage/index.js new file mode 100644 index 0000000..1d7b75a --- /dev/null +++ b/src/components/HomePage/index.js @@ -0,0 +1,11 @@ +import { connect } from 'react-redux'; +import HomePage from './HomePage'; + +function mapStoreToProps(store) { + return { + songs: store.SQueue.songs, + queueId: store.DQueue.queueId + }; +} + +export default connect(mapStoreToProps)(HomePage); diff --git a/src/components/Login/Login.jsx b/src/components/Login/Login.jsx new file mode 100644 index 0000000..73f9a98 --- /dev/null +++ b/src/components/Login/Login.jsx @@ -0,0 +1,52 @@ +import React, { Component } from 'react'; +import { Redirect } from 'react-router'; +import { updateUsername, updatePassword, postLogin } from './LoginActions'; + +export default class Login extends Component { + constructor(props) { + super(props); + this.handleUsername = this.handleUsername.bind(this); + this.handlePassword = this.handlePassword.bind(this); + this.submitLogin = this.submitLogin.bind(this); + } + + handleUsername(e) { + const { dispatch } = this.props; + dispatch(updateUsername(e.target.value)); + } + + handlePassword(e) { + const { dispatch } = this.props; + dispatch(updatePassword(e.target.value)) + } + + submitLogin(e) { + e.preventDefault(); + const { dispatch, username, password } = this.props; + dispatch(postLogin({ username, password })); + } + + render() { + if (this.props.token) { + return + } + return ( +
    +
    +

    LOGIN

    +
    +
    +
    + + + + +
    +
    + +
    +
    +
    + ) + } +} diff --git a/src/components/Login/LoginActions.js b/src/components/Login/LoginActions.js new file mode 100644 index 0000000..3486766 --- /dev/null +++ b/src/components/Login/LoginActions.js @@ -0,0 +1,21 @@ +const axios = require('axios'); + +export const updateUsername = (value) => ({ + type: 'UPDATE_USERNAME', + payload: value, +}) + +export const updatePassword = (value) => ({ + type: 'UPDATE_PASSWORD', + payload: value, +}) + +export const postLogin = (userData) => { + return { + type: 'POST_LOGIN', + payload: axios.post('api/users/login', userData) + .then(response => { + return response.data + }) + } +} diff --git a/src/components/Login/LoginReducer.js b/src/components/Login/LoginReducer.js new file mode 100644 index 0000000..31a8d72 --- /dev/null +++ b/src/components/Login/LoginReducer.js @@ -0,0 +1,39 @@ +const initialstate = { + username: '', + password: '', +} + +export default function LoginReducer(state = initialstate, action) { + const { payload, type } = action; + + switch (type) { + case 'UPDATE_USERNAME': { + return { + ...state, + username: payload + } + } + case 'UPDATE_PASSWORD': { + return { + ...state, + password: payload + } + } + case 'POST_LOGIN_REJECTED': { + return { + ...state, + error: payload + } + } + case 'POST_LOGIN_FULFILLED': { + return { + ...state, + token: payload.id, + userID: payload.userID + } + } + default: { + return state + } + } +} diff --git a/src/components/Login/index.js b/src/components/Login/index.js new file mode 100644 index 0000000..74d724a --- /dev/null +++ b/src/components/Login/index.js @@ -0,0 +1,13 @@ +import { connect } from 'react-redux'; +import Login from './Login'; + +function mapStoreToProps(store) { + return { + username: store.Login.username, + password: store.Login.password, + token: store.Login.token, + userID: store.Login.userID + }; +} + +export default connect(mapStoreToProps)(Login); diff --git a/src/components/Room/Room.jsx b/src/components/Room/Room.jsx new file mode 100644 index 0000000..d7e18d0 --- /dev/null +++ b/src/components/Room/Room.jsx @@ -0,0 +1,30 @@ +import React, { Component } from 'react'; +import HomePage from '../HomePage/index'; +import { withRouter } from 'react-router'; +import { updateSongs } from '../SongQueue/SQueueActions'; +import io from 'socket.io-client'; +const socket = io(); + +class Room extends Component { + constructor(props) { + super(props); + const { dispatch ,match: { params } } = this.props; + + socket.on('connect', () => { + socket.emit('room', params.queueId) + }) + socket.on('update', (tracks) => { + dispatch(updateSongs(tracks)); + }); + } + + render() { + return ( +
    + +
    + ); + }; +} + +export default withRouter(Room); diff --git a/src/components/Room/index.js b/src/components/Room/index.js new file mode 100644 index 0000000..0426b58 --- /dev/null +++ b/src/components/Room/index.js @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import Room from './Room'; + +function mapStoreToProps(store) { + return {}; +} + +export default connect(mapStoreToProps)(Room); diff --git a/src/components/Search/Search.jsx b/src/components/Search/Search.jsx new file mode 100644 index 0000000..93cd06b --- /dev/null +++ b/src/components/Search/Search.jsx @@ -0,0 +1,81 @@ +import React, { Component } from 'react'; +import { updateQuery, dbSearch, spotifySearch, updateType, addToQueue, updateData, getSongs } from './SearchAction'; + +class Search extends Component { + constructor(props) { + super(props); + this.handleSpotifyCall = this.handleSpotifyCall.bind(this); + this.handleSelect = this.handleSelect.bind(this); + this.handleQuery = this.handleQuery.bind(this); + this.handleDbSearch = this.handleDbSearch.bind(this); + this.handleSelectedSongUri = this.handleSelectedSongUri.bind(this); + } + + handleSelect(event) { + const { dispatch } = this.props; + const { value } = event.target; + dispatch(updateType( value )); + } + + handleQuery(event) { + const { dispatch } = this.props; + const { value } = event.target; + dispatch(updateQuery( value )); + } + + handleDbSearch() { + const { dispatch, query, type } = this.props; + dispatch(dbSearch(type, query)); + } + + handleSelectedSongUri(e) { + const { match: { params } } = this.props; + const { dispatch, userId } = this.props; + var uri = e.target.name + dispatch(addToQueue(uri, userId, queueId)) + dispatch(updateData()) + dispatch(getSongs(params.queueId)) + } + + handleSpotifyCall() { + const { dispatch, userId, query } = this.props; + var type = 'track' + dispatch({ type: "UPDATE_TYPE" }) + dispatch(spotifySearch(type, query, userId)); + } + + render() { + return ( +
    +
    + + + + +
    + + {(this.props.data) && + this.props.data.map((listItem, index) => { + return
    +
    + Artist: {listItem.artist} +
    +
    + Song: {listItem.name} +
    + +
    + + }) + } + +
    + )} + +} + +export default Search; diff --git a/src/components/Search/SearchAction.js b/src/components/Search/SearchAction.js new file mode 100644 index 0000000..0c1a531 --- /dev/null +++ b/src/components/Search/SearchAction.js @@ -0,0 +1,71 @@ +const axios = require("axios"); + +export function dbSearch(type, query) { + return { + type: "DB_SEARCH", + payload: axios.get(`/api/Songs?filter={"where":{"${type}":"${query}"}}`).then(response => response.data) + } +} +export function handleSpotifyCall() { + return { + type: "SEARCH_SPOTIFY", + payload: data + } +} +export function updateType(type) { + return { + type: "UPDATE_TYPE", + payload: type + } +} +export function updateQuery(query) { + return { + type: "UPDATE_QUERY", + payload: query + } +} +export function addToQueue(uri, userId, queueId) { + return { + type: "ADD_TO_QUEUE", + payload: + axios.post(`/api/Songs/getTrackData`, { songUri: `${uri}`, userID: `${userId}` }) + .then(res => { + if (res.data.song[0]) { + return axios.put(`/api/Queues/updatePlaylist`, { id: `${queueId}`, songID: `${res.data.song[0].id}` }) + .catch(err => alert('We caught an error; song not added.')); + } else { + return axios.put(`/api/Queues/updatePlaylist`, { id: `${queueId}`, songID: `${res.data.song.id}` }) + .catch(err => alert('We caught an error; song not added.')); + } + }) + } + +} +export function spotifySearch(type, query, userId) { + return { + type: "SPOTIFY_SEARCH", + payload: axios.get(`/api/Songs/getMoreFromSpotify/`, { + params: { + userId: userId, + query: query, + types: type, + } + }) + .then(res => { + return res.data + }) + + } +} +export function updateData() { + return { + type: 'UPDATE_DATA', + payload: [] + } +} +export function getSongs(queueId) { + return { + type: 'GET_SONGS', + payload: queueId + } +} diff --git a/src/components/Search/SearchReducer.js b/src/components/Search/SearchReducer.js new file mode 100644 index 0000000..5494a68 --- /dev/null +++ b/src/components/Search/SearchReducer.js @@ -0,0 +1,69 @@ +const initialState = { + disableButton : false, + data : [], + error : '', + query : '', + type : '', + +}; + +function SearchReducer(state = initialState, action) { + const { type, payload } = action; + switch (type) { + case 'TOGGLE_DISABLE_BUTTON': + return { + ...state, + disableButton: payload + } + case 'UPDATE_SEARCH_BY_TYPE': + return { + ...state, + type: payload + } + case 'UPDATE_DATA': + return { + ...state, + data: payload + } + case 'DB_SEARCH_FULFILLED': + return { + ...state, + data: payload + } + case 'DB_SEARCH_REJECTED': + return { + ...state, + error: 'The song or artist you are looking for is not in our database.' + } + case 'SPOTIFY_SEARCH_FULFILLED': + return { + ...state, + data: payload + } + case 'SPOTIFY_SEARCH_REJECTED': + return { + ...state, + error: 'The song or artist you are looking for was not found.' + } + case 'UPDATE_TYPE': + return { + ...state, + type: payload + } + case 'UPDATE_QUERY': + return { + ...state, + query: payload + } + case 'UPDATE_SELECTED_SONG_URI': + return { + ...state, + selectedSongUri: payload + } + default: + return { + ...state + } + } +} +export default SearchReducer; diff --git a/src/components/Search/index.js b/src/components/Search/index.js new file mode 100644 index 0000000..680934d --- /dev/null +++ b/src/components/Search/index.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux'; +import Search from './Search'; + +function mapStoreToProps(store) { + return { + data : store.Search.data, + disabled : store.Search.disabled, + error : store.Search.error, + query : store.Search.query, + userId : store.Login.userId, + selectedSongUri: store.Search.selectedSongUri, + type : store.Search.type, + }; +} + +export default connect(mapStoreToProps)(Search); diff --git a/src/components/SongQueue/SQueueActions.js b/src/components/SongQueue/SQueueActions.js new file mode 100644 index 0000000..0cb5d94 --- /dev/null +++ b/src/components/SongQueue/SQueueActions.js @@ -0,0 +1,17 @@ +const axios = require('axios'); + +export const getSongs = (queueId) => ({ + type: 'GET_SONGS', + payload: axios.get(`/api/Queues/${queueId}/songs`) + .then(response => response.data) +}) +export const deleteSong = (queueId, songId) => ({ + type: 'DELETE_SONG', + payload: axios.delete(`/api/Queues/${queueId}/songs/${songId}`) +}) +export function updateSongs(songs) { + return { + type: 'UPDATE_SONGS', + payload: songs + }; + } diff --git a/src/components/SongQueue/SQueueReducers.js b/src/components/SongQueue/SQueueReducers.js new file mode 100644 index 0000000..63beb45 --- /dev/null +++ b/src/components/SongQueue/SQueueReducers.js @@ -0,0 +1,25 @@ +const initialState = { + songs: [], +}; + +export default function queueReducer(state = initialState, action) { + const { type, payload } = action; + + switch(type) { + case 'GET_SONGS_FULFILLED': { + return { + ...state, + songs: payload + } + } + case 'UPDATE_SONGS': + return { + ...state, + songs: payload + } + default: + return { + ...state, + } + } +} diff --git a/src/components/SongQueue/SongQueue.jsx b/src/components/SongQueue/SongQueue.jsx new file mode 100644 index 0000000..4b38b73 --- /dev/null +++ b/src/components/SongQueue/SongQueue.jsx @@ -0,0 +1,48 @@ +import React, { Component } from 'react'; +import { getSongs, deleteSong } from './SQueueActions'; + +export default class SongQueue extends Component { + constructor(props) { + super(props); + + this.getQueue = this.getQueue.bind(this); + this.deleteSong = this.deleteSong.bind(this); + } + + getQueue() { + const {dispatch, queueId } = this.props; + dispatch(getSongs(queueId)) + } + + deleteSong(event) { + const { dispatch, queueId } = this.props; + dispatch(deleteSong(queueId, event.target.name)) + } + + render() { + const { songs } = this.props; + return ( +
    +

    Coming Up Next...

    + + +
    + ) + } +} diff --git a/src/components/SongQueue/index.js b/src/components/SongQueue/index.js new file mode 100644 index 0000000..62c9250 --- /dev/null +++ b/src/components/SongQueue/index.js @@ -0,0 +1,11 @@ +import { connect } from 'react-redux'; +import SongQueue from './SongQueue'; + +function mapStoreToProps(store) { + return { + songs: store.SQueue.songs, + queueId: store.DQueue.queueId + } +} + +export default connect(mapStoreToProps)(SongQueue); diff --git a/src/containers/AdminContainer.jsx b/src/containers/AdminContainer.jsx new file mode 100644 index 0000000..c1b46c3 --- /dev/null +++ b/src/containers/AdminContainer.jsx @@ -0,0 +1,23 @@ +import React, { Component } from 'react'; +import DefaultQueue from '../components/DefaultQueue'; +import CurrentSong from '../components/CurrentSong'; +import SongQueue from '../components/SongQueue'; + +export default class AdminContainer extends Component { + constructor(props) { + super(props); + } + + render() { + return ( +
    +

    + DASHBOARD +

    + + + +
    + ) + } +} diff --git a/src/containers/HomeContainer.jsx b/src/containers/HomeContainer.jsx new file mode 100644 index 0000000..50fb72b --- /dev/null +++ b/src/containers/HomeContainer.jsx @@ -0,0 +1,13 @@ +import React, { Component } from 'react'; +import HomePage from '../components/HomePage'; + +export default class HomeContainer extends Component { + + render() { + return ( +
    + +
    + ) + } +} diff --git a/src/containers/Logincontainer.jsx b/src/containers/Logincontainer.jsx new file mode 100644 index 0000000..b5acb99 --- /dev/null +++ b/src/containers/Logincontainer.jsx @@ -0,0 +1,15 @@ +import React, { Component } from 'react'; +import Login from '../components/Login'; + +export default class LoginContainer extends Component { + + render() { + return ( +
    +
    + +
    +
    + ) + } +} diff --git a/src/containers/RoomContainer.jsx b/src/containers/RoomContainer.jsx new file mode 100644 index 0000000..ace1bc4 --- /dev/null +++ b/src/containers/RoomContainer.jsx @@ -0,0 +1,13 @@ +import React, { Component } from 'react'; +import Room from '../components/Room/index'; + +export default class RoomContainer extends Component { + + render() { + return ( +
    + +
    + ) + } +} diff --git a/src/index.js b/src/index.js deleted file mode 100755 index bcb1995..0000000 --- a/src/index.js +++ /dev/null @@ -1,81 +0,0 @@ -import axios from 'axios'; -import React, { Component } from 'react'; -import ReactDOM from 'react-dom'; -import io from 'socket.io-client'; - -const socket = io(); - -export default class App extends Component { - constructor() { - super(); - this.state = { - uri: "", - disabled: false, - songs: [] - } - socket.on('update', songs => this.setState({ songs })); - this.getUri = this.getUri.bind(this) - this.handleSearch = this.handleSearch.bind(this) - } - - - componentDidMount() { - axios.get('/api/playlist').then(response => { - if (response.data.error) return alert(response.data.error); - this.setState({ songs: response.data }) - }); - } - - getUri(e) { - this.setState({ - [e.target.name]: e.target.value - }) - } - - handleSearch() { - this.setState({disabled: true}); - axios.post('/api/request', {uri: this.state.uri}) - .then( res => { - if (res.data.error) return alert(res.data.error) - return res.data - }) - .then(() => this.setState({disabled: false})) - .catch(err => { - alert(err.message) - this.setState({disabled: true}) - }) - } - - render() { - return ( -
    -

    SPACEBOX

    -
    - - -
    -
    - {this.state.songs.map((song, index) => { - if (index < 3) { - return ( -
    - -
    - {index === 0 &&

    // Recently Playing

    } -

    {song.name}

    -

    {song.artist}

    -
    -
    - ) - } - return ( -
  • {song.name}
  • - ) - })} -
    -
    - ); - } -} - -ReactDOM.render(, document.getElementById('root')); diff --git a/src/index.jsx b/src/index.jsx new file mode 100644 index 0000000..1844083 --- /dev/null +++ b/src/index.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { render } from 'react-dom'; +import App from './App'; +import { Provider } from 'react-redux'; +import { createStore, applyMiddleware, compose } from 'redux'; +import promiseMiddleware from 'redux-promise-middleware'; +import rootReducer from './rootReducer'; + +const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; + +/* eslint-disable no-underscore-dangle */ +const store = createStore(rootReducer, composeEnhancers( + applyMiddleware( + promiseMiddleware() + ) +)); +/* eslint-enable */ + +render( + + + , + document.getElementById('root') +); diff --git a/src/rootReducer.js b/src/rootReducer.js new file mode 100644 index 0000000..c6692fa --- /dev/null +++ b/src/rootReducer.js @@ -0,0 +1,16 @@ +import { combineReducers } from 'redux'; +import SearchReducer from './components/Search/SearchReducer'; +import DQueueReducer from './components/DefaultQueue/DQueueReducer'; +import SQueueReducer from './components/SongQueue/SQueueReducers'; +import SongReducer from './components/CurrentSong/SongReducer'; +import LoginReducer from './components/Login/LoginReducer' + +const rootReducer = combineReducers({ + Search: SearchReducer, + DQueue: DQueueReducer, + SQueue: SQueueReducer, + Song: SongReducer, + Login: LoginReducer, +}); + +export default rootReducer; diff --git a/src/styles.scss b/src/styles.scss index d88c2d3..10c1956 100755 --- a/src/styles.scss +++ b/src/styles.scss @@ -11,7 +11,7 @@ html { background-attachment: fixed; box-sizing: border-box; color: $bg-text; - font-family: 'NATS', Avenir, Helvetica, sans-serif; + font-family: Avenir, Helvetica, sans-serif; font-size: 25px; height: 100%; margin: 0; @@ -23,7 +23,7 @@ section { max-width: 1150px; } -h1 { +.glitch{ color: transparent; font-family: NATS; font-size: 100px; @@ -101,6 +101,15 @@ li { } } +.dropdown-content { + display: none; + position: absolute; + z-index: 1; + background-color: #3023AE; + background-image: linear-gradient(-134deg, #C86DD7 0%,#3023AE 100%); + width: 100%; +} + .request { width: 100%; position: relative; @@ -126,8 +135,31 @@ li { color: white; border-radius: 0; } + select { + height: 100%; + width: 20%; + background-image: linear-gradient(-134deg, #C86DD7 0%,#3023AE 100%); + font-size: 25px; + color: #fff; + border: none; + } + option { + color: #000; + } + button { + width: 20%; + margin: 20px 0; + background-image: linear-gradient(-134deg, #3023AE 0%, #C86DD7 100%); + color: white; + border-radius: 0; + } } +#button:hover { + background-image: linear-gradient(-134deg,#3023ae,#72257e); + border-color: #c86dd7; +} + .playlist { img { max-width: 200px; @@ -146,7 +178,7 @@ li { .currently-playing { justify-content: center; - .song-info { + .song-info { transform: translateX(0); animation: fadeInLeftFirst 750ms ease 250ms forwards; } @@ -179,7 +211,7 @@ li { } img { animation: fadeIn 500ms ease 1s forwards; - } + } } } @@ -217,6 +249,8 @@ li { color: white; font-size: 100px; position: relative; + max-width: fit-content; + margin: auto; } @keyframes noise-anim { @@ -281,4 +315,283 @@ li { font-style: normal; font-weight: normal; src: url('./fonts/NATS.woff2') format('woff2'); -} \ No newline at end of file +} + +#header{ + margin-bottom: 6vw; +} + +.grid { + display: grid; + grid-template-columns: 1fr 2fr; + +} + +.label { + display: grid; + justify-items: end; + font-size: 35pt; + height: 55px; + width: 34vw; + margin-top: 20px; +} + +#password { + font-size: 24pt +} + +#submit{ + width: 23.05vw; + margin-left: 35.1vw; + margin-top: 45px; + background-image: linear-gradient(-134deg, #3023AE 0%, #C86DD7 100%); + color: white; + border-radius: 0; + height: 60px; + border: none; + font-size: 20pt; + padding-left: 0; + padding-right: 0; +} + +#pw-flex{ + height: 50px +} + +#song-container { + width: 100%; + position: relative; +} + +.current-track-bottom { + justify-content: center; +} + +.current-track-container { + height: 33%; +} + +.list-item-container{ + display: block; + align-items: center; + position: relative; +} + +.queue-container { + overflow: scroll; + background-image: linear-gradient(rgb(86,75,67),rgb(64,74,89) 85%); + padding: 15px; + box-sizing: border-box; +} + +.queue-header { + margin: 0; + border-bottom: 1px solid hsla(0,0%,100%,.1); +} + +.queue-item { + padding: 0; + margin: 0; + border: 0; + display: flex; + color: hsla(0,0%,100%,.6); + width: 100%; +} + +.queue-list { + display: flex; + list-style-type: none; + font-size: 20px; +} + +.track-album { + font-size: inherit; +} + +.track-artist { + font-size: inherit; +} + +.track-info { + font-size: inherit; +} + +.track-length { + text-align: right; + margin-left: auto; + margin-right: 1em; + +} + +.track-title { + font-size: 24px; + line-height: 22px; + letter-spacing: .015em; + color: #fff; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + text-align: left; + vertical-align: baseline; + unicode-bidi: -webkit-isolate; +} + +#current-track { + grid-area: track; + display: flex; + justify-content: center; +} + +#current-track-name { + flex: center; +} + +#dashboard-container { + display: grid; + width: 90vw; + height: 90vh; + grid-template-areas: + "header header header" + "track track track" + "search search button" + "dQueue . sQueue" + "dQueue . sQueue" + "dQueue . sQueue"; + grid-template-columns: repeat(3, 33%); + grid-template-rows: 20% 10% 10% 20% 20% 20%; + margin: 0 auto; +} + +#dashboard-title { + grid-area: header; +} + +#default-queue { + grid-area: dQueue; +} + +#play-btn-circle { + display: block; + height: 60px; + width: 60px; + border-radius: 50%; + border: 1px solid white; + background: silver; + justify-content: center; + box-sizing: content-box; + box-shadow: 0 1px 0 #666, 0 5px 0 #444, 0 6px 6px; +} + +#play-btn-circle:active { + -webkit-transform: translateY(3px); + transform: translateY(3px); + box-shadow: 0 1px 0 #666, 0 2px 0 #444, 0 2px 2px; +} + +.press-play { + position: relative; + top: 10px; + left: 21px; + box-sizing: border-box; + height: 40px; + border-color: transparent transparent transparent #202020; + transition: 100ms all ease; + will-change: border-width; + cursor: pointer; + + border-style: solid; + border-width: 20px 0 20px 25px; +} + +.press-pause { + position: relative; + top: 9px; + left: 14px; + box-sizing: border-box; + height: 38px; + border-color: transparent transparent transparent #202020; + transition: 100ms all ease; + will-change: border-width; + cursor: pointer; + + border-style: double; + border-width: 0px 0 0px 32px; +} + +#song-queue { + grid-area: sQueue; +} + +.dropbtn { + background-color: rgb(204, 32, 32); + color: white; + font-size: 16px; + border: none; + } + + .dropdown { + position: relative; + display: inline-block; + width: 20%; + position: relative; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + a: 60px; + background-image: linear-gradient(-134deg, #3023AE 0%, #C86DD7 100%); + height: 60px; + color: white; + } + #dropdown:hover { + background-image: linear-gradient(-134deg,#3023ae,#72257e); + border-color: #c86dd7; +} + + .main-flex { + width: 100%; + height: 60px; + margin-top: 15px; + background-image: linear-gradient(-134deg,#3023ae,#c86dd7); + color: #fff; + border-radius: 0; + display: flex; + justify-content: space-between; + } + + .artist-name { + margin-left: 15px; + } + + .dropdown:hover .dropdown-content {display: block;} + + .song-name { + margin-right: 15px; +} + +#addToQueue { + width:14.5%; + color: white; + font-size: 20px; + background-image: linear-gradient(-134deg,#3023ae,#c86dd7); +} + +#addToQueue:hover { + background-image: linear-gradient(-134deg,#3023ae,#72257e); + border-color: #c86dd7; +} + +#spotify-btn { + margin-top: 15px; + width:14.5%; + color: white; + font-size: 20px; + background-image: linear-gradient(-134deg,#3023ae,#c86dd7); + height: 60px; + flex-direction: row-reverse; +} + +#spotify-btn:hover { + background-image: linear-gradient(-134deg,#3023ae,#72257e); + border-color: #c86dd7; +} diff --git a/tests/login.spec.js b/tests/login.spec.js new file mode 100644 index 0000000..a0e26af --- /dev/null +++ b/tests/login.spec.js @@ -0,0 +1,42 @@ +'use strict'; +const express = require('express'); +const Nightmare = require('nightmare'); +const expect = require('chai').expect; +const server = require('../server/server'); + +server.listen(8888) + + +let nightmare; + +describe('Login Page', function main() { + this.timeout(10000); + const url = 'http://localhost:8888/#/admin'; + + + + beforeEach(() => { + nightmare = new Nightmare({ show: true }); + }); + + it('should be able to login using the default username and password', (done) => { + nightmare + .goto(url) + .wait(1000) + .type('#username', 'DefaultAdmin') + .type('#password', 'admin') + .wait(1000) + .click('#submit') + .wait(2000) + .evaluate(() => document.querySelector('h1').innerText) + .end() + .then(text => { + expect(text).to.contain('DASHBOARD'); + done(); + }) + .catch(err => console.log(err)); + }) + +}) + + diff --git a/tests/playback.spec.js b/tests/playback.spec.js new file mode 100644 index 0000000..fa8dba3 --- /dev/null +++ b/tests/playback.spec.js @@ -0,0 +1,46 @@ +'use strict'; + +const React = require('react'); +const expect = require('chai').expect; +const Nightmare = require('nightmare'); +const server = require('../server/server'); +const url = 'http://localhost:3000/#'; + +let nightmare; + +server.listen(3000); + +describe('Playback Button', () => { + beforeEach(() => { + nightmare = Nightmare({ + show: true + }) + }); + + it('should exist', function (done) { + this.timeout(10000); + nightmare + .goto(url + '/dashboard') + .wait(1000) + .evaluate(() => document.querySelector('#play-btn-circle').id) + .end() + .then(id => { + expect(id).to.equal('play-btn-circle') + done() + }) + }); + + it('should alter playing on-click', function () { + nightmare + .goto(url + '/dashboard') + .wait(3000) + .click('div[name=play]') + .wait(3000) + .evaluate(() => document.querySelector('.play-pause').value) + .end() + .then(value => { + expect(value).to.equal('true') + done() + }) + }); +}) diff --git a/tests/playlist.spec.js b/tests/playlist.spec.js new file mode 100644 index 0000000..6adaa39 --- /dev/null +++ b/tests/playlist.spec.js @@ -0,0 +1,85 @@ +'use strict'; +const chai = require('chai'); +const chaiHttp = require('chai-http'); +const server = require('../server/server'); +const { removeCurrentlyPlaying, addNewSong, addDefaultSongsAndGetURIs } = require('../server/utils/playlist'); + +chai.use(chaiHttp); +const expect = chai.expect; + +server.listen(4444); + +// Variables used in tests +var songArr1 = [{ + id: '5HE2u1IOizZwM4kWMF5Jpb', + name: 'Sunset', + artist: 'Coubo', + albumCover: 'https://i.scdn.co/image/2362328b20a356c1d23897d38531b1b93844c788', + duration: 213361, + uri: 'spotify:track:5HE2u1IOizZwM4kWMF5Jpb' +}] + +var songArr2 = [{ + id: '299vmLW2iaQxe8y9HLWNiH', + name: 'just ask', + artist: 'weird inside', + albumCover: 'https://i.scdn.co/image/6b440d0d81145350b4bf87c7a77d679701dd4f22', + duration: 173846, + uri: 'spotify:track:299vmLW2iaQxe8y9HLWNiH' +}, +{ + id: '5HE2u1IOizZwM4kWMF5Jpb', + name: 'Sunset', + artist: 'Coubo', + albumCover: 'https://i.scdn.co/image/2362328b20a356c1d23897d38531b1b93844c788', + duration: 213361, + uri: 'spotify:track:5HE2u1IOizZwM4kWMF5Jpb' +}] + +var songArr3 = [{ + name: 'Where Is My Mind', + duration: 165160, + artist: 'Maxence Cyrin', + uri: 'spotify:track:4jNQkWhuzqrbqQuqanFFJ6', + albumCover: 'https://i.scdn.co/image/c21c1e4c58342abb6f88dfe1b291c718f6a70e43', + spotifyId: '4jNQkWhuzqrbqQuqanFFJ6', + id: '5bd8d7c2466abee8136f2501' +}] + +var songCurrentlyPlaying = { uri: 'spotify:track:299vmLW2iaQxe8y9HLWNiH' } + + +// Tests +describe('updatePlaylist functionality', () => { + + + it('should remove the currently playing song from the songs array', (done) => { + const promiseRemoveCurrentlyPlaying = removeCurrentlyPlaying(songArr2, songCurrentlyPlaying, '5bd78822b290189455e581ea') + promiseRemoveCurrentlyPlaying.then((response) => { + expect(response.songs[0].name).to.equal('Sunset') + expect(response.songs[0].artist).to.equal('Coubo') + expect(response.songs[0].uri).to.equal('spotify:track:5HE2u1IOizZwM4kWMF5Jpb') + done(); + }); + }); + + it('should find a song and add it to the end of the songs array when passed an empty array', (done) => { + addNewSong('5bd8d7c2466abee8136f2501', []) + .then((response) => { + expect(String(response[0].name)).to.equal('Where Is My Mind') + expect(String(response[0].artist)).to.equal('Maxence Cyrin') + expect(String(response[0].uri)).to.equal('spotify:track:4jNQkWhuzqrbqQuqanFFJ6') + done(); + }); + }); + + it('should find a song and add it to the end of the songs array when passed an array with songs', (done) => { + const promiseAddNewSong = addNewSong('5bd8d7c2466abee8136f2501', songArr1) + promiseAddNewSong.then((response) => { + expect(String(response[1].name)).to.equal('Where Is My Mind') + expect(String(response[1].artist)).to.equal('Maxence Cyrin') + expect(String(response[1].uri)).to.equal('spotify:track:4jNQkWhuzqrbqQuqanFFJ6') + done(); + }); + }); +}); diff --git a/tests/server.spec.js b/tests/server.spec.js index f9cf5a9..5cdd095 100644 --- a/tests/server.spec.js +++ b/tests/server.spec.js @@ -1,25 +1,27 @@ 'use strict'; const chai = require('chai'); +const path = require('path'); +const MongoClient = require('mongodb').MongoClient; const chaiHttp = require('chai-http'); -const { server } = require('../server/server'); +const {server} = require('../server/server'); chai.use(chaiHttp); const expect = chai.expect; -describe('server.js', () => { - beforeEach((done) => { - server.listen(4444); - done(); +describe('MongoDB', function() { + this.timeout(10000); + + beforeEach(() => { + }); - it('responds to /', (done) => { - chai.request(server) - .get('/') - .end((err, res) => { - const exists = expect(err).not.exist; - const status = expect(res).to.have.status(200); - done(); - }); + it('Should be able to connect to MongoDB', (done) => { + const url = process.env.MONGODB_URI; + MongoClient.connect(url, (err, db) => { + expect(err).to.equal(null); + db.close(); + done(); + }); }); -}); \ No newline at end of file +}); diff --git a/tests/song.spec.js b/tests/song.spec.js new file mode 100644 index 0000000..931d1d2 --- /dev/null +++ b/tests/song.spec.js @@ -0,0 +1,41 @@ +'use strict'; +const chai = require('chai'); +const chaiHttp = require('chai-http'); +const server = require('../server/server'); +const song = require('../common/models/song.js'); +const {getSong} = require('../server/utils/song'); + +chai.use(chaiHttp); +const expect = chai.expect; + +server.listen(4444); + +describe('getSong functionality', function() { + this.timeout(7000); + + it('responds to /Song/Song_find', (done) => { + chai.request(server) + .get('/explorer/#!/Song/Song_find') + .end((err, res) => { + expect(err).not.exist; + expect(res).to.have.status(200); + done(); + }); + }); + + it('should return an error if bad URI is given', (done) => { + getSong(undefined, '5bd74cdb91ba9430a03c469e') + .then((response) => { + expect(response).to.equal('Bad URI'); + done(); + }); + }); + + it('should return an error if bad userID is given', (done) => { + getSong('spotify:track:5HE2u1IOizZwM4kWMF5Jpb', undefined) + .then((response) => { + expect(response).to.equal('Bad userID'); + done(); + }); + }); +}); diff --git a/webpack.config.js b/webpack.config.js index 7876ab4..5ce203f 100755 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,11 +1,22 @@ +'use strict'; const path = require('path'); const config = { - entry: [path.resolve(__dirname, 'src'), path.resolve(__dirname, 'src/styles.scss')], + devtool: 'source-map', + entry: [path.resolve(__dirname, 'src'), + path.resolve(__dirname, 'src/styles.scss')], output: { - path: path.resolve(__dirname, 'build'), + path: path.resolve(__dirname, 'dist'), filename: 'bundle.js', }, + devtool: 'source-map', + resolve: { + alias: { + react: path.join(__dirname, 'node_modules', 'react'), + }, + extensions: ['.js', '.jsx'], + }, + module: { rules: [{ test: /\.jsx?$/,