├── .github └── workflows │ └── gh-pages-deploy.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── babel.config.js ├── deploy.sh ├── package-lock.json ├── package.json ├── public ├── favicon.ico └── index.html ├── src ├── App.vue ├── assets │ ├── js │ │ ├── Generator.js │ │ └── Utils.js │ └── scss │ │ ├── _vars.scss │ │ ├── reset.css │ │ ├── style.scss │ │ └── themes.scss ├── components │ ├── ArtistsList.vue │ ├── CloudBox.vue │ ├── CollapseButton.vue │ ├── ControlPanel.vue │ ├── ControlPanelOption.vue │ ├── ResultTitle.vue │ ├── TaggingsList.vue │ └── TagsList.vue └── main.js └── vue.config.js /.github/workflows/gh-pages-deploy.yml: -------------------------------------------------------------------------------- 1 | name: gh-pages-deploy 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | autodeploy: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | - name: Install and Build 15 | run: | 16 | npm install 17 | npm run build 18 | 19 | - name: Deploy 20 | uses: JamesIves/github-pages-deploy-action@releases/v3 21 | with: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | BRANCH: gh-pages 24 | FOLDER: dist 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Joshua O'Sullivan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lastfm-tag-cloud 2 | A last.fm tag cloud generator built with Vue! 3 | 4 | Give it a whirl: [https://tagcloud.rainosullivan.com/](https://tagcloud.rainosullivan.com/) 5 | 6 | ## How are the tags chosen & scaled? 7 | 8 | A sample of your artists (up to the size and from the time period you specify) is taken from last.fm via the [user.getTopArtists](https://www.last.fm/api/show/user.getTopArtists) endpoint. For each artist, their top tags are fetched, using [artist.getTopTags](https://www.last.fm/api/show/artist.getTopTags). 9 | 10 | Each tag has a `count` on each artist that has a maximum value of 100. This `count` is a percentage of the people who have tagged that artist that tagged it this tag (e.g. if one person tags an artist "Lo-Fi", and a hundred people tag that artist, then "Lo-Fi" would have a `count` of 1 on that artist.). 11 | 12 | Consider the following three example artists, with the following three sample tags and their corresponding counts on each artist: 13 | 14 | | Artist | Scrobbles | Tag 1: Count | Tag 2: Count | Tag 3: Count | 15 | | ----------- | --------- | -------------- | -------------- | ------------- | 16 | | Tennis | 2019 | Lo-Fi: 100 | Indie Pop: 100 | Chillwave: 70 | 17 | | Men I Trust | 1330 | Dream Pop: 100 | Indie: 67 | Indie Pop: 60 | 18 | | Thundercat | 700 | Funk: 100 | Electronic: 91 | Jazz: 74 | 19 | 20 | Before we move on, the sum of each tag's `count` over all the artists in your sample is calculated, and used as a razor - only up to the top 100 tags by this metric are kept, the rest are discarded to avoid reaching the last.fm API's rate limits. 21 | 22 | Two metrics are then taken about each tag from last.fm using the [tag.getInfo](https://www.last.fm/api/show/tag.getInfo) endpoint: the tag's `reach`, which is defined as the number of users who have used the tag; and the tag's `total` (last.fm call this `taggings` in their docs but it's labelled as `total` in the actual data???), which is the total amount of times the tag has been used over all artists on last.fm. 23 | 24 | Here are some `reach` and `total`/`taggings` values for the tags used above: 25 | 26 | | Tag | Reach | Total/Taggings | 27 | | ---------- | ------ | -------------- | 28 | | Lo-Fi | 32892 | 160851 | 29 | | Indie Pop | 64939 | 367857 | 30 | | Chillwave | 7922 | 31368 | 31 | | Dream Pop | 24113 | 118911 | 32 | | Indie | 253595 | 2017702 | 33 | | Funk | 82092 | 422156 | 34 | | Electronic | 254177 | 2372062 | 35 | | Jazz | 146580 | 1150923 | 36 | 37 | Now we have all the data, we can start using it. 38 | 39 | A `score` is created for each tag as the sum of the products of the scores of the tag (divided by 100) on each artist, and your scrobbles of that artist. For example, "Indie Pop" from the example above would have a `score` of `(100/100 * 2019) + (60/100 * 1330) = 2541.4`. 40 | 41 | This `score` of each tag is then scaled (multiplied) by: 42 | 43 | - The sum of the `count` of that tag on the artists in your sample, divided by the `total` of that tag from the `tag.getInfo` endpoint (this is intended to capture how much of the total uses of that tag fall within your sample). 44 | - The number of artists within your sample that are tagged that tag, squared. 45 | - The base-10 logarithm of the `reach` of that tag from the `tag.getInfo` endpoint (so, a tag gets twice as big for every factor of 10 people that use it - 1 would be half the size of 10, 10 half the size of 100, 100 of 1000...). 46 | 47 | For "Indie Pop", this would be `2541.4 * ((100 + 60) / 367857) * 2^2 * log_10(64939) = ~21.28`. 48 | 49 | This value is arbitrary, before it is passed to [timdream's word cloud generator](https://github.com/timdream/wordcloud2.js/) they're all scaled non-linearly to be in the range of 25-200. If you want to see exactly how this is done, check the CloudBox component's Mounted function. It's not that exciting. 50 | 51 | I've tried to make this take into account the "uniqueness" of the tag to a user's library, as if they were all just scored by frequency the biggest tag on everyone's clouds would probably just be "all". If this causes issues for you, I know. See [here](https://github.com/TheTeaCat/lastfm-tag-cloud/issues/10). I don't care. :rowboat: 52 | 53 | ## What does the tag filter do? 54 | 55 | The tag filter checks tags against an offensive word list, "all", "seen live" and a geohash filter to remove tags that are overly generic/obscene. 56 | 57 | The source of the tag filter's offensive word list is [Ofcom's September 2016 Attitudes to potentially offensive language and gestures on TV and radio research report](https://www.ofcom.org.uk/__data/assets/pdf_file/0022/91624/OfcomOffensiveLanguage.pdf). Those used are the medium, strong, and stronger words that are **not** marked as "least recognised". 58 | 59 | ## Acknowledgements 60 | 61 | I'm using [timdream's word cloud generator](https://github.com/timdream/wordcloud2.js/). 62 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | npm run build 2 | cd dist 3 | git init 4 | git add -A 5 | git commit -m "deploy" 6 | git push -f git@github.com:theteacat/lastfm-tag-cloud.git master:gh-pages 7 | cd .. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tag-cloud", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "axios": "^0.19.2", 12 | "core-js": "^3.6.5", 13 | "vue": "^2.6.12", 14 | "vue-cookies": "^1.7.3", 15 | "vue-router": "^3.4.3", 16 | "wordcloud": "^1.1.1" 17 | }, 18 | "devDependencies": { 19 | "@vue/cli-plugin-babel": "^4.5.4", 20 | "@vue/cli-plugin-eslint": "^4.5.4", 21 | "@vue/cli-service": "^4.5.4", 22 | "babel-eslint": "^10.1.0", 23 | "eslint": "^5.16.0", 24 | "eslint-plugin-vue": "^5.0.0", 25 | "node-sass": "^4.14.1", 26 | "sass-loader": "^9.0.3", 27 | "vue-template-compiler": "^2.6.12" 28 | }, 29 | "eslintConfig": { 30 | "root": true, 31 | "env": { 32 | "node": true 33 | }, 34 | "extends": [ 35 | "plugin:vue/essential", 36 | "eslint:recommended" 37 | ], 38 | "rules": {}, 39 | "parserOptions": { 40 | "parser": "babel-eslint" 41 | } 42 | }, 43 | "browserslist": [ 44 | "> 1%", 45 | "last 2 versions" 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheTeaCat/lastfm-tag-cloud/a83d5e7f7f8d7efc6ca78d40fe920e057017a9f4/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | lastfm-tag-cloud 11 | 12 | 13 | 14 | 21 | 22 | 23 | 24 | 27 |
28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 173 | 174 | -------------------------------------------------------------------------------- /src/assets/js/Generator.js: -------------------------------------------------------------------------------- 1 | import axios from "axios" 2 | 3 | const API_KEY = '97773975bd1d3fdf89b362a27d2b6313' 4 | const MAX_TAGS = 100 5 | 6 | /**The filtered words are taken from Ofcom's September 2016 Attitudes to potentially offensive language and gestures on TV and radio Research report. 7 | * src: https://www.ofcom.org.uk/__data/assets/pdf_file/0022/91624/OfcomOffensiveLanguage.pdf 8 | * Those used are the medium, strong, and stronger words that were not marked as least recognised. 9 | * "Geohash", "all" and "seen live" have also been added to this list. 10 | */ 11 | const FILTERED_WORDS = ['geohash','all','seen live','i have seen live','cunt','fuck','motherfucker','bastard','beaver','bellend','clunge','cock','dick','dickhead','fanny','flaps','gash','knob','minge','prick','punani','pussy','snatch','twat','arsehole','balls','bint','bitch','bollocks','bullshit','feck','munter','pissed','pissed off','shit','son of a bitch','tits','cocksucker','dildo','jizz','ho','nonce','prickteaser','skank','slag','slut','wanker','whore','shag','slapper','tart','prod','yid','batty boy','chick with a dick','faggot','gender bender','fudge-packer','shirt lifter','bender','bum boy','dyke','he-she','homo','lezza','lesbo','muff driver','nancy','poof','queer','rug muncher','carpet muncher','tranny','bummer','fairy','pansy','mong','retard','spastic','spakka','spaz','window licker','cripple','midget','schizo','special','vegetable','chinky','coon','darky','golliwog','nigger','nig-nog','paki','wog','honky','jap','negro','polack','raghead','spade','coloured','gippo','kraut','pikey'] 12 | 13 | class Generator { 14 | /**~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ Constructor ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ */ 15 | constructor() { 16 | this.state = undefined 17 | } 18 | 19 | /**~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ Generation func ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ */ 20 | async generate(username,period,max_artists,filtered) { 21 | var result = { 22 | username:username, 23 | period:period, 24 | max_artists:max_artists, 25 | filtered:filtered, 26 | artists:[], 27 | listens:{}, 28 | tags:[], 29 | taggings:{}, 30 | tag_meta:{}, 31 | scores:{}, 32 | } 33 | var error = undefined 34 | this.state = "Getting artists' data..." 35 | await this.get_artist_data(result).then( 36 | e => error = e 37 | ) 38 | 39 | if (error == undefined) { 40 | this.state = "Pruning tags..." 41 | await this.prune_tags(result) 42 | this.state = "Getting tags' data..." 43 | await this.get_tag_data(result) 44 | this.state = "Scoring tags..." 45 | await this.score_tags(result) 46 | this.state = "Sorting tags..." 47 | await this.sort_data(result) 48 | } 49 | this.state = undefined; 50 | return {'result':error == undefined ? result : undefined, 'error':error} 51 | } 52 | 53 | /**~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ Generation sub-funcs ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ */ 54 | async get_artist_data(result) { 55 | return await axios.get( 56 | "https://ws.audioscrobbler.com/2.0/?method=user.gettopartists"+ 57 | "&api_key="+API_KEY+ 58 | "&user="+result.username+ 59 | "&period="+result.period+ 60 | "&limit="+result.max_artists+ 61 | "&format=json").then( 62 | async function(response){ 63 | var artist_promises = [] 64 | for (var artist of response.data.topartists.artist) { 65 | artist_promises.push( new Promise( 66 | async function(resolve) { 67 | /**Sanitising result */ 68 | var artist_name = artist.name.toLowerCase() 69 | /**Adding the artist to the artists list... */ 70 | result.artists.push(artist_name) 71 | result.listens[artist_name] = parseInt(artist.playcount) 72 | /**Getting their tags... */ 73 | await axios.get("https://ws.audioscrobbler.com/2.0/?method=artist.getTopTags"+ 74 | "&api_key="+API_KEY+ 75 | "&artist="+artist_name.replace(/&/g,"%26")+ 76 | "&format=json").then( function(response){ 77 | /**If the response doesn't have the data we need, we just return and declare the request as failed. */ 78 | if (response.data.toptags == undefined) { return } 79 | for (var tag of response.data.toptags.tag) { 80 | /**I'm currently ignoring tags that contain ampersands because the last.fm API is broken for them. */ 81 | if (tag.name.includes("&")) { continue } 82 | /**Sanitising data */ 83 | tag.name = tag.name.toLowerCase() 84 | /**Adding the tag to the tags list if it's not already present... */ 85 | if (result.taggings[tag.name] == undefined) { 86 | result.tags.push(tag.name) 87 | /**Initialising the taggings of the tag on the artist to the tag's list in the taggings object... */ 88 | result.taggings[tag.name] = [{artist:artist_name,count:tag.count}] 89 | /**Initialising the count of taggings on the artist to the tag's library_total & saving the tag's URL... */ 90 | result.tag_meta[tag.name] = {library_total:tag.count/100, 91 | url:tag.url, 92 | tot_scrobbles:result.listens[artist_name]} 93 | } else { 94 | /**Adding the taggings of the tag on the artist to the tag's list in the taggings object... */ 95 | result.taggings[tag.name].push({artist:artist_name,count:tag.count}) 96 | /**Adding the count of taggings on the artist to the tag's library_total... */ 97 | result.tag_meta[tag.name].library_total += tag.count/100 98 | result.tag_meta[tag.name].tot_scrobbles += result.listens[artist_name] 99 | } 100 | } 101 | }.bind(this) 102 | ).catch( 103 | function(error) { 104 | return error 105 | }.bind(this) 106 | ) 107 | resolve(true) 108 | }.bind(this) 109 | ) 110 | ) 111 | } 112 | await Promise.all(artist_promises) 113 | }.bind(this) 114 | ).catch( 115 | function(error) { 116 | return error 117 | }.bind(this) 118 | ) 119 | } 120 | 121 | prune_tags(result){ 122 | /**First, we remove duplicate tags. 123 | * The tag data is not merged, the one that is used less in the user's library is simply ignored. 124 | * Tags are considered the same if: 125 | * - One is identical to the other, except it ends in an "s". 126 | * - One is identical to the other, except it has a hyphen, space, or no space where the other has any of those three in lieu. 127 | */ 128 | for (var i in result.tags) { 129 | for (var j in result.tags) { 130 | if (i != j) { 131 | var tagA = result.tags[i] 132 | var tagB = result.tags[j] 133 | if (tagB[tagB.length-1] == "s" && tagB.slice(0,tagB.length-1) == tagA 134 | ||tagB.replace(/ | /g,"") == tagA.replace(/ | /g,"")) { 135 | if (result.tag_meta[tagA].library_total >= result.tag_meta[tagB].library_total) { 136 | //if tagA is bigger, we get rid of tagA. 137 | result.tags.splice(j,1) 138 | } else { 139 | //if tagB is bigger, we get rid of tagA. 140 | result.tags.splice(i,1) 141 | } 142 | } 143 | } 144 | } 145 | } 146 | /**We also have to remove tags that might have names that are just whitespace, apparently... */ 147 | for (i in result.tags) { 148 | if (result.tags[i].trim() == "") { 149 | result.tags.splice(i,1) 150 | } 151 | } 152 | 153 | result.tags.sort(function(a,b){return result.tag_meta[b].library_total - result.tag_meta[a].library_total}.bind(this)) 154 | 155 | /**If the filter is enabled, for each tag it's checked against the list of filtered words. 156 | * This shouldn't result in many 'scunthorpe problems' as it's only comparing: 157 | * 1) each component of the tag split by spaces or hyphens 158 | * and 159 | * 2) the whole tag, if the tag contains spaces or hyphens 160 | * against all filtered words. 161 | */ 162 | if (result.filtered) { 163 | for (i = 0; i < MAX_TAGS; i++) { 164 | if (i >= result.tags.length) { break } 165 | if (FILTERED_WORDS.some( 166 | word => result.tags[i] == word 167 | ||result.tags[i].split(/-| /).some(subword => subword == word.replace(/-| /g,"")) 168 | || result.tags[i].match(/-| /) && word.match(/-| /) && result.tags[i] == word 169 | ) 170 | || result.tags[i].split(":")[0] == "geohash") { 171 | result.tags.splice(i,1) 172 | i-- 173 | } 174 | } 175 | } 176 | 177 | result.tags = result.tags.slice(0,MAX_TAGS) 178 | } 179 | 180 | async get_tag_data(result){ 181 | var tag_promises = [] 182 | for (var tag of result.tags) { 183 | tag_promises.push(new Promise( 184 | function(tag_name){ 185 | return async function (resolve){ 186 | await axios.get("https://ws.audioscrobbler.com/2.0/?method=tag.getinfo"+ 187 | "&api_key="+API_KEY+ 188 | "&tag="+tag_name+ 189 | "&format=json").then( 190 | async function(response){ 191 | if (response.data.tag == undefined ) { return } 192 | result.tag_meta[tag_name].reach = response.data.tag.reach 193 | result.tag_meta[tag_name].total = response.data.tag.total 194 | }.bind(this) 195 | ).catch( 196 | function(error) { 197 | return error 198 | }.bind(this) 199 | ) 200 | resolve(true) 201 | }.bind(this) 202 | }.bind(this)(tag) 203 | )) 204 | } 205 | await Promise.all(tag_promises) 206 | } 207 | 208 | score_tags(result){ 209 | for (var tag of result.tags) { 210 | result.scores[tag] = 0 211 | /**First, each tagging is weighted by the product of: 212 | * - How many times the user has listened to the artist on which the tag was used, 213 | * and... 214 | * - The "count" of that tag on the artist. 215 | * I am assuming that this "count" is a confidence % given by last.fm as to the accuracy of the tag on that artist. 216 | * I can't find any doccumentation, but this would make sense, as they cap out at 100. 217 | */ 218 | for (var tagging of result.taggings[tag]) { 219 | result.scores[tag] += tagging.count/100 * result.listens[tagging.artist] 220 | } 221 | /**The sum of all these weighted taggings is then scaled by: 222 | * 1. How many of the uses of that tag overall fall within the user's library sample (its "uniqueness" to the sample). 223 | * 224 | * 2. How many artists within the sample are tagged with that tag (its "spread" over the sample). 225 | * 226 | * 3. The base 10 logarithm of how many people have used that tag overall (its "reach"; see last.fm API docs). 227 | * Base 10 is used so 100 people using the tag makes it twice as significant as 10 people using the tag; a nice balance. 228 | * It's also conveniently provided as a function by Math. 229 | */ 230 | result.scores[tag] = result.scores[tag] 231 | * (result.tag_meta[tag].library_total / result.tag_meta[tag].total) 232 | * result.taggings[tag].length * result.taggings[tag].length 233 | * Math.log10(result.tag_meta[tag].reach) 234 | } 235 | } 236 | 237 | sort_data(result){ 238 | for (var tag of result.tags) { 239 | /**Sorting the tags' artists based upon how many times each artist has been tagged that tag */ 240 | result.taggings[tag].sort(function(a,b){return b.count-a.count}) 241 | } 242 | /**Sorting the tags based upon their scores */ 243 | result.tags.sort(function(a,b){return result.scores[b]-result.scores[a]}.bind(this)) 244 | } 245 | } 246 | 247 | export default Generator; -------------------------------------------------------------------------------- /src/assets/js/Utils.js: -------------------------------------------------------------------------------- 1 | class Utils { 2 | //js makes me sick. 3 | static rgb2hex(rgb) { 4 | rgb = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/); 5 | function hex(x) { 6 | return ("0" + parseInt(x).toString(16)).slice(-2); 7 | } 8 | return "#" + hex(rgb[1]) + hex(rgb[2]) + hex(rgb[3]); 9 | } 10 | } 11 | 12 | export default Utils; -------------------------------------------------------------------------------- /src/assets/scss/_vars.scss: -------------------------------------------------------------------------------- 1 | $font-size-big: 50px; 2 | $font-size-medium: 25px; 3 | $font-size-normal: 13px; 4 | 5 | $font-size-big-mob: 35px; 6 | $font-size-medium-mob: 20px; 7 | $font-size-normal-mob: 12px; 8 | 9 | $normal: 400; 10 | $bold: 700; 11 | 12 | $red-dd: #300; 13 | $red-d: #900; 14 | $red: #f00; 15 | $red-l: #fdd; 16 | $red-ll: #fee; 17 | 18 | $black: #000; 19 | $grey-dd: #333; 20 | $grey-d: #777; 21 | $grey: #ccc; 22 | $grey-l: #ddd; 23 | $white: #fff; 24 | 25 | $spacer: 1vmin; 26 | $input-height: 16px; -------------------------------------------------------------------------------- /src/assets/scss/reset.css: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, body, div, span, applet, object, iframe, 7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 8 | a, abbr, acronym, address, big, cite, code, 9 | del, dfn, em, img, ins, kbd, q, s, samp, 10 | small, strike, strong, sub, sup, tt, var, 11 | b, u, i, center, 12 | dl, dt, dd, ol, ul, li, 13 | fieldset, form, label, legend, 14 | table, caption, tbody, tfoot, thead, tr, th, td, 15 | article, aside, canvas, details, embed, 16 | figure, figcaption, footer, header, hgroup, 17 | menu, nav, output, ruby, section, summary, 18 | time, mark, audio, video { 19 | margin: 0; 20 | padding: 0; 21 | border: 0; 22 | font-size: 100%; 23 | font: inherit; 24 | vertical-align: baseline; 25 | box-sizing: border-box; 26 | } 27 | /* HTML5 display-role reset for older browsers */ 28 | article, aside, details, figcaption, figure, 29 | footer, header, hgroup, menu, nav, section { 30 | display: block; 31 | } 32 | body { 33 | line-height: 1; 34 | } 35 | ol, ul { 36 | list-style: none; 37 | } 38 | blockquote, q { 39 | quotes: none; 40 | } 41 | blockquote:before, blockquote:after, 42 | q:before, q:after { 43 | content: ''; 44 | content: none; 45 | } 46 | table { 47 | border-collapse: collapse; 48 | border-spacing: 0; 49 | } -------------------------------------------------------------------------------- /src/assets/scss/style.scss: -------------------------------------------------------------------------------- 1 | @import 'reset.css'; 2 | @import '_vars.scss'; 3 | @import 'themes.scss'; 4 | @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap'); 5 | @import url('https://fonts.googleapis.com/css2?family=Varela+Round&display=swap'); 6 | 7 | body, html { min-height:100%; } 8 | body { 9 | background: linear-gradient(180deg, var(--top-gradient) 0, var(--background-colour) 80px); 10 | background-repeat: no-repeat; 11 | } 12 | body, input, select, button { 13 | font-family: 'Roboto', sans-serif; 14 | font-size: $font-size-normal; 15 | color: var(--text-colour); 16 | } 17 | 18 | h1 { 19 | font-size: $font-size-big; 20 | font-family: 'Varela Round', sans-serif; 21 | } 22 | h2 { 23 | font-size: $font-size-medium 24 | } 25 | h1, h2 { font-weight: $bold } 26 | @media(orientation:portrait) { 27 | h1 { font-size: $font-size-big-mob } 28 | h2 { font-size: $font-size-medium-mob } 29 | } 30 | 31 | a { 32 | color: var(--text-alt-colour); 33 | font-weight:bold; 34 | text-decoration: underline; 35 | } 36 | 37 | input, select, button, .button { 38 | border: 1px solid var(--border-colour); 39 | padding: $spacer; 40 | height: $input-height; 41 | min-width: $input-height; 42 | box-sizing: content-box; 43 | background: var(--input-colour); 44 | font-weight: var(--input-weight); 45 | } 46 | button:disabled { 47 | color: $grey; 48 | border-color: $grey; 49 | border-style: dashed; 50 | } 51 | 52 | .button { 53 | width:auto; 54 | .checkbox { 55 | display:none; 56 | } 57 | .checkmark:after { 58 | display:none; 59 | content:""; 60 | height:90%; 61 | width:40%; 62 | margin: auto; 63 | transform: translate(5%, -10%) rotate(45deg); 64 | border: solid var(--text-colour); 65 | border-width: 0px 3px 3px 0px; 66 | } 67 | .checkbox:checked ~ .checkmark:after { 68 | display:block; 69 | } 70 | } -------------------------------------------------------------------------------- /src/assets/scss/themes.scss: -------------------------------------------------------------------------------- 1 | @import '_vars'; 2 | 3 | @mixin light { 4 | --background-colour: #{$white}; 5 | --top-gradient: #{$grey-l}; 6 | --border-colour: #{$black}; 7 | --text-colour: #{$black}; 8 | --text-alt-colour: #{$red}; 9 | --input-colour: #{$red-l}; 10 | --input-weight: #{$normal}; 11 | --list-red: #{$red-ll}; 12 | } 13 | 14 | @mixin dark { 15 | --background-colour: #{$black}; 16 | --top-gradient: #{$grey-dd}; 17 | --border-colour: #{$red}; 18 | --text-colour: #{$white}; 19 | --text-alt-colour: #{$white}; 20 | --input-colour: #{$red-d}; 21 | --input-weight: #{$bold}; 22 | --list-red: #{$red-dd}; 23 | } 24 | 25 | :root { @include light; } 26 | @media(prefers-color-scheme: dark) { 27 | :root { @include dark; } 28 | } 29 | :root[theme='light'] { @include light; } 30 | :root[theme='dark'] { @include dark; } -------------------------------------------------------------------------------- /src/components/ArtistsList.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 40 | 41 | -------------------------------------------------------------------------------- /src/components/CloudBox.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 149 | 150 | -------------------------------------------------------------------------------- /src/components/CollapseButton.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/ControlPanel.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 55 | 56 | -------------------------------------------------------------------------------- /src/components/ControlPanelOption.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 42 | 43 | -------------------------------------------------------------------------------- /src/components/ResultTitle.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 32 | 33 | -------------------------------------------------------------------------------- /src/components/TaggingsList.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 28 | 29 | -------------------------------------------------------------------------------- /src/components/TagsList.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 43 | 44 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | 4 | import '@/assets/scss/style.scss' 5 | 6 | import VueRouter from 'vue-router' 7 | Vue.use(VueRouter) 8 | var router = new VueRouter({ 9 | mode: 'history', 10 | routes: [] 11 | }); 12 | 13 | import VueCookies from 'vue-cookies' 14 | Vue.use(VueCookies) 15 | Vue.$cookies.config('7d') 16 | 17 | Vue.config.productionTip = false 18 | 19 | new Vue({ 20 | router, 21 | render: h => h(App), 22 | }).$mount('#app') 23 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | css: { 3 | loaderOptions: { 4 | sass: { 5 | additionalData: ` 6 | @import "@/assets/scss/_vars.scss"; 7 | ` 8 | } 9 | } 10 | }, 11 | }; --------------------------------------------------------------------------------