diff --git a/app.js b/app.js new file mode 100644 index 0000000..bf8c45c --- /dev/null +++ b/app.js @@ -0,0 +1,71 @@ +let app = new Vue({ + el: `#app`, + data: { + api_base: `https://api.spotify.com/v1`, + duration: ``, + type: ``, + count: ``, + show: { + popularity_popup: false, + popularity_hover: false, + follower_hover: false + }, + error: { + main: ``, + auth: ``, + }, + auth: { + alert: `We will only be able to access your top tracks and artists, nothing else. This is also only done on your browser. Our servers do not see any of the data from your account.`, + base_url: `https://accounts.spotify.com/authorize`, + redirect: `http://localhost:5000`, + client_id: `3a1795e9d55445b0aa0c05dd74c866fb`, + scopes: [ + `user-top-read` + ], + show_dialog: true, + use_state: false + }, + user: { + name: ``, + image: `` + }, + data: { + tracks: [], + artists: [] + } + }, + computed: { + spotify_auth_url: auth_url, + is_authed: verify_auth, + button_type: get_button_text, + }, + methods: { + get_token: function () { + let params = new URLSearchParams(window.location.hash.slice(1)); + return params.get(`access_token`); + }, + get_user: function () { + axios.get( + `${this.api_base}/me`, + { headers: { Authorization: `Bearer ${this.get_token()}` } } + ).then((response) => { + if (response.error) { + window.location.hash = ``; + window.location.href = this.auth.redirect; + return + } + let data = response.data; + + // Set the Vue user object + this.user.name = data.display_name; + this.user.image = data.images.length > 0 ? data.images[0].url : ``; + + }).catch((err) => { + window.location.hash = ``; + window.location.href = this.auth.redirect; + return + }) + }, + get_data: fetch_data, + } +}) \ No newline at end of file diff --git a/components/artist.html b/components/artist.html new file mode 100644 index 0000000..0a09fd8 --- /dev/null +++ b/components/artist.html @@ -0,0 +1,14 @@ +
+
+ +
+
+ + {{artist.name}} + +
+ {{artist.genres.join(", ")}} +
+
{{artist.popularity}}
+
{{artist.follower_count}}
+
\ No newline at end of file diff --git a/components/artist.js b/components/artist.js new file mode 100644 index 0000000..7fcd7ca --- /dev/null +++ b/components/artist.js @@ -0,0 +1,24 @@ +Vue.component( + `artist`, + { + props: [ `artist` ], + data: function () { + return {}; + }, + computed: {}, + template: `
+
+ +
+
+ + {{artist.name}} + +
+ {{artist.genres.join(", ")}} +
+
{{artist.popularity}}
+
{{artist.follower_count}}
+
` + } +) \ No newline at end of file diff --git a/components/icons.js b/components/icons.js new file mode 100644 index 0000000..e69de29 diff --git a/components/track.html b/components/track.html new file mode 100644 index 0000000..1ff52fe --- /dev/null +++ b/components/track.html @@ -0,0 +1,20 @@ +
+
+ + + +
+
+ + {{track.name}} + + + {{track.name}} + +
+ +
+
+
{{track.popularity}}
+
{{duration}}
+
\ No newline at end of file diff --git a/components/track.js b/components/track.js new file mode 100644 index 0000000..ed2dd9f --- /dev/null +++ b/components/track.js @@ -0,0 +1,63 @@ +Vue.component( + `track-card`, + { + props: [ `track` ], + data: function () { + return { + popularity_tooltip: `Popularity.\nClick for more information.` + } + }, + template: `
+
+ + + +
+
+ + {{track.name}} + + + {{track.name}} + +
+ +
+
+
{{track.popularity}}
+
{{duration}}
+
`, + computed: { + duration: function () { + let timestamp = ``; + + // Converting to seconds + let duration = Math.trunc(this.track.duration / 1000); + let seconds = duration % 60; + + // Converting to minutes + duration = Math.trunc(duration / 60); + let minutes = duration % 60 + + // Converting to hours + duration = Math.trunc(duration / 60); + let hours = duration % 24; + + if (seconds < 10) { + seconds = `0${seconds}` + }; + + return `${hours > 0 ? `${hours}:` : ''}${minutes}:${seconds}`; + }, + artists: function () { + let artists = []; + for (var artist of this.track.artists) { + artists.push( + `${artist.name}` + ) + } + return artists.join(`, `) + }, + } + } +); \ No newline at end of file diff --git a/css/artist.css b/css/artist.css new file mode 100644 index 0000000..ffa6d93 --- /dev/null +++ b/css/artist.css @@ -0,0 +1,84 @@ +div.artist { + background-color: var(--card-colour); + color: var(--card-text); + border-radius: 7px; + border-style: none; + padding: 10px; + padding-top: 20px; + margin: 5px auto; + width: 90%; + position: relative; + + display: flex; + flex-direction: column; +} + + +div.artist > div.profile_pic { + text-align: center; +} +div.artist > div.profile_pic img { + width: 200px; + height: 200px; +} + +div.artist > div.info { + margin: 0; + text-align: center; + position: relative; + padding-bottom: 20px; +} + +div.artist > div.info > span.name { + text-decoration: none; + color: var(--text-on-card); + vertical-align: middle; + font-size: larger; +} + +div.artist > .followers { + background-color: var(--on-card-colour); + color: var(--on-card-text); + vertical-align: middle; + position: absolute; + padding: 1px 6px; + bottom: 0px; + right: 0px; + + /* top-left top-right lower-right lower-left */ + border-radius: 7px 0 7px 0; +} + +div.artist > div.info > span.genres { + font-size: smaller; +} + + +div.artist > .popularity { + background-color: var(--on-card-colour); + color: var(--on-card-text); + position: absolute; + padding: 1px 6px; + bottom: 0px; + left: 0px; + + /* top-left top-right lower-right lower-left */ + border-radius: 0px 7px 0px 7px; +} + +div.artist a { + color: var(--text-on-card); + text-decoration: none; +} +div.artist a:hover { + text-decoration: underline; +} + + +/* DESKTOP STYLES */ +@media only screen and (min-width: 768px) { + div.artist { + width: 230px; + margin: 5px; + } +} \ No newline at end of file diff --git a/css/track.css b/css/track.css new file mode 100644 index 0000000..ae07fd1 --- /dev/null +++ b/css/track.css @@ -0,0 +1,84 @@ +div.track { + background-color: var(--card-colour); + color: var(--card-text); + border-radius: 7px; + border-style: none; + padding: 10px; + padding-top: 20px; + margin: 5px auto; + width: 90%; + position: relative; + + display: flex; + flex-direction: column; +} + + +div.track > div.cover { + text-align: center; +} +div.track > div.cover img { + width: 200px; + height: 200px; +} + +div.track > div.info { + margin: 0; + text-align: center; + position: relative; + padding-bottom: 20px; +} + +div.track > div.info > span.name { + text-decoration: none; + color: var(--text-on-card); + vertical-align: middle; + font-size: larger; +} + +div.track > .duration { + background-color: var(--on-card-colour); + color: var(--on-card-text); + vertical-align: middle; + position: absolute; + padding: 1px 6px; + bottom: 0px; + right: 0px; + + /* top-left top-right lower-right lower-left */ + border-radius: 7px 0 7px 0; +} + +div.track > div.info > span.artist { + font-size: smaller; +} + + +div.track > .popularity { + background-color: var(--on-card-colour); + color: var(--on-card-text); + position: absolute; + padding: 1px 6px; + bottom: 0px; + left: 0px; + + /* top-left top-right lower-right lower-left */ + border-radius: 0px 7px 0px 7px; +} + +div.track a { + color: var(--text-on-card); + text-decoration: none; +} +div.track a:hover { + text-decoration: underline; +} + + +/* DESKTOP STYLES */ +@media only screen and (min-width: 768px) { + div.track { + width: 230px; + margin: 5px; + } +} \ No newline at end of file diff --git a/expired_tokens b/expired_tokens new file mode 100644 index 0000000..892febf --- /dev/null +++ b/expired_tokens @@ -0,0 +1 @@ +BQCai7XSoBzX6DhHscdWeWOeg_0J6KC6-mL2YFDzvQsWmlGUFMdXCTFji0TpxpSX-bak1GretW99OyyvXAY4xiECbou6H2_wDq8Zq-Yzr9UUeph0zF57BZ3nZjLA0vMDu3AXzGMQqPml9POL \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..3357e72 --- /dev/null +++ b/index.html @@ -0,0 +1,82 @@ + + + + + + Top Lists For Spotify + + + + + + + + + + + + + + + + + + + +
+
+
+ + + +

{{error.auth}}

+

{{auth.alert}}

+
+
+
+
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+
{{error.main}}
+
+ +
+
+ +
+
+
+ + \ No newline at end of file diff --git a/js/auth.js b/js/auth.js new file mode 100644 index 0000000..24d8bdc --- /dev/null +++ b/js/auth.js @@ -0,0 +1,53 @@ +function auth_url () { + let params = [ + `client_id=${this.auth.client_id}`, + `response_type=token`, + `redirect_uri=${encodeURIComponent(this.auth.redirect)}`, + `scope=${encodeURIComponent(this.auth.scopes.join(" "))}`, + `show_dialog=${this.auth.show_dialog}` + ]; + + // Create the state data if we are using it + if (this.auth.use_state) { + let state = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); + params.push(`state=${state}`); + localStorage.setItem(`top-spotify-state`, state); + }; + + return `${this.auth.base_url}?${params.join("&")}`; +}; + + +function verify_auth () { + let params = new URLSearchParams(window.location.hash.slice(1)); + + // Check to ensure the authorization was a success + if (params.get(`access_token`)) { + this.get_user() + + // Check if we compare state + if (this.use_state) { + + // Compare given state to localstorage state + let LS_state = localStorage.getItem(`top-spotify-state`); + if (LS_state = params.get(`state`)) { + console.info(`State compare success`) + this.authed = true; + return true + } + console.error(`State compare failed`) + return false + } else { + return true + } + } else { + let error = (new URLSearchParams(window.location.search)).get(`error`) + + // Authorization failed, error to the user + if (error !== null) { + this.error.auth = `Authentication failed or was cancelled, please try again.`; + window.location.hash = ``; + } + return false; + } +} \ No newline at end of file diff --git a/js/data.js b/js/data.js new file mode 100644 index 0000000..aa0ed14 --- /dev/null +++ b/js/data.js @@ -0,0 +1,57 @@ +function fetch_data () { + let url = `${this.api_base}/me/top/${this.type.toLowerCase()}`; + + let limit = parseInt(this.count); + if (!limit) { limit = 10; }; + + url += `?limit=${limit}&time_range=${this.duration}`; + + axios.get( + url, + { headers: { Authorization: `Bearer ${this.get_token()}` } } + ).then((response) => { + this.data.artists = []; + this.data.tracks = []; + this.error.main = ``; + switch (this.type) { + case `Tracks`: + for (var track of response.data.items) { + this.data.tracks.push({ + name: track.name, + popularity: track.popularity, + artists: track.artists, + link: track.external_urls.spotify, + duration: track.duration_ms, + locality: track.is_local, + id: track.uri, + album: { + name: track.album.name, + image: track.album.images[1], + link: track.album.external_urls.spotify + } + }); + }; + break; + + case `Artists`: + for (var artist of response.data.items) { + this.data.artists.push({ + name: artist.name, + id: artist.id, + popularity: artist.popularity, + follower_count: artist.followers.total, + image: artist.images[1], + genres: artist.genres, + link: artist.external_urls.spotify + }); + }; + break; + + default: + this.error.main = `TypeError: ${this.type} is not a supported category`; + break + }; + }).catch((err) => { + this.error.main = `${err.name}: ${err.message}` + }) +} \ No newline at end of file diff --git a/js/text_computation.js b/js/text_computation.js new file mode 100644 index 0000000..c62cc63 --- /dev/null +++ b/js/text_computation.js @@ -0,0 +1,19 @@ +function get_button_text () { + if (this.count === ``) { + return this.type; + } + else if (this.count === `1`) { + this.error.main = ``; + return this.type.slice(0,-1); + } + else if (this.count <= 0) { + this.error.main = `Cannot get 0 or fewer ${this.type.toLowerCase()}`; + return false; + } + else if (this.count > 50) { + this.error.main = `Cannot get more than 50 ${this.type.toLowerCase()}`; + return false; + } + this.error.main = ``; + return this.type; +}; \ No newline at end of file diff --git a/style.css b/style.css new file mode 100644 index 0000000..8ab3d75 --- /dev/null +++ b/style.css @@ -0,0 +1,293 @@ +:root { + --spotify-green: #1DB954; + --spotify-white: #FFFFFF; + --spotify-black: #000000; + + --accent1: #7289da; + --accent2: #00aa00; + + --error: #ff0000; + + --background: #23272A; + --background-text: var(--spotify-white); + + --card-colour: #2C2F33; + --card-text: #ffffff80; + + --on-card-colour: #4c4c4c; + --on-card-text: var(--spotify-green); + + --fonts: 'Open Sans', sans-serif; +} + + +html, body, #app { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + overflow-x: hidden; + font-family: var(--fonts); +} + + +body { + background-color: var(--background); +} + + +p { + padding: 10px; +} + +select { + background-color: var(--spotify-black); + color: var(--spotify-white); + padding: 15px; + border-style: none; + border-radius: 7px; + font-family: var(--fonts); + outline: none; +} + +select:focus { + border-style: none; +} + +button { + padding: 10px 20px; + background-color: var(--spotify-green); + border-style: none; + border-radius: 50px; + font-size: larger; + font-family: var(--fonts); + outline: none; +} + +input[type=number] { + background-color: var(--spotify-black); + color: var(--spotify-white); + padding: 15px; + border-style: none; + border-radius: 7px; + font-family: var(--fonts); + outline: none; +} +input[type=number]:active { + border-color: var(--spotify-green); +} + + +div.body { + display: flex !important; + flex-direction: column; + flex-wrap: wrap; + justify-content: center; + width: 95%; + margin-left: auto; + margin-right: auto; +} + + +[v-cloak], .hidden { + display: none !important; +} + +#login { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + color: var(--spotify-green); +} + + +.flex-row { + display: flex; + flex-direction: column; + justify-content: space-evenly; + align-items: center; + width: 90%; + background-color: var(--card-colour); + color: var(--card-text); + margin: 10px auto; + border-radius: 10px; + padding: 5px; +} + +.flex-row > div { + margin: 10px; +} + +.row { + width: 90%; + background-color: var(--card-colour); + color: var(--card-text); + margin: 10px auto; + padding: 20px; + border-radius: 10px; +} + +.center { + text-align: center; +} + +.error { + color: var(--error); + border-style: solid; + border-radius: 5px; + border-width: 2px; +} + +.card { + display: inline-block; + color: var(--card-text); + padding: 20px; + margin: 5px; + background-color: var(--card-colour); + border-radius: 7px; + font-size: large; + width: 95%; +} + +.account-info { + color: var(--spotify-white); +} + +.profile-picture { + --profile-pic-width: 50px; + width: var(--profile-pic-width); + height: var(--profile-pic-width); + vertical-align: middle; + border-radius: 50%; + margin-right: 5px; +} + + +/* Tooltip Styling */ +.tooltip { + display: none !important; + z-index: 10000; +} + +.tooltip .tooltip-inner { + background: var(--spotify-black); + color: var(--spotify-green); + border-radius: 16px; + padding: 5px 10px 4px; +} + +.tooltip .tooltip-arrow { + width: 0; + height: 0; + border-style: solid; + position: absolute; + margin: 5px; + border-color: black; + z-index: 1; +} + +.tooltip[x-placement^="top"] { + margin-bottom: 5px; +} + +.tooltip[x-placement^="top"] .tooltip-arrow { + border-width: 5px 5px 0 5px; + border-left-color: transparent !important; + border-right-color: transparent !important; + border-bottom-color: transparent !important; + bottom: -5px; + left: calc(50% - 5px); + margin-top: 0; + margin-bottom: 0; +} + +.tooltip[x-placement^="bottom"] { + margin-top: 5px; +} + +.tooltip[x-placement^="bottom"] .tooltip-arrow { + border-width: 0 5px 5px 5px; + border-left-color: transparent !important; + border-right-color: transparent !important; + border-top-color: transparent !important; + top: -5px; + left: calc(50% - 5px); + margin-top: 0; + margin-bottom: 0; +} + +.tooltip[x-placement^="right"] { + margin-left: 5px; +} + +.tooltip[x-placement^="right"] .tooltip-arrow { + border-width: 5px 5px 5px 0; + border-left-color: transparent !important; + border-top-color: transparent !important; + border-bottom-color: transparent !important; + left: -5px; + top: calc(50% - 5px); + margin-left: 0; + margin-right: 0; +} + +.tooltip[x-placement^="left"] { + margin-right: 5px; +} + +.tooltip[x-placement^="left"] .tooltip-arrow { + border-width: 5px 0 5px 5px; + border-top-color: transparent !important; + border-right-color: transparent !important; + border-bottom-color: transparent !important; + right: -5px; + top: calc(50% - 5px); + margin-left: 0; + margin-right: 0; +} + +.tooltip.popover .popover-inner { + background: #f9f9f9; + color: black; + padding: 24px; + border-radius: 5px; + box-shadow: 0 5px 30px rgba(black, .1); +} + +.tooltip.popover .popover-arrow { + border-color: #f9f9f9; +} + +.tooltip[aria-hidden='true'] { + visibility: hidden; + opacity: 0; + transition: opacity .15s, visibility .15s; +} + +.tooltip[aria-hidden='false'] { + visibility: visible; + opacity: 1; + transition: opacity .15s; +} +/* End of Tooltip */ + + +@media only screen and (min-width: 768px) { + .tooltip { + display: block !important; + } + div.body { + flex-direction: row; + flex-wrap: wrap; + width: 90%; + } + .flex-row { + flex-direction: row; + flex-wrap: wrap; + } + .card { + width: 33%; + } +} \ No newline at end of file