├── .github └── workflows │ └── npm-gulp.yml ├── .gitignore ├── .vscode └── settings.json ├── Gulpfile.js ├── README.md ├── package-lock.json ├── package.json └── src ├── CNAME ├── assets ├── data │ ├── artist-countries.json │ ├── countries.csv │ ├── explr-worldmap │ │ └── ne_50m_admin_0_countries │ │ │ ├── ne_50m_admin_0_countries.README.html │ │ │ ├── ne_50m_admin_0_countries.VERSION.txt │ │ │ ├── ne_50m_admin_0_countries.cpg │ │ │ ├── ne_50m_admin_0_countries.dbf │ │ │ ├── ne_50m_admin_0_countries.prj │ │ │ ├── ne_50m_admin_0_countries.shp │ │ │ └── ne_50m_admin_0_countries.shx │ ├── playlists.json │ └── world-50m.json ├── img │ ├── explr-logo.png │ ├── explrlogo.png │ ├── facebook-icon.svg │ ├── favicon.png │ ├── file-export-solid.png │ ├── flattr-logo.svg │ ├── github-icon.svg │ ├── glyphicons_009_magic.png │ ├── gobutton.png │ ├── last-fm-logo.png │ ├── loader_horizontal.gif │ ├── loader_spinner.gif │ ├── logo.png │ ├── reddit-icon.svg │ ├── refresh.png │ ├── remove-user.png │ ├── search.png │ └── snapshot.png ├── js │ ├── api │ │ ├── api.js │ │ └── lastfm.js │ ├── aria-announcer.js │ ├── artists.js │ ├── auditory-feedback.js │ ├── country-list.js │ ├── keyboard-mode.js │ ├── map.js │ ├── no-countries.js │ ├── screenshot.js │ ├── script.js │ ├── search.js │ └── utils.js └── scss │ ├── base │ ├── README.md │ ├── _base.scss │ ├── _fonts.scss │ ├── _helpers.scss │ └── _typography.scss │ ├── components │ ├── README.md │ ├── _buttons.scss │ ├── _loader.scss │ ├── _search.scss │ └── _tooltip.scss │ ├── layout │ └── README.md │ ├── main.scss │ ├── pages │ ├── README.md │ ├── _map.scss │ ├── _splash.scss │ └── _zoomed.scss │ ├── themes │ ├── README.md │ └── _default.scss │ ├── utils │ ├── README.md │ ├── _functions.scss │ ├── _mixins.scss │ └── _variables.scss │ └── vendor │ ├── README.md │ ├── _bmc.scss │ └── _normalize.scss └── index.html /.github/workflows/npm-gulp.yml: -------------------------------------------------------------------------------- 1 | name: NodeJS with Gulp 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [16.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | 25 | - name: Build 26 | run: | 27 | npm install 28 | gulp build 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.sublime* 3 | venv/ 4 | node_modules 5 | **/sourcemaps/ 6 | build 7 | .publish 8 | .DS_Store 9 | .gqs~ 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": false, 3 | "editor.formatOnPaste": false 4 | } -------------------------------------------------------------------------------- /Gulpfile.js: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------------- 2 | // Plugins and variables 3 | // ----------------------------------------------------------------------------- 4 | 5 | /** 6 | * Gulp plugins 7 | */ 8 | 9 | var gulp = require("gulp"); 10 | var browserSync = require("browser-sync").create(); 11 | var del = require("del"); 12 | var sass = require("gulp-sass")(require('sass')); 13 | var sourcemaps = require("gulp-sourcemaps"); 14 | var prefix = require("gulp-autoprefixer"); 15 | var concat = require("gulp-concat"); 16 | var rename = require("gulp-rename"); 17 | var uglify = require("gulp-uglify"); 18 | var deporder = require("gulp-deporder"); 19 | var changed = require("gulp-changed"); 20 | var imagemin = require("gulp-imagemin"); 21 | var minifyCss = require("gulp-minify-css"); 22 | var ghPages = require("gulp-gh-pages"); 23 | var babel = require('gulp-babel'); 24 | 25 | 26 | 27 | /** 28 | * Predefined filepaths to be used in the tasks 29 | */ 30 | const path = { 31 | build:{ 32 | css: "build/assets/css/", 33 | js: "build/assets/js/", 34 | img: "build/assets/img/", 35 | data: "build/assets/data/", 36 | html: "build/" 37 | }, 38 | src:{ 39 | sass: "src/assets/scss/", 40 | js: "src/assets/js/", 41 | img: "src/assets/img/", 42 | data: "src/assets/data/", 43 | html: "src/" 44 | } 45 | }; 46 | 47 | // ----------------------------------------------------------------------------- 48 | // Utilities 49 | // ----------------------------------------------------------------------------- 50 | 51 | /** 52 | * Pre-cleaning the build folder 53 | */ 54 | 55 | function clean() { 56 | return del([ 57 | "build/", 58 | ]); 59 | } 60 | 61 | /** 62 | * Deploy to gh-pages branch! Run using 'gulp deploy' 63 | */ 64 | function upload() { 65 | return gulp.src("./build/**/*") 66 | .pipe(ghPages()); 67 | } 68 | 69 | // ----------------------------------------------------------------------------- 70 | // Build tasks 71 | // ----------------------------------------------------------------------------- 72 | 73 | /** 74 | * Compiles SCSS sourcefiles and outputs autoprefixed, minified CSS + sourcemaps 75 | */ 76 | function compileSass() { 77 | return gulp.src(path.src.sass + "/*.scss") 78 | 79 | .pipe(sourcemaps.init()) 80 | .pipe(sass.sync()) 81 | .on("error", sass.logError) //Log SCSS errors in console! 82 | .pipe(prefix(["last 2 versions", "> 1%"], { cascade: true })) 83 | .pipe(rename("main.min.css")) 84 | .pipe(minifyCss()) 85 | 86 | .pipe(sourcemaps.write("sourcemaps")) 87 | .pipe(gulp.dest(path.build.css)) 88 | //Update browser sync! 89 | .pipe(browserSync.stream()); 90 | } 91 | 92 | /** 93 | * Combines and minifies all source JavaScript files, including sourcemaps. Dependencies and ordering is done with the deporder plugin 94 | */ 95 | function js() { 96 | return gulp.src(path.src.js + "**/*.js") 97 | .pipe(deporder()) 98 | .pipe(sourcemaps.init()) 99 | .pipe(concat("concat.js")) 100 | .pipe(rename("all.min.js")) 101 | .pipe(babel({ presets: ['@babel/env'] })) 102 | .pipe(uglify()) 103 | .pipe(sourcemaps.write("sourcemaps")) 104 | .pipe(gulp.dest(path.build.js)) 105 | .pipe(browserSync.stream()); 106 | } 107 | 108 | /** 109 | * Optimizes images for web and outputs them to build folder. 110 | */ 111 | function img() { 112 | return gulp.src(path.src.img + "*.*") 113 | .pipe(changed(path.build.img)) // Ignore unchanged files 114 | .pipe(imagemin({optimizationLevel: 5})) 115 | .pipe(gulp.dest(path.build.img)) 116 | .pipe(browserSync.stream()); 117 | 118 | } 119 | 120 | /** 121 | * Outputs data files to build folder 122 | */ 123 | function data() { 124 | return gulp.src(path.src.data + "*.*") 125 | .pipe(changed(path.build.data)) // Ignore unchanged files 126 | .pipe(gulp.dest(path.build.data)) 127 | .pipe(browserSync.stream()); 128 | } 129 | 130 | /** 131 | * Outputs html files to build folder 132 | */ 133 | function html() { 134 | return gulp.src([path.src.html + "*.html", path.src.html + "CNAME"]) 135 | .pipe(changed(path.build.html)) // Ignore unchanged files 136 | .pipe(gulp.dest(path.build.html)) 137 | .pipe(browserSync.stream()); 138 | } 139 | 140 | // ----------------------------------------------------------------------------- 141 | // Watch and serve tasks 142 | // ----------------------------------------------------------------------------- 143 | 144 | /** 145 | * Start BrowserSync server and watch files for changes! 146 | */ 147 | function serve(cb) { 148 | 149 | //Start browsersync server! 150 | browserSync.init({ 151 | server: "build/", 152 | port: 8000, 153 | https: true, 154 | }); 155 | //Watch folders! 156 | gulp.watch(path.src.sass + "**/*.scss", gulp.series("sass")); 157 | gulp.watch(path.src.html + "*.html", gulp.series("html")); 158 | gulp.watch(path.src.js + "**/*.js", gulp.series("js")); 159 | gulp.watch(path.src.img + "**/*", gulp.series("img")); 160 | gulp.watch(path.src.data + "**/*.(csv|json)", gulp.series("data")); 161 | cb(); 162 | } 163 | 164 | /** 165 | * Outputs all files. 166 | */ 167 | const build = gulp.parallel(compileSass, js, img, html, data); 168 | 169 | // export tasks 170 | exports.clean = clean; 171 | exports.sass = compileSass; 172 | exports.js = js; 173 | exports.img = img; 174 | exports.data = data; 175 | exports.html = html; 176 | exports.upload = upload; 177 | exports.build = gulp.series(clean, build); 178 | 179 | /** 180 | * Run tasks in specified order! (1. clean, 2. build, 3. serve and watch) 181 | */ 182 | exports.default = gulp.series(clean, build, serve); 183 | 184 | /** 185 | * Alternative: Build, then deploy to gh-pages! 186 | */ 187 | exports.deploy = gulp.series(clean, build, upload); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![Explr.fm](src/assets/img/explrlogo.png) 2 | 3 | [Explr.fm](https://mold.github.io/explr) is an online information visualization for Last.fm users. It displays the user's music taste distributed on a world map. Built with love and D3.js. 4 | 5 | 6 | 7 | ## Install 8 | 9 | First you need Node. To install gulp and all necessary plugins, run: 10 | 11 | ``` 12 | $ npm install 13 | ``` 14 | To build the project and start a local server, run: 15 | ``` 16 | $ gulp 17 | ``` 18 | To build and then deploy the website to https://mold.github.io/explr, run: 19 | ``` 20 | $ gulp deploy 21 | ``` 22 | 23 | 24 | ## Todo-list 25 | ----- 26 | - [ ] Improve screenshotting 27 | - [ ] Show a warning when screenshotting before all artists have been loaded? 28 | - [ ] imgur upload? 29 | - [x] Improve api code 30 | - [ ] Refactor everything 31 | 32 | ## Team 33 | 34 | Tommy Feldt 35 | Daniel Molin 36 | Moa Bergsmark 37 | Anna Movin 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "explr", 3 | "version": "1.0.0", 4 | "description": "Explr.fm - color music map the wolrd", 5 | "main": "Gulpfile.js", 6 | "private": true, 7 | "devDependencies": { 8 | "@babel/core": "^7.8.3", 9 | "@babel/preset-env": "^7.8.3", 10 | "browser-sync": "^2.26.7", 11 | "del": "^5.0.0", 12 | "gulp": "^4.0.2", 13 | "gulp-autoprefixer": "^8.0.0", 14 | "gulp-babel": "^8.0.0", 15 | "gulp-changed": "^3.2.0", 16 | "gulp-concat": "^2.6.1", 17 | "gulp-deporder": "^1.2.0", 18 | "gulp-gh-pages": "^0.6.0-6", 19 | "gulp-imagemin": "^7", 20 | "gulp-minify-css": "^1.2.4", 21 | "gulp-rename": "^1.4.0", 22 | "gulp-sass": "^5.1.0", 23 | "gulp-sourcemaps": "^2.6.5", 24 | "gulp-uglify": "^3.0.2", 25 | "sass": "^1.49.0" 26 | }, 27 | "scripts": { 28 | "start": "npm install && gulp", 29 | "gulp": "gulp", 30 | "deploy": "gulp deploy", 31 | "build": "gulp build" 32 | }, 33 | "repository": { 34 | "type": "git", 35 | "url": "https://github.com/mold/Explr" 36 | }, 37 | "keywords": [ 38 | "music", 39 | "tommy" 40 | ], 41 | "author": "Explr.fm", 42 | "bugs": { 43 | "url": "https://github.com/mold/Explr/issues" 44 | }, 45 | "homepage": "https://github.com/mold/Explr", 46 | "resolutions": { 47 | "gift": "^0.10.2" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/CNAME: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mold/explr/8125f766a310e85f8f08cafd093ecd9c613f4260/src/CNAME -------------------------------------------------------------------------------- /src/assets/data/artist-countries.json: -------------------------------------------------------------------------------- 1 | { 2 | "The Knife": "Sweden" 3 | } 4 | -------------------------------------------------------------------------------- /src/assets/data/countries.csv: -------------------------------------------------------------------------------- 1 | id,names,tags,continent 2 | 4,Afghanistan,Afghan,Asia 3 | 8,Albania,Albanian,Europe 4 | 12,Algeria,Algerian,Africa 5 | 20,Andorra,Andorran,Europe 6 | 24,Angola,Angolan,Africa 7 | 28,Antigua and Barbuda,Antiguan,North America 8 | 31,Azerbaijan,Azerbaijani,Asia 9 | 32,Argentina,Argentinian,South America 10 | 36,Australia,Australian,Oceania 11 | 40,Austria,Austrian,Europe 12 | 44,Bahamas,Bahamian,North America 13 | 48,Bahrain,Bahraini,Asia 14 | 50,Bangladesh,Bangladeshi,Asia 15 | 51,Armenia,Armenian,Asia 16 | 52,Barbados,Barbadian,North America 17 | 56,Belgium,Belgian,Europe 18 | 64,Bhutan,Bhutanese,Asia 19 | 68,Bolivia,Bolivian,South America 20 | 70,Bosnia-Herzegovina,Bosnian,Europe 21 | 72,Botswana,Botswanan,Africa 22 | 76,Brazil|Brasil,Brazilian,South America 23 | 84,Belize,Belizean,North America 24 | 90,Solomon Islands,,Oceania 25 | 100,Bulgaria,Bulgarian,Europe 26 | 108,Burundi,Burundian,Africa 27 | 112,Belarus,Belarusian,Europe 28 | 116,Cambodia,Cambodian,Asia 29 | 120,Cameroon,Cameroonian,Africa 30 | 124,Canada,Canadian,North America 31 | 132,Cape Verde,Cape Verdean,Africa 32 | 144,Sri Lanka,Sri Lankan,Asia 33 | 148,Chad,Chadian,Africa 34 | 152,Chile,Chilean,South America 35 | 156,China,Chinese,Asia 36 | 158,Taiwan,Taiwanese,Asia 37 | 170,Colombia,Colombian,South America 38 | 174,Comoros,Comoran,Africa 39 | 178,Congo-Brazzaville,Congolese,Africa 40 | 180,DR Congo,Congolese,Africa 41 | 184,Cook Islands,Cook Islander,Oceania 42 | 188,Costa Rica,Costa Rican,North America 43 | 192,Cuba,Cuban,North America 44 | 196,Cyprus,Cypriot,Asia 45 | 203,Czech Republic,Czech,Europe 46 | 204,Benin,Beninese,Africa 47 | 208,Denmark,Danish,Europe 48 | 212,Dominica,Dominican,North America 49 | 214,Dominican Republic,Dominican,North America 50 | 218,Ecuador,Ecuadorean,South America 51 | 222,El Salvador,Salvadorean,North America 52 | 231,Ethiopia,Ethiopian,Africa 53 | 232,Eritrea,Eritrean,Africa 54 | 233,Estonia,Estonian,Europe 55 | 410,South Korea,Korean,Asia 56 | 242,Fiji,Fijian,Oceania 57 | 246,Finland,Finnish,Europe 58 | 250,France,French,Europe 59 | 262,Djibouti,Djiboutian,Africa 60 | 266,Gabon,Gabonese,Africa 61 | 268,Georgia,Georgian,Asia 62 | 270,Gambia,Gambian,Africa 63 | 276,Germany,German,Europe 64 | 288,Ghana,Ghanaian,Africa 65 | 300,Greece,Greek,Europe 66 | 308,Grenada,Grenadian,North America 67 | 320,Guatemala,Guatemalan,North America 68 | 324,Guinea,Guinean,Africa 69 | 328,Guyana,Guyanese,South America 70 | 332,Haiti,Haitian,North America 71 | 340,Honduras,Honduran,North America 72 | 348,Hungary,Hungarian,Europe 73 | 352,Iceland,Icelandic,Europe 74 | 356,India,Indian,Asia 75 | 360,Indonesia,Indonesian,Asia 76 | 364,Iran,Iranian,Asia 77 | 368,Iraq,Iraqi,Asia 78 | 372,Ireland,Irish,Europe 79 | 380,Italy,Italian,Europe 80 | 191,Croatia,Croatian,Europe 81 | 388,Jamaica,Jamaican,North America 82 | 392,Japan,Japanese,Asia 83 | 398,Kazakhstan,Kazakh,Asia 84 | 400,Jordan,Jordanian,Asia 85 | 404,Kenya,Kenyan,Africa 86 | 414,Kuwait,Kuwaiti,Asia 87 | 418,Laos,Laotian,Asia 88 | 422,Lebanon,Lebanese,Asia 89 | 428,Latvia,Latvian,Europe 90 | 430,Liberia,Liberian,Africa 91 | 434,Libya,Libyan,Africa 92 | 438,Liechtenstein,,Europe 93 | 440,Lithuania,Lithuanian,Europe 94 | 442,Luxembourg,,Europe 95 | 807,Macedonia,Macedonian,Europe 96 | 450,Madagascar,Madagascan,Africa 97 | 454,Malawi,Malawian,Africa 98 | 458,Malaysia,Malaysian,Asia 99 | 462,Maldives,Maldivian,Asia 100 | 466,Mali,Malian,Africa 101 | 470,Malta,Maltese,Europe 102 | 478,Mauritania,Mauritanian,Africa 103 | 480,Mauritius,Mauritian,Africa 104 | 484,Mexico,Mexican,North America 105 | 492,Monaco,Monacan,Europe 106 | 496,Mongolia,Mongolian,Asia 107 | 499,Montenegro,Montenegrin,Europe 108 | 504,Morocco,Moroccan,Africa 109 | 508,Mozambique,Mozambican,Africa 110 | 512,Oman,Omani,Asia 111 | 516,Namibia,Namibian,Africa 112 | 520,Nauru,Nauruan,Oceania 113 | 524,Nepal,Nepalese,Asia 114 | 528,Netherlands,Dutch,Europe 115 | 548,Vanuatu,Vanuatuan,Oceania 116 | 554,New Zealand,New Zealand,Oceania 117 | 558,Nicaragua,Nicaraguan,North America 118 | 562,Niger,Nigerien,Africa 119 | 566,Nigeria,Nigerian,Africa 120 | 408,North Korea,North Korean,Asia 121 | 570,Niue,Niuean,Oceania 122 | 578,Norway|Norge,Norwegian,Europe 123 | 840,United States|US|USA,American,North America 124 | 498,Moldova,Moldovan,Europe 125 | 583,Federated States of Micronesia,Micronesian,Oceania 126 | 584,Marshall Islands,Marshallese,Oceania 127 | 296,Kiribati,Kiribatian,Oceania 128 | 585,Palau,Palauan,Oceania 129 | 586,Pakistan,Pakistani,Asia 130 | 591,Panama,Panamanian,North America 131 | 598,Papua New Guinea,Papa New Guinean,Oceania 132 | 600,Paraguay,Paraguayan,South America 133 | 604,Peru,Peruvian,South America 134 | 616,Poland,Polish,Europe 135 | 620,Portugal,Portuguese,Europe 136 | 634,Qatar,Qatari,Asia 137 | 642,Romania,Romanian,Europe 138 | 643,Russia,Russian,Europe 139 | 646,Rwanda,Rwandan,Africa 140 | 659,Saint Kitts and Nevis,Kittitian,North America 141 | 662,Saint Lucia,Saint Lucian,North America 142 | 670,Saint Vincent and the Grenadines,Vincentian,North America 143 | 682,Saudi Arabia,Saudi,Asia 144 | 686,Senegal,Senegalese,Africa 145 | 688,Serbia,Serbian,Europe 146 | 690,Seychelles,Seychellois,Africa 147 | 694,Sierra Leone,Sierra Leonean,Africa 148 | 702,Singapore,Singaporean,Asia 149 | 703,Slovakia,Slovak,Europe 150 | 704,Vietnam,Vietnamese,Asia 151 | 705,Slovenia,Slovenian,Europe 152 | 706,Somalia,Somali,Africa 153 | 710,South Africa,South African,Africa 154 | 716,Zimbabwe,Zimbabwean,Africa 155 | 724,Spain,Spanish,Europe 156 | 882,Samoa,Samoan,Oceania 157 | 729,Sudan,Sudanese,Africa 158 | 740,Suriname,Surinamese,South America 159 | 748,Eswatini,Swazi,Africa 160 | 752,Sweden|Sverige,Swedish|Svensk,Europe 161 | 756,Switzerland,Swiss,Europe 162 | 760,Syria,Syrian,Asia 163 | 762,Tajikistan,Tajik,Asia 164 | 776,Tonga,Tongan,Oceania 165 | 834,Tanzania,Tanzanian,Africa 166 | 608,Philippines,Philippine,Asia 167 | 764,Thailand,Thai,Asia 168 | 768,Togo,Togolese,Africa 169 | 780,Trinidad and Tobago,Trinidadian,North America 170 | 784,United Arab Emirates,Emirati,Asia 171 | 788,Tunisia,Tunisian,Africa 172 | 792,Turkey,Turkish,Asia 173 | 795,Turkmenistan,Turkmen,Asia 174 | 798,Tuvalu,Tuvaluan,Oceania 175 | 800,Uganda,Ugandan,Africa 176 | 804,Ukraine,Ukrainian,Europe 177 | 818,Egypt,Egyptian,Africa 178 | 826,United Kingdom|UK|England|Wales|Scotland,British|English|Welsh|Scottish,Europe 179 | 854,Burkina Faso,Burkinese|Burkinabe,Africa 180 | 104,Myanmar|Burma,Burmese,Asia 181 | 858,Uruguay,Uruguayan,South America 182 | 860,Uzbekistan,Uzbek,Asia 183 | 862,Venezuela,Venezuelan,South America 184 | 887,Yemen,Yemeni,Asia 185 | 894,Zambia,Zambian,Africa 186 | 426,Lesotho,Lesothian,Africa 187 | 140,Central African Republic,Central African,Africa 188 | 728,South Sudan,South Sudanese,Africa 189 | 384,Cote d'Ivoire|Cote Divoire,Ivorian|Ivory Coast,Africa 190 | 678,Sao Tome and Principe,Sao Tomean,Africa 191 | 226,Equatorial Guinea,Equatoguinean,Africa 192 | 304,Greenland,Greenlandic,North America 193 | 10,Antarctica,Antarctican,Antarctica 194 | 732,Western Sahara,West Saharan,Africa 195 | 624,Guinea-Bissau,Guinea-Bissauan,Africa 196 | 376,Israel,Israeli,Asia 197 | 417,Kyrgyzstan,Kyrgyz,Asia 198 | 275,Palestine,Palestinian,Asia 199 | 630,Puerto Rico,Puerto Rican,North America 200 | 248,Aland,Alandish,Europe 201 | 626,Timor-Leste,Timorese,Asia 202 | 234,Faroe Islands,Faroese,Europe 203 | 344,Hong Kong,Hongkongese,Asia 204 | 96,Brunei,Bruneian,Asia 205 | 904,Kosovo,Kosovan,Europe 206 | 901,North Cyprus,Turkish Cypriot,Asia 207 | 905,Somaliland,Somali,Africa 208 | 540,New Caledonia,New Caledonian,Oceania 209 | 260,French Southern Territories, 210 | 674,San Marino,Sammarinese,Europe 211 | -------------------------------------------------------------------------------- /src/assets/data/explr-worldmap/ne_50m_admin_0_countries/ne_50m_admin_0_countries.VERSION.txt: -------------------------------------------------------------------------------- 1 | 4.1.0 2 | -------------------------------------------------------------------------------- /src/assets/data/explr-worldmap/ne_50m_admin_0_countries/ne_50m_admin_0_countries.cpg: -------------------------------------------------------------------------------- 1 | UTF-8 -------------------------------------------------------------------------------- /src/assets/data/explr-worldmap/ne_50m_admin_0_countries/ne_50m_admin_0_countries.dbf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mold/explr/8125f766a310e85f8f08cafd093ecd9c613f4260/src/assets/data/explr-worldmap/ne_50m_admin_0_countries/ne_50m_admin_0_countries.dbf -------------------------------------------------------------------------------- /src/assets/data/explr-worldmap/ne_50m_admin_0_countries/ne_50m_admin_0_countries.prj: -------------------------------------------------------------------------------- 1 | GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["Degree",0.017453292519943295]] -------------------------------------------------------------------------------- /src/assets/data/explr-worldmap/ne_50m_admin_0_countries/ne_50m_admin_0_countries.shp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mold/explr/8125f766a310e85f8f08cafd093ecd9c613f4260/src/assets/data/explr-worldmap/ne_50m_admin_0_countries/ne_50m_admin_0_countries.shp -------------------------------------------------------------------------------- /src/assets/data/explr-worldmap/ne_50m_admin_0_countries/ne_50m_admin_0_countries.shx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mold/explr/8125f766a310e85f8f08cafd093ecd9c613f4260/src/assets/data/explr-worldmap/ne_50m_admin_0_countries/ne_50m_admin_0_countries.shx -------------------------------------------------------------------------------- /src/assets/data/playlists.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "name": "Albania", 3 | "playlistName": "The Sound of Albania", 4 | "uri": "spotify:user:thesoundsofspotify:playlist:5FuuvFDE1GUh138tXzhXi6" 5 | }, 6 | { 7 | "name": "Algeria", 8 | "playlistName": "The Sound of Algeria", 9 | "uri": "spotify:user:thesoundsofspotify:playlist:5AwXHaHjXeAdViA0fYX4Wh" 10 | }, 11 | { 12 | "name": "Angola", 13 | "playlistName": "The Sound of Angola", 14 | "uri": "spotify:user:thesoundsofspotify:playlist:3JnjjO0fon5SSwXcdVhQRX" 15 | }, 16 | { 17 | "name": "Argentina", 18 | "playlistName": "The Sound of Argentina", 19 | "uri": "spotify:user:thesoundsofspotify:playlist:6fv5nMucCr4JLbanpHmVA5" 20 | }, 21 | { 22 | "name": "Armenia", 23 | "playlistName": "The Sound of Armenia", 24 | "uri": "spotify:user:thesoundsofspotify:playlist:60XDgHfPVx9WXUP9O1RHmR" 25 | }, 26 | { 27 | "name": "Australia", 28 | "playlistName": "The Sound of Australia", 29 | "uri": "spotify:user:thesoundsofspotify:playlist:2XVNjIYqXmzPbneYemRrix" 30 | }, 31 | { 32 | "name": "Austria", 33 | "playlistName": "The Sound of Austria", 34 | "uri": "spotify:user:thesoundsofspotify:playlist:0gCqClabjS49E3IvPxJmXe" 35 | }, 36 | { 37 | "name": "Azerbaijan", 38 | "playlistName": "The Sound of Azerbaijan", 39 | "uri": "spotify:user:thesoundsofspotify:playlist:4NDIAkYbzUWScRh0crPKbK" 40 | }, 41 | { 42 | "name": "Bahamas", 43 | "playlistName": "The Sound of Bahamas", 44 | "uri": "spotify:user:thesoundsofspotify:playlist:1FdYRIF1GpCptaXKp7ca1O" 45 | }, 46 | { 47 | "name": "Bangladesh", 48 | "playlistName": "The Sound of Bangladesh", 49 | "uri": "spotify:user:thesoundsofspotify:playlist:72KZpp6ui4v9iXBTBZPMB5" 50 | }, 51 | { 52 | "name": "Barbados", 53 | "playlistName": "The Sound of Barbados", 54 | "uri": "spotify:user:thesoundsofspotify:playlist:7LoDph8nD8uRwu77Zq2H7R" 55 | }, 56 | { 57 | "name": "Belarus", 58 | "playlistName": "The Sound of Belarus", 59 | "uri": "spotify:user:thesoundsofspotify:playlist:7lg52VtBfC7daJUEZbtFV3" 60 | }, 61 | { 62 | "name": "Belgium", 63 | "playlistName": "The Sound of Belgium", 64 | "uri": "spotify:user:thesoundsofspotify:playlist:3WY9ZhUBxQXjI9ipQbR6kL" 65 | }, 66 | { 67 | "name": "Bolivia", 68 | "playlistName": "The Sound of Bolivia", 69 | "uri": "spotify:user:thesoundsofspotify:playlist:1vzZRy7kp4rn7ZgV0RdP8p" 70 | }, 71 | { 72 | "name": "Brazil", 73 | "playlistName": "The Sound of Brazil", 74 | "uri": "spotify:user:thesoundsofspotify:playlist:5KHbclM6e6EVONQirubFMO" 75 | }, 76 | { 77 | "name": "Bulgaria", 78 | "playlistName": "The Sound of Bulgaria", 79 | "uri": "spotify:user:thesoundsofspotify:playlist:0feZbPn3DcsrLAF26CeNmp" 80 | }, 81 | { 82 | "name": "Cameroon", 83 | "playlistName": "The Sound of Cameroon", 84 | "uri": "spotify:user:thesoundsofspotify:playlist:6jGOLPKsOq6j8C6u4OyjTh" 85 | }, 86 | { 87 | "name": "Canada", 88 | "playlistName": "The Sound of Canada", 89 | "uri": "spotify:user:thesoundsofspotify:playlist:7EeZTIWCXCGbeiEclJoBBn" 90 | }, 91 | { 92 | "name": "Chile", 93 | "playlistName": "The Sound of Chile", 94 | "uri": "spotify:user:thesoundsofspotify:playlist:0BpFdlssgmbxj5dkamrqC0" 95 | }, 96 | { 97 | "name": "China", 98 | "playlistName": "The Sound of China", 99 | "uri": "spotify:user:thesoundsofspotify:playlist:1otAiOltVxRirrrH50hfWx" 100 | }, 101 | { 102 | "name": "Colombia", 103 | "playlistName": "The Sound of Colombia", 104 | "uri": "spotify:user:thesoundsofspotify:playlist:0nK6y27PHOAsWc24VchHNb" 105 | }, 106 | { 107 | "name": "Congo", 108 | "playlistName": "The Sound of Congo", 109 | "uri": "spotify:user:thesoundsofspotify:playlist:3jOQww7EbDJERoxVRYJgz7" 110 | }, 111 | { 112 | "name": "Costa Rica", 113 | "playlistName": "The Sound of Costa Rica", 114 | "uri": "spotify:user:thesoundsofspotify:playlist:65kdQo5kDLvb1971RY8pOy" 115 | }, 116 | { 117 | "name": "Cote d'Ivoire", 118 | "playlistName": "The Sound of Cote d'Ivoire", 119 | "uri": "spotify:user:thesoundsofspotify:playlist:0TRVhOLs0gqIlP720UTscY" 120 | }, 121 | { 122 | "name": "Croatia", 123 | "playlistName": "The Sound of Croatia", 124 | "uri": "spotify:user:thesoundsofspotify:playlist:0znxuqGLZY1gcMH8Uogbd4" 125 | }, 126 | { 127 | "name": "Cyprus", 128 | "playlistName": "The Sound of Cyprus", 129 | "uri": "spotify:user:thesoundsofspotify:playlist:7vPrsJST9BMQs5iiap4HU1" 130 | }, 131 | { 132 | "name": "Czech Republic", 133 | "playlistName": "The Sound of Czech Republic", 134 | "uri": "spotify:user:thesoundsofspotify:playlist:4E4rfKvIvM58k9owC7GeJt" 135 | }, 136 | { 137 | "name": "Denmark", 138 | "playlistName": "The Sound of Denmark", 139 | "uri": "spotify:user:thesoundsofspotify:playlist:7c0IjW5MmngEjLm9ii7LeT" 140 | }, 141 | { 142 | "name": "Dominican Republic", 143 | "playlistName": "The Sound of Dominican Republic", 144 | "uri": "spotify:user:thesoundsofspotify:playlist:0Kh97QBYQ8OtUI8Mk8U09a" 145 | }, 146 | { 147 | "name": "Ecuador", 148 | "playlistName": "The Sound of Ecuador", 149 | "uri": "spotify:user:thesoundsofspotify:playlist:4E34NzKniK1FSSu33ZbVCS" 150 | }, 151 | { 152 | "name": "Egypt", 153 | "playlistName": "The Sound of Egypt", 154 | "uri": "spotify:user:thesoundsofspotify:playlist:3dM8mbIAa94xP71GdAWfPD" 155 | }, 156 | { 157 | "name": "El Salvador", 158 | "playlistName": "The Sound of El Salvador", 159 | "uri": "spotify:user:thesoundsofspotify:playlist:5cgHq0FJENtKQq1GlGtt8c" 160 | }, 161 | { 162 | "name": "England", 163 | "playlistName": "The Sound of England", 164 | "uri": "spotify:user:glennpmcdonald:playlist:1m30k5Aw2DQARXkA3JGBcv" 165 | }, 166 | { 167 | "name": "Estonia", 168 | "playlistName": "The Sound of Estonia", 169 | "uri": "spotify:user:thesoundsofspotify:playlist:23RHAc40tjPnFXHbNuxajM" 170 | }, 171 | { 172 | "name": "Ethiopia", 173 | "playlistName": "The Sound of Ethiopia", 174 | "uri": "spotify:user:thesoundsofspotify:playlist:6aWM2l8JzGMoR4VuDqxtnu" 175 | }, 176 | { 177 | "name": "Faroe Islands", 178 | "playlistName": "The Sound of Faroe Islands", 179 | "uri": "spotify:user:thesoundsofspotify:playlist:6O7hmkU1oS9lqBsSpn8nxZ" 180 | }, 181 | { 182 | "name": "Finland", 183 | "playlistName": "The Sound of Finland", 184 | "uri": "spotify:user:thesoundsofspotify:playlist:4qsbirgyCjBlQXKAf68H1V" 185 | }, 186 | { 187 | "name": "France", 188 | "playlistName": "The Sound of France", 189 | "uri": "spotify:user:thesoundsofspotify:playlist:0TM63udBodwHZE7jCXw5Pc" 190 | }, 191 | { 192 | "name": "Germany", 193 | "playlistName": "The Sound of Germany", 194 | "uri": "spotify:user:thesoundsofspotify:playlist:4L6pZ0ihP6RN35WcyvZQJB" 195 | }, 196 | { 197 | "name": "Ghana", 198 | "playlistName": "The Sound of Ghana", 199 | "uri": "spotify:user:thesoundsofspotify:playlist:6zvs325lRproQd0Sd83oQg" 200 | }, 201 | { 202 | "name": "Greece", 203 | "playlistName": "The Sound of Greece", 204 | "uri": "spotify:user:thesoundsofspotify:playlist:6x4EFVW8OpaVtP1HZY6QeH" 205 | }, 206 | { 207 | "name": "Guadeloupe", 208 | "playlistName": "The Sound of Guadeloupe", 209 | "uri": "spotify:user:thesoundsofspotify:playlist:1Isre44J8fM4T4fjvmFc4S" 210 | }, 211 | { 212 | "name": "Guatemala", 213 | "playlistName": "The Sound of Guatemala", 214 | "uri": "spotify:user:thesoundsofspotify:playlist:07iV0530DfwvE1458hYCsD" 215 | }, 216 | { 217 | "name": "Guinea", 218 | "playlistName": "The Sound of Guinea", 219 | "uri": "spotify:playlist:03Gd3YZklN0Seh6MusfXVo" 220 | }, 221 | { 222 | "name": "Haiti", 223 | "playlistName": "The Sound of Haiti", 224 | "uri": "spotify:user:thesoundsofspotify:playlist:6gpIsAC2MgSlUbnfeEmhDk" 225 | }, 226 | { 227 | "name": "Honduras", 228 | "playlistName": "The Sound of Honduras", 229 | "uri": "spotify:user:thesoundsofspotify:playlist:04zkswFSpU3aK2SkBwPLv8" 230 | }, 231 | { 232 | "name": "Hong Kong", 233 | "playlistName": "The Sound of Hong Kong", 234 | "uri": "spotify:user:thesoundsofspotify:playlist:3TrjAEBsU0xEpYsaHfdoCl" 235 | }, 236 | { 237 | "name": "Hungary", 238 | "playlistName": "The Sound of Hungary", 239 | "uri": "spotify:user:thesoundsofspotify:playlist:3m4pWTBMYVDYdpouF7h654" 240 | }, 241 | { 242 | "name": "Iceland", 243 | "playlistName": "The Sound of Iceland", 244 | "uri": "spotify:user:thesoundsofspotify:playlist:2LQh4jrw98zQqo20sBZAb6" 245 | }, 246 | { 247 | "name": "India", 248 | "playlistName": "The Sound of India", 249 | "uri": "spotify:user:thesoundsofspotify:playlist:4PHxzXyPsm2O89BUQZ79mU" 250 | }, 251 | { 252 | "name": "Indonesia", 253 | "playlistName": "The Sound of Indonesia", 254 | "uri": "spotify:user:thesoundsofspotify:playlist:7rvfCudCXTGNrUeKPOaUTi" 255 | }, 256 | { 257 | "name": "Ireland", 258 | "playlistName": "The Sound of Ireland", 259 | "uri": "spotify:user:thesoundsofspotify:playlist:4Bg23XBXrv3TmNKBoWR7Z9" 260 | }, 261 | { 262 | "name": "Israel", 263 | "playlistName": "The Sound of Israel", 264 | "uri": "spotify:user:thesoundsofspotify:playlist:5eQM0nuGRnzMTvVcsnkK9J" 265 | }, 266 | { 267 | "name": "Italy", 268 | "playlistName": "The Sound of Italy", 269 | "uri": "spotify:user:thesoundsofspotify:playlist:2TtVE9heBUr1c4hmIQhIpQ" 270 | }, 271 | { 272 | "name": "Jamaica", 273 | "playlistName": "The Sound of Jamaica", 274 | "uri": "spotify:user:thesoundsofspotify:playlist:64IoS9dnXVYAjAEKrmbOnq" 275 | }, 276 | { 277 | "name": "Japan", 278 | "playlistName": "The Sound of Japan", 279 | "uri": "spotify:user:thesoundsofspotify:playlist:6Ul144kSbf8uO2plqc2kC0" 280 | }, 281 | { 282 | "name": "Jordan", 283 | "playlistName": "The Sound of Jordan", 284 | "uri": "spotify:user:thesoundsofspotify:playlist:2jmUqTa40ibjCNMlkFFdRx" 285 | }, 286 | { 287 | "name": "Kenya", 288 | "playlistName": "The Sound of Kenya", 289 | "uri": "spotify:user:thesoundsofspotify:playlist:2WMjmDXuKj7xS7eRVi58lq" 290 | }, 291 | { 292 | "name": "Latvia", 293 | "playlistName": "The Sound of Latvia", 294 | "uri": "spotify:user:thesoundsofspotify:playlist:3IUg2y3vvMotrHGIp03SX2" 295 | }, 296 | { 297 | "name": "Lebanon", 298 | "playlistName": "The Sound of Lebanon", 299 | "uri": "spotify:user:thesoundsofspotify:playlist:0wLKeY1tVzIWQezNxHiwe8" 300 | }, 301 | { 302 | "name": "Lithuania", 303 | "playlistName": "The Sound of Lithuania", 304 | "uri": "spotify:user:thesoundsofspotify:playlist:0VJCmxJDPwzmZiK2bk6ZtT" 305 | }, 306 | { 307 | "name": "Luxembourg", 308 | "playlistName": "The Sound of Luxembourg", 309 | "uri": "spotify:user:thesoundsofspotify:playlist:2xusVUU2GAztGsmnx39mrX" 310 | }, 311 | { 312 | "name": "Macedonia", 313 | "playlistName": "The Sound of Macedonia", 314 | "uri": "spotify:user:thesoundsofspotify:playlist:4GgQJoSn6UuXFIc17cbsEu" 315 | }, 316 | { 317 | "name": "Malaysia", 318 | "playlistName": "The Sound of Malaysia", 319 | "uri": "spotify:user:thesoundsofspotify:playlist:0wUIYdCrysNjmdqY3XAiUL" 320 | }, 321 | { 322 | "name": "Mali", 323 | "playlistName": "The Sound of Mali", 324 | "uri": "spotify:playlist:2TwIx5tug7ZNYdCOESbMnZ" 325 | }, 326 | { 327 | "name": "Malta", 328 | "playlistName": "The Sound of Malta", 329 | "uri": "spotify:user:thesoundsofspotify:playlist:12UnwNXrAWcqg7bh2i7JjR" 330 | }, 331 | { 332 | "name": "Martinique", 333 | "playlistName": "The Sound of Martinique", 334 | "uri": "spotify:user:thesoundsofspotify:playlist:4BcTwgtdVGldxxIP2uLZZp" 335 | }, 336 | { 337 | "name": "Mexico", 338 | "playlistName": "The Sound of Mexico", 339 | "uri": "spotify:user:thesoundsofspotify:playlist:4KEDTn9diHpbSeMAORDfS6" 340 | }, 341 | { 342 | "name": "Morocco", 343 | "playlistName": "The Sound of Morocco", 344 | "uri": "spotify:user:thesoundsofspotify:playlist:2YJuVrXO5UhQ6LnFeovPxi" 345 | }, 346 | { 347 | "name": "Nepal", 348 | "playlistName": "The Sound of Nepal", 349 | "uri": "spotify:user:thesoundsofspotify:playlist:1oTWxwffWn3y6SWq6Hsh3S" 350 | }, 351 | { 352 | "name": "Netherlands", 353 | "playlistName": "The Sound of Netherlands", 354 | "uri": "spotify:user:thesoundsofspotify:playlist:3KuxYcB3SY9lCNMeVVq5wl" 355 | }, 356 | { 357 | "name": "New Zealand", 358 | "playlistName": "The Sound of New Zealand", 359 | "uri": "spotify:user:thesoundsofspotify:playlist:6mbBBmvZ9Rmupa30sG08yx" 360 | }, 361 | { 362 | "name": "Nigeria", 363 | "playlistName": "The Sound of Nigeria", 364 | "uri": "spotify:user:thesoundsofspotify:playlist:0gpwdCWOt1eBCX9QkvIk7J" 365 | }, 366 | { 367 | "name": "Norway", 368 | "playlistName": "The Sound of Norway", 369 | "uri": "spotify:user:thesoundsofspotify:playlist:6i4EhwqgzBFr8m36oSaId7" 370 | }, 371 | { 372 | "name": "Pakistan", 373 | "playlistName": "The Sound of Pakistan", 374 | "uri": "spotify:user:thesoundsofspotify:playlist:0FlQ9FODiPVmSgKPYtuvtv" 375 | }, 376 | { 377 | "name": "Panama", 378 | "playlistName": "The Sound of Panama", 379 | "uri": "spotify:user:thesoundsofspotify:playlist:0RlFUSKKLMGdjqUmbcmTOG" 380 | }, 381 | { 382 | "name": "Paraguay", 383 | "playlistName": "The Sound of Paraguay", 384 | "uri": "spotify:user:thesoundsofspotify:playlist:3vGWbwhksMNaxeUKPcO4mM" 385 | }, 386 | { 387 | "name": "Peru", 388 | "playlistName": "The Sound of Peru", 389 | "uri": "spotify:user:thesoundsofspotify:playlist:11kNR35Gm19Uja3mM5kkQ0" 390 | }, 391 | { 392 | "name": "Philippines", 393 | "playlistName": "The Sound of Philippines", 394 | "uri": "spotify:user:thesoundsofspotify:playlist:30UNGJf8WiW5qwGI83WRIc" 395 | }, 396 | { 397 | "name": "Poland", 398 | "playlistName": "The Sound of Poland", 399 | "uri": "spotify:user:thesoundsofspotify:playlist:7B8G22apRJ4pSGLhJI8ph4" 400 | }, 401 | { 402 | "name": "Portugal", 403 | "playlistName": "The Sound of Portugal", 404 | "uri": "spotify:user:thesoundsofspotify:playlist:6RhCb3uucw06ETaPrwUpPz" 405 | }, 406 | { 407 | "name": "Puerto Rico", 408 | "playlistName": "The Sound of Puerto Rico", 409 | "uri": "spotify:user:thesoundsofspotify:playlist:4TroTx6oxNuVC94tMN53uD" 410 | }, 411 | { 412 | "name": "Republic of the Congo", 413 | "playlistName": "The Sound of Republic of the Congo", 414 | "uri": "spotify:playlist:3jOQww7EbDJERoxVRYJgz7" 415 | }, 416 | { 417 | "name": "Reunion", 418 | "playlistName": "The Sound of Reunion", 419 | "uri": "spotify:user:thesoundsofspotify:playlist:4n1gY1OoQBd8tb8nSGkaj0" 420 | }, 421 | { 422 | "name": "Romania", 423 | "playlistName": "The Sound of Romania", 424 | "uri": "spotify:user:thesoundsofspotify:playlist:0llQlXU7qy1ubNcevVNiBV" 425 | }, 426 | { 427 | "name": "Russia", 428 | "playlistName": "The Sound of Russia", 429 | "uri": "spotify:user:thesoundsofspotify:playlist:4r1bqx0pcl99LWew9pSGEL" 430 | }, 431 | { 432 | "name": "Scotland", 433 | "playlistName": "The Sound of Scotland", 434 | "uri": "spotify:user:glennpmcdonald:playlist:0XTr5F2t1oQjo6wKFNSc2V" 435 | }, 436 | { 437 | "name": "Senegal", 438 | "playlistName": "The Sound of Senegal", 439 | "uri": "spotify:user:thesoundsofspotify:playlist:2Jp7Pe0CKDhFYHW3ZJTP3G" 440 | }, 441 | { 442 | "name": "Serbia", 443 | "playlistName": "The Sound of Serbia", 444 | "uri": "spotify:user:thesoundsofspotify:playlist:55JkDUT2t288c8Fppd7woz" 445 | }, 446 | { 447 | "name": "Singapore", 448 | "playlistName": "The Sound of Singapore", 449 | "uri": "spotify:user:thesoundsofspotify:playlist:595CAPQRuGdsTFTGav4hce" 450 | }, 451 | { 452 | "name": "Slovakia", 453 | "playlistName": "The Sound of Slovakia", 454 | "uri": "spotify:user:thesoundsofspotify:playlist:2aKp7vT30fCQD2qAHB1bxA" 455 | }, 456 | { 457 | "name": "Slovenia", 458 | "playlistName": "The Sound of Slovenia", 459 | "uri": "spotify:user:thesoundsofspotify:playlist:0cSeAbTUeoxTvvOcEnb8Nn" 460 | }, 461 | { 462 | "name": "South Africa", 463 | "playlistName": "The Sound of South Africa", 464 | "uri": "spotify:user:thesoundsofspotify:playlist:6KeYZIeYiElx86iOhDigH3" 465 | }, 466 | { 467 | "name": "South Korea", 468 | "playlistName": "The Sound of South Korea", 469 | "uri": "spotify:user:thesoundsofspotify:playlist:5UXrG4KQfQqTx5qrrSPeIx" 470 | }, 471 | { 472 | "name": "Spain", 473 | "playlistName": "The Sound of Spain", 474 | "uri": "spotify:user:thesoundsofspotify:playlist:2Pwbi4SQiROWX2SSwlGHNH" 475 | }, 476 | { 477 | "name": "Sri Lanka", 478 | "playlistName": "The Sound of Sri Lanka", 479 | "uri": "spotify:user:thesoundsofspotify:playlist:6kLpGx3m3dJvV2lO0rmCV2" 480 | }, 481 | { 482 | "name": "Sweden", 483 | "playlistName": "The Sound of Sweden", 484 | "uri": "spotify:user:thesoundsofspotify:playlist:1r32lZ0rBYRox0JNHQyyZw" 485 | }, 486 | { 487 | "name": "Switzerland", 488 | "playlistName": "The Sound of Switzerland", 489 | "uri": "spotify:user:thesoundsofspotify:playlist:6ParkTbaxqd3m5tjuPlIbT" 490 | }, 491 | { 492 | "name": "Taiwan", 493 | "playlistName": "The Sound of Taiwan", 494 | "uri": "spotify:user:thesoundsofspotify:playlist:5kIbCAkF1bydnWYJF76Tfd" 495 | }, 496 | { 497 | "name": "Tanzania", 498 | "playlistName": "The Sound of Tanzania", 499 | "uri": "spotify:user:thesoundsofspotify:playlist:56S93wwXTegxAuz9Np96zY" 500 | }, 501 | { 502 | "name": "Thailand", 503 | "playlistName": "The Sound of Thailand", 504 | "uri": "spotify:user:thesoundsofspotify:playlist:2jze9uOjsqnz9AYTIobJBN" 505 | }, 506 | { 507 | "name": "Trinidad and Tobago", 508 | "playlistName": "The Sound of Trinidad and Tobago", 509 | "uri": "spotify:user:thesoundsofspotify:playlist:2MOsy7lhNk5f2QaUStT3Ji" 510 | }, 511 | { 512 | "name": "Tunisia", 513 | "playlistName": "The Sound of Tunisia", 514 | "uri": "spotify:user:thesoundsofspotify:playlist:6BVdKHExWxcFBg7mMB8jH8" 515 | }, 516 | { 517 | "name": "Turkey", 518 | "playlistName": "The Sound of Turkey", 519 | "uri": "spotify:user:thesoundsofspotify:playlist:7Bmk5Tk58D2w6TI8Jfe8fn" 520 | }, 521 | { 522 | "name": "US Virgin Islands", 523 | "playlistName": "The Sound of US Virgin Islands", 524 | "uri": "spotify:user:thesoundsofspotify:playlist:6S7ztLlzlW44YWbE03yGok" 525 | }, 526 | { 527 | "name": "Uganda", 528 | "playlistName": "The Sound of Uganda", 529 | "uri": "spotify:user:thesoundsofspotify:playlist:1eupXwGWEOwe980kCEjOaW" 530 | }, 531 | { 532 | "name": "Ukraine", 533 | "playlistName": "The Sound of Ukraine", 534 | "uri": "spotify:user:thesoundsofspotify:playlist:3g3b1X7KGFbhrrJSaOd33E" 535 | }, 536 | { 537 | "name": "United Arab Emirates", 538 | "playlistName": "The Sound of United Arab Emirates", 539 | "uri": "spotify:user:thesoundsofspotify:playlist:3eDrYB8uZwWjMVdZBnVyd2" 540 | }, 541 | { 542 | "name": "United Kingdom", 543 | "playlistName": "The Sound of United Kingdom", 544 | "uri": "spotify:user:thesoundsofspotify:playlist:7enaPLxfS4owel1hFBW7KU" 545 | }, 546 | { 547 | "name": "United States", 548 | "playlistName": "The Sound of United States", 549 | "uri": "spotify:user:thesoundsofspotify:playlist:6SXqTPTGz3FXZ7JZgrTa6S" 550 | }, 551 | { 552 | "name": "Uruguay", 553 | "playlistName": "The Sound of Uruguay", 554 | "uri": "spotify:user:thesoundsofspotify:playlist:7veYKAgvBaom11iHhdIelV" 555 | }, 556 | { 557 | "name": "Venezuela", 558 | "playlistName": "The Sound of Venezuela", 559 | "uri": "spotify:user:thesoundsofspotify:playlist:1QXWTBLj78mYJwCCkVXQZd" 560 | }, 561 | { 562 | "name": "Vietnam", 563 | "playlistName": "The Sound of Vietnam", 564 | "uri": "spotify:user:thesoundsofspotify:playlist:7CpbHSAFqJcwy78oXCcwBq" 565 | }, 566 | { 567 | "name": "Zimbabwe", 568 | "playlistName": "The Sound of Zimbabwe", 569 | "uri": "spotify:user:thesoundsofspotify:playlist:5Q5oZY8yq6zFNhzdOVTonV" 570 | } 571 | ] -------------------------------------------------------------------------------- /src/assets/img/explr-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mold/explr/8125f766a310e85f8f08cafd093ecd9c613f4260/src/assets/img/explr-logo.png -------------------------------------------------------------------------------- /src/assets/img/explrlogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mold/explr/8125f766a310e85f8f08cafd093ecd9c613f4260/src/assets/img/explrlogo.png -------------------------------------------------------------------------------- /src/assets/img/facebook-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 10 | 11 | 12 | 13 | 15 | 17 | 18 | -------------------------------------------------------------------------------- /src/assets/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mold/explr/8125f766a310e85f8f08cafd093ecd9c613f4260/src/assets/img/favicon.png -------------------------------------------------------------------------------- /src/assets/img/file-export-solid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mold/explr/8125f766a310e85f8f08cafd093ecd9c613f4260/src/assets/img/file-export-solid.png -------------------------------------------------------------------------------- /src/assets/img/flattr-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/img/github-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/img/glyphicons_009_magic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mold/explr/8125f766a310e85f8f08cafd093ecd9c613f4260/src/assets/img/glyphicons_009_magic.png -------------------------------------------------------------------------------- /src/assets/img/gobutton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mold/explr/8125f766a310e85f8f08cafd093ecd9c613f4260/src/assets/img/gobutton.png -------------------------------------------------------------------------------- /src/assets/img/last-fm-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mold/explr/8125f766a310e85f8f08cafd093ecd9c613f4260/src/assets/img/last-fm-logo.png -------------------------------------------------------------------------------- /src/assets/img/loader_horizontal.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mold/explr/8125f766a310e85f8f08cafd093ecd9c613f4260/src/assets/img/loader_horizontal.gif -------------------------------------------------------------------------------- /src/assets/img/loader_spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mold/explr/8125f766a310e85f8f08cafd093ecd9c613f4260/src/assets/img/loader_spinner.gif -------------------------------------------------------------------------------- /src/assets/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mold/explr/8125f766a310e85f8f08cafd093ecd9c613f4260/src/assets/img/logo.png -------------------------------------------------------------------------------- /src/assets/img/reddit-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/img/refresh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mold/explr/8125f766a310e85f8f08cafd093ecd9c613f4260/src/assets/img/refresh.png -------------------------------------------------------------------------------- /src/assets/img/remove-user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mold/explr/8125f766a310e85f8f08cafd093ecd9c613f4260/src/assets/img/remove-user.png -------------------------------------------------------------------------------- /src/assets/img/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mold/explr/8125f766a310e85f8f08cafd093ecd9c613f4260/src/assets/img/search.png -------------------------------------------------------------------------------- /src/assets/img/snapshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mold/explr/8125f766a310e85f8f08cafd093ecd9c613f4260/src/assets/img/snapshot.png -------------------------------------------------------------------------------- /src/assets/js/api/api.js: -------------------------------------------------------------------------------- 1 | /*requires: 2 | api/lastfm.js 3 | */ 4 | 5 | var api = api || {}; 6 | var superCount = 0; 7 | 8 | (function (window, document) { 9 | let getHardcodedCountries = () => new Promise((res, rej) => 10 | d3.json("assets/data/artist-countries.json", (err, data) => 11 | err ? rej(err) : res(data) 12 | )); 13 | 14 | api.getCountriesData = (() => { 15 | console.log("Loading countries data...") 16 | let promise; 17 | 18 | return () => { 19 | if (promise) { return promise; } 20 | 21 | return promise = new Promise((res, rej) => { 22 | d3.csv("assets/data/countries.csv", function (err, data) { 23 | data.forEach(d => { 24 | d.id = +d.id; 25 | d.names = d.names ? d.names.split("|") : []; 26 | d.tags = d.tags ? d.tags.split("|") : []; 27 | d.mainName = d.names[0]; 28 | d.tag = d.tags[0]; 29 | d.name = d.mainName; 30 | d.continent = d.continent || ''; 31 | }); 32 | 33 | res(data); 34 | }); 35 | }); 36 | } 37 | })(); 38 | 39 | Promise.all([api.getCountriesData(), getHardcodedCountries()]).then(([countryData, hardcodedCountries]) => { 40 | countryData = countryData.map(d => { 41 | let splits = []; 42 | 43 | if (d.names.length === 1 && d.tags.length === 0) { 44 | splits = [d]; 45 | } 46 | if (d.names.length > 1) { 47 | splits = splits.concat(d.names.map(name => ({ ...d, name }))); 48 | } 49 | if (d.tags.length > 0) { 50 | splits = splits.concat(d.tags.map(tag => ({ ...d, tag }))); 51 | } 52 | 53 | if(d.names.length > 1 &&d.tags.length > 0){ splits.splice(0,1); } 54 | 55 | return splits; 56 | }).flat(); 57 | 58 | let alias = d3.nest() 59 | .key(function(d) { 60 | if (d && d.tag) { 61 | return d.tag.toLowerCase(); 62 | } else { 63 | return ""; 64 | } 65 | }) 66 | .map(countryData); 67 | 68 | let cname = d3.nest() 69 | .key(function(d) { 70 | return d.name.toLowerCase(); 71 | }) 72 | .map(countryData); 73 | 74 | /** 75 | * Tries to find out the country for a specified artist. 76 | * @param {String} artist Name of the artist to get country for 77 | * @param {Function} callback Callback function, called when the search is over (whether a country's been found or not) 78 | * The callback function takes one argument, this object: 79 | * 80 | * ``` 81 | * { 82 | * "artist": "", // , 83 | * "country": "", // , 84 | * "id": "", // , 85 | * "tag": "", // 86 | * } 87 | * ``` 88 | * 89 | * If no country could be found, "country", "tag" and "id" are undefined. 90 | * 91 | */ 92 | api.getCountry = function(artist, callback) { 93 | if (hardcodedCountries[artist]) { 94 | let hardcodedTagName = hardcodedCountries[artist].toLowerCase(); 95 | 96 | console.log(`Using hardcoded country tag "${hardcodedTagName}" for artist "${artist}"`) 97 | 98 | callback({ 99 | artist, 100 | tag: hardcodedTagName, 101 | id: cname[hardcodedTagName][0].id, 102 | country: cname[hardcodedTagName][0].mainName 103 | }); 104 | return; 105 | } 106 | 107 | // Get artists country code here, from last.fm or whatever 108 | api.lastfm.send("artist.gettoptags", [["artist", artist]], function(err, responseData2) { 109 | // Return if something failed 110 | if (err || !responseData2.toptags || !responseData2.toptags.tag || ! 111 | responseData2.toptags.tag.length) { 112 | callback({ 113 | "artist": artist 114 | }); 115 | return; 116 | } 117 | 118 | // Lista med taggar vi vill dubbelkolla 119 | var troubleCountries = ["georgia", "ireland"]; 120 | var troubleLanguages = ["spanish", "french", "english", "portuguese", "russian", "italian", "japanese", "korean", "indian", "swedish", "irish"]; 121 | var theTroubles = [].concat(troubleCountries, troubleLanguages); 122 | 123 | // check for country-tags in the artist's tags 124 | let demonymTag = { tag: "", id: null, country: "", count: 0 }; 125 | let countryTag = demonymTag; 126 | 127 | responseData2.toptags.tag.some(function (t, i) { 128 | var tname = t.name.toLowerCase(); 129 | 130 | // no need to search anymore since we only care 131 | // about the créme de la creme i.e. the tag with the 132 | // highest count 133 | if (countryTag.id && demonymTag.id) { return true; } 134 | 135 | try { 136 | // sweden->sweden 137 | if (!countryTag.id && cname[tname] && cname[tname][0].id) { 138 | countryTag = { tag: tname, id: cname[tname][0].id, country: cname[tname][0].mainName, count: t.count }; 139 | } 140 | 141 | // swedish -> sweden 142 | if (!demonymTag.id && alias[tname] && alias[tname][0].id) { 143 | demonymTag = { tag: tname, id: alias[tname][0].id, country: alias[tname][0].name, count: t.count }; 144 | } 145 | } catch (e) {} 146 | }); 147 | 148 | // country is best, demonym second 149 | var bestTag = (countryTag.id && demonymTag.count < 8 * countryTag.count) ? 150 | countryTag : 151 | (demonymTag.id 152 | ? demonymTag 153 | : {}); 154 | 155 | if (countryTag.tag === "georgia" && responseData2.toptags.tag.some(function (t) { 156 | return ["american", "us", "usa"].includes(t.name.toLowerCase()) 157 | })) { 158 | // it's not the country... 159 | bestTag = demonymTag; 160 | 161 | console.info("'" + artist + "' is tagged with 'georgia', but I'm gonna go ahead and guess they're really from the U.S."); 162 | } 163 | 164 | if (theTroubles.includes(bestTag.tag)) { 165 | console.info("Potentially incorrect country for '" + artist + "': " + bestTag.country + ", using the tag '" + bestTag.tag + "'"); 166 | } 167 | 168 | callback(Object.assign({ "artist": artist, }, bestTag)); 169 | }); 170 | } 171 | 172 | /** 173 | * Returns a list of country objects for a list of artist names. 174 | * 175 | * Beware!!! overwrites localstorage.artists when done!!! woaps!!!!!! dododod!!! 176 | * @param {Array} artists Array of artist names (String) 177 | * @param {Function} callback Callback function. Argument is a list of country objects, 178 | * containing only those artists that have a country 179 | * associated with them. For object structure, see api.getCountry 180 | */ 181 | api.getCountries = function(artists, callback) { 182 | var returnList = [], 183 | count = 0; 184 | /** 185 | * Increases a count and checks if we've tried 186 | * to get country for all artists 187 | */ 188 | var checkCount = function() { 189 | count++; 190 | superCount++; 191 | script.setLoadingStatus(`Loading artists, please wait... (${superCount} / ${SESSION.total_artists})`); 192 | d3.select("#loading-text").html("Loading artists...
(" + superCount + "/" + SESSION.total_artists + ")
You can start exploring,
but it might interfere
with loading your artists."); 193 | if (count === artists.length) { 194 | // We done, save artists and call back 195 | localforage.setItem("artists", STORED_ARTISTS, function (err) { 196 | if (err) { console.error("Failed saving artists to storage: ", err); } 197 | callback(returnList); 198 | }); 199 | } 200 | } 201 | 202 | // Get countries for all artists 203 | artists.forEach(function(el, i) { 204 | // first check stored artists to see if we've already checked this artist 205 | if (STORED_ARTISTS[el] && STORED_ARTISTS[el].country) { 206 | var returnObject = STORED_ARTISTS[el].country; 207 | returnObject.artist = el; 208 | returnList.push(returnObject); 209 | checkCount(); 210 | } else { 211 | var start = new Date().getTime(); 212 | 213 | api.getCountry(el, function(data) { 214 | STORED_ARTISTS[el] = STORED_ARTISTS[el] || {}; 215 | // console.error(data) 216 | 217 | // if (data.name) { 218 | STORED_ARTISTS[el].country = { 219 | "id": data.id, 220 | "name": data.name, 221 | }; 222 | returnList.push(data); 223 | // } 224 | // console.log("apicall " + (new Date().getTime() - start) + " ms"); 225 | 226 | // Update loading div, whoah ugly code yeah whaddayagonnado 227 | 228 | 229 | checkCount(); 230 | }) 231 | } 232 | 233 | }) 234 | } 235 | }) 236 | 237 | /** 238 | * Get all tags for an artist. 239 | * @param {String} artist Artist name 240 | * @param {Function} callback Callback function. Takes one argument which is an array 241 | * of tag objects (see the last.fm api doc for tag object structure) 242 | */ 243 | api.getTags = function(artist, callback) { 244 | // Check if artist tags are already saved, if so return them 245 | if (STORED_ARTISTS[artist] && STORED_ARTISTS[artist].tags) { 246 | // console.log("Had in store, no api call"); 247 | callback(STORED_ARTISTS[artist].tags); 248 | } else { 249 | // Create object in localstorage 250 | STORED_ARTISTS[artist] = STORED_ARTISTS[artist] || {}; 251 | STORED_ARTISTS[artist].tags = []; 252 | 253 | // Get from lastfm 254 | api.lastfm.send("artist.gettoptags", [["artist", artist]], 255 | function(err, responseData2) { 256 | STORED_ARTISTS[artist].tags = responseData2.toptags.tag; 257 | localforage.setItem("artists", STORED_ARTISTS, function (err) { 258 | if (err) { console.error("Failed saving artists to storage: ", err); } 259 | callback(STORED_ARTISTS[artist].tags); 260 | }); 261 | }); 262 | } 263 | } 264 | 265 | api.getArtistInfo = function(artist, callback) { 266 | var artistInfo = []; 267 | 268 | api.lastfm.send("artist.getinfo", [["artist", artist]], function(err, data1) { 269 | //Creating a list of tag names 270 | var tagnamelist = []; 271 | if (data1.artist.tags.tag) { 272 | data1.artist.tags.tag.forEach(function(t, i) { 273 | tagnamelist.push(t.name); 274 | }) 275 | } 276 | 277 | artistInfo.push({ 278 | name: artist, 279 | url: data1.artist.url, 280 | image: data1.artist.image[3]["#text"], 281 | description: data1.artist.bio.summary, 282 | tags: tagnamelist 283 | }) 284 | callback(artistInfo); 285 | }) 286 | 287 | 288 | 289 | } 290 | 291 | /** 292 | * Gets a list of artists with tags similar to the user's top tags, sorted in descending order. 293 | * Also included are which tags matched. 294 | * 295 | * Calling this function cancels previous requests initiated by this function. 296 | * @param {String} country Name of country or country alias (sweden, swedish, your choice) 297 | * @param {Function} callback Callback function. Argument is a list of artists. 298 | */ 299 | var recommendationRequests = []; 300 | api.cancelRecommendationRequests = function () { 301 | recommendationRequests.forEach(function (xhr) { 302 | xhr.abort(); 303 | }); 304 | 305 | recommendationRequests = []; 306 | } 307 | api.getRecommendations = function (country, callback) { 308 | api.cancelRecommendationRequests(); 309 | 310 | var recommendations = []; 311 | 312 | // get top tags for user 313 | var toptags = USER_TAGS.slice(0, 15); 314 | // make tag list to an object (back n forthss) 315 | var userTagObj = d3.nest().key(function(d) { 316 | return d.tag; 317 | }).rollup(function(d) { 318 | return d[0].count; 319 | }).map(toptags); 320 | 321 | 322 | //console.log("Got top tags for user!") 323 | 324 | // Get top artists for tag country 325 | var xhr1 = api.lastfm.send("tag.gettopartists", [["tag", country], ["limit", 100]], function(err, data1) { 326 | // Gotta count matching tags to then sort 327 | var tagCounts = {}; 328 | 329 | // Get the tags for these artists 330 | //console.log(data1, err) 331 | if (err || data1.error || !data1.topartists || !data1.topartists.artist) { 332 | callback([]); 333 | return; 334 | } 335 | var artists = data1.topartists.artist; 336 | 337 | artists.forEach(function(a, num) { 338 | tagCounts[a.name] = []; 339 | var xhr2 = api.lastfm.send("artist.gettoptags", [["artist", a.name]], function(err, data2) { 340 | var hasTags = !data2.error && (data2.toptags.tag ? true : false); 341 | d3.select("#rec-loading-current").html("(" + a.name + ")"); 342 | if (hasTags) { 343 | // Compare top 10 tags to user tags 344 | var tags = d3.nest().key(function(d) { 345 | return d.name; 346 | }).map(data2.toptags.tag); 347 | 348 | // Get rid of justin bieber 349 | if (tags[country]) { 350 | for (var i = data2.toptags.tag.length - 1; i >= 0; i--) { 351 | if (userTagObj[data2.toptags.tag[i].name] && data2.toptags.tag[i].count > 5) { 352 | tagCounts[a.name].push(data2.toptags.tag[i].name); 353 | } 354 | }; 355 | } 356 | } 357 | 358 | if (num === artists.length - 1) { 359 | //console.log("We've gotten tag counts for all artists, make a list!") 360 | d3.keys(tagCounts).forEach(function(d) { 361 | recommendations.push({ 362 | name: d, 363 | count: tagCounts[d].length, 364 | tags: tagCounts[d] 365 | }) 366 | }); 367 | 368 | recommendations.sort(function(a, b) { 369 | return b.count < a.count ? -1 : b.count > a.count ? 1 : 0; 370 | }) 371 | //console.log(recommendations) 372 | callback(recommendations); 373 | } 374 | 375 | }) 376 | 377 | recommendationRequests.push(xhr2); 378 | }) 379 | }) 380 | 381 | recommendationRequests.push(xhr1); 382 | } 383 | 384 | api.getFriends = function(callback) { 385 | api.lastfm.send("user.getFriends", [["user", SESSION.name]], callback); 386 | } 387 | 388 | })(window, document); 389 | -------------------------------------------------------------------------------- /src/assets/js/api/lastfm.js: -------------------------------------------------------------------------------- 1 | var api = api || {}; 2 | 3 | api.lastfm = {}; 4 | api.lastfm.key = "865b1653dbe200905a5b75d9d839467a"; 5 | api.lastfm.url = "https://ws.audioscrobbler.com/2.0/"; 6 | 7 | (function (api) { 8 | let keyI = 0; 9 | let keys = [ 10 | // https://gitlab.gnome.org/World/lollypop/blob/master/lollypop/lastfm.py 11 | "7a9619a850ccf7377c46cf233c51e3c6", 12 | 13 | // https://github.com/ampache/ampache/issues/1694 14 | "13893ba930c63b1b2cbe21441dc7f550", 15 | 16 | // https://www.reddit.com/r/lastfm/comments/3okkij/cant_create_lastfm_api_key/ 17 | "4cb074e4b8ec4ee9ad3eb37d6f7eb240", 18 | 19 | // https://www.w3resource.com/API/last.fm/tutorial.php 20 | "4a9f5581a9cdf20a699f540ac52a95c9", 21 | 22 | // https://www.reddit.com/r/lastfm/comments/3l3cae/cant_get_a_lastfm_api_key/ 23 | "57ee3318536b23ee81d6b27e36997cde", 24 | 25 | // original explr api key 26 | "865b1653dbe200905a5b75d9d839467a", 27 | 28 | // https://www.w3resource.com/API/last.fm/example.html 29 | "68b2125fd8f8fbadeb2195e551f32bc4", 30 | 31 | // https://rstudio-pubs-static.s3.amazonaws.com/236264_81312ba4d795474c8641dd0e2af83cba.html 32 | "1ba315d4d1673bbf88aed473f1917306" 33 | ]; 34 | let keyInfo = window.keyInfo = {}; 35 | keys.forEach(k => keyInfo[k] = { success: 0, fails: 0, total: 0 }); 36 | 37 | let rotateKey = function () { 38 | let avgErrors = keys.reduce((avg, k, i, arr) => avg + keyInfo[k].fails / arr.length, 0); 39 | let bestKeys = keys.filter(k => keyInfo[k].fails <= avgErrors); 40 | bestKeys = bestKeys.length ? bestKeys : keys; 41 | let key = bestKeys[++keyI % bestKeys.length]; 42 | 43 | // console.log({ key, avgErrors, bestKeys }); 44 | 45 | return key; 46 | } 47 | 48 | let setKeyInfo = function (key, success) { 49 | keyInfo[key].total++; 50 | keyInfo[key].success += success ? 1 : 0; 51 | keyInfo[key].fails += success ? 0 : 1; 52 | } 53 | 54 | /** 55 | * Send an API call to last.fm 56 | * @param {String} method The method name (e.g. "library.getartists") 57 | * @param {Array} options An array of tuples (arrays with two elements) 58 | with options for the request: ["key", "value"] 59 | * @param {Function} callback The callback function to call with the data 60 | returned from the request. Takes two arguments, 61 | error and data (callback(error, data)) 62 | */ 63 | api.lastfm.send = function (method, options, callback, retries) { 64 | let getUrl = (apiKey) => { 65 | let _url = api.lastfm.url + "?" + "method=" + method + "&api_key=" + 66 | apiKey + "&format=json"; 67 | 68 | options.forEach(function (el) { 69 | _url += "&" + el[0] + "=" + 70 | (el[1] + "") 71 | .replace("&", "%26") 72 | .replace("/", "%2F") 73 | .replace("+", "%2B") 74 | .replace("\\", "%5C"); 75 | }); 76 | 77 | return _url; 78 | }; 79 | 80 | retries = undefined === retries ? 10 : retries 81 | let xhr, gotResponse, aborted = false; 82 | 83 | function tryGet(tries, cb) { 84 | let _key = rotateKey(); 85 | xhr = d3.json(getUrl(_key), function (e, d) { 86 | if (aborted) { 87 | clearTimeout(timeout); 88 | return; 89 | } 90 | 91 | if (e) { // we got an actual server error: 4xx, 5xx 92 | d = JSON.parse(e.response); 93 | // now e and d are the same 94 | } else if (d.error) { 95 | // we got 200 BUT it's an error 96 | e = d; 97 | } 98 | 99 | if (e) { 100 | setKeyInfo(_key, false); 101 | 102 | let errInfo = { 103 | method: method, 104 | errorCode: e && e.error, 105 | try: tries, 106 | options: options, 107 | key: _key, 108 | message: e.message || JSON.parse(e.response)?.message, 109 | }; 110 | // alert("ERROR"); 111 | if (tries < retries) { 112 | console.warn("Retry request: ", errInfo); 113 | setTimeout(tryGet.bind(null, tries + 1, cb), tries * 3000); 114 | return; 115 | } 116 | 117 | if (tries >= retries) { 118 | console.warn("Retry failed after " + retries + " attempts, will stop trying.", errInfo); 119 | clearTimeout(timeout); 120 | aborted = true; 121 | e = "ERROR"; 122 | d = { 123 | error: "Took to long to respond" 124 | }; 125 | } 126 | } else { 127 | setKeyInfo(_key, true); 128 | } 129 | 130 | gotResponse = true; 131 | cb(e, d); 132 | }); 133 | } 134 | 135 | tryGet(0, callback); 136 | 137 | // Abort if the request takes too long - it sometimes ballar ur and fails after a minute :( 138 | let timeout = setTimeout(function () { 139 | if (!gotResponse) { 140 | //console.log("GET " + url + " took to long, aborting"); 141 | xhr.abort(); 142 | callback("ERROR", { 143 | error: "Took to long to respond" 144 | }); 145 | } 146 | }, 20000); 147 | 148 | return { 149 | abort: function () { 150 | aborted = true; 151 | xhr.abort(); 152 | } 153 | }; 154 | } 155 | 156 | })(api); -------------------------------------------------------------------------------- /src/assets/js/aria-announcer.js: -------------------------------------------------------------------------------- 1 | var announcer = {}; 2 | 3 | /* Inspired by https://github.com/AlmeroSteyn/react-aria-live */ 4 | const LIVEREGION_TIMEOUT_DELAY = 7000; 5 | 6 | let liveAnnouncer = null; 7 | 8 | function debounce(func, wait) { 9 | let timeout; 10 | return function executedFunction(...args) { 11 | const later = () => { 12 | clearTimeout(timeout); 13 | func(...args); 14 | }; 15 | clearTimeout(timeout); 16 | timeout = setTimeout(later, wait); 17 | }; 18 | }; 19 | 20 | /** 21 | * Announces the message using screen reader technology. 22 | */ 23 | function announce(message, assertiveness = 'assertive', timeout = LIVEREGION_TIMEOUT_DELAY) { 24 | if (!liveAnnouncer) { 25 | liveAnnouncer = new LiveAnnouncer(); 26 | } 27 | 28 | liveAnnouncer.announce(message, assertiveness, timeout); 29 | } 30 | 31 | /** 32 | * Stops all queued announcements. 33 | */ 34 | function clearAnnouncer(assertiveness) { 35 | if (liveAnnouncer) { 36 | liveAnnouncer.clear(assertiveness); 37 | } 38 | } 39 | 40 | /** 41 | * Removes the announcer from the DOM. 42 | */ 43 | function destroyAnnouncer() { 44 | if (liveAnnouncer) { 45 | liveAnnouncer.destroy(); 46 | liveAnnouncer = null; 47 | } 48 | } 49 | 50 | class LiveAnnouncer { 51 | constructor() { 52 | this.node = document.createElement('div'); 53 | this.node.dataset.liveAnnouncer = 'true'; 54 | // copied from VisuallyHidden 55 | Object.assign(this.node.style, { 56 | border: 0, 57 | clip: 'rect(0 0 0 0)', 58 | clipPath: 'inset(50%)', 59 | height: '1px', 60 | margin: '-1px', 61 | overflow: 'hidden', 62 | padding: 0, 63 | position: 'absolute', 64 | width: '1px', 65 | whiteSpace: 'nowrap' 66 | }); 67 | 68 | this.assertiveLog = this.createLog('assertive'); 69 | this.node.appendChild(this.assertiveLog); 70 | 71 | this.politeLog = this.createLog('polite'); 72 | this.node.appendChild(this.politeLog); 73 | 74 | document.body.prepend(this.node); 75 | } 76 | 77 | createLog(ariaLive) { 78 | let node = document.createElement('div'); 79 | node.setAttribute('role', 'log'); 80 | node.setAttribute('aria-live', ariaLive); 81 | node.setAttribute('aria-relevant', 'additions'); 82 | return node; 83 | } 84 | 85 | destroy() { 86 | if (!this.node) { 87 | return; 88 | } 89 | 90 | document.body.removeChild(this.node); 91 | this.node = null; 92 | } 93 | 94 | announce(message, assertiveness = 'assertive', timeout = LIVEREGION_TIMEOUT_DELAY) { 95 | if (!this.node) { 96 | return; 97 | } 98 | 99 | let node = document.createElement('div'); 100 | node.textContent = message; 101 | 102 | if (assertiveness === 'assertive') { 103 | this.assertiveLog.appendChild(node); 104 | } else { 105 | this.politeLog.appendChild(node); 106 | } 107 | 108 | if (message !== '') { 109 | setTimeout(() => { 110 | node.remove(); 111 | }, timeout); 112 | } 113 | } 114 | 115 | clear(assertiveness) { 116 | if (!this.node) { 117 | return; 118 | } 119 | 120 | if (!assertiveness || assertiveness === 'assertive') { 121 | this.assertiveLog.innerHTML = ''; 122 | } 123 | 124 | if (!assertiveness || assertiveness === 'polite') { 125 | this.politeLog.innerHTML = ''; 126 | } 127 | } 128 | } 129 | 130 | announcer.announce = announce; 131 | announcer.clearAnnouncer = clearAnnouncer; 132 | announcer.destroyAnnouncer = destroyAnnouncer; 133 | announcer.debounce = debounce; -------------------------------------------------------------------------------- /src/assets/js/artists.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mold/explr/8125f766a310e85f8f08cafd093ecd9c613f4260/src/assets/js/artists.js -------------------------------------------------------------------------------- /src/assets/js/auditory-feedback.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Audio Feedback Module 3 | * Provides audio visualization of artist density on the music map 4 | * for improved accessibility for blind users 5 | */ 6 | 7 | const auditoryFeedback = (function() { 8 | // Private variables 9 | let audioContext = null; 10 | let oscillator = null; 11 | let gainNode = null; 12 | let audioFeedbackEnabled = false; 13 | let keyboardNavigationOnly = true; // Only trigger on keyboard navigation 14 | let continuousTone = true; // Whether to use a continuous tone that modulates 15 | let isPlaying = false; // Track if the tone is currently playing 16 | let toneTimeout = null; // Timeout for stopping the tone 17 | let toneDuration = 750; // Duration in ms that the tone plays 18 | let lastInteractionWasKeyboard = false; // Track if the last interaction was keyboard 19 | 20 | // Initialize Web Audio API 21 | function init() { 22 | try { 23 | // Create audio context with user interaction to avoid autoplay restrictions 24 | document.addEventListener('click', function initAudioContext() { 25 | if (!audioContext) { 26 | audioContext = new (window.AudioContext || window.webkitAudioContext)(); 27 | console.log("Audio context initialized on user interaction"); 28 | } 29 | lastInteractionWasKeyboard = false; // Mouse click, not keyboard 30 | document.removeEventListener('click', initAudioContext); 31 | }, { once: true }); 32 | 33 | // Add keyboard shortcut for toggling audio feedback 34 | document.addEventListener('keydown', function(e) { 35 | // Toggle audio feedback with 'A' key 36 | if (e.key.toLowerCase() === 'a' && !e.repeat) { 37 | // Don't trigger if we're in an input field or if keyboard mode is not active 38 | if (e.target.tagName === "INPUT" || !window.keyboardMode || !window.keyboardMode.isActive()) { 39 | return; 40 | } 41 | toggleAudioFeedback(); 42 | } 43 | 44 | // Set flag for keyboard interaction 45 | if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || 46 | e.key === 'ArrowLeft' || e.key === 'ArrowRight') { 47 | lastInteractionWasKeyboard = true; 48 | 49 | // Play feedback on arrow key navigation 50 | if (keyboardNavigationOnly && audioFeedbackEnabled) { 51 | // Small delay to allow the map to update first 52 | setTimeout(updateFeedback, 100); 53 | } 54 | } 55 | }); 56 | 57 | // Track mouse interactions 58 | document.addEventListener('mousemove', function() { 59 | lastInteractionWasKeyboard = false; 60 | }); 61 | 62 | document.addEventListener('mousedown', function() { 63 | lastInteractionWasKeyboard = false; 64 | }); 65 | 66 | document.addEventListener('wheel', function() { 67 | lastInteractionWasKeyboard = false; 68 | }); 69 | 70 | console.log("Auditory feedback module initialized"); 71 | 72 | // Try to initialize audio context immediately if possible 73 | try { 74 | audioContext = new (window.AudioContext || window.webkitAudioContext)(); 75 | } catch (e) { 76 | console.log("Audio context will be initialized on first user interaction"); 77 | } 78 | } catch (e) { 79 | console.error("Web Audio API is not supported in this browser", e); 80 | } 81 | } 82 | 83 | // Set up the tone audio nodes 84 | function setupTone() { 85 | if (!audioContext) return; 86 | 87 | // Clean up any existing oscillator 88 | if (oscillator) { 89 | try { 90 | oscillator.stop(); 91 | } catch (e) { 92 | // Ignore errors if oscillator was already stopped 93 | } 94 | oscillator = null; 95 | } 96 | 97 | // Create oscillator and gain nodes 98 | oscillator = audioContext.createOscillator(); 99 | gainNode = audioContext.createGain(); 100 | 101 | // Configure oscillator 102 | oscillator.type = 'sine'; // Sine wave is less harsh 103 | oscillator.frequency.value = 0; // Start at 0 Hz (silent) 104 | 105 | // Configure gain (volume) 106 | gainNode.gain.value = 0; // Start silent 107 | 108 | // Connect nodes 109 | oscillator.connect(gainNode); 110 | gainNode.connect(audioContext.destination); 111 | 112 | // Start the oscillator 113 | oscillator.start(); 114 | isPlaying = true; 115 | 116 | // Schedule the tone to stop after the duration 117 | scheduleToneStop(); 118 | } 119 | 120 | // Schedule the tone to stop after the duration 121 | function scheduleToneStop() { 122 | // Clear any existing timeout 123 | if (toneTimeout) { 124 | clearTimeout(toneTimeout); 125 | } 126 | 127 | // Set a new timeout to fade out and stop the tone 128 | toneTimeout = setTimeout(() => { 129 | if (gainNode) { 130 | // Fade out 131 | gainNode.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.5); 132 | 133 | // Stop oscillator after fade out 134 | setTimeout(() => { 135 | if (oscillator) { 136 | oscillator.stop(); 137 | oscillator = null; 138 | isPlaying = false; 139 | } 140 | }, 600); 141 | } 142 | }, toneDuration); 143 | } 144 | 145 | // Toggle audio feedback on/off 146 | function toggleAudioFeedback() { 147 | audioFeedbackEnabled = !audioFeedbackEnabled; 148 | 149 | // Announce status change to screen readers 150 | if (window.announcer) { 151 | window.announcer.announce( 152 | audioFeedbackEnabled ? 153 | "Audio feedback enabled. Use arrow keys to navigate and hear artist density for the currently visible countries." : 154 | "Audio feedback disabled." 155 | ); 156 | } 157 | 158 | if (audioFeedbackEnabled) { 159 | // Play initial tone based on current view 160 | lastInteractionWasKeyboard = true; // Force keyboard mode for initial tone 161 | updateFeedback(); 162 | } else if (gainNode) { 163 | // Fade out the tone 164 | gainNode.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.5); 165 | 166 | // Clear any scheduled stop 167 | if (toneTimeout) { 168 | clearTimeout(toneTimeout); 169 | toneTimeout = null; 170 | } 171 | } 172 | } 173 | 174 | // Update the tone based on artist density 175 | function updateTone(density) { 176 | if (!audioContext || !audioFeedbackEnabled) return; 177 | 178 | // Skip if keyboard only and last interaction was not keyboard 179 | if (keyboardNavigationOnly && !lastInteractionWasKeyboard) return; 180 | 181 | // Map density to frequency (pitch) 182 | // Low density: 220Hz (low A), High density: 880Hz (high A) 183 | const minFreq = 220; 184 | const maxFreq = 880; 185 | 186 | // Apply a slight curve to give better distinction at lower values 187 | // while preserving the overall scale 188 | // This power value (0.8) is closer to linear (1.0) but still provides 189 | // some enhancement for lower values 190 | const curvedDensity = Math.pow(density, 0.8); 191 | 192 | const frequency = minFreq + (curvedDensity * (maxFreq - minFreq)); 193 | 194 | // Create or restart the tone if it's not playing 195 | if (!isPlaying) { 196 | setupTone(); 197 | } else { 198 | // Extend the duration by rescheduling the stop 199 | scheduleToneStop(); 200 | } 201 | 202 | // Smoothly change frequency 203 | oscillator.frequency.exponentialRampToValueAtTime( 204 | frequency, 205 | audioContext.currentTime + 0.2 206 | ); 207 | 208 | // Fade in if needed 209 | if (gainNode.gain.value < 0.1) { 210 | gainNode.gain.exponentialRampToValueAtTime(0.1, audioContext.currentTime + 0.1); 211 | } 212 | } 213 | 214 | // Update auditory feedback based on current map view 215 | function updateFeedback() { 216 | // Check if feedback is enabled 217 | if (!audioFeedbackEnabled) return; 218 | 219 | // Skip if keyboard only and last interaction was not keyboard 220 | if (keyboardNavigationOnly && !lastInteractionWasKeyboard) return; 221 | 222 | // Check if keyboard mode is active (only when zoomed in far enough) 223 | if (window.keyboardMode && typeof window.keyboardMode.isActive === 'function') { 224 | if (!window.keyboardMode.isActive()) { 225 | // Keyboard mode is not active, so don't play audio feedback 226 | if (isPlaying && oscillator) { 227 | // Fade out any currently playing tone 228 | if (gainNode) { 229 | gainNode.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.5); 230 | } 231 | return; 232 | } 233 | return; 234 | } 235 | } 236 | 237 | // Get the current data from the keyboard mode 238 | const countries = getCurrentlyVisibleCountries(); 239 | if (!countries || countries.length === 0) return; 240 | 241 | let totalArtists = 0; 242 | 243 | // Handle different data formats 244 | if (Array.isArray(countries) && typeof countries[0] === 'object' && 'artistCount' in countries[0]) { 245 | // Format: [{name: "Country", artistCount: 5}, ...] 246 | totalArtists = countries.reduce((sum, country) => sum + country.artistCount, 0); 247 | } else if (Array.isArray(countries) && typeof countries[0] === 'object' && 'number' in countries[0]) { 248 | // Format: [{name: "Country", number: "A", artistCount: 5}, ...] 249 | totalArtists = countries.reduce((sum, country) => sum + country.artistCount, 0); 250 | } else { 251 | // Try to get artist counts from the script data 252 | const data = window.script && window.script.getCurrentData ? window.script.getCurrentData() : {}; 253 | const userName = window.location.href.split("username=")[1]; 254 | 255 | countries.forEach(countryId => { 256 | if (data[countryId] && data[countryId][userName]) { 257 | totalArtists += data[countryId][userName].length; 258 | } 259 | }); 260 | } 261 | 262 | // If there are no artists in any of the visible countries, remain silent 263 | if (totalArtists === 0) { 264 | // Fade out any currently playing tone 265 | if (isPlaying && oscillator && gainNode) { 266 | gainNode.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.5); 267 | 268 | // Clear any scheduled stop 269 | if (toneTimeout) { 270 | clearTimeout(toneTimeout); 271 | toneTimeout = null; 272 | } 273 | 274 | // Stop oscillator after fade out 275 | setTimeout(() => { 276 | if (oscillator) { 277 | oscillator.stop(); 278 | oscillator = null; 279 | isPlaying = false; 280 | } 281 | }, 600); 282 | } 283 | return; 284 | } 285 | 286 | const avgDensity = countries.length > 0 ? totalArtists / countries.length : 0; 287 | 288 | // Get the color domain from the map 289 | let normalizedDensity = 0; 290 | 291 | if (window.map && window.map.getColorDomain) { 292 | const colorDomain = window.map.getColorDomain(); 293 | if (colorDomain && colorDomain.length > 0) { 294 | // Find where our density falls in the color domain 295 | const maxDomain = colorDomain[colorDomain.length - 1]; 296 | normalizedDensity = Math.min(avgDensity / maxDomain, 1); 297 | } else { 298 | // Fallback to old method if color domain isn't available 299 | const maxPossibleAvg = 100; 300 | normalizedDensity = Math.min(avgDensity / maxPossibleAvg, 1); 301 | } 302 | } else { 303 | // Fallback to old method if map or getColorDomain isn't available 304 | const maxPossibleAvg = 100; 305 | normalizedDensity = Math.min(avgDensity / maxPossibleAvg, 1); 306 | } 307 | 308 | // Update the tone 309 | updateTone(normalizedDensity); 310 | } 311 | 312 | // Use the existing function from keyboard-mode.js to get visible countries 313 | function getCurrentlyVisibleCountries() { 314 | // Try to access it through the keyboardMode object 315 | if (window.keyboardMode && typeof window.keyboardMode.getCurrentlyVisibleCountries === 'function') { 316 | return window.keyboardMode.getCurrentlyVisibleCountries(); 317 | } 318 | 319 | // If we can't find the function, return an empty array 320 | console.warn('Could not find getCurrentlyVisibleCountries function'); 321 | return []; 322 | } 323 | 324 | // Clean up resources when the page is unloaded 325 | window.addEventListener('beforeunload', function() { 326 | if (oscillator) { 327 | oscillator.stop(); 328 | oscillator = null; 329 | } 330 | 331 | if (toneTimeout) { 332 | clearTimeout(toneTimeout); 333 | toneTimeout = null; 334 | } 335 | }); 336 | 337 | // Public API 338 | return { 339 | init: init, 340 | updateFeedback: updateFeedback, 341 | toggleAudioFeedback: toggleAudioFeedback, 342 | isEnabled: function() { return audioFeedbackEnabled; }, 343 | setKeyboardOnly: function(value) { 344 | keyboardNavigationOnly = value; 345 | }, 346 | setToneDuration: function(duration) { 347 | toneDuration = duration; 348 | } 349 | }; 350 | })(); 351 | 352 | // Initialize when the DOM is ready 353 | document.addEventListener('DOMContentLoaded', function() { 354 | auditoryFeedback.init(); 355 | }); -------------------------------------------------------------------------------- /src/assets/js/country-list.js: -------------------------------------------------------------------------------- 1 | // Country List Dialog Logic with Accessible Tabs 2 | (function() { 3 | const button = document.getElementById('country-list-button'); 4 | const dialog = document.getElementById('country-list-dialog'); 5 | const closeBtn = dialog.querySelector('.close'); 6 | const continentsDiv = dialog.querySelector('.country-list__continents'); 7 | 8 | const CONTINENTS = ['All','Europe','North America','South America','Asia','Africa','Oceania','Antarctica','Other']; 9 | 10 | // Helper: get scrobbles for a country 11 | function getCountryScrobbles(country) { 12 | if (!country || !country.id) return 0; 13 | if (!window.countryCountObj || !window.countryCountObj[country.id]) return 0; 14 | let total = 0; 15 | Object.values(window.countryCountObj[country.id]).forEach(artistArr => { 16 | artistArr.forEach(a => { total += a.playcount || 0; }); 17 | }); 18 | return total; 19 | } 20 | 21 | // Helper: get number of artists for a country 22 | function getCountryArtistCount(country) { 23 | if (!country || !country.id) return 0; 24 | if (!window.countryCountObj || !window.countryCountObj[country.id]) return 0; 25 | return Object.values(window.countryCountObj[country.id]).flat().length; 26 | } 27 | 28 | // Helper: group countries by continent 29 | function groupByContinent(countries) { 30 | const result = {}; 31 | countries.forEach(c => { 32 | const cont = c.continent || 'Other'; 33 | if (!result[cont]) result[cont] = []; 34 | result[cont].push(c); 35 | }); 36 | return result; 37 | } 38 | 39 | // Helper: sort countries by number of artists 40 | function sortCountriesByArtists(countries) { 41 | return countries.slice().sort((a, b) => getCountryArtistCount(b) - getCountryArtistCount(a)); 42 | } 43 | 44 | // Helper: sort countries alphabetically 45 | function sortCountriesAlpha(countries) { 46 | return countries.slice().sort((a, b) => a.name.localeCompare(b.name)); 47 | } 48 | 49 | // Helper: sort countries by number of scrobbles (existing) 50 | function sortCountriesByScrobbles(countries) { 51 | return countries.slice().sort((a, b) => getCountryScrobbles(b) - getCountryScrobbles(a)); 52 | } 53 | 54 | // Store sort order (persist while dialog is open) 55 | let currentSort = 'artists'; // 'artists', 'scrobbles', 'alpha' 56 | let lastFocusedTabIndex = 0; 57 | let tabRefs = []; 58 | let panelRefs = []; 59 | let tablistRef = null; 60 | 61 | // Create sort select element 62 | function createSortSelect(onChange) { 63 | const select = document.createElement('select'); 64 | select.className = 'country-sort-select'; 65 | select.setAttribute('aria-label', 'Sort countries'); 66 | [ 67 | { value: 'artists', label: 'Most artists' }, 68 | { value: 'scrobbles', label: 'Most scrobbles' }, 69 | { value: 'alpha', label: 'A–Z' } 70 | ].forEach(opt => { 71 | const option = document.createElement('option'); 72 | option.value = opt.value; 73 | option.textContent = opt.label; 74 | if (opt.value === currentSort) option.selected = true; 75 | select.appendChild(option); 76 | }); 77 | select.addEventListener('change', e => { 78 | currentSort = select.value; 79 | onChange(); 80 | }); 81 | return select; 82 | } 83 | 84 | // Main renderTabs logic 85 | function renderTabs(grouped, selectedIdx = 0, focusTabIndex = null, scrollDirection = 'center') { 86 | // Only render tablist and panels once 87 | if (!tablistRef) { 88 | tablistRef = document.createElement('div'); 89 | tablistRef.setAttribute('role', 'tablist'); 90 | tablistRef.setAttribute('aria-label', 'Continents'); 91 | tablistRef.className = 'country-tabs'; 92 | tabRefs = []; 93 | CONTINENTS.forEach((cont, i) => { 94 | if (cont !== 'All' && !grouped[cont]) return; 95 | const tab = document.createElement('button'); 96 | tab.setAttribute('role', 'tab'); 97 | tab.setAttribute('id', `tab-${cont}`); 98 | tab.setAttribute('aria-controls', `tabpanel-${cont}`); 99 | tab.className = 'country-tab'; 100 | tab.textContent = cont === 'All' ? 'All' : cont; 101 | tab.addEventListener('click', () => activateTab(i)); 102 | tab.addEventListener('keydown', e => handleTabKeydown(e, i)); 103 | tabRefs.push(tab); 104 | tablistRef.appendChild(tab); 105 | }); 106 | continentsDiv.appendChild(tablistRef); 107 | // Panels 108 | panelRefs = []; 109 | CONTINENTS.forEach((cont, i) => { 110 | if (cont !== 'All' && !grouped[cont]) return; 111 | const panel = document.createElement('div'); 112 | panel.setAttribute('role', 'tabpanel'); 113 | panel.setAttribute('id', `tabpanel-${cont}`); 114 | panel.setAttribute('aria-labelledby', `tab-${cont}`); 115 | panel.className = 'country-tabpanel'; 116 | panelRefs.push(panel); 117 | continentsDiv.appendChild(panel); 118 | }); 119 | } 120 | // Update tabs and panels 121 | tabRefs.forEach((tab, i) => { 122 | tab.setAttribute('aria-selected', i === selectedIdx ? 'true' : 'false'); 123 | tab.setAttribute('tabindex', i === selectedIdx ? '0' : '-1'); 124 | if (i === selectedIdx && focusTabIndex !== null) { 125 | setTimeout(() => { 126 | tab.focus(); 127 | }, 0); 128 | } 129 | }); 130 | panelRefs.forEach((panel, i) => { 131 | panel.hidden = i !== selectedIdx; 132 | if (i === selectedIdx) { 133 | // Render panel content 134 | panel.innerHTML = ''; 135 | const cont = CONTINENTS[i]; 136 | // Heading row: h2 and sort select 137 | const headingRow = document.createElement('div'); 138 | headingRow.className = 'country-tabpanel-heading-row'; 139 | const heading = document.createElement('h2'); 140 | heading.className = 'country-tabpanel-heading'; 141 | heading.textContent = cont === 'All' ? 'All countries' : cont; 142 | headingRow.appendChild(heading); 143 | // Sort select 144 | const sortContainer = document.createElement('div'); 145 | sortContainer.className = 'country-sort-container'; 146 | const sortLabel = document.createElement('span'); 147 | sortLabel.className = 'country-sort-label'; 148 | sortLabel.textContent = 'Sort'; 149 | sortContainer.appendChild(sortLabel); 150 | const sortSelect = createSortSelect(() => { 151 | renderTabs(grouped, i, i, 'center'); 152 | }); 153 | sortContainer.appendChild(sortSelect); 154 | headingRow.appendChild(sortContainer); 155 | panel.appendChild(headingRow); 156 | // Country list 157 | const countryList = document.createElement('ul'); 158 | countryList.className = 'country-list'; 159 | let countriesToShow; 160 | if (cont === 'All') { 161 | countriesToShow = Object.values(grouped).flat(); 162 | } else { 163 | countriesToShow = grouped[cont]; 164 | } 165 | if (currentSort === 'artists') { 166 | countriesToShow = sortCountriesByArtists(countriesToShow); 167 | } else if (currentSort === 'scrobbles') { 168 | countriesToShow = sortCountriesByScrobbles(countriesToShow); 169 | } else { 170 | countriesToShow = sortCountriesAlpha(countriesToShow); 171 | } 172 | countriesToShow.forEach(country => { 173 | const li = document.createElement('li'); 174 | li.className = 'country-list__country'; 175 | const btn = document.createElement('button'); 176 | btn.type = 'button'; 177 | btn.className = 'country-list__country-btn'; 178 | // Country name 179 | const nameSpan = document.createElement('span'); 180 | nameSpan.className = 'country-list__country-name'; 181 | nameSpan.textContent = country.name; 182 | // Secondary text: artist count or scrobbles 183 | const countSpan = document.createElement('span'); 184 | countSpan.className = 'country-list__country-count'; 185 | if (currentSort === 'scrobbles') { 186 | const nScrobbles = getCountryScrobbles(country); 187 | countSpan.textContent = nScrobbles.toLocaleString() + ' scrobbles'; 188 | } else { 189 | const nArtists = getCountryArtistCount(country); 190 | countSpan.textContent = nArtists + ' artists'; 191 | } 192 | btn.appendChild(nameSpan); 193 | btn.appendChild(countSpan); 194 | btn.onclick = function() { 195 | dialog.close(); 196 | setTimeout(() => { 197 | const el = document.getElementById('c'+country.id); 198 | if (el) el.dispatchEvent(new Event('click')); 199 | }, 100); 200 | }; 201 | li.appendChild(btn); 202 | countryList.appendChild(li); 203 | }); 204 | panel.appendChild(countryList); 205 | } else { 206 | panel.innerHTML = ''; 207 | } 208 | }); 209 | } 210 | 211 | // Tab activation logic 212 | function activateTab(idx) { 213 | let scrollDirection = 'center'; 214 | if (typeof lastFocusedTabIndex === 'number') { 215 | if (idx > lastFocusedTabIndex) scrollDirection = 'end'; 216 | else if (idx < lastFocusedTabIndex) scrollDirection = 'start'; 217 | } 218 | renderTabs(window.map && window.map.countryNames ? groupByContinent(window.map.countryNames) : {}, idx, idx, scrollDirection); 219 | lastFocusedTabIndex = idx; 220 | } 221 | 222 | // Keyboard navigation for tabs 223 | function handleTabKeydown(e, idx) { 224 | let newIdx = idx; 225 | if (e.key === 'ArrowRight') { 226 | do { newIdx = (newIdx + 1) % tabRefs.length; } while (!tabRefs[newIdx]); 227 | tabRefs[newIdx].focus(); 228 | e.preventDefault(); 229 | } else if (e.key === 'ArrowLeft') { 230 | do { newIdx = (newIdx - 1 + tabRefs.length) % tabRefs.length; } while (!tabRefs[newIdx]); 231 | tabRefs[newIdx].focus(); 232 | e.preventDefault(); 233 | } else if (e.key === 'Home') { 234 | tabRefs[0].focus(); 235 | e.preventDefault(); 236 | } else if (e.key === 'End') { 237 | tabRefs[tabRefs.length - 1].focus(); 238 | e.preventDefault(); 239 | } else if (e.key === 'Enter' || e.key === ' ') { 240 | activateTab(idx); 241 | e.preventDefault(); 242 | } 243 | } 244 | 245 | // Open dialog 246 | button.addEventListener('click', function() { 247 | if (!window.map || !window.map.countryNames) return; 248 | const grouped = groupByContinent(window.map.countryNames); 249 | lastFocusedTabIndex = 0; 250 | tabRefs = []; 251 | panelRefs = []; 252 | tablistRef = null; 253 | continentsDiv.innerHTML = ''; 254 | renderTabs(grouped, 0, 0, 'center'); 255 | dialog.showModal(); 256 | setTimeout(() => dialog.querySelector('h1').focus(), 100); 257 | }); 258 | // Close dialog 259 | closeBtn.addEventListener('click', function() { 260 | dialog.close(); 261 | button.focus(); 262 | currentSort = 'artists'; 263 | }); 264 | // ESC closes dialog 265 | dialog.addEventListener('keydown', function(e) { 266 | if (e.key === 'Escape') { 267 | dialog.close(); 268 | button.focus(); 269 | currentSort = 'artists'; 270 | } 271 | }); 272 | })(); -------------------------------------------------------------------------------- /src/assets/js/no-countries.js: -------------------------------------------------------------------------------- 1 | const noCountries = noCountries || {}; 2 | 3 | var listOfArtistsWithNoCountry = []; 4 | 5 | var saveToStorage = function (key, object, cb) { 6 | localforage.setItem(key, object, cb || function () {}); 7 | } 8 | 9 | function sortArtists(data, method) { 10 | if (method === "scrobbles") 11 | return data.sort((a, b) => b.playcount - a.playcount); 12 | else if (method === "name") 13 | return data.sort((a, b) => a.artist.localeCompare(b.artist)); 14 | } 15 | 16 | var addArtistsWithNoCountry = function (data) { 17 | listOfArtistsWithNoCountry = listOfArtistsWithNoCountry.concat(data); 18 | saveToStorage("no_countries", listOfArtistsWithNoCountry); 19 | 20 | function handleCheckboxChange() { 21 | let artistName = this.id; 22 | let checked = this.checked; 23 | let artistsState = JSON.parse(localStorage.getItem('noCountryArtistsProgress')) || {}; 24 | artistsState[artistName] = { artistName, checked }; 25 | localStorage.setItem('noCountryArtistsProgress', JSON.stringify(artistsState)); 26 | // If you just checked and the filter is on, remove the artist from the DOM 27 | if (checked && document.querySelector("#hide-checked")?.checked) { 28 | this.parentNode.style.display = 'none'; 29 | let nextCheckbox = this.parentNode.nextElementSibling.querySelector('input'); 30 | if (nextCheckbox) { 31 | nextCheckbox.focus(); 32 | } 33 | } 34 | // get the label element for the filter checked checkbox 35 | let filterCheckedLabel = document.querySelector("label[for='hide-checked']"); 36 | // Update the label to include the number of checked artists 37 | filterCheckedLabel.innerHTML = `Hide checked artists (${document.querySelectorAll("dialog[open] ul li input[type='checkbox']:checked").length})`; 38 | ga('send', { 39 | hitType: 'event', 40 | eventCategory: 'No countries', 41 | eventAction: 'Check artist as done', 42 | eventLabel: 'test' 43 | }); 44 | } 45 | 46 | 47 | 48 | function updateNoCountriesList() { 49 | let artistsState = JSON.parse(localStorage.getItem('noCountryArtistsProgress')) || {}; 50 | const sortedData = sortArtists(listOfArtistsWithNoCountry, noCountryArtistSortMethod); 51 | var noCountriesListEl = d3.select(".no-countries__content ul"); 52 | noCountriesListEl.html(""); 53 | sortedData.forEach(function (_art) { 54 | let artistState = artistsState[_art.artist] || { artistName: _art.artist, checked: false }; 55 | let listItem = noCountriesListEl.append("li"); 56 | listItem.append("input") 57 | .attr("type", "checkbox") 58 | .property("checked", artistState.checked) 59 | .attr("id", _art.artist) 60 | .on("change", handleCheckboxChange); 61 | listItem.append("label") 62 | .attr("for", _art.artist) 63 | .html('' + _art.artist + '' + _art.playcount + ' scrobbles'); 64 | if (document.querySelector("#hide-checked")?.checked && artistState.checked) { 65 | listItem.style("display", "none"); 66 | } 67 | }) 68 | d3.select(".no-countries__info").html(listOfArtistsWithNoCountry.length + " artists without a country:"); 69 | } 70 | 71 | // Check if the checkbox and label already exist 72 | if (!d3.select("#hide-checked").node() && !d3.select("label[for='hide-checked']").node()) { 73 | // Add the checkbox next to the filter radios 74 | d3.select("dialog fieldset").append("input") 75 | .attr("type", "checkbox") 76 | .attr("id", "hide-checked") 77 | .on("change", updateNoCountriesList); 78 | d3.select("dialog fieldset").append("label") 79 | .attr("for", "hide-checked") 80 | .text("Hide checked artists"); 81 | } 82 | 83 | // Handle sorting with radios 84 | let radios = document.getElementsByName('sort'); 85 | function sortFunction() { 86 | let selectedValue; 87 | for (let radio of radios) { 88 | if (radio.checked) { 89 | selectedValue = radio.value; 90 | noCountryArtistSortMethod = selectedValue; 91 | updateNoCountriesList(); 92 | break; 93 | } 94 | } 95 | ga('send', { 96 | hitType: 'event', 97 | eventCategory: 'No countries', 98 | eventAction: 'Sort artists', 99 | eventLabel: 'test' 100 | }); 101 | } 102 | 103 | for (let radio of radios) { 104 | radio.addEventListener('change', sortFunction); 105 | } 106 | 107 | updateNoCountriesList("scrobbles"); 108 | 109 | document.querySelector(".no-countries__title").addEventListener("click", function () { 110 | const dialog = document.querySelector(".no-countries__content"); 111 | dialog.showModal(); 112 | 113 | document.querySelector("#no-countries__heading").focus(); 114 | 115 | // Update the label to include the number of checked artists 116 | let filterCheckedLabel = document.querySelector("label[for='hide-checked']"); 117 | filterCheckedLabel.innerHTML = `Hide checked artists (${document.querySelectorAll("dialog[open] ul li input[type='checkbox']:checked").length})`; 118 | 119 | document.addEventListener("keydown", function (e) { 120 | if (e.keyCode == 27) { 121 | const dialog = document.querySelector(".no-countries__content"); 122 | dialog.close(); 123 | document.querySelector(".no-countries__title").focus(); 124 | } 125 | }); 126 | ga('send', { 127 | hitType: 'event', 128 | eventCategory: 'No countries', 129 | eventAction: 'Open dialog', 130 | eventLabel: 'test' 131 | }); 132 | }); 133 | 134 | document.querySelector(".no-countries__content .close").addEventListener("click", function () { 135 | const dialog = document.querySelector(".no-countries__content"); 136 | dialog.close(); 137 | document.querySelector(".no-countries__title").focus(); 138 | }); 139 | const dialog = document.querySelector(".no-countries__content"); 140 | dialog.addEventListener("click", function (event) { 141 | if (event.target === dialog) { 142 | dialog.close(); 143 | } 144 | }); 145 | 146 | if (listOfArtistsWithNoCountry.length) { 147 | setTimeout(function () { 148 | document.querySelector(".no-countries").classList.remove("hidden"); 149 | }, 850); 150 | } 151 | } 152 | 153 | noCountries.addArtistsWithNoCountry = addArtistsWithNoCountry; -------------------------------------------------------------------------------- /src/assets/js/screenshot.js: -------------------------------------------------------------------------------- 1 | var screenshot = {}; 2 | 3 | (function (window, document) { 4 | screenshot.render = function (autoDownload = false) { 5 | var titleString, 6 | subtitleString = "Make your own at explr.fm", 7 | img; 8 | 9 | var explrLogo = new Image(); 10 | 11 | var svg = d3.select("#map-svg"); 12 | var w = svg.attr("width"); 13 | var h = svg.attr("height"); 14 | 15 | var canvas = document.createElement("canvas"); 16 | var ctx = canvas.getContext("2d"); 17 | 18 | // canvg(canvas, document.getElementById("map-svg").outerHTML); 19 | 20 | var backgroundColor = window.getComputedStyle(document.body).backgroundColor; 21 | var textColor = window.getComputedStyle(document.body).color; 22 | 23 | var drawCenteredText = function (obj) { 24 | ctx.font = obj.font; 25 | ctx.fillText(obj.string, w / 2 - ctx.measureText(obj.string).width / 2, obj.y); 26 | 27 | if (obj.lineWidth) { 28 | ctx.lineWidth = obj.lineWidth; 29 | ctx.strokeStyle = obj.strokeStyle; 30 | ctx.strokeText(obj.string, w / 2 - ctx.measureText(obj.string).width / 2, obj.y); 31 | } 32 | } 33 | 34 | canvas.width = w; 35 | canvas.height = h; 36 | 37 | // insert background rect 38 | svg.insert("rect", "g") 39 | .attr({ 40 | id: "background-rect", 41 | width: "100%", 42 | height: "100%", 43 | 44 | }) 45 | .style({ 46 | fill: backgroundColor, 47 | }); 48 | 49 | // Add color, font to legend text 50 | d3.selectAll('.legend text, text.legend').style({ 51 | "font-family": function () { 52 | return window.getComputedStyle(this)["fontFamily"]; 53 | }, 54 | "font-size": function () { 55 | return window.getComputedStyle(this)["fontSize"]; 56 | }, 57 | "fill": textColor, 58 | }); 59 | d3.selectAll(".legend rect").style({ 60 | stroke: backgroundColor, 61 | }) 62 | 63 | canvg(canvas, new XMLSerializer().serializeToString(svg[0][0])); 64 | 65 | explrLogo.onload = function () { 66 | /* Add text and shiiet */ 67 | // Add text background box 68 | ctx.save(); // To draw with different opaticy 69 | ctx.globalAlpha = 0.6; 70 | ctx.fillStyle = backgroundColor; 71 | let scoreString = SESSION.total_artists + " artists from " + countryScore + " / 210 countries"; 72 | let titleString = SESSION.name + "'s musical world map"; 73 | ctx.font = "34px Patua One"; 74 | ctx.fillRect(w / 2 - ctx.measureText(titleString).width / 2 - 20, h - 110, ctx.measureText(titleString).width + 40, 100); 75 | ctx.fillStyle = textColor; 76 | 77 | // Add text 78 | ctx.fillStyle = textColor; 79 | drawCenteredText({ 80 | string: titleString, 81 | font: "34px Patua One", 82 | y: h - 60, 83 | }); 84 | drawCenteredText({ 85 | string: scoreString, 86 | font: "20px Didact Gothic", 87 | y: h - 40, 88 | }); 89 | 90 | // Add explr.fm logo 91 | ctx.restore(); 92 | ctx.drawImage(explrLogo, w - 130, h - 60, 100, 36); 93 | 94 | d3.select("#background-rect").remove(); 95 | 96 | //console.log(canvas.toDataURL()) 97 | // img = document.createElement("img").src = canvas.toDataURL(); 98 | document.getElementById("screenshot-img").src = canvas.toDataURL("image/png"); 99 | // d3.select("body").append(img); 100 | // 101 | 102 | var dataurl = canvas.toDataURL("image/png"); 103 | // console.log("dataurl:", dataurl) 104 | 105 | // window.open(dataurl, "_blank"); 106 | 107 | const overlay = document.getElementsByClassName("screenshot-overlay")[0]; 108 | overlay.style = ""; 109 | overlay.ariaModal = true 110 | 111 | 112 | if (autoDownload) { 113 | setTimeout(function () { 114 | screenshot.download(); 115 | screenshot.close(); 116 | }, 0); 117 | } 118 | 119 | } 120 | explrLogo.src = "assets/img/explrlogo.png"; 121 | } 122 | 123 | screenshot.close = function () { 124 | document.getElementsByClassName("screenshot-overlay")[0].style = "display:none;"; 125 | document.getElementsByClassName("screenshot-overlay")[0].ariaModal = false; 126 | } 127 | 128 | screenshot.download = function () { 129 | var dataurl = document.getElementById("screenshot-img").src; 130 | 131 | // Create a new anchor element 132 | var a = document.createElement('a'); 133 | 134 | // Set the href and download attributes of the anchor 135 | a.href = dataurl; 136 | a.download = 'screenshot.png'; // or any other filename you want 137 | 138 | // Append the anchor to the body (this is necessary for Firefox) 139 | document.body.appendChild(a); 140 | 141 | // Programmatically click the anchor 142 | a.click(); 143 | 144 | // Remove the anchor from the body 145 | document.body.removeChild(a); 146 | } 147 | 148 | })(window, document); 149 | -------------------------------------------------------------------------------- /src/assets/js/script.js: -------------------------------------------------------------------------------- 1 | /* requires: 2 | api/api.js 3 | api/lastfm.js 4 | utils.js 5 | search.js 6 | aria-announcer.js 7 | no-countries.js 8 | keyboard-mode.js 9 | */ 10 | 11 | var script = script || {}; 12 | let loadingReady = false; 13 | let loadingStatus = loadingReady ? "Ready to Explr!" : "Loading..."; 14 | let announcementIntervalId; 15 | let noCountryArtistSortMethod = "scrobbles"; 16 | 17 | 18 | var STORED_ARTISTS; 19 | var STORED_ARTISTS_PROMISE = localforage.getItem("artists").then(val => 20 | STORED_ARTISTS = val || {} 21 | ); 22 | 23 | var CACHED_NO_COUNTRIES; 24 | var CACHED_NO_COUNTRIES_PROMISE = localforage.getItem("no_countries").then(val => 25 | CACHED_NO_COUNTRIES = val || {} 26 | ); 27 | 28 | var USER_TAGS = []; // JSON.parse(window.localStorage.user_tags || "[]"); 29 | var CACHED_USERS = JSON.parse(window.localStorage.cached_users || "{}"); 30 | var SESSION = {}; 31 | 32 | 33 | function clearExplrCache() { 34 | var theme = window.localStorage.getItem("theme"); 35 | window.localStorage.clear(); 36 | window.localStorage.setItem("theme", theme); 37 | 38 | announcer.announce("Cleared artist cache, reloading page..."); 39 | 40 | return localforage.clear(); 41 | } 42 | 43 | var countryCountObj = {}; 44 | 45 | (function () { 46 | // user = prompt("Input your user name, get top 20 artists") 47 | var user, currPage = 1, 48 | maxPage; 49 | var count = 0; 50 | var tries = 0; 51 | var randomcountrylist = ["Malawi", "Malaysia", "Peru", "Sierra Leone", "Trinidad & Tobago", "Greece", "Laos", "Iran", "Haiti", "Nicaragua", "Mongolia", "Slovakia"]; 52 | 53 | var getAllArtists = function () { 54 | // console.log("get artists") 55 | 56 | loadingReady = false; 57 | 58 | api.lastfm.send("library.getartists", [ 59 | ["user", user], 60 | ["limit", 50], 61 | ["page", currPage] 62 | ], 63 | function (error, responseData) { 64 | // Special case for unfortunate users 65 | if (responseData === "") { 66 | console.error('Got empty string ("") as response, skipping page.') 67 | currPage++; 68 | getAllArtists(); 69 | return; 70 | } 71 | if (error || responseData.error) { 72 | console.error("Error in getAllArtists, page " + currPage, error, responseData); 73 | 74 | // Try again, but not forever 75 | if (tries++ < 5) { 76 | getAllArtists(); 77 | 78 | // TODO: Show erorr message ;) 79 | } else { 80 | var refresh = confirm("Last.fm took too long to respond.\n\nPress OK to refresh the page and try again, or Cancel to use the page as it is."); 81 | if (refresh) { 82 | clearExplrCache().then(function () { 83 | saveToStorage("artists", STORED_ARTISTS, function () { 84 | window.location.reload() 85 | }); 86 | }) 87 | } 88 | } 89 | return; 90 | } 91 | 92 | tries = 0; 93 | 94 | if (currPage === 1) { 95 | SESSION.total_artists = +responseData.artists["@attr"].total; 96 | maxPage = +responseData.artists["@attr"].totalPages; 97 | 98 | if (SESSION.total_artists === 0) { 99 | d3.select(".bubblingG").remove(); 100 | d3.select("#loading-text") 101 | .html("You haven't listened to any
artists yet. Start scrobbling with
\ 102 | your favorite music player!"); 104 | d3.select(".loader").style("pointer-events", "all"); 105 | return; 106 | } 107 | } 108 | 109 | currPage++; 110 | // console.log("Artists done, get countries"); 111 | 112 | // Save artist data to localStorage (and create a list of artist names) 113 | var artistNames = [] 114 | responseData.artists.artist.forEach(function (newArtist) { 115 | var a = STORED_ARTISTS[newArtist.name] || {}; 116 | 117 | a.playcount = +newArtist.playcount; 118 | a.url = newArtist.url; 119 | 120 | STORED_ARTISTS[newArtist.name] = a; 121 | artistNames.push(newArtist.name); 122 | }) 123 | saveToStorage("artists", STORED_ARTISTS); 124 | // var n = count++; 125 | 126 | // Get country for all artists 127 | api.getCountries(artistNames, function (data) { 128 | //Gör så att man kan slå upp på land-id och få upp en lista på artister. 129 | var newArtistCountries = d3.nest().key((d) => d.id) 130 | // gör så att man får en lista på alla artister för ett land. 131 | .rollup((leaves) => leaves) 132 | // Skickar in en lista med ett objekt för varje artist. 133 | .map(data); 134 | 135 | d3.keys(newArtistCountries).forEach(function (id) { 136 | countryCountObj[id] = countryCountObj[id] || {}; 137 | countryCountObj[id][user] = countryCountObj[id][user] || []; 138 | 139 | var artistsFromCountry = countryCountObj[id][user]; 140 | 141 | artistsFromCountry = artistsFromCountry.concat(newArtistCountries[id]); 142 | 143 | artistsFromCountry.forEach(function (el, i) { 144 | //Här lägger vi till ett fält image med artistens bild-url som ett fält till det "inre" objektet. 145 | artistsFromCountry[i].url = STORED_ARTISTS[el.artist].url; 146 | artistsFromCountry[i].playcount = STORED_ARTISTS[el.artist].playcount; 147 | }); 148 | // countryCountObj är en lista med "country"-objekt. 149 | // Varje country-objekt innehåller en lista med "inre" objekt med artistnamn, lands-id och landsnamn. 150 | // dataObj är typ samma som countryCountObj, fast är bara för de tillfälligt sparade artisterna (intervallet). 151 | countryCountObj[id][user] = artistsFromCountry; 152 | }) 153 | 154 | noCountries.addArtistsWithNoCountry(data.filter((artist) => !artist.id)); 155 | 156 | map.addArtists(newArtistCountries); 157 | 158 | if (currPage > maxPage) { 159 | end(); 160 | return; 161 | } else { 162 | getAllArtists(); 163 | } 164 | }); 165 | }); 166 | } 167 | 168 | var getRecommendations = function () { 169 | var currPage = 1, 170 | limit = 50, 171 | maxPage = 1000 / limit; 172 | var countriesList = JSON.parse(window.localStorage.countries); 173 | 174 | var countriesObj = d3.nest().key(function (d) { 175 | return d.name; 176 | }).rollup(function (d) { 177 | return d[0]; 178 | }).map(countriesList); 179 | // Get "all" artists from one country 180 | // countriesList.forEach(function(country){ 181 | 182 | // }); 183 | api.lastfm.send("tag.gettopartists", [ 184 | ["tag", "swedish"], 185 | ["limit", limit], 186 | ["page", currPage] 187 | ], function (err, data) { 188 | var artists = data.topartists.artist; 189 | // For each artist, get their tags 190 | artists.forEach(function (a) { 191 | api.lastfm.send("artist.gettoptags", [ 192 | ["artist", a.name] 193 | ], function (err, data) { 194 | // console.log(data); 195 | }) 196 | }) 197 | // Look for user's top tags in artist tags 198 | // If a lot of matches, save to recommended artists for that country 199 | }); 200 | 201 | } 202 | 203 | var getUserTags = function (err, data) { 204 | // err = err ||data.error; 205 | if (err || data.error) { 206 | if (data && data.error === 6) { 207 | alert("User not found"); 208 | window.location.assign(window.location.origin + window.location.pathname); 209 | } 210 | } 211 | 212 | 213 | /*if (err || data.error) { 214 | console.error("Erorr in getUserTags", err, data); 215 | alert("Something went wrong when contacting the Last.fm API\n\nEither:\n - The specified user does not exist\n - Last.fm is down\n\nPlease try again."); 216 | window.location.replace(window.location.origin + window.location.pathname); 217 | }*/ 218 | 219 | var c = 0; 220 | 221 | var tagCount = {}; 222 | 223 | //console.log("Gotta get tags") 224 | 225 | var topArtists = data.topartists.artist; 226 | var done = function () { 227 | // make list of tags to sort 228 | USER_TAGS = []; 229 | //Remove specific tags from user's top tags 230 | let forbidden = ["american", "swedish", "british", "female vocalists", "male vocalists", "german", "seen live", "english", "singer-songwriter", "spanish", "french"]; 231 | d3.keys(tagCount).forEach(function (el) { 232 | var nogood = false 233 | for (let i = 0; i < forbidden.length; i++) { 234 | if (el === forbidden[i]) { 235 | nogood = true; 236 | } 237 | } 238 | if (!nogood) { 239 | USER_TAGS.push({ 240 | tag: el, 241 | count: tagCount[el] 242 | }); 243 | } 244 | }) 245 | USER_TAGS.sort(function (a, b) { 246 | return b.count < a.count ? -1 : b.count > a.count ? 1 : 0; 247 | }); 248 | console.info("Done getting tags, saved to localStorage.user_tags") 249 | window.localStorage.user_tags = JSON.stringify(USER_TAGS); 250 | } 251 | 252 | 253 | topArtists.forEach(function (el, i) { 254 | // get top ten tags and save to users tag count.... 255 | setTimeout(function () { // Set timeout to not stop artists from loading... 256 | api.lastfm.send("artist.gettoptags", [ 257 | ["artist", el.name] 258 | ], function (err, data) { 259 | let taglist = data.toptags && data.toptags.tag; 260 | if (taglist) { 261 | var lim = Math.min(taglist.length, 10); 262 | for (var i = 0; i < lim; i++) { 263 | if (tagCount[taglist[i].name]) { 264 | tagCount[taglist[i].name]++; 265 | } else { 266 | tagCount[taglist[i].name] = 1; 267 | } 268 | } 269 | // console.log(c, topArtists.length) 270 | } 271 | 272 | c++; 273 | if (c == topArtists.length - 1) { 274 | done(); 275 | } 276 | }); 277 | }, Math.random() * 3000); 278 | }); 279 | 280 | } 281 | 282 | var begin = function () { 283 | //Send analytics event 284 | ga('send', 'event', 'splash screen', 'Go!', 'test'); 285 | document.getElementById("map-label").innerHTML = `${user}'s world map`; 286 | // fade out username input box 287 | var welcomeOverlay = d3.select("#welcome-container"); 288 | welcomeOverlay.transition().duration(2000) 289 | .style("opacity", 0) 290 | .each("end", function () { 291 | welcomeOverlay.remove(); 292 | }); 293 | 294 | // Fade in loader 295 | d3.select(".loader").transition().duration(2000).style("opacity", 1); 296 | d3.select("#loading-text").html("Getting library..."); 297 | 298 | // Screen reader status update every 30 seconds 299 | setTimeout(function () { 300 | announcer.announce(document.getElementById("loading-text")?.innerText); 301 | }, 6000); 302 | 303 | setTimeout(function () { 304 | if (d3.select("#loading-text")?.html() === "Getting library...") { 305 | d3.select("#loading-text").html("Last.fm is taking
a long time to
respond..."); 306 | setTimeout(function () { 307 | if (d3.select("#loading-text").html() === "Last.fm is taking
a long time to
respond...") { 308 | d3.select("#loading-text").html("Maybe last.fm has
gone offline...") 309 | .style("pointer-events", "all"); 310 | } 311 | }, 8000); 312 | } 313 | }, 8000); 314 | 315 | // Show hidden screen reader help text 316 | document.getElementById("a11y-map-info").classList.remove("hidden"); 317 | 318 | // Fade in legend, progress-bar etc 319 | d3.selectAll(".on-map-view").style({ 320 | "visibility": "visible", 321 | // "opacity": 0 322 | }) //.transition().duration(1000).style("opacity", 1); 323 | 324 | // Get user tags 325 | api.lastfm.send("user.gettopartists", [ 326 | ["user", user], 327 | ["period", "12months"], 328 | ["limit", "50"] 329 | ], getUserTags); 330 | 331 | // Get user friends 332 | api.getFriends(function (err, data) { 333 | try { 334 | var friends = data.friends.user; 335 | var i = 0; 336 | var friendName = d3.select("#friend-name"); 337 | 338 | var updateName = function () { 339 | friendName.html(""); 340 | friendName.append("a").attr({ 341 | href: window.location.origin + window.location.pathname + "?username=" + friends[i].name, 342 | target: "_self", 343 | }).html(friends[i].name); 344 | } 345 | 346 | d3.selectAll(".arrow").on("click", function () { 347 | if (d3.select(this).classed("left")) { 348 | // Go left 349 | i = (i === 0 ? friends.length - 1 : i - 1); 350 | } else { 351 | // Go right 352 | i = (i + 1) % friends.length; 353 | } 354 | 355 | updateName(); 356 | }) 357 | 358 | updateName(); 359 | d3.select("#friends #msg").html("Check out " + user + "'s friends") 360 | d3.select("#friends").transition().duration(1000).style("opacity", 1); 361 | 362 | } catch (e) { 363 | console.error("getFriends()", e); 364 | d3.select("#friends").html(" Couldn't find any
friends on last.fm :( ") 365 | d3.select("#friends").transition().duration(1000).style("opacity", 1); 366 | } 367 | }); 368 | 369 | if (CACHED_USERS[user]) { 370 | // TODO: use timestamp 371 | console.info("No new artists on last.fm!"); 372 | countryCountObj = JSON.parse(window.localStorage.countryCountObj); 373 | 374 | localforage.getItem("no_countries", function (err, val) { 375 | noCountries.addArtistsWithNoCountry(val || []); 376 | }); 377 | 378 | // Get number of artists for screenshot etc. 379 | api.lastfm.send("library.getartists", [ 380 | ["user", user], 381 | ["limit", 1], 382 | ["page", 1] 383 | ], 384 | function (error, responseData) { 385 | SESSION.total_artists = +responseData.artists["@attr"].total; 386 | }); 387 | 388 | setTimeout(function () { 389 | map.addArtists( 390 | Object.keys(countryCountObj).reduce((acc, countryId) => ({ 391 | ...acc, 392 | [countryId]: countryCountObj[countryId][SESSION.name] 393 | }), {})); 394 | end(); 395 | }, 1000) 396 | } else { 397 | // Save theme 398 | var theme = window.localStorage.theme; 399 | window.localStorage.clear(); 400 | if (theme) { 401 | window.localStorage.theme = theme; 402 | } 403 | getAllArtists(); 404 | } 405 | } 406 | 407 | var end = function () { 408 | loadingReady = true; 409 | 410 | // Screen reader status update 411 | clearInterval(announcementIntervalId); 412 | announcer.announce("All artists have been loaded!"); 413 | const map = document.querySelector("#map-container svg") 414 | const existingAriaLabelledBy = map.getAttribute("aria-labelledby"); 415 | map.setAttribute("aria-labelledby", `${existingAriaLabelledBy} progress-text sr-instructions`); 416 | 417 | // We're done, fade out loader 418 | var loader = d3.select(".loader"); 419 | loader.transition().duration(2000) 420 | .style("opacity", 0) 421 | .each("end", function () { 422 | loader.remove(); 423 | }); 424 | //Also fade out progress bar text (after a short delay) 425 | d3.select("#progress-text").transition().delay(5000).duration(1500) 426 | .style("opacity", 0); 427 | 428 | CACHED_USERS = {}; 429 | CACHED_USERS[user] = new Date().getTime(); 430 | window.localStorage.cached_users = JSON.stringify(CACHED_USERS); 431 | window.localStorage.countryCountObj = JSON.stringify(countryCountObj); 432 | } 433 | 434 | // // Set theme 435 | // map.nextTheme(window.localStorage.theme || "pink_white"); 436 | 437 | // Try to get username from url 438 | var param = window.location.href.split("username=")[1]; 439 | 440 | if (param) { // We already have a user 441 | 442 | // Set up search button listener 443 | document.addEventListener('DOMContentLoaded', (event) => { 444 | document.getElementById('search-button').addEventListener('click', function() { 445 | // Set timeout needed to make sure the browser is ready to focus the search box 446 | setTimeout(()=> { search.initSearch() }, 0) ; 447 | }); 448 | }); 449 | 450 | // set up keyboard shortcuts 451 | window.addEventListener("keydown", function (evt) { 452 | 453 | if ((evt.ctrlKey || evt.metaKey) && evt.keyCode === 70 && !evt.shiftKey && !keyboardMode.getStatus()) { 454 | console.log(keyboardMode.getStatus()); 455 | // Prevent the browser's default "ctrl + f" or "cmd + f" action (usually "Find") 456 | evt.preventDefault(); 457 | 458 | // Initialize the search box 459 | search.initSearch(); 460 | 461 | } 462 | // Supress hotkeys if search or keyboard mode is open 463 | if (search.getSearchStatus() || keyboardMode.getStatus()) { 464 | return; 465 | }; 466 | switch (evt.keyCode) { 467 | case 83: 468 | screenshot.render(); 469 | //Send google analytics event 470 | ga('send', { 471 | hitType: 'event', 472 | eventCategory: 'Hotkeys', 473 | eventAction: 'Take screenshot', 474 | eventLabel: 'test' 475 | }); 476 | break; 477 | // t 478 | case 84: 479 | nextTheme(); 480 | //Send google analytics event 481 | ga('send', { 482 | hitType: 'event', 483 | eventCategory: 'Hotkeys', 484 | eventAction: 'Cycle theme', 485 | eventLabel: 'test' 486 | }); 487 | break; 488 | default: 489 | break; 490 | } 491 | }); 492 | 493 | if (param.length > 15) { 494 | param = param.substr(0, 15); 495 | } 496 | user = param; 497 | SESSION.name = param; 498 | Promise.all([CACHED_NO_COUNTRIES_PROMISE, STORED_ARTISTS_PROMISE]).then(() => begin()); 499 | } else { 500 | d3.select("#welcome-container").style("visibility", "visible"); 501 | d3.select("#randomCountry").html(randomcountrylist[Math.floor(Math.random() * (randomcountrylist.length))] + "?") 502 | } 503 | 504 | var saveToStorage = function (key, object, cb) { 505 | localforage.setItem(key, object, cb || function () {}); 506 | } 507 | 508 | })(); 509 | 510 | script.getCurrentData = function () { 511 | if (loadingReady) { 512 | return JSON.parse(window.localStorage.getItem('countryCountObj'));; 513 | } else { 514 | return countryCountObj; 515 | } 516 | 517 | } 518 | 519 | script.getLoadingStatus = function () { 520 | return loadingStatus; 521 | } 522 | script.setLoadingStatus = function (status) { 523 | loadingStatus = status; 524 | } -------------------------------------------------------------------------------- /src/assets/js/utils.js: -------------------------------------------------------------------------------- 1 | const utils = utils || {}; 2 | 3 | (function () { 4 | utils.exportToCSV = function (countryCountObj) { 5 | const list = map.countryNames.map((country) => { 6 | const countryCount = countryCountObj[country.id]; 7 | return { 8 | countryId: country.id, 9 | countryName: country.mainName, 10 | artists: (countryCount && countryCount[SESSION.name]) || [], 11 | }; 12 | }); 13 | 14 | let csv = json2csv.parse( 15 | list.sort(({ countryName: a }, { countryName: b }) => 16 | a.localeCompare(b, "en") 17 | ), 18 | { 19 | fields: [ 20 | { label: "Country", value: "countryName" }, 21 | { label: "Number of artists", value: (row) => row.artists.length }, 22 | { 23 | label: "Scrobbles", 24 | value: (row) => 25 | row.artists.reduce((acc, artist) => acc + artist.playcount, 0), 26 | }, 27 | ], 28 | } 29 | ); 30 | 31 | csv = "data:text/csv;charset=utf-8," + csv.replaceAll(`"`, ""); 32 | 33 | window.open(encodeURI(csv)); 34 | }; 35 | 36 | utils.getCountryNameFromId = function (countryId) { 37 | const match = map.countryNames.find((country) => country.id === countryId); 38 | if (match && match.mainName) { 39 | return match.mainName; 40 | } 41 | else return "" 42 | } 43 | 44 | utils.getNumberOfArtistsForCountry = function (countryId) { 45 | // Get the current data 46 | let data = script.getCurrentData(); 47 | 48 | // Flatten and prepare the data 49 | let artists = [].concat(...Object.values(data)); 50 | artists = artists.reduce((acc, item) => { 51 | for (let key in item) { 52 | if (item.hasOwnProperty(key)) { 53 | acc = acc.concat(item[key]); 54 | } 55 | } 56 | return acc; 57 | }, []) 58 | const artistList = artists.filter(artist => artist.id === countryId) 59 | return artists.filter(artist => artist.id === countryId).length 60 | } 61 | })(); 62 | -------------------------------------------------------------------------------- /src/assets/scss/base/README.md: -------------------------------------------------------------------------------- 1 | # Base 2 | 3 | The `base/` folder holds what we might call the boilerplate code for the project. In there, you might find some typographic rules, and probably a stylesheet (that I’m used to calling `_base.scss`), defining some standard styles for commonly used HTML elements. 4 | 5 | Reference: [Sass Guidelines](http://sass-guidelin.es/) > [Architecture](http://sass-guidelin.es/#architecture) > [Base folder](http://sass-guidelin.es/#base-folder) 6 | -------------------------------------------------------------------------------- /src/assets/scss/base/_base.scss: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------------- 2 | // This file contains very basic styles. 3 | // ----------------------------------------------------------------------------- 4 | 5 | /** 6 | * Set up a decent box model on the root element 7 | */ 8 | html { 9 | //box-sizing: border-box; 10 | } 11 | 12 | /* 13 | * Body basics 14 | */ 15 | body { 16 | background-color: white; 17 | margin: 0; 18 | overflow-x: hidden; 19 | overflow-y: hidden; 20 | } 21 | 22 | *:focus-visible { 23 | outline: 4px solid var(--focus); 24 | outline-offset: 2px; 25 | } 26 | 27 | input { 28 | border-radius: 4px; 29 | &:focus-visible { 30 | outline-offset: 0px; 31 | } 32 | } 33 | 34 | dl { 35 | display: grid; 36 | grid-template-columns: auto auto; 37 | justify-content: space-between; 38 | grid-gap: 8px; 39 | margin: 0; 40 | padding: 0; 41 | } 42 | 43 | dt { 44 | grid-column: 1; 45 | margin: 0; 46 | padding: 0; 47 | } 48 | 49 | dd { 50 | grid-column: 2; 51 | margin: 0; 52 | padding: 0; 53 | } 54 | 55 | /** 56 | * Make all elements from the DOM inherit from the parent box-sizing 57 | * Since `*` has a specificity of 0, it does not override the `html` value 58 | * making all elements inheriting from the root box-sizing value 59 | * See: https://css-tricks.com/inheriting-box-sizing-probably-slightly-better-best-practice/ 60 | */ 61 | *, *::before, *::after { 62 | box-sizing: inherit; 63 | } -------------------------------------------------------------------------------- /src/assets/scss/base/_fonts.scss: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------------- 2 | // This file contains all @font-face declarations, if any. 3 | // ----------------------------------------------------------------------------- 4 | 5 | @font-face { 6 | font-family: "Josefin Sans"; 7 | src: url("https://fonts.googleapis.com/css?family=Josefin+Sans"); 8 | } -------------------------------------------------------------------------------- /src/assets/scss/base/_helpers.scss: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------------- 2 | // This file contains CSS helper classes. 3 | // ----------------------------------------------------------------------------- 4 | 5 | /** 6 | * Clear inner floats 7 | */ 8 | .clearfix::after { 9 | clear: both; 10 | content: ''; 11 | display: table; 12 | } 13 | 14 | /** 15 | * Hide text while making it readable for screen readers 16 | * 1. Needed in WebKit-based browsers because of an implementation bug; 17 | * See: https://code.google.com/p/chromium/issues/detail?id=457146 18 | */ 19 | .hide-text { 20 | overflow: hidden; 21 | padding: 0; 22 | 23 | /* 1 */ 24 | text-indent: 101%; 25 | white-space: nowrap; 26 | } 27 | 28 | /** 29 | * Hide element while making it readable for screen readers 30 | * Shamelessly borrowed from HTML5Boilerplate: 31 | * https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css#L119-L133 32 | */ 33 | .visually-hidden { 34 | border: 0; 35 | clip: rect(0 0 0 0); 36 | height: 1px; 37 | margin: -1px; 38 | overflow: hidden; 39 | padding: 0; 40 | position: absolute; 41 | width: 1px; 42 | } 43 | 44 | /* 45 | * Helper class to center texts 46 | */ 47 | .centered { 48 | text-align: center; 49 | } 50 | 51 | /* 52 | * Class for hiding elements 53 | */ 54 | .hidden { 55 | display: none!important; 56 | } 57 | 58 | /* These are hidden until user has logged in */ 59 | .on-map-view { 60 | visibility: hidden; 61 | } 62 | 63 | .full-opacity { 64 | opacity: 0.9; 65 | } 66 | 67 | .no-select { 68 | user-select: none; 69 | } -------------------------------------------------------------------------------- /src/assets/scss/base/_typography.scss: -------------------------------------------------------------------------------- 1 | /* ========================================================================== 2 | TYPOGRAPHY STYLES 3 | ========================================================================== */ 4 | 5 | /* 6 | * Basic typography for copy text 7 | */ 8 | 9 | body { 10 | font-family: $text-font-stack; 11 | font-size: 16px; 12 | 13 | h1 { 14 | color: $heading-color; 15 | font-size: 3em; 16 | margin-bottom: 0em; 17 | text-transform: capitalize; 18 | font-family: $heading-font-stack; 19 | font-weight: 400; 20 | } 21 | 22 | h2 { 23 | font-size: 3.5rem; 24 | line-height: 1.45; 25 | color: $heading-color; 26 | } 27 | 28 | h3 { 29 | font-size: 3.5rem; 30 | line-height: 1.45; 31 | } 32 | 33 | h4 { 34 | font-family: $heading-font-stack; 35 | margin-top: 0.3em; 36 | margin-bottom: 0.8em; 37 | font-weight: 400; 38 | } 39 | 40 | h5 { 41 | color: $heading-color; 42 | font-size: 1.5em; 43 | margin-top: 0em; 44 | text-transform: lowercase; 45 | } 46 | } 47 | 48 | /* 49 | * Basic link styles 50 | */ 51 | a { 52 | font-weight: bold; 53 | text-decoration: underline; 54 | text-underline-offset: 0.1em; 55 | } -------------------------------------------------------------------------------- /src/assets/scss/components/README.md: -------------------------------------------------------------------------------- 1 | # Components 2 | 3 | For small components, there is the `components/` folder. While `layout/` is macro (defining the global wireframe), `components/` is more focused on widgets. It contains all kind of specific modules like a slider, a loader, a widget, and basically anything along those lines. There are usually a lot of files in components/ since the whole site/application should be mostly composed of tiny modules. 4 | 5 | Reference: [Sass Guidelines](http://sass-guidelin.es/) > [Architecture](http://sass-guidelin.es/#architecture) > [Components folder](http://sass-guidelin.es/#components-folder) 6 | -------------------------------------------------------------------------------- /src/assets/scss/components/_buttons.scss: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------------- 2 | // This file contains all styles related to the button component. 3 | // ----------------------------------------------------------------------------- 4 | 5 | // media query for forced colors 6 | @media screen and (forced-colors: active) { 7 | button { 8 | color: ButtonText!important; 9 | fill: ButtonText!important; 10 | } 11 | .change-button { 12 | border: 1px solid transparent!important; 13 | } 14 | } 15 | 16 | /* CHANGE BUTTON 17 | ========================================================================== */ 18 | .change-button { 19 | box-sizing: border-box; 20 | width: 48px; 21 | height: 48px; 22 | border-radius: 50%; 23 | display: flex; 24 | justify-content: center; 25 | align-items: center; 26 | border: none; 27 | overflow: hidden; 28 | background-color: var(--buttonBackground); 29 | 30 | &:hover { 31 | overflow: visible; 32 | filter: brightness(1.2); 33 | } 34 | 35 | &:active { 36 | overflow: visible; 37 | filter: brightness(1.2); 38 | } 39 | 40 | img { 41 | width: 32px; 42 | height: 32px; 43 | 44 | } 45 | 46 | p { 47 | width: 200px; 48 | margin: -30px -210px; 49 | text-align: right; 50 | font-weight: 700; 51 | } 52 | } 53 | 54 | /* 55 | * Specific change buttons 56 | */ 57 | 58 | 59 | .button-group { 60 | display: flex; 61 | align-items: center; 62 | gap: 4px; 63 | 64 | .change-button:hover, .change-button:focus { 65 | +.button-group__label { 66 | display: block; 67 | } 68 | } 69 | 70 | &__label { 71 | display: none; 72 | order: -1; 73 | font-weight: 700; 74 | margin: -6px 5px 0; 75 | } 76 | } 77 | 78 | /* GO-BUTTON (SPLASH) 79 | ========================================================================== */ 80 | 81 | .gobutton { 82 | width: 130px; 83 | height: 40px; 84 | overflow: hidden; 85 | margin-left: auto; 86 | margin-right: auto; 87 | margin-bottom: 16px; 88 | margin-top: 16px; 89 | padding: 3px 6px 6px 6px; 90 | border-radius: 10px; 91 | -webkit-border-radius: 10px; 92 | background: #ffffff; 93 | background: linear-gradient(to bottom, #ffffff 0%, #ffc3c3 100%); 94 | border: 2px solid grey; 95 | 96 | &:focus-within { 97 | outline: 4px solid var(--focus); 98 | outline-offset: 2px; 99 | 100 | } 101 | 102 | &:hover { 103 | background: white; 104 | background: #ffc3c3; 105 | background: linear-gradient(to bottom, #ffffff 0%, #ffeaea 100%); 106 | } 107 | 108 | &:active { 109 | background: #ffc3c3; 110 | background: linear-gradient(to bottom, #ffc3c3 0%, #ffffff 100%); 111 | 112 | input { 113 | margin-top: 1px; 114 | } 115 | } 116 | 117 | input { 118 | background: url(../img/gobutton.png) no-repeat; 119 | background-size: 90%; 120 | background-position: center top; 121 | cursor: pointer; 122 | text-align: center; 123 | border: none; 124 | width: inherit; 125 | height: inherit; 126 | &:focus { 127 | outline: none; 128 | } 129 | } 130 | } -------------------------------------------------------------------------------- /src/assets/scss/components/_loader.scss: -------------------------------------------------------------------------------- 1 | /* ========================================================================== 2 | THE LOADER DIV 3 | ========================================================================== */ 4 | 5 | .loader { 6 | position: absolute; 7 | z-index: 1; 8 | top: 50px; 9 | padding: 25px 40px; 10 | left: 80%; 11 | background: rgba(45, 45, 45, 0.5); 12 | border-radius: 40px; 13 | color: white; 14 | text-align: center; 15 | pointer-events: none; 16 | opacity: 0; 17 | 18 | a { 19 | color: white !important; 20 | } 21 | 22 | /* Fade in later */ 23 | } 24 | 25 | //Themed version 26 | body.black-theme .loader { 27 | background: rgba(80, 80, 80, 0.4); 28 | } 29 | 30 | /* Animated bubbles 31 | ========================================================================== */ 32 | 33 | .bubblingG { 34 | text-align: center; 35 | width: 107px; 36 | height: 67px; 37 | 38 | span { 39 | display: inline-block; 40 | vertical-align: middle; 41 | width: 13px; 42 | height: 13px; 43 | margin: 33px auto; 44 | background: rgba(255, 255, 255, 1); 45 | -moz-border-radius: 67px; 46 | -moz-animation: bubblingG 1.3s infinite alternate; 47 | -webkit-border-radius: 67px; 48 | -webkit-animation: bubblingG 1.3s infinite alternate; 49 | -ms-border-radius: 67px; 50 | -ms-animation: bubblingG 1.3s infinite alternate; 51 | -o-border-radius: 67px; 52 | -o-animation: bubblingG 1.3s infinite alternate; 53 | border-radius: 67px; 54 | animation: bubblingG 1.3s infinite alternate; 55 | /* No animation when user prefers reduced motion */ 56 | @media (prefers-reduced-motion: reduce) { 57 | -moz-animation: none; 58 | -webkit-animation: none; 59 | -ms-animation: none; 60 | -o-animation: none; 61 | animation: none; 62 | } 63 | } 64 | } 65 | 66 | /* Animation styles 67 | ========================================================================== */ 68 | 69 | #bubblingG_1 { 70 | -moz-animation-delay: 0s; 71 | -webkit-animation-delay: 0s; 72 | -ms-animation-delay: 0s; 73 | -o-animation-delay: 0s; 74 | animation-delay: 0s; 75 | } 76 | 77 | #bubblingG_2 { 78 | -moz-animation-delay: 0.39s; 79 | -webkit-animation-delay: 0.39s; 80 | -ms-animation-delay: 0.39s; 81 | -o-animation-delay: 0.39s; 82 | animation-delay: 0.39s; 83 | } 84 | 85 | #bubblingG_3 { 86 | -moz-animation-delay: 0.78s; 87 | -webkit-animation-delay: 0.78s; 88 | -ms-animation-delay: 0.78s; 89 | -o-animation-delay: 0.78s; 90 | animation-delay: 0.78s; 91 | } 92 | 93 | @-moz-keyframes bubblingG { 94 | 0% { 95 | width: 13px; 96 | height: 13px; 97 | background-color: rgba(255, 255, 255, 1); 98 | -moz-transform: translateY(0); 99 | } 100 | 101 | 100% { 102 | width: 32px; 103 | height: 32px; 104 | background-color: #FFFFFF; 105 | -moz-transform: translateY(-28px); 106 | } 107 | } 108 | 109 | @-webkit-keyframes bubblingG { 110 | 0% { 111 | width: 13px; 112 | height: 13px; 113 | background-color: rgba(255, 255, 255, 1); 114 | -webkit-transform: translateY(0); 115 | } 116 | 117 | 100% { 118 | width: 32px; 119 | height: 32px; 120 | background-color: #FFFFFF; 121 | -webkit-transform: translateY(-28px); 122 | } 123 | } 124 | 125 | @-ms-keyframes bubblingG { 126 | 0% { 127 | width: 13px; 128 | height: 13px; 129 | background-color: rgba(255, 255, 255, 1); 130 | -ms-transform: translateY(0); 131 | } 132 | 133 | 100% { 134 | width: 32px; 135 | height: 32px; 136 | background-color: #FFFFFF; 137 | -ms-transform: translateY(-28px); 138 | } 139 | } 140 | 141 | @-o-keyframes bubblingG { 142 | 0% { 143 | width: 13px; 144 | height: 13px; 145 | background-color: rgba(255, 255, 255, 1); 146 | -o-transform: translateY(0); 147 | } 148 | 149 | 100% { 150 | width: 32px; 151 | height: 32px; 152 | background-color: #FFFFFF; 153 | -o-transform: translateY(-28px); 154 | } 155 | } 156 | 157 | @keyframes bubblingG { 158 | 0% { 159 | width: 13px; 160 | height: 13px; 161 | background-color: rgba(255, 255, 255, 1); 162 | transform: translateY(0); 163 | } 164 | 165 | 100% { 166 | width: 32px; 167 | height: 32px; 168 | background-color: #FFFFFF; 169 | transform: translateY(-28px); 170 | } 171 | } -------------------------------------------------------------------------------- /src/assets/scss/components/_search.scss: -------------------------------------------------------------------------------- 1 | /* Free text search function */ 2 | 3 | .search-container { 4 | position: absolute; 5 | top: 33vh; 6 | left: 50%; 7 | transform: translate(-50%); 8 | box-sizing: border-box; 9 | z-index: 2000; 10 | 11 | ul { 12 | list-style: none; 13 | padding: 0; 14 | } 15 | } 16 | 17 | .search-input-wrapper { 18 | position: relative; 19 | &::before { 20 | content: ""; 21 | display: inline-block; 22 | width: 1.25em; 23 | height: 1.25em; 24 | background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMjQgMjQiIGZvY3VzYWJsZT0iZmFsc2UiIGFyaWEtaGlkZGVuPSJ0cnVlIiBjbGFzcz0iIj48ZyBjbGlwLXBhdGg9InVybCgjc2VhcmNoX3N2Z19fYSkiPjxwYXRoIGZpbGw9ImN1cnJlbnRDb2xvciIgZD0iTTE2LjEgMTQuNDI4YS45ODcuOTg3IDAgMCAwLTEuNDI3LjAxNSA1Ljk5OCA1Ljk5OCAwIDEgMSAxLjUyNS00LjE0M2MuMDE0LjU0Ny0uMTk0IDEuMjEyLjI0NyAxLjY1MmExIDEgMCAwIDAgMS43LS41NiA4IDggMCAxIDAtMi43NjUgNS4xNTdsNSA1IDEuNDItMS40Mi01LjctNS43WiI+PC9wYXRoPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9InNlYXJjaF9zdmdfX2EiPjxwYXRoIGQ9Ik0wIDBoMjR2MjRIMHoiPjwvcGF0aD48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4=); 25 | background-repeat: no-repeat; 26 | background-size: contain; 27 | position: absolute; 28 | left: 12px; 29 | top: 51%; 30 | transform: translateY(-50%); 31 | z-index: 1000; 32 | } 33 | .dark &::before { 34 | filter: invert(1); 35 | } 36 | } 37 | 38 | .search-results { 39 | border-radius: 8px; 40 | margin-top: 8px; 41 | overflow-y: auto; 42 | max-height: 400px; 43 | box-shadow: rgba(0, 0, 0, 0.1) 0px 2px 12px 0px; 44 | backdrop-filter: blur(20px); 45 | background-color: var(--backgroundInput); 46 | box-sizing: border-box; 47 | } 48 | 49 | .result-wrapper { 50 | box-sizing: border-box; 51 | position: relative; 52 | margin: 0; 53 | padding: 8px 12px ; 54 | display: flex; 55 | flex-direction: row; 56 | justify-content: space-between; 57 | max-width: 400px; 58 | gap: 4px; 59 | cursor: pointer; 60 | 61 | &.country { 62 | flex-direction: column; 63 | align-items: start; 64 | } 65 | 66 | &:first-child { 67 | padding-top: 16px; 68 | } 69 | &:last-child { 70 | // padding-bottom: 16px; 71 | } 72 | .artists-wrapper & { 73 | &:last-of-type::after { 74 | border: none; 75 | } 76 | } 77 | &:after { 78 | content: ""; 79 | position: absolute; 80 | left: 12px; 81 | right: 12px; 82 | bottom: 0; 83 | border-bottom: 1px solid var(--borderSecondary); 84 | } 85 | &:first-of-type::before { 86 | content: ""; 87 | position: absolute; 88 | left: 12px; 89 | right: 12px; 90 | top: 0; 91 | border-top: 1px solid var(--borderSecondary); 92 | } 93 | &.focused { 94 | box-shadow: inset 0 0 0 3px var(--focus); 95 | } 96 | &:hover, &.focused { 97 | background-color: var(--hover); 98 | } 99 | 100 | /* &[id^='shortcut'] { 101 | .shortcut-name { 102 | margin-left: 32px; 103 | } 104 | 105 | &#shortcut-clear-cached-users { 106 | // a before element with background image icon 107 | .shortcut-name::before { 108 | content: ""; 109 | display: inline-block; 110 | width: 1.25em; 111 | height: 1.25em; 112 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACsAAAAsCAYAAAD8WEF4AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAhdEVYdENyZWF0aW9uIFRpbWUAMjAyMjowMTozMCAxMTo1NzoyOdLL1qcAAAtMSURBVFhHzVlpbFzVFT73vmX2sT2Ok9hZnNBspCGlYadKIS1LVXVDhbAkKrQqUhea9k+F4A9q1T+pqFRVoqoqigr/WlRKVUAJNKSQkNKQpWmmCXGSGnsYLzNexjOembfe2+8+P15istgGQfNJZ957dzn3u9u559yhSxFS/lHL53en1VNKycJkil4+DKBQKxQKpqXracNmKYoT6Z7nZYSoZ227wVau9Bhjflj8osj353OsVu80/cm1mkv7Y7JeXnT1Vxoq7wORBTkOMapEKe7QwoZttbh2s812nAXc8+ZzjcjgZBlcDhu6WTaT8YmExqpkyhGibB3EHYgM1UXI5/OmMPkNwnNuSjSHNpme+ytTkwdBtl/lz5ksSJqTUra6LnUaBq0WQt5t161P2Ja10HOdVHfXAlAlWRoeEhr3LMNIjMdNY8CIaae5cHaA4TEytEHGWmog7E5pncLbg4PL65b4rrAm77lq4bvt/YdL27nGdyy96e79YZHZQ013VYivj3virxXHLwshbIhA+oxQ5YCm746dEu7400JUbkMyD1UHuo+Nub/rrdg9QYXxXbJvx1M/Lex85rqwyOxGFqwy0Hoj5n5LnLEvxYgSSDYhQX2M0Ix6VPvhq0/SbaBmRTZGXmza2iGbjIlarG112pDfzxksB3UmVV6l0XzxB6TLXfNuuP+4qhj17HyAfq0kxEo8H+GSHjWJfQFk26AMD8YVSYWw+EURFlXQiRlprOou0pN3EOM/lIIejpN/P4i2o6gxVQPkDLOqkdkMPy88smo0saAWYxv8CJv75hZGS9CQGtFpQEewUyX2GlXAf1i4jhCccx9NcWJZzigDSSE/dYH6nnpA1MAFA6DS1ciOnRq+jyTb237tvQWVdF6yRSmTniuvQiO3JRlta+OUDEYkBBpw8DuB1wJUH8UED0JVCd+DwrV8X9M0nwyTCWoxdMqhkQ7kdQmiDajcrjGm9F10VgOyPaUtYLjngmRBhPe68hpd0mZs661dJpsfZqk8jIBfR7VTsNV5NPga5GVkjUGUSYrMEYoq3UrieG/H8C12fLnF8+VGsFyeiWmZoOCFALLjPUNbSTP25K7eHJiuab1TRN+BIUSL39M53fE+ohgYtTn8A3hux0z/GPIkCPZDJs8mqqC+IQLSQLmCwdj+iu092Tfu7euruMWw2JwwjexJjAAe38HWv6VTZ8unUkOI5gjJ2j+I2Xcypj0HEkNhzqzhGiZpWBjJlpiyxTPCt92sb9nROo/I9giR1Ty2WjjywXmcFOkIwiqNkVvdSZ7/EGPZcRDFmp09DuC0GyBqY7rYOt+k65fHaFGYdVFYtXqXVa21hZ9nyJoureBCblxu0GJ8BuYDU6/gYZnuJOm8SMbICTW1Km+2QH19kZSrsVbvgjn4copRN5JhYGYBSQmcJsqeBwjIqp7XXfokNsDNIGNC3uuE8MeODEjH2kFNZx/nq+wwfdaoSdmGeVzbwuTnMV2LE2dZAnTEgTRDsSAuRJ2Ifr1YryXbW08n2zPlQBEQWINjE6IdB982dOHBFWnWGeQAgZLR/X9gPPY4y115DI1M20SzQUOIJRqJG3D63ea7/lokcaYxV+d6E62PMQr8AzVbah3DLXRNsquCykcHPKv+hCH8//LLv1ZD3hTZflus8yVt6zbpGyCE0zQgKmT12AhzvXuZwQ+z1vXjKn2uGIfTk5B2h/BEp9W0lhqcO4YRr8VisRLaGEYRtf6V+6jseI65bkw64z5rHO+l+X9vMPaT6ctuSIivlnzxF1SOAKej7g+8nBc9L8XweY49/n9A+aWsgSliJFeGaQHcwr5avb84yFd90f4g0/9RgBekjGO/fwrme2GYFsCxRdUS6eCYu1TAs3D3YKIz8OojE6HA47wUy7Xmw89LAoEJyTI2EiPlPU3Bqx6vm6nYycS81r1h0seG3bDL/f0ike/vz50+fbqlv78/gaUa8GTYSDCDYpv0vQ1CeN1wjIn7w6eYsF+iVP3PnN8Y+ZMfNZS9z47aK5nVWOHVxxbGNdfNJsVwOuYVzNz6PFMbDNLmuo1l0nW6cMiJmKjup8wg/NNNytf82HBiRCwi4TzAfPtOURtZHtd9ryXlnMwmcCBlJh4Oi10a+HdJbD4+4r+EwYvgNXorfuWNN0V5rwqtLg2AFx8oyRsbNi0JkwIwu0hs4pCk8YPOJUEWRNlvD5JWSyGaiLHpTjkzK1LvKLCVY+4lQfZZWKUF3TKeTNCidIzeF0EkBlms87A6dqeRVT2EdYiVy+XM8PBwulgsJtUtCdJ1iLp3UieeeuIb6b29CFl2q7wP1WkEaMYyneZ1pdj81SlKhsmKj09M74PH/k/1HZ35yFBWIdn0vLWT1eoS17JMH0DsZyMs8fAUSnD0MrjwIOerSJS3Jm1EDE6RJf0RxtbNySl/D6eFWCBc9tkMk7+erwLM0IX0KyfqTNq/YYb8Oc9cWTqbrIFwdYkm5KP1ythGp9ls813FkSOy1tSLD9KBB4QOQ5svdE16LWl3LxPe88yXe1h2zUigbA7IS2kmXflpT8pHVhjsdhCNHHN36K0iCfGE0aU/ztjVZ66axqRsKQuxadgVY1gKygm+KFDGh1hi4PXdorh3i+zbE4Ufc4G6RCk64qG+hl9VOkP1AZyBA694A/u3hkXPhDWTDZluuGyeoQVrZjZr0BLFPYPwmR+DO/0K6944Z393Qoh2T9B9GpffXhJnaSQFMz1FddzVdf6UFuevqzSFaaRmdYEKeKM9E2LgrYMktO2s2TjCFjrq3mDWABO1UeM4Hh9AmLMpzegytRcUwiJNUTxyFJFqngbiURQdkTUS5GKbWyg9o+86WR4YrY4Mve067t/osltqczmWlbWpSrkCEcSdMDObE0yuRVCmrpcCoBOWrPynlxvm46xpF9i6M5s2ImshvNY0Up7XDNGrhQKMBI+hAhYNPRtt0gsBBLRejCSiwssqPt0ufNqiSfoWot0r4JdGux/lfFk7dRKR9C7Sci/QspvVHVqEqKHgIs6jDQiZX1wcY5idKMI9B1apd5yEfczQY8/zRPpV4vooxbVJxrJerTZgNBqwd7ammaZuylQiyaSf4RpvS5ixDQhdP4fJXp3R2QKo0s+aehKVE9gD3rNMiud4bv1rYXKEqOCIEFnXp6viGr3Qgrj+YmQVMAqubJQnKZ7Yw0g/SFzr8X05WauMtdZdP9l0tVTCTOT0uNlNjHdjoy/NJBMdaZPFoPvsSz617CCWJ0dPPodWn2G59TtR5pwZjsgOCNHhOHRt1qQ/tWIJz0T2bCjieKj9qRpQOs8WNXoX1IW6nrSK47JWfpvzxD1s3pphlD/vXo+UVG1KT9pCXU1GN9pQ5BdPvDFcKA4dKFWtwzC+76r080CNlArh1b2UMurqXelRNzuBrvNBNIcmZLPvdeZZv+TJlrtAdOhCRBUiRftG7SuYz25d02H8AiOriDarfQeHbCkfE4klhViqRWTS8Vb07pvIXo+Ki6A4uGOYC6DXlv7kBDbREByR35O0j5BkJ1liURH6Lrq5I7J7h+0roerWtR3mzxTZ8XcOHJWes9sU8e2jsfbJZcuWKUXKqbkWz2uwyNZJxi6HQ7EUStSIqlFUtypK1DpU5dUTZk3aeIOlcfvgWPRgwk6R7/ZgcN6UcXuU8846ys2IiOy/StYqyb3PZA33UXPweFkXzi7djD/fseL6g2GRACCrvC51edcNNqtAdjVGRLl1irBanwbykQWPaYqwhWbqSKsyZp/Ct5ICYwk15dP+WpoJEdl8pZJDaysS1Ngiy4U88+ShzjXXHYJCNTrnAI2rumrNZ/CuTJ1aEupkUt6YIhn85YR3dZmH1YQDB+9h3gcA0f8AhBBKbm+OafIAAAAASUVORK5CYII='); 113 | background-repeat: no-repeat; 114 | } 115 | } 116 | } */ 117 | 118 | } 119 | 120 | input.search { 121 | padding-left: 38px; 122 | height: 56px; 123 | min-width: 400px; 124 | min-width: 400px; 125 | backdrop-filter: blur(20px); 126 | background-color: var(--backgroundInput); 127 | box-sizing: border-box; 128 | -webkit-appearance: none; 129 | -moz-appearance: none; 130 | appearance: none; 131 | } 132 | 133 | .country.result-wrapper { 134 | .country-artist-count { 135 | opacity: 0.54; 136 | } 137 | } 138 | 139 | .artist-wrapper { 140 | display: flex; 141 | flex-direction: column; 142 | gap: 4px; 143 | .playcount { 144 | opacity: 0.54; 145 | font-size: 0.9rem; 146 | } 147 | } 148 | 149 | .artist-name, .country-name, .shortcut-name { 150 | .highlight { 151 | font-family: $heading-font-stack; 152 | font-weight: 400; 153 | } 154 | } 155 | .country-wrapper { 156 | display: flex; 157 | justify-content: center; 158 | flex-direction: column; 159 | flex-wrap: nowrap; 160 | text-align: right; 161 | .add-tags { 162 | opacity: 0.54; 163 | font-size: 0.9rem; 164 | } 165 | } 166 | .result-wrapper { 167 | align-items: center; 168 | } 169 | 170 | .search-result-heading { 171 | font-size: 0.9rem; 172 | font-weight: normal; 173 | padding-left: 12px; 174 | padding-right: 12px; 175 | margin-bottom: 4px; 176 | color: var(--textPrimary); 177 | } 178 | 179 | // Country List Dialog styles 180 | 181 | // Tab styles 182 | .country-tabs { 183 | display: flex; 184 | gap: 2px; 185 | border-bottom: 1px solid var(--borderSecondary); 186 | margin-bottom: 1.5em; 187 | padding: 0 12px; 188 | overflow-x: auto; 189 | white-space: nowrap; 190 | scrollbar-width: thin; 191 | scrollbar-color: var(--borderSecondary) transparent; 192 | } 193 | 194 | .country-tab { 195 | display: inline-block; 196 | padding: 12px 16px; 197 | background: none; 198 | border: none; 199 | border-bottom: 2px solid transparent; 200 | color: var(--textPrimary); 201 | font-size: 1em; 202 | cursor: pointer; 203 | transition: all 0.15s ease; 204 | opacity: 0.7; 205 | position: relative; 206 | margin-bottom: -1px; 207 | 208 | &:hover { 209 | opacity: 0.9; 210 | } 211 | 212 | &:focus-visible { 213 | outline: none; 214 | box-shadow: inset 0 0 0 3px var(--focus); 215 | } 216 | 217 | &[aria-selected="true"] { 218 | opacity: 1; 219 | border-bottom-color: var(--textPrimary); 220 | font-weight: 600; 221 | } 222 | } 223 | 224 | .country-tabpanel { 225 | // padding: 0 12px; 226 | 227 | &:focus { 228 | outline: none; 229 | } 230 | } 231 | 232 | .country-tabpanel-heading-row { 233 | display: flex; 234 | align-items: center; 235 | justify-content: space-between; 236 | margin-bottom: 0.5em; 237 | gap: 1em; 238 | } 239 | 240 | .country-tabpanel-heading { 241 | font-size: 1.1em; 242 | font-weight: 700; 243 | margin: 0; 244 | } 245 | 246 | .country-sort-container { 247 | display: flex; 248 | align-items: center; 249 | gap: 0.5em; 250 | } 251 | 252 | .country-sort-label { 253 | font-size: 1em; 254 | color: var(--textPrimary, #fff); 255 | opacity: 0.7; 256 | } 257 | 258 | .country-sort-select { 259 | font-size: 1em; 260 | padding: 4px 10px; 261 | border-radius: 4px; 262 | border: 1px solid var(--borderSecondary); 263 | background: var(--backgroundInput, #222); 264 | color: var(--textPrimary, #fff); 265 | outline: none; 266 | transition: border 0.15s; 267 | } 268 | 269 | .country-sort-select:focus-visible { 270 | border-color: var(--focus); 271 | } 272 | 273 | // Existing country list styles 274 | li.country-list__country { 275 | padding: 0!important; 276 | } 277 | 278 | .country-list__country-btn { 279 | display: flex; 280 | justify-content: space-between; 281 | align-items: center; 282 | width: 100%; 283 | padding: 12px 0px; 284 | background: none; 285 | border: none; 286 | border-radius: 0; 287 | font-size: 1.1em; 288 | cursor: pointer; 289 | transition: background 0.15s; 290 | outline: none; 291 | 292 | &:hover { 293 | background: rgba(80, 0, 80, 0.07); 294 | } 295 | &:focus-visible { 296 | background: rgba(80, 0, 80, 0.07); 297 | box-shadow: inset 0 0 0 3px var(--focus); 298 | outline: none; 299 | } 300 | } 301 | 302 | .country-list__country-name { 303 | font-weight: 600; 304 | color: var(--textPrimary); 305 | text-align: left; 306 | } 307 | 308 | .country-list__country-count { 309 | font-weight: 400; 310 | color: var(--textPrimary); 311 | opacity: 0.54; 312 | font-size: 1em; 313 | margin-left: 16px; 314 | text-align: right; 315 | white-space: nowrap; 316 | } 317 | 318 | .country-list__country { 319 | border-bottom: 1px solid var(--borderSecondary); 320 | } 321 | 322 | .country-list { 323 | list-style: none; 324 | margin: 0; 325 | padding: 0; 326 | } 327 | 328 | .country-list__country-btn:active { 329 | background: rgba(80, 0, 80, 0.13); 330 | } -------------------------------------------------------------------------------- /src/assets/scss/components/_tooltip.scss: -------------------------------------------------------------------------------- 1 | div.tooltip { 2 | font-size: 0.8em; 3 | background: var(--backgroundPrimary); 4 | color: var(--textPrimary); 5 | padding: .5em; 6 | border-radius: 2px; 7 | box-shadow: 0px 0px 2px 0px #a6a6a6; 8 | opacity: 0.9; 9 | position: absolute; 10 | z-index: 1; 11 | } -------------------------------------------------------------------------------- /src/assets/scss/layout/README.md: -------------------------------------------------------------------------------- 1 | # Layout 2 | 3 | The `layout/` folder contains everything that takes part in laying out the site or application. This folder could have stylesheets for the main parts of the site (header, footer, navigation, sidebar…), the grid system or even CSS styles for all the forms. 4 | 5 | Reference: [Sass Guidelines](http://sass-guidelin.es/) > [Architecture](http://sass-guidelin.es/#architecture) > [Layout folder](http://sass-guidelin.es/#layout-folder) 6 | -------------------------------------------------------------------------------- /src/assets/scss/main.scss: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | 3 | // 1. Vendors 4 | 5 | @import "vendor/normalize"; 6 | @import "vendor/bmc"; 7 | 8 | //'bourbon'; 9 | 10 | // 2. Configuration and helpers 11 | 12 | @import "utils/variables"; 13 | @import "utils/functions"; 14 | @import "utils/mixins"; 15 | 16 | // 3. Base stuff 17 | 18 | @import "base/base"; 19 | @import "base/fonts"; 20 | @import "base/typography"; 21 | @import "base/helpers"; 22 | 23 | // 4. Layout-related sections 24 | 25 | // 5. Components 26 | 27 | @import "components/buttons"; 28 | @import "components/loader"; 29 | @import "components/tooltip"; 30 | @import "components/search"; 31 | 32 | 33 | // 6. Page-specific styles 34 | 35 | @import "pages/map"; 36 | @import "pages/splash"; 37 | @import "pages/zoomed"; 38 | 39 | // 7. Themes 40 | 41 | @import "themes/default"; -------------------------------------------------------------------------------- /src/assets/scss/pages/README.md: -------------------------------------------------------------------------------- 1 | # Pages 2 | 3 | If you have page-specific styles, it is better to put them in a `pages/` folder, in a file named after the page. For instance, it’s not uncommon to have very specific styles for the home page hence the need for a `_home.scss` file in `pages/`. 4 | 5 | *Note — Depending on your deployment process, these files could be called on their own to avoid merging them with the others in the resulting stylesheet. It is really up to you.* 6 | 7 | Reference: [Sass Guidelines](http://sass-guidelin.es/) > [Architecture](http://sass-guidelin.es/#architecture) > [Pages folder](http://sass-guidelin.es/#pages-folder) 8 | -------------------------------------------------------------------------------- /src/assets/scss/pages/_map.scss: -------------------------------------------------------------------------------- 1 | /* ========================================================================== 2 | Map container 3 | ========================================================================== */ 4 | 5 | #map-container { 6 | height: 100%; 7 | overflow: hidden; 8 | svg:focus-visible { 9 | outline: 4px solid var(--focus); 10 | outline-offset: -4px; 11 | } 12 | } 13 | 14 | /* ========================================================================== 15 | Countries (SVG) 16 | ========================================================================== */ 17 | 18 | svg:active { 19 | cursor: move; 20 | } 21 | 22 | .country { 23 | cursor: pointer; 24 | &:hover { 25 | stroke: #FFF7EE; 26 | stroke-width: 1.2px; 27 | } 28 | &.highlighted, 29 | &.highlighted:hover { 30 | stroke: #444; 31 | stroke-width: 2.5px; 32 | } 33 | } 34 | 35 | /* ========================================================================== 36 | Map legend 37 | ========================================================================== */ 38 | 39 | .legend { 40 | font-size: 14px; 41 | font-family: $heading-font-stack; 42 | font-weight: 400; 43 | rect { 44 | stroke: #fff; 45 | } 46 | text { 47 | font-family: $text-font-stack; 48 | } 49 | &#filter { 50 | cursor: pointer; 51 | text-decoration: underline; 52 | font-style: italic; 53 | } 54 | } 55 | 56 | 57 | /* ========================================================================== 58 | Controls container 59 | ========================================================================== */ 60 | 61 | $controls-padding: 20px; 62 | #controls { 63 | gap: 4px; 64 | position: absolute; 65 | top: 0; 66 | right: 0; 67 | bottom: 80px; 68 | padding: $controls-padding; 69 | z-index: 1; 70 | display: flex; 71 | flex-direction: column; 72 | align-items: flex-end; 73 | } 74 | 75 | 76 | /* ========================================================================== 77 | Progress bar 78 | ========================================================================== */ 79 | 80 | //(Progressbar container) 81 | #countryCount { 82 | position: absolute; 83 | // 3 * (buttonheight + padding) :^) 84 | top: 3 * (48px+4px) + $controls-padding + 10px; 85 | width: 10px; 86 | bottom: $controls-padding; 87 | right: 32px; 88 | box-shadow: inset 1px 1px 3px 0px rgba(0, 0, 0, 0.39); 89 | border: 0.1em solid; 90 | z-index: 0; 91 | border-radius: 50px; 92 | margin-top: 64px; 93 | } 94 | 95 | #progress-bar { 96 | float: left; 97 | width: 100%; 98 | height: 0; 99 | font-size: 12px; 100 | line-height: 20px; 101 | text-align: center; 102 | position: absolute; 103 | bottom: 0; 104 | background-color: var(--focus); 105 | transition: height 0.6s ease; 106 | box-shadow: inset 1px 1px 3px 0px rgba(0, 0, 0, 0.39); 107 | } 108 | 109 | #progress-text { 110 | float: right; 111 | margin-right: 22px; 112 | margin-top: -20px; 113 | width: auto; 114 | line-height: 12px; 115 | border-radius: 8px; 116 | padding: 5px; 117 | background: var(--backgroundSecondary); 118 | border: 1px solid var(--borderSecondary); 119 | &:after, 120 | &:before { 121 | left: -13px; 122 | top: 0px; 123 | border: solid transparent; 124 | content: " "; 125 | height: 0; 126 | width: 0; 127 | position: absolute; 128 | pointer-events: none; 129 | } 130 | &:after { 131 | border-left-color: var(--border); 132 | border-width: 7px; 133 | margin-top: -7px; 134 | } 135 | &:before { 136 | border-left-color: var(--border); 137 | border-width: 9px; 138 | margin-top: -9px; 139 | } 140 | } 141 | 142 | /* ========================================================================== 143 | Friendbox 144 | ========================================================================== */ 145 | 146 | /* Check out friends box */ 147 | 148 | #friends { 149 | position: absolute; 150 | bottom: 35px; 151 | right: 130px; 152 | font-size: 0.9em; 153 | border-radius: 10px; 154 | padding: 5px; 155 | border: 1px solid var(--borderSecondary); 156 | background: var(--backgroundSecondary); 157 | opacity: 0; 158 | p { 159 | text-align: center; 160 | margin: 0; 161 | } 162 | .friends-inner { 163 | display: flex; 164 | flex-direction: row; 165 | justify-content: space-between; 166 | } 167 | } 168 | 169 | .arrow { 170 | padding: 8px; 171 | border-radius: 100%; 172 | background-color: var(--buttonBackground); 173 | cursor: pointer; 174 | border: none; 175 | background: none; 176 | padding: 0; 177 | appearance: none; 178 | &:hover { 179 | filter: brightness(1.2) 180 | } 181 | } 182 | 183 | 184 | /* ========================================================================== 185 | Misc 186 | ========================================================================== */ 187 | 188 | .text { 189 | font-size: 13px; 190 | text-transform: capitalize; 191 | } 192 | 193 | #colors { 194 | pointer-events: all; 195 | position: fixed; 196 | left: 150px; 197 | bottom: 30px; 198 | top: 450px; 199 | /*width: 107px; 200 | height: 67px;*/ 201 | } 202 | 203 | div.colorChange { 204 | text-align: center; 205 | /*position: fixed; */ 206 | /*left: 150px; 207 | bottom: 45px;*/ 208 | width: 107px; 209 | height: 67px; 210 | } 211 | 212 | #journeyText { 213 | font-size: 0.8em; 214 | font-style: italic; 215 | margin-top: 0; 216 | text-align: center; 217 | } 218 | 219 | .screenshot-overlay { 220 | position: fixed; 221 | top: 0; 222 | bottom: 0; 223 | left: 0; 224 | right: 0; 225 | z-index: 10; 226 | background: var(--backgroundPrimary); 227 | display: flex; 228 | align-items: center; 229 | justify-content: center; 230 | &__header { 231 | position: absolute; 232 | top: 0; 233 | left: 50%; 234 | transform: translateX(-50%); 235 | padding: 1em; 236 | } 237 | &__close { 238 | user-select: none; 239 | cursor: pointer; 240 | border: 2px solid #000; 241 | border-radius: 4px; 242 | padding: 0.3em 0.7em; 243 | font-weight: 700; 244 | font-size: 1.3em; 245 | position: absolute; 246 | right: 0; 247 | top: 0; 248 | margin: 0.6em; 249 | opacity: 0.9; 250 | &:hover { 251 | opacity: 1; 252 | } 253 | } 254 | &__img { 255 | display: flex; 256 | align-items: center; 257 | justify-content: center; 258 | width: 80%; 259 | margin: auto; 260 | box-shadow: 0 10px 30px -14px; 261 | } 262 | } 263 | 264 | .no-countries { 265 | &__title { 266 | position: absolute; 267 | left: 3%; 268 | bottom: 225px; 269 | font-size: 14px; 270 | margin-bottom: 0.3em; 271 | font-family: $heading-font-stack; 272 | font-weight: 400; 273 | // button reset 274 | appearance: none; 275 | border: none; 276 | background: none; 277 | padding: 0; 278 | cursor: pointer; 279 | text-decoration: underline; 280 | text-underline-offset: 0.1em; 281 | } 282 | &__link { 283 | font-weight: normal; 284 | } 285 | 286 | &__secondary { 287 | opacity: 0.54; 288 | } 289 | } 290 | 291 | dialog { 292 | animation: fadein 0.5s; 293 | opacity: 0; 294 | @keyframes fadein { 295 | from { opacity: 0; } 296 | to { opacity: 1; } 297 | } 298 | &[open] { 299 | opacity: 1; 300 | background-color: var(--backgroundSecondary); 301 | border: 5px solid var(--borderSecondary); 302 | border-radius: 30px; 303 | margin: auto; 304 | max-height: 80%; 305 | position: fixed; 306 | padding: 1rem 2rem; 307 | overflow: scroll; 308 | max-width: 30rem; 309 | height: 80vh; 310 | 311 | &::backdrop { 312 | background-color: rgba(255,255,255,0.4); 313 | .dark & { 314 | background-color: rgba(0,0,0,0.7); 315 | } 316 | } 317 | 318 | button.close { 319 | position: absolute; 320 | font-size: 24px; 321 | top: 0; 322 | right: 0; 323 | width: 48px; 324 | height: 48px; 325 | border-radius: 100%; 326 | color: var(--backgroundPrimary); 327 | margin: 1rem; 328 | appearance: none; 329 | border: none; 330 | background: none; 331 | padding: 0; 332 | cursor: pointer; 333 | background-color: var(--textPrimary); 334 | &:hover { 335 | transform: scale(1.1); 336 | filter: brightness(1.1) 337 | } 338 | } 339 | h1 { 340 | text-transform: none; 341 | font-size: 2rem; 342 | } 343 | ul { 344 | padding: 0; 345 | margin: 0; 346 | line-height: 1.1em; 347 | } 348 | h2 { 349 | font-size: 1rem; 350 | font-family: $heading-font-stack; 351 | } 352 | 353 | li { 354 | list-style: none; 355 | padding-bottom: 0.5em; 356 | border-top: 1px solid var(--borderSecondary); 357 | display: flex; 358 | flex-direction: row; 359 | justify-content: space-between; 360 | padding: 6px 0; 361 | gap: 12px; 362 | label { 363 | width: 100%; 364 | display: flex; 365 | flex-direction: row; 366 | justify-content: space-between; 367 | } 368 | &:first-of-type { 369 | border-top: none; 370 | } 371 | &:last-of-type { 372 | // border-bottom: 1px solid var(--borderSecondary); 373 | } 374 | } 375 | fieldset { 376 | margin-bottom: 2rem; 377 | display: flex; 378 | width: fit-content; 379 | gap: 8px; 380 | border-color: var(--border); 381 | legend { 382 | // font-family: $heading-font-stack; 383 | } 384 | label { 385 | margin-right: 1rem; 386 | } 387 | } 388 | } 389 | } 390 | 391 | .artist-test{ 392 | display:none; 393 | position: absolute; 394 | top: 20px; 395 | left: 20px; 396 | } 397 | 398 | // Keyboard mode 399 | 400 | .a11y-country-name { 401 | fill: var(--textPrimary); 402 | } 403 | .a11y-number-bg { 404 | stroke: 1px solid var(--textPrimary); 405 | } 406 | 407 | .a11y-number { 408 | font-size: 0.1rem; 409 | font-family: $heading-font-stack; 410 | fill: var(--backgroundPrimary); 411 | width: 0.2rem; 412 | } 413 | 414 | #keyboard-mode-message { 415 | position: absolute; 416 | width: 400px; 417 | bottom: 40px; 418 | left: 50%; 419 | transform: translateX(-50%); 420 | background-color: var(--backgroundSecondary); 421 | color: var(--textPriary); 422 | border-radius: 20px; 423 | padding: 8px; 424 | text-align: center; 425 | border: 6px solid var(--themeColorDark); 426 | 427 | p { 428 | margin-top: 0.25rem; 429 | margin-bottom: 0.25rem; 430 | } 431 | 432 | h2 { 433 | margin-top: 0; 434 | margin-bottom: 0.25rem; 435 | font-size: 1.2rem; 436 | text-transform: none; 437 | font-family: $heading-font-stack; 438 | } 439 | 440 | 441 | } -------------------------------------------------------------------------------- /src/assets/scss/pages/_splash.scss: -------------------------------------------------------------------------------- 1 | /* ========================================================================== 2 | In this file we store styles for the splash screen 3 | ========================================================================== */ 4 | 5 | /* Container 6 | ========================================================================== */ 7 | 8 | /* 9 | * splash themes 10 | */ 11 | 12 | #welcome-container { 13 | position: absolute; 14 | width: 100%; 15 | display: flex; 16 | height: 100%; 17 | background: var(--welcomeBackground); 18 | visibility: hidden; 19 | } 20 | 21 | #splashTextContainer { 22 | overflow-y: auto; 23 | // max-height: 400px; 24 | padding: 0px 13px 0px 13px; 25 | } 26 | 27 | /* Welcome text 28 | ========================================================================== */ 29 | 30 | .welcome-message { 31 | max-height: 80%; 32 | overflow-y: auto; 33 | margin: 60px auto auto auto; 34 | padding: 20px 30px 20px 30px; 35 | background: var(--welcomeMessageBackground); 36 | border-radius: 30px; 37 | text-align: center; 38 | width: 330px; 39 | border: 5px solid var(--welcomeMessageBorder); 40 | vertical-align: top; 41 | backdrop-filter: blur(30px); 42 | 43 | strong { 44 | font-family: $heading-font-stack; 45 | margin-top: 0.3em; 46 | margin-bottom: 0.8em; 47 | font-weight: 400; 48 | } 49 | } 50 | 51 | .info { 52 | text-align: left; 53 | margin-top: 5px; 54 | margin-bottom: 10px; 55 | } 56 | 57 | .infoPoint { 58 | text-align: left; 59 | margin: 0.3em 0 0 0; 60 | font-size: 0.9em; 61 | } 62 | 63 | #made-by { 64 | font-size: 13px; 65 | margin: 20px 0 0 0; 66 | opacity: 0.7; 67 | ul { 68 | list-style: none; 69 | padding: 0; 70 | display: inline-flex; 71 | justify-content: center; 72 | gap: 4px; 73 | } 74 | } 75 | 76 | .social-icons { 77 | margin-top:20px; 78 | display: flex; 79 | flex-direction: row; 80 | justify-content: center; 81 | gap: 8px; 82 | 83 | a { 84 | text-decoration: none; 85 | font-size: 24px; 86 | padding: 8px; 87 | } 88 | } 89 | 90 | .splash-link { 91 | font-size: 16px; 92 | margin: 20px 0 0 0; 93 | opacity: 1; 94 | cursor: pointer; 95 | line-height: 1.5; 96 | font-weight: 700; 97 | text-align: center; 98 | background: none; 99 | appearance: none; 100 | border: none; 101 | padding: 0; 102 | color: inherit; 103 | text-decoration: underline; 104 | text-underline-offset: 0.1em; 105 | } 106 | 107 | #faqtext h2, #a11ytext h2, #a11ytext h3, #faqtext h3 { 108 | font-family: $heading-font-stack; 109 | margin-top: 1.5rem; 110 | margin-bottom: 0.8em; 111 | font-size: 0.9rem; 112 | font-weight: 400; 113 | } 114 | #a11ytext h1, #faqtext h1 { 115 | font-family: $heading-font-stack; 116 | margin-top: 1rem; 117 | margin-bottom: 0.8em; 118 | font-size: 1.2rem; 119 | font-weight: 400; 120 | } 121 | 122 | .donate { 123 | position: relative; 124 | padding: 1em 0; 125 | margin: 1em 0; 126 | &__title { 127 | font-size: 0.9em; 128 | opacity: 0.7; 129 | margin-top: 0; 130 | } 131 | &__list { 132 | display: flex; 133 | align-items: center; 134 | justify-content: center; 135 | a { 136 | margin: 0 0.4em; 137 | } 138 | } 139 | &:before, 140 | &:after { 141 | content: ""; 142 | position: absolute; 143 | width: 263px; 144 | height: 1px; 145 | background: hsl(0, 0%, 77%); 146 | transform: translate(-50%); 147 | opacity: 0.5; 148 | } 149 | &:before { 150 | top: 0; 151 | } 152 | &:after { 153 | bottom: 0; 154 | } 155 | } 156 | 157 | .flattr-logo { 158 | height: 1em; 159 | text-decoration: none; 160 | } 161 | 162 | /* Image 163 | ========================================================================== */ 164 | 165 | .logoImageWelcome { 166 | margin-bottom: 4px; 167 | } 168 | 169 | #logo { 170 | position: absolute; 171 | bottom: 35px; 172 | right: 20px; 173 | /*opacity: 0.5;*/ 174 | pointer-events: none; 175 | } 176 | 177 | /* Form 178 | ========================================================================== */ 179 | 180 | #start-form { 181 | margin-top: 0.25em; 182 | } 183 | 184 | .usernameinput { 185 | height: 30px; 186 | width: 170px; 187 | font-weight: bold; 188 | margin-top: 0.5em; 189 | text-align: center; 190 | &:focus { 191 | outline-color: var(--focus); 192 | outline-width: 4px; 193 | } 194 | } 195 | .welcome-message label:not(.no-countries__content label) { 196 | font-weight: bolder; 197 | margin-top: 16px; 198 | display: flex; 199 | flex-direction: column; 200 | gap: 4px; 201 | align-items: center; 202 | } -------------------------------------------------------------------------------- /src/assets/scss/pages/_zoomed.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * In this file we store styles for the zoomed in map view 3 | */ 4 | 5 | /* ========================================================================== 6 | Main container 7 | ========================================================================== */ 8 | 9 | .infoContainer { 10 | margin-left: 7px; 11 | margin-right: 7px; 12 | width: 99%; 13 | height: 150px; 14 | position: absolute; 15 | top: 7.5%; 16 | z-index: 4; 17 | opacity: 0; 18 | display: flex; 19 | flex-direction: row; 20 | justify-content: flex-end; 21 | gap: 5%; 22 | background-color: var(--backgroundSecondary); 23 | // box-shadow: rgba(0,0,0,.1) 0 2px 12px 0; 24 | backdrop-filter: blur(20px); 25 | } 26 | 27 | 28 | /* ========================================================================== 29 | Selected country info box 30 | ========================================================================== */ 31 | 32 | //Country name 33 | .cnameDiv { 34 | display: block; 35 | margin-right: 5%; 36 | text-align: right; 37 | pointer-events: none; 38 | } 39 | 40 | .cnameContainer { 41 | h1 { 42 | margin-top: 0.27em; 43 | } 44 | 45 | strong { 46 | color: var(--textHeading); 47 | font-size: 1.5em; 48 | margin-top: 0; 49 | } 50 | 51 | .playlist-link{ 52 | pointer-events: all; 53 | margin-top: 5px; 54 | 55 | a{ 56 | display: inline-flex; 57 | align-items: center; 58 | } 59 | 60 | &__img{ 61 | border-radius: 100%; 62 | width: 1.1em; 63 | height: 1.1em; 64 | background: #222; 65 | vertical-align: middle; 66 | margin-right: 0.3em; 67 | } 68 | 69 | .divider { 70 | margin: 0 0.5em; 71 | user-select: none; 72 | opacity: 0.6; 73 | } 74 | } 75 | } 76 | 77 | /* ========================================================================== 78 | Artist info 79 | ========================================================================== */ 80 | 81 | .artistContainer { 82 | //Main container for artist information, recommendations and details 83 | margin-right: 6%; 84 | padding: 8px 24px; 85 | border-radius: 20px; 86 | width: 592px; 87 | display: block; 88 | border: 5px solid var(--borderSecondary); 89 | background: var(--backgroundPrimary); 90 | position: relative; 91 | height: fit-content; 92 | box-shadow: rgba(0,0,0,.1) 0 2px 12px 0; 93 | 94 | h2 { 95 | font-size: 1.15em; 96 | font-weight: bold; 97 | color: inherit; 98 | font-family: $heading-font-stack; 99 | font-weight: 400; 100 | margin-bottom: 0.5rem; 101 | margin-top: 0.75rem; 102 | } 103 | 104 | p { 105 | font-size: 0.9em; 106 | } 107 | 108 | ol#top-artist-list, ul#recom-list { 109 | // reset list styles 110 | list-style: none; 111 | padding: 0; 112 | margin: 0; 113 | display: flex; 114 | flex-wrap: wrap; 115 | text-align: center; 116 | li { 117 | flex-basis: 20%; 118 | } 119 | } 120 | 121 | li { 122 | // reset list item styles 123 | margin: 0; 124 | padding: 0; 125 | display: inline-block; 126 | 127 | } 128 | } 129 | 130 | .detailsDiv { 131 | padding: 0 30px; 132 | padding: 0; 133 | opacity: 0.9; 134 | width: 100%; 135 | vertical-align: top; 136 | position: relative; 137 | 138 | h5 { 139 | color: rgba(55, 55, 55, 0.5); 140 | font-size: 0.9em; 141 | margin-top: 0; 142 | text-transform: lowercase; 143 | } 144 | } 145 | .topartists-desc{ 146 | font-size: 1em; 147 | margin-top: 1em; 148 | margin-bottom: 0.8em; 149 | span:not(.demonym) { 150 | font-family: $text-font-stack; 151 | } 152 | .demonym{ 153 | font-size: 1.15em; 154 | font-weight: bold; 155 | font-family: $heading-font-stack; 156 | font-weight: 400; 157 | } 158 | } 159 | 160 | #top-artist-list-container { 161 | position: relative; 162 | } 163 | 164 | //Artist controls 165 | .artist-control{ 166 | font-size: 24px; 167 | padding: 0; 168 | height: 48px; 169 | width: 48px; 170 | top: 32px; 171 | color: inherit; 172 | cursor: pointer; 173 | transition: 0.1s; 174 | box-shadow: rgba(0,0,0,.1) 0 2px 12px 0; 175 | // button resets 176 | border: none; 177 | background: none; 178 | border-radius: 100%; 179 | background-color: var(--textPrimary); 180 | color: var(--backgroundPrimary); 181 | margin: 0; 182 | appearance: none; 183 | position: absolute; 184 | 185 | &.right{ 186 | right: -44px; 187 | } 188 | 189 | &.left{ 190 | left: -44px; 191 | } 192 | &:hover { 193 | transform: scale(1.1); 194 | filter: brightness(0.92); 195 | } 196 | &:disabled { 197 | display: none; 198 | } 199 | } 200 | 201 | .details-p { 202 | font-size: 0.8em; 203 | margin-bottom: 4px; 204 | word-break: break-word; 205 | strong { 206 | font-family: $heading-font-stack; 207 | font-weight: 400; 208 | } 209 | } 210 | 211 | .summaryText { 212 | display: inline-block; 213 | background: inherit; 214 | overflow-x: hidden; 215 | overflow-y: auto; 216 | width: 100%; 217 | margin-top: 10px; 218 | padding-top: 10px; 219 | border-top: 1px solid rgba(175, 175, 175, 0.5); 220 | 221 | p { 222 | margin-bottom: 0; 223 | margin-top: 1em; 224 | } 225 | 226 | h4 { 227 | font-size: 1.1em; 228 | margin: 0; 229 | } 230 | } 231 | 232 | /* ========================================================================== 233 | Artist images 234 | ========================================================================== */ 235 | 236 | .artist-div { 237 | text-align: center; 238 | padding-top: 8px; 239 | padding-bottom: 8px; 240 | cursor: pointer; 241 | border-radius: 6px; 242 | box-sizing: border-box; 243 | transition: background 0.1s; 244 | background: inherit; 245 | border: none; 246 | margin: 0; 247 | appearance: none; 248 | 249 | &:focus-visible { 250 | outline-offset: 0; 251 | } 252 | 253 | a { 254 | text-decoration: none; 255 | } 256 | } 257 | 258 | .image-div { 259 | position: relative; 260 | height: 96px; 261 | width: 96px; 262 | border: 1px solid var(--border); 263 | border-radius: 2px; 264 | margin-left: auto; 265 | margin-right: auto; 266 | text-align: center; 267 | background: var(--backgroundPrimary); 268 | transition: border-radius 0.1s; 269 | 270 | 271 | &::before { 272 | content: ""; 273 | position: absolute; 274 | top: 0; 275 | left: 0; 276 | width: 100%; 277 | height: 100%; 278 | background-position: center top; 279 | border-radius: 2px; 280 | transition: border-radius 0.1s; 281 | background-size: cover; 282 | background-image: url(https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png); 283 | z-index: 1; 284 | } 285 | } 286 | 287 | .dark .image-div::before { 288 | filter: invert(1) brightness(2); 289 | } 290 | 291 | .overlayNo{ 292 | //position: absolute; 293 | opacity: 0.35; 294 | color: #fff; 295 | text-decoration: none; 296 | //transform: translateY(-50%); 297 | font-size: 5em; 298 | line-height: 86px; 299 | 300 | } 301 | 302 | .artist-div.highlight, .reco-div.highlight { 303 | background: var(--themeColorDark); 304 | color: var(--backgroundPrimary); 305 | .blue_black & { 306 | color: var(--textPrimary); 307 | } 308 | } 309 | 310 | .artist-div.lowlight .image-div { 311 | border-radius: 50%; 312 | &::before { 313 | border-radius: 50%; 314 | } 315 | } 316 | 317 | .artist-div.lowlight .image-div:hover { 318 | border-radius: 30%; 319 | 320 | &::before { 321 | border-radius: 30%; 322 | } 323 | } 324 | 325 | 326 | /* ========================================================================== 327 | Artist tags 328 | ========================================================================== */ 329 | 330 | .taglist { 331 | gap: 4px; 332 | align-items: center; 333 | flex-wrap: wrap; 334 | margin: 0; 335 | padding: 0; 336 | display: flex; 337 | 338 | } 339 | 340 | .tagdiv { 341 | color: inherit; 342 | display: inline-block; 343 | background: inherit; 344 | margin-top: 0.6em; 345 | margin-bottom: 0.3em; 346 | padding: 0.3em; 347 | margin-right: 0.2em; 348 | width: auto; 349 | padding: 4px 12px!important; 350 | border-radius: 48px; 351 | background-color: var(--borderSecondary); 352 | display: inline-flex!important; 353 | align-items: center!important; 354 | text-align: center; 355 | height: fit-content; 356 | 357 | &.usertag { 358 | background-color: var(--themeColorLight); 359 | } 360 | } 361 | 362 | /* ========================================================================== 363 | Recommendations 364 | ========================================================================== */ 365 | 366 | .recoDiv { 367 | margin-bottom: 16px; 368 | padding: 0 30px; 369 | padding: 0; 370 | opacity: 0.9; 371 | vertical-align: top; 372 | width: 100%; 373 | } 374 | 375 | .recLoadingDiv { 376 | width: 100%; 377 | margin-bottom: 1em; 378 | } 379 | 380 | /* ========================================================================== 381 | Close button 382 | ========================================================================== */ 383 | 384 | .close-button { 385 | color: var(--backgroundPrimary); 386 | width: 48px; 387 | height: 48px; 388 | font-size: 1.5rem; 389 | background-color: var(--textPrimary); 390 | padding: 0; 391 | border: 0; 392 | border-radius: 100%; 393 | right: 10px; 394 | top: -20px; 395 | position: absolute; 396 | cursor: pointer; 397 | box-shadow: rgba(0,0,0,.1) 0 2px 12px 0; 398 | &:hover { 399 | transform: scale(1.1); 400 | filter: brightness(0.92); 401 | } 402 | } -------------------------------------------------------------------------------- /src/assets/scss/themes/README.md: -------------------------------------------------------------------------------- 1 | # Theme 2 | 3 | On large sites and applications, it is not unusual to have different themes. There are certainly different ways of dealing with themes but I personally like having them all in a `themes/` folder. 4 | 5 | *Note — This is very project-specific and is likely to be non-existent on many projects.* 6 | 7 | Reference: [Sass Guidelines](http://sass-guidelin.es/) > [Architecture](http://sass-guidelin.es/#architecture) > [Themes folder](http://sass-guidelin.es/#themes-folder) 8 | -------------------------------------------------------------------------------- /src/assets/scss/themes/_default.scss: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------------- 2 | // When having several themes, this file contains everything related to the 3 | // default one. 4 | // ----------------------------------------------------------------------------- 5 | /** FOR THEMES!! **/ 6 | 7 | body { 8 | color-scheme: light; 9 | --backgroundPrimary: #fff; 10 | --backgroundSecondary: #fafafa; 11 | --backgroundTertiary: rgba(180, 225, 225, 0.5); 12 | --welcomeBackground: rgba(255, 200, 200, 0.4); 13 | --welcomeMessageBackground: rgba(255, 255, 255, 0.6); 14 | --welcomeMessageBorder: rgba(255, 255, 255, 1); 15 | --backgroundInput: rgba(249, 249, 249, 0.94); 16 | --buttonBackground: #949494; 17 | --textPrimary: #222; 18 | --textSecondary: rgba(0, 0, 0, 0.8); 19 | --textTertiary: rgba(0, 0, 0, 0.54); 20 | --textHeading: rgba(55, 55, 55, 0.8); 21 | --link: #222; 22 | --border: rgba(0, 0, 0, 0.5); 23 | --borderSecondary: rgba(0, 0, 0, 0.12); 24 | --hover: rgba(0, 0, 0, 0.06); 25 | --highlight: rgba(175, 175, 175, 0.2); 26 | --focus: rgba(174, 1, 126, 1); 27 | --focusSecondary: rgba(174, 1, 126, 0.24); 28 | } 29 | 30 | body.dark { 31 | color-scheme: dark; 32 | --backgroundPrimary: #000; 33 | --backgroundSecondary: rgba(20, 20, 20); 34 | --backgroundTertiary: rgba(0, 0, 0, 0.6); 35 | --backgroundInput: rgba(20, 20, 20, 0.94); 36 | --welcomeBackground: rgba(0,55,55,.5); 37 | --welcomeMessageBackground: rgba(0,0,0,.6); 38 | --welcomeMessageBorder: rgba(0, 0, 0, 1); 39 | --buttonBackground: #202020; 40 | --textPrimary: #fff; 41 | --textSecondary: rgba(255, 255, 255, 0.8); 42 | --textTertiary: rgba(255, 255, 255, 0.54); 43 | --textHeading: rgba(255, 255, 255, 0.8); 44 | --link: #fff; 45 | --border: rgba(255, 255, 255, 0.5); 46 | --borderSecondary: rgba(255, 255, 255, 0.12); 47 | --hover: rgba(255, 255, 255, 0.06); 48 | --highlight: rgba(175, 175, 175, 0.2); 49 | --focus: rgba(174, 1, 126, 1); 50 | --focusSecondary: rgba(174, 1, 126, 0.24); 51 | } 52 | 53 | body.blue_black { 54 | --themeColorLight: #2A075A; 55 | --themeColorDark: #4651C5; 56 | } 57 | 58 | body.green_black { 59 | --themeColorLight: #032F30; 60 | --themeColorDark: #1CB162; 61 | } 62 | 63 | body.pink_black { 64 | --themeColorLight: #4B0627; 65 | --themeColorDark: #C355A4; 66 | } 67 | 68 | body.pink_white { 69 | --themeColorLight: #fcc5c0; 70 | --themeColorDark: #ae017e; 71 | } 72 | 73 | body.green_white { 74 | --themeColorLight: #ccece6; 75 | --themeColorDark: #238b45; 76 | } 77 | 78 | body.red_white { 79 | --themeColorLight: #feb24c; 80 | --themeColorDark: #bd0026; 81 | } 82 | 83 | $themes: ( 84 | blue_black: ("#03020D", "#140E1F", "#2A075A", "#321C78", "#362688", "#3E3CA7", "#4651C5", "#5371F4"), 85 | green_black: ("#03020D", "#08120C", "#032F30", "#064137", "#0E6745", "#158C54", "#1CB162", "#28EA78"), 86 | pink_black: ("#03020D", "#1F0310", "#4B0627", "#5C1138", "#7E285C", "#A13F80", "#C355A4", "#F778DA"), 87 | pink_white: ("#feebe2", "#feebe2", "#fcc5c0", "#fa9fb5", "#f768a1", "#dd3497", "#ae017e", "#7a0177"), 88 | green_white: ("#ece2f0", "#F6EBFA", "#ccece6", "#99d8c9", "#66c2a4", "#41ae76", "#238b45", "#006d2c"), 89 | red_white: ("#F0F0D8", "#F0F0D8", "#feb24c", "#fd8d3c", "#fc4e2a", "#e31a1c", "#bd0026", "#800026"), 90 | ); 91 | 92 | 93 | body { 94 | background: var(--backgroundPrimary); 95 | color: var(--textPrimary); 96 | fill: var(--textPrimary); 97 | } 98 | 99 | svg[tabindex="-1"] { 100 | outline: none; 101 | } 102 | 103 | /* 104 | * Link theme 105 | */ 106 | 107 | a, a:visited, a:active { 108 | color: var(--link); 109 | } 110 | 111 | /* 112 | * HEADING THEMES 113 | */ 114 | 115 | h1 { 116 | color: var(--textSecondary) 117 | } 118 | 119 | h5 { 120 | color: var(--textTertiary) 121 | } 122 | 123 | /* 124 | * Keyboard key styling 125 | */ 126 | kbd { 127 | font-size: 0.9rem; 128 | padding: 2px 4px; 129 | font-size: 0.85em; 130 | color: #fff; 131 | background-color: #333; 132 | border: solid 1px #666; 133 | border-radius: 3px; 134 | box-shadow: 0 1px 0 rgba(255, 255, 255, 0.2), 0 0 0 2px #222 inset; 135 | white-space: nowrap; 136 | kbd+& { 137 | margin-left: 4px; 138 | } 139 | } 140 | 141 | body.dark kbd { 142 | color: #222; 143 | background-color: #fcfcfc; 144 | border: solid 1px #ccc; 145 | box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2), 0 0 0 2px #ffffff inset; 146 | } 147 | 148 | /* 149 | * Tooltip 150 | */ 151 | 152 | .tooltip { 153 | background: var(--textPrimary); 154 | color: var(--backgroundPrimary) 155 | } 156 | 157 | /* 158 | * Map legend 159 | */ 160 | 161 | .legend rect { 162 | stroke: var(--backgroundPrimary); 163 | } 164 | 165 | /* 166 | * Progress bar 167 | */ 168 | 169 | body.blue_black #progress-text, 170 | body.pink_black #progress-text, 171 | body.green_black #progress-text { 172 | background: rgba(0, 0, 0, 0.5); 173 | } 174 | 175 | body.blue_black #progress-text:after, 176 | body.pink_black #progress-text:after, 177 | body.green_black #progress-text:after { 178 | border-color: rgba(0, 0, 0, 0); 179 | border-left-color: rgba(0, 0, 0, 0.5); 180 | } 181 | 182 | .dark #countryCount { 183 | -webkit-box-shadow: 4px 4px 9px 0px rgba(0, 0, 0, 0.75); 184 | -moz-box-shadow: 4px 4px 9px 0px rgba(0, 0, 0, 0.75); 185 | box-shadow: 4px 4px 9px 0px rgba(0, 0, 0, 0.75); 186 | } 187 | 188 | 189 | /* 190 | * flattr logo 191 | */ 192 | 193 | .dark .flattr-logo { 194 | fill: white; 195 | } 196 | 197 | /** 198 | * bmc logo 199 | */ 200 | 201 | .dark .bmc-button { 202 | background: none; 203 | border: none; 204 | } 205 | 206 | .dark .screenshot-overlay { 207 | &__close { 208 | background: black; 209 | color: white; 210 | border-color: white; 211 | } 212 | } 213 | 214 | #viewport-box-indicator { 215 | // No animation needed 216 | } -------------------------------------------------------------------------------- /src/assets/scss/utils/README.md: -------------------------------------------------------------------------------- 1 | # Utilities 2 | 3 | The `utils/` folder gathers all Sass tools and helpers used across the project. Every global variable, function, mixin and placeholder should be put in here. 4 | 5 | The rule of thumb for this folder is that it should not output a single line of CSS when compiled on its own. These are nothing but Sass helpers. 6 | 7 | Reference: [Sass Guidelines](http://sass-guidelin.es/) > [Architecture](http://sass-guidelin.es/#architecture) > [Utilities folder](http://sass-guidelin.es/#utils-folder) 8 | -------------------------------------------------------------------------------- /src/assets/scss/utils/_functions.scss: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------------- 2 | // This file contains all application-wide Sass functions. 3 | // ----------------------------------------------------------------------------- 4 | 5 | /// Native `url(..)` function wrapper 6 | /// @param {String} $base - base URL for the asset 7 | /// @param {String} $type - asset type folder (e.g. `fonts/`) 8 | /// @param {String} $path - asset path 9 | /// @return {Url} 10 | @function asset($base, $type, $path) { 11 | @return url($base + $type + $path); 12 | } 13 | 14 | /// Returns URL to an image based on its path 15 | /// @param {String} $path - image path 16 | /// @param {String} $base [$base-url] - base URL 17 | /// @return {Url} 18 | /// @require $base-url 19 | @function image($path, $base: $base-url) { 20 | @return asset($base, 'images/', $path); 21 | } 22 | 23 | /// Returns URL to a font based on its path 24 | /// @param {String} $path - font path 25 | /// @param {String} $base [$base-url] - base URL 26 | /// @return {Url} 27 | /// @require $base-url 28 | @function font($path, $base: $base-url) { 29 | @return asset($base, 'fonts/', $path); 30 | } 31 | -------------------------------------------------------------------------------- /src/assets/scss/utils/_mixins.scss: -------------------------------------------------------------------------------- 1 | // ----------------------------------------------------------------------------- 2 | // This file contains all application-wide Sass mixins. 3 | // ----------------------------------------------------------------------------- 4 | 5 | /// Event wrapper 6 | /// @author Harry Roberts 7 | /// @param {Bool} $self [false] - Whether or not to include current selector 8 | /// @link https://twitter.com/csswizardry/status/478938530342006784 Original tweet from Harry Roberts 9 | @mixin on-event($self: false) { 10 | @if $self { 11 | &, 12 | &:hover, 13 | &:active, 14 | &:focus { 15 | @content; 16 | } 17 | } @else { 18 | &:hover, 19 | &:active, 20 | &:focus { 21 | @content; 22 | } 23 | } 24 | } 25 | 26 | /// Make a context based selector a little more friendly 27 | /// @author Hugo Giraudel 28 | /// @param {String} $context 29 | @mixin when-inside($context) { 30 | #{$context} & { 31 | @content; 32 | } 33 | } 34 | 35 | /// Responsive manager 36 | /// @param {String} $breakpoint - Breakpoint 37 | /// @requires $breakpoints 38 | /// @link http://sass-guidelin.es/#breakpoint-manager Sass Guidelines - Breakpoint Manager 39 | @mixin respond-to($breakpoint) { 40 | $query: map-get($breakpoints, $breakpoint); 41 | 42 | @if not $query { 43 | @error 'No value found for `#{$breakpoint}`. Please make sure it is defined in `$breakpoints` map.'; 44 | } 45 | 46 | @media #{if(type-of($query) == 'string', unquote($query), inspect($query))} { 47 | @content; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/assets/scss/utils/_variables.scss: -------------------------------------------------------------------------------- 1 | /* ========================================================================== 2 | Font stacks 3 | ========================================================================== */ 4 | /// Regular font family 5 | /// @type List 6 | $text-font-stack: "Didact Gothic", "Futura Medium", "Century Gothic", "Apple Gothic", "Avant Garde", sans-serif !default; 7 | 8 | /// Code (monospace) font family 9 | /// @type List 10 | $code-font-stack: "Courier New", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", "Monaco", monospace !default; 11 | 12 | /// Main heading font family 13 | $heading-font-stack: "Patua One", "Roboto Slab", serif !default; 14 | 15 | /* ========================================================================== 16 | Pre-defined colors 17 | ========================================================================== */ 18 | 19 | /// Copy text color 20 | /// @type Color 21 | $text-color: #222 !default; 22 | 23 | /// Main heading color 24 | /// @type Color 25 | 26 | $heading-color: var(--textHeading) !default; 27 | 28 | /// Main brand color 29 | /// @type Color 30 | $brand-color: #E50050 !default; 31 | 32 | /// Light grey 33 | /// @type Color 34 | $light-grey: #EDEDED !default; 35 | 36 | /// Medium grey 37 | /// @type Color 38 | $mid-grey: #999 !default; 39 | 40 | /// Dark grey 41 | /// @type Color 42 | $dark-grey: #444 !default; 43 | 44 | /* ========================================================================== 45 | Breakpoints and responsive stuff 46 | ========================================================================== */ 47 | 48 | /// Container's maximum width 49 | /// Below that is 100% 50 | /// @type Length 51 | $max-width: 1200px !default; 52 | 53 | /// Breakpoints map 54 | /// @prop {String} keys - Keys are identifiers mapped to a given length 55 | /// @prop {Map} values - Values are actual breakpoints expressed in pixels 56 | /// @see {mixin} respond-to 57 | $breakpoints: ("small": (min-width: 320px), "medium": (min-width: 768px), "large": (min-width: 1024px)) !default; 58 | 59 | /* ========================================================================== 60 | Base url 61 | ========================================================================== */ 62 | 63 | /// Relative or absolute URL where all assets are served from 64 | /// @type String 65 | /// @example scss - When using a CDN 66 | /// $base-url: 'http://cdn.example.com/assets/'; 67 | $base-url: "/assets/" !default; -------------------------------------------------------------------------------- /src/assets/scss/vendor/README.md: -------------------------------------------------------------------------------- 1 | # Vendors 2 | 3 | Most projects will have a `vendors/` folder containing all the CSS files from external libraries and frameworks – Normalize, Bootstrap, jQueryUI, FancyCarouselSliderjQueryPowered, and so on. Putting those aside in the same folder is a good way to say “Hey, this is not from me, not my code, not my responsibility”. 4 | 5 | If you have to override a section of any vendor, I recommend you have an 8th folder called `vendors-extensions/` in which you may have files named exactly after the vendors they overwrite. For instance, `vendors-extensions/_bootstrap.scss` is a file containing all CSS rules intended to re-declare some of Bootstrap’s default CSS. This is to avoid editing the vendor files themselves, which is generally not a good idea. 6 | 7 | Reference: [Sass Guidelines](http://sass-guidelin.es/) > [Architecture](http://sass-guidelin.es/#architecture) > [Vendors folder](http://sass-guidelin.es/#vendors-folder) 8 | -------------------------------------------------------------------------------- /src/assets/scss/vendor/_bmc.scss: -------------------------------------------------------------------------------- 1 | .bmc-button img { 2 | width: 27px !important; 3 | margin-bottom: 1px !important; 4 | box-shadow: none !important; 5 | border: none !important; 6 | vertical-align: middle !important; 7 | } 8 | 9 | .bmc-button { 10 | text-decoration: none !important; 11 | font-family: 'Cookie', cursive !important; 12 | display: inline-flex; 13 | border-radius: 4px; 14 | align-items: center; 15 | } -------------------------------------------------------------------------------- /src/assets/scss/vendor/_normalize.scss: -------------------------------------------------------------------------------- 1 | /*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /** 4 | * 1. Set default font family to sans-serif. 5 | * 2. Prevent iOS and IE text size adjust after device orientation change, 6 | * without disabling user zoom. 7 | */ 8 | 9 | html { 10 | font-family: sans-serif; /* 1 */ 11 | -ms-text-size-adjust: 100%; /* 2 */ 12 | -webkit-text-size-adjust: 100%; /* 2 */ 13 | } 14 | 15 | /** 16 | * Remove default margin. 17 | */ 18 | 19 | body { 20 | margin: 0; 21 | } 22 | 23 | /* HTML5 display definitions 24 | ========================================================================== */ 25 | 26 | /** 27 | * Correct `block` display not defined for any HTML5 element in IE 8/9. 28 | * Correct `block` display not defined for `details` or `summary` in IE 10/11 29 | * and Firefox. 30 | * Correct `block` display not defined for `main` in IE 11. 31 | */ 32 | 33 | article, 34 | aside, 35 | details, 36 | figcaption, 37 | figure, 38 | footer, 39 | header, 40 | hgroup, 41 | main, 42 | menu, 43 | nav, 44 | section, 45 | summary { 46 | display: block; 47 | } 48 | 49 | /** 50 | * 1. Correct `inline-block` display not defined in IE 8/9. 51 | * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. 52 | */ 53 | 54 | audio, 55 | canvas, 56 | progress, 57 | video { 58 | display: inline-block; /* 1 */ 59 | vertical-align: baseline; /* 2 */ 60 | } 61 | 62 | /** 63 | * Prevent modern browsers from displaying `audio` without controls. 64 | * Remove excess height in iOS 5 devices. 65 | */ 66 | 67 | audio:not([controls]) { 68 | display: none; 69 | height: 0; 70 | } 71 | 72 | /** 73 | * Address `[hidden]` styling not present in IE 8/9/10. 74 | * Hide the `template` element in IE 8/9/10/11, Safari, and Firefox < 22. 75 | */ 76 | 77 | [hidden], 78 | template { 79 | display: none; 80 | } 81 | 82 | /* Links 83 | ========================================================================== */ 84 | 85 | /** 86 | * Remove the gray background color from active links in IE 10. 87 | */ 88 | 89 | a { 90 | background-color: transparent; 91 | } 92 | 93 | /** 94 | * Improve readability of focused elements when they are also in an 95 | * active/hover state. 96 | */ 97 | 98 | a:active, 99 | a:hover { 100 | outline: 0; 101 | } 102 | 103 | /* Text-level semantics 104 | ========================================================================== */ 105 | 106 | /** 107 | * Address styling not present in IE 8/9/10/11, Safari, and Chrome. 108 | */ 109 | 110 | abbr[title] { 111 | border-bottom: 1px dotted; 112 | } 113 | 114 | /** 115 | * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. 116 | */ 117 | 118 | b, 119 | strong { 120 | font-weight: bold; 121 | } 122 | 123 | /** 124 | * Address styling not present in Safari and Chrome. 125 | */ 126 | 127 | dfn { 128 | font-style: italic; 129 | } 130 | 131 | /** 132 | * Address variable `h1` font-size and margin within `section` and `article` 133 | * contexts in Firefox 4+, Safari, and Chrome. 134 | */ 135 | 136 | h1 { 137 | font-size: 2em; 138 | margin: 0.67em 0; 139 | } 140 | 141 | /** 142 | * Address styling not present in IE 8/9. 143 | */ 144 | 145 | mark { 146 | background: #ff0; 147 | color: #000; 148 | } 149 | 150 | /** 151 | * Address inconsistent and variable font size in all browsers. 152 | */ 153 | 154 | small { 155 | font-size: 80%; 156 | } 157 | 158 | /** 159 | * Prevent `sub` and `sup` affecting `line-height` in all browsers. 160 | */ 161 | 162 | sub, 163 | sup { 164 | font-size: 75%; 165 | line-height: 0; 166 | position: relative; 167 | vertical-align: baseline; 168 | } 169 | 170 | sup { 171 | top: -0.5em; 172 | } 173 | 174 | sub { 175 | bottom: -0.25em; 176 | } 177 | 178 | /* Embedded content 179 | ========================================================================== */ 180 | 181 | /** 182 | * Remove border when inside `a` element in IE 8/9/10. 183 | */ 184 | 185 | img { 186 | border: 0; 187 | } 188 | 189 | /** 190 | * Correct overflow not hidden in IE 9/10/11. 191 | */ 192 | 193 | svg:not(:root) { 194 | overflow: hidden; 195 | } 196 | 197 | /* Grouping content 198 | ========================================================================== */ 199 | 200 | /** 201 | * Address margin not present in IE 8/9 and Safari. 202 | */ 203 | 204 | figure { 205 | margin: 1em 40px; 206 | } 207 | 208 | /** 209 | * Address differences between Firefox and other browsers. 210 | */ 211 | 212 | hr { 213 | box-sizing: content-box; 214 | height: 0; 215 | } 216 | 217 | /** 218 | * Contain overflow in all browsers. 219 | */ 220 | 221 | pre { 222 | overflow: auto; 223 | } 224 | 225 | /** 226 | * Address odd `em`-unit font size rendering in all browsers. 227 | */ 228 | 229 | code, 230 | kbd, 231 | pre, 232 | samp { 233 | font-family: monospace, monospace; 234 | font-size: 1em; 235 | } 236 | 237 | /* Forms 238 | ========================================================================== */ 239 | 240 | /** 241 | * Known limitation: by default, Chrome and Safari on OS X allow very limited 242 | * styling of `select`, unless a `border` property is set. 243 | */ 244 | 245 | /** 246 | * 1. Correct color not being inherited. 247 | * Known issue: affects color of disabled elements. 248 | * 2. Correct font properties not being inherited. 249 | * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. 250 | */ 251 | 252 | button, 253 | input, 254 | optgroup, 255 | select, 256 | textarea { 257 | color: inherit; /* 1 */ 258 | font: inherit; /* 2 */ 259 | margin: 0; /* 3 */ 260 | } 261 | 262 | /** 263 | * Address `overflow` set to `hidden` in IE 8/9/10/11. 264 | */ 265 | 266 | button { 267 | overflow: visible; 268 | } 269 | 270 | /** 271 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 272 | * All other form control elements do not inherit `text-transform` values. 273 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 274 | * Correct `select` style inheritance in Firefox. 275 | */ 276 | 277 | button, 278 | select { 279 | text-transform: none; 280 | } 281 | 282 | /** 283 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 284 | * and `video` controls. 285 | * 2. Correct inability to style clickable `input` types in iOS. 286 | * 3. Improve usability and consistency of cursor style between image-type 287 | * `input` and others. 288 | */ 289 | 290 | button, 291 | html input[type="button"], /* 1 */ 292 | input[type="reset"], 293 | input[type="submit"] { 294 | -webkit-appearance: button; /* 2 */ 295 | cursor: pointer; /* 3 */ 296 | } 297 | 298 | /** 299 | * Re-set default cursor for disabled elements. 300 | */ 301 | 302 | button[disabled], 303 | html input[disabled] { 304 | cursor: default; 305 | } 306 | 307 | /** 308 | * Remove inner padding and border in Firefox 4+. 309 | */ 310 | 311 | button::-moz-focus-inner, 312 | input::-moz-focus-inner { 313 | border: 0; 314 | padding: 0; 315 | } 316 | 317 | /** 318 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in 319 | * the UA stylesheet. 320 | */ 321 | 322 | input { 323 | line-height: normal; 324 | } 325 | 326 | /** 327 | * It's recommended that you don't attempt to style these elements. 328 | * Firefox's implementation doesn't respect box-sizing, padding, or width. 329 | * 330 | * 1. Address box sizing set to `content-box` in IE 8/9/10. 331 | * 2. Remove excess padding in IE 8/9/10. 332 | */ 333 | 334 | input[type="checkbox"], 335 | input[type="radio"] { 336 | box-sizing: border-box; /* 1 */ 337 | padding: 0; /* 2 */ 338 | } 339 | 340 | /** 341 | * Fix the cursor style for Chrome's increment/decrement buttons. For certain 342 | * `font-size` values of the `input`, it causes the cursor style of the 343 | * decrement button to change from `default` to `text`. 344 | */ 345 | 346 | input[type="number"]::-webkit-inner-spin-button, 347 | input[type="number"]::-webkit-outer-spin-button { 348 | height: auto; 349 | } 350 | 351 | /** 352 | * 1. Address `appearance` set to `searchfield` in Safari and Chrome. 353 | * 2. Address `box-sizing` set to `border-box` in Safari and Chrome. 354 | */ 355 | 356 | input[type="search"] { 357 | -webkit-appearance: textfield; /* 1 */ 358 | box-sizing: content-box; /* 2 */ 359 | } 360 | 361 | /** 362 | * Remove inner padding and search cancel button in Safari and Chrome on OS X. 363 | * Safari (but not Chrome) clips the cancel button when the search input has 364 | * padding (and `textfield` appearance). 365 | */ 366 | 367 | input[type="search"]::-webkit-search-cancel-button, 368 | input[type="search"]::-webkit-search-decoration { 369 | -webkit-appearance: none; 370 | } 371 | 372 | /** 373 | * Define consistent border, margin, and padding. 374 | */ 375 | 376 | fieldset { 377 | border: 1px solid #c0c0c0; 378 | margin: 0 2px; 379 | padding: 0.35em 0.625em 0.75em; 380 | } 381 | 382 | /** 383 | * 1. Correct `color` not being inherited in IE 8/9/10/11. 384 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 385 | */ 386 | 387 | legend { 388 | border: 0; /* 1 */ 389 | padding: 0; /* 2 */ 390 | } 391 | 392 | /** 393 | * Remove default vertical scrollbar in IE 8/9/10/11. 394 | */ 395 | 396 | textarea { 397 | overflow: auto; 398 | } 399 | 400 | /** 401 | * Don't inherit the `font-weight` (applied by a rule above). 402 | * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. 403 | */ 404 | 405 | optgroup { 406 | font-weight: bold; 407 | } 408 | 409 | /* Tables 410 | ========================================================================== */ 411 | 412 | /** 413 | * Remove most spacing between table cells. 414 | */ 415 | 416 | table { 417 | border-collapse: collapse; 418 | border-spacing: 0; 419 | } 420 | 421 | td, 422 | th { 423 | padding: 0; 424 | } 425 | --------------------------------------------------------------------------------