0
0
Fork 0

Merge pull request #1 from Tyler-A/dev

First version
This commit is contained in:
Tyler 2020-07-06 00:16:53 -06:00 committed by GitHub
commit e248b0be82
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 893 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
expired_tokens

71
app.js Normal file
View file

@ -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,
}
})

19
components/artist.html Normal file
View file

@ -0,0 +1,19 @@
<div class="artist">
<div class="image profile_pic" v-if="artist.image">
<img :src="artist.image.url" :alt="artist.name + 's profile picture'">
</div>
<div class="image profile_pic" v-else>
<div class="missing-circle">
<music-note colour="#1DB954"></music-note>
</div>
</div>
<div class="info">
<span class="name">
<a :href="artist.link" target="_blank" rel="noopener">{{artist.name}}</a>
</span>
<br>
<span class="genres">{{artist.genres.join(", ")}}</span>
</div>
<div class="popularity">{{artist.popularity}}</div>
<div class="followers">{{artist.follower_count}}</div>
</div>

26
components/artist.js Normal file
View file

@ -0,0 +1,26 @@
Vue.component(
`artist`,
{
props: [ `artist` ],
computed: {},
template: `<div class="artist">
<div class="image profile_pic" v-if="artist.image">
<img :src="artist.image.url" :alt="artist.name + 's profile picture'">
</div>
<div class="image profile_pic" v-else>
<div class="missing-circle">
<music-note colour="#1DB954"></music-note>
</div>
</div>
<div class="info">
<span class="name">
<a :href="artist.link" target="_blank" rel="noopener">{{artist.name}}</a>
</span>
<br>
<span class="genres">{{artist.genres.join(", ")}}</span>
</div>
<div class="popularity">{{artist.popularity}}</div>
<div class="followers">{{artist.follower_count}}</div>
</div>`
}
)

7
components/icons.js Normal file
View file

@ -0,0 +1,7 @@
Vue.component(
`music-note`,
{
props: [ `colour` ],
template: `<svg width="72" height="72" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="24" fill="none" rx="0" ry="0"></rect><path fill-rule="evenodd" clip-rule="evenodd" d="M17.4911 2.11667L21.4511 4.09667C21.9411 4.34667 22.1411 4.93667 21.8911 5.43667C21.6411 5.93667 21.0411 6.13667 20.5511 5.88667L18.0011 4.61667L17.9911 16.4367C17.9911 16.4467 17.9936 16.4567 17.9961 16.4667C17.9986 16.4767 18.0011 16.4867 18.0011 16.4967C18.0011 18.4267 16.4311 19.9967 14.5011 19.9967C12.5711 19.9967 11.0011 18.4267 11.0011 16.4967C11.0011 14.5667 12.5711 12.9967 14.5011 12.9967C15.0411 12.9967 15.5411 13.1267 16.0011 13.3467L16.0111 3.10667C15.9911 2.99667 16.0011 2.87667 16.0311 2.75667C16.1711 2.14667 16.9411 1.80667 17.4911 2.11667ZM12.8911 5.43664C12.6511 5.93664 12.0511 6.13664 11.5511 5.88664L9.0011 4.61664L8.9911 18.4366C8.9911 18.4466 8.9936 18.4566 8.9961 18.4666C8.9986 18.4766 9.0011 18.4866 9.0011 18.4966C9.0011 20.4266 7.4311 21.9966 5.5011 21.9966C3.5711 21.9966 2.0011 20.4266 2.0011 18.4966C2.0011 16.5666 3.5711 14.9966 5.5011 14.9966C6.0411 14.9966 6.5411 15.1266 7.0011 15.3466L7.0111 3.10664C6.9911 2.99664 7.0011 2.87664 7.0311 2.76664C7.1711 2.14664 7.9411 1.81664 8.4911 2.12664L12.4411 4.09664C12.9411 4.34664 13.1411 4.94664 12.8911 5.43664Z" :fill="colour || '#ffffff'"></path></svg>`
}
);

20
components/track.html Normal file
View file

@ -0,0 +1,20 @@
<div class="track">
<div class="cover center">
<a :href="track.album.link" target="_blank" rel="noopener">
<img :src="track.album.image.url" :alt="track.album.name + ' Cover Image'" class="cover">
</a>
</div>
<div class="info">
<span class="name remote" v-if="!track.locality">
<a :href="track.link" target="_blank" rel="noopener">{{track.name}}</a>
</span>
<span class="name local" v-else>
{{track.name}}
</span>
<br>
<span class="artist" v-html="artists"></span>
<br>
</div>
<div class="popularity" v-tooltip="popularity_tooltip">{{track.popularity}}</div>
<div class="duration">{{duration}}</div>
</div>

63
components/track.js Normal file
View file

@ -0,0 +1,63 @@
Vue.component(
`track-card`,
{
props: [ `track` ],
data: function () {
return {
popularity_tooltip: `Popularity`
}
},
template: `<div class="track">
<div class="cover center">
<a :href="track.album.link" target="_blank" rel="noopener">
<img :src="track.album.image.url" :alt="track.album.name + ' Cover Image'" class="cover">
</a>
</div>
<div class="info">
<span class="name remote" v-if="!track.locality">
<a :href="track.link" target="_blank" rel="noopener">{{track.name}}</a>
</span>
<span class="name local" v-else>
{{track.name}}
</span>
<br>
<span class="artist" v-html="artists"></span>
<br>
</div>
<div class="popularity" v-tooltip="popularity_tooltip">{{track.popularity}}</div>
<div class="duration">{{duration}}</div>
</div>`,
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(
`<a href="${artist.external_urls.spotify}" target="_blank" rel="noopener">${artist.name}</a>`
)
}
return artists.join(`, `)
},
}
}
);

97
css/artist.css Normal file
View file

@ -0,0 +1,97 @@
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.profile_pic > div.missing-circle {
background-color: #3a3a3aaa;
border-radius: 100px;
display: flex;
height: 200px;
width: 200px;
justify-content: center;
align-items: center;
margin: 0 auto;
margin-bottom: 5px;
}
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;
}
}

84
css/track.css Normal file
View file

@ -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;
}
}

83
index.html Normal file
View file

@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Top Lists For Spotify</title>
<!-- Stylesheets -->
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="./css/track.css">
<link rel="stylesheet" href="./css/artist.css">
<!-- Javascript Imports -->
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="./components/artist.js" defer async></script>
<script src="./components/track.js" defer async></script>
<script src="./components/icons.js" defer async></script>
<script src="https://unpkg.com/v-tooltip"></script>
<script src="./js/text_computation.js"></script>
<script src="./app.js" defer></script>
<script src="./js/auth.js"></script>
<script src="./js/data.js"></script>
</head>
<body>
<div id="app" v-cloak>
<div id="login" v-if="!is_authed">
<div class="card center">
<a :href="spotify_auth_url">
<button id="spotify-login">Login With Spotify</button>
</a>
<p class="error" v-if="error.auth">{{error.auth}}</p>
<p>{{auth.alert}}</p>
</div>
</div>
<div id="main" v-else v-cloak>
<div class="flex-row">
<div v-cloak class="account-info">
<img v-if="user.image" :src="user.image" :alt="`${user.name}'s profile picture`" class="profile-picture">
{{user.name}}
</div>
<div class="type">
<select v-model="type">
<option value="" disabled>Please Select A Type</option>
<option value="Tracks">Tracks</option>
<option value="Artists">Artists</option>
</select>
</div>
<div class="duration">
<select v-model="duration">
<option value="" disabled>Please Select A Duration</option>
<option value="long_term">Several Years</option>
<option value="medium_term">~6 Months</option>
<option value="short_term">~4 Weeks</option>
</select>
</div>
<div class="limit">
<input type="number" v-model="count" placeholder="How Many?">
</div>
<div id="submit-button" v-if="button_type && duration">
<button @click="get_data()">Get Top {{count > 1 ? count : ``}} {{button_type}}</button>
</div>
</div>
<div class="flex-row error" v-if="error.main">{{error.main}}</div>
<div class="body" v-if="data.tracks.length > 0">
<track-card
v-for="top_track in data.tracks"
:track="top_track"
:key="top_track.id"
></track-card>
</div>
<div class="body" v-if="data.artists.length > 0">
<artist
v-for="top_artist in data.artists"
:artist="top_artist"
:key="top_artist.id"
></artist>
</div>
</div>
</div>
</body>
</html>

53
js/auth.js Normal file
View file

@ -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;
}
}

57
js/data.js Normal file
View file

@ -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}`
})
}

19
js/text_computation.js Normal file
View file

@ -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;
};

293
style.css Normal file
View file

@ -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%;
}
}