├── .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 | # 
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 |
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 |
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 |
--------------------------------------------------------------------------------