Repository: web-scrobbler/web-scrobbler
Pull request #4399 (2024-01-06 10:29:26)

Fix Planet Radio connector
**Describe the changes you made**
Closes: #4397 

**Additional context**
<!-- Add any other context or screenshots here. -->



Patch:
diff --git a/.nvmrc b/.nvmrc
index d5a15960..8b0beab1 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1,1 +1,1 @@
-20.10.0
+20.11.0
diff --git a/.tx/config b/.tx/config
index d197a136..69187370 100644
--- a/.tx/config
+++ b/.tx/config
@@ -1,5 +1,5 @@
 [main]
-host = https://www.transifex.com
+host = https://app.transifex.com
 
 [o:web-scrobbler:p:web-scrobbler:r:messages-json--master]
 file_filter   = src/_locales/<lang>/messages.json
diff --git a/README.md b/README.md
index 4141608c..28847f22 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,7 @@ Web Scrobbler helps online music listeners to scrobble their playback history.
 -   [Libre.fm][LibreFm]
 -   [ListenBrainz][ListenBrainz]
 -   [Maloja][Maloja]
+-   [Pleroma][Pleroma]
 
 ## Installation
 
@@ -147,6 +148,7 @@ Licensed under the [MIT License][License].
 [LibreFm]: https://libre.fm/
 [ListenBrainz]: https://listenbrainz.org/
 [Maloja]: https://github.com/krateng/maloja
+[Pleroma]: https://pleroma.social/
 
 <!-- Wiki pages -->
 
diff --git a/package-lock.json b/package-lock.json
index e2dd8016..3892baee 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,19 +1,18 @@
 {
   "name": "web-scrobbler",
-  "version": "3.4.0",
+  "version": "3.5.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "web-scrobbler",
-      "version": "3.4.0",
+      "version": "3.5.0",
       "license": "MIT",
       "dependencies": {
-        "@suid/icons-material": "0.6.11",
         "@web-scrobbler/metadata-filter": "3.1.0",
         "blueimp-md5": "2.19.0",
         "stylelint": "15.11.0",
-        "vite": "4.5.1",
+        "vite": "5.0.11",
         "webextension-polyfill": "0.10.0"
       },
       "devDependencies": {
@@ -22,10 +21,10 @@
         "@types/fs-extra": "11.0.3",
         "@types/imagemin": "8.0.5",
         "@types/imagemin-jpegtran": "5.0.4",
-        "@types/node": "20.10.6",
-        "@types/react": "18.2.46",
+        "@types/node": "20.11.0",
+        "@types/react": "18.2.47",
         "@types/webextension-polyfill": "0.10.7",
-        "@typescript-eslint/eslint-plugin": "6.16.0",
+        "@typescript-eslint/eslint-plugin": "6.18.1",
         "@web-scrobbler/eslint-config": "3.0.0",
         "@web-scrobbler/stylelint-config": "1.0.1",
         "archiver": "6.0.1",
@@ -34,7 +33,7 @@
         "esbuild": "0.19.11",
         "eslint": "8.56.0",
         "eslint-plugin-react": "7.33.2",
-        "eslint-plugin-solid": "0.13.0",
+        "eslint-plugin-solid": "0.13.1",
         "eslint-plugin-tsdoc": "0.2.17",
         "fs-extra": "11.1.1",
         "glob": "10.3.10",
@@ -46,14 +45,14 @@
         "remark": "15.0.1",
         "remark-cli": "12.0.0",
         "remark-preset-lint-recommended": "6.1.3",
-        "sass": "1.69.6",
-        "solid-js": "1.8.7",
+        "sass": "1.69.7",
+        "solid-js": "1.8.11",
         "tsx": "4.7.0",
         "typescript": "5.3.3",
         "vite-plugin-conditional-compiler": "0.2.1",
         "vite-plugin-solid": "2.8.0",
-        "vite-plugin-static-copy": "0.17.1",
-        "vitest": "0.34.6"
+        "vite-plugin-static-copy": "1.0.0",
+        "vitest": "1.1.3"
       },
       "engines": {
         "node": ">=18.0.0"
@@ -843,7 +842,6 @@
       "cpu": [
         "ppc64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "aix"
@@ -859,7 +857,6 @@
       "cpu": [
         "arm"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "android"
@@ -875,7 +872,6 @@
       "cpu": [
         "arm64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "android"
@@ -891,7 +887,6 @@
       "cpu": [
         "x64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "android"
@@ -907,7 +902,6 @@
       "cpu": [
         "arm64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "darwin"
@@ -923,7 +917,6 @@
       "cpu": [
         "x64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "darwin"
@@ -939,7 +932,6 @@
       "cpu": [
         "arm64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "freebsd"
@@ -955,7 +947,6 @@
       "cpu": [
         "x64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "freebsd"
@@ -971,7 +962,6 @@
       "cpu": [
         "arm"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "linux"
@@ -987,7 +977,6 @@
       "cpu": [
         "arm64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "linux"
@@ -1003,7 +992,6 @@
       "cpu": [
         "ia32"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "linux"
@@ -1019,7 +1007,6 @@
       "cpu": [
         "loong64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "linux"
@@ -1035,7 +1022,6 @@
       "cpu": [
         "mips64el"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "linux"
@@ -1051,7 +1037,6 @@
       "cpu": [
         "ppc64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "linux"
@@ -1067,7 +1052,6 @@
       "cpu": [
         "riscv64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "linux"
@@ -1083,7 +1067,6 @@
       "cpu": [
         "s390x"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "linux"
@@ -1099,7 +1082,6 @@
       "cpu": [
         "x64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "linux"
@@ -1115,7 +1097,6 @@
       "cpu": [
         "x64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "netbsd"
@@ -1131,7 +1112,6 @@
       "cpu": [
         "x64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "openbsd"
@@ -1147,7 +1127,6 @@
       "cpu": [
         "x64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "sunos"
@@ -1163,7 +1142,6 @@
       "cpu": [
         "arm64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "win32"
@@ -1179,7 +1157,6 @@
       "cpu": [
         "ia32"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "win32"
@@ -1195,7 +1172,6 @@
       "cpu": [
         "x64"
       ],
-      "dev": true,
       "optional": true,
       "os": [
         "win32"
@@ -1590,14 +1566,161 @@
         "node": ">=14"
       }
     },
-    "node_modules/@popperjs/core": {
-      "version": "2.11.8",
-      "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
-      "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/popperjs"
-      }
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.9.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.4.tgz",
+      "integrity": "sha512-ub/SN3yWqIv5CWiAZPHVS1DloyZsJbtXmX4HxUTIpS0BHm9pW5iYBo2mIZi+hE3AeiTzHz33blwSnhdUo+9NpA==",
+      "cpu": [
+        "arm"
+      ],
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.9.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.9.4.tgz",
+      "integrity": "sha512-ehcBrOR5XTl0W0t2WxfTyHCR/3Cq2jfb+I4W+Ch8Y9b5G+vbAecVv0Fx/J1QKktOrgUYsIKxWAKgIpvw56IFNA==",
+      "cpu": [
+        "arm64"
+      ],
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.9.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.9.4.tgz",
+      "integrity": "sha512-1fzh1lWExwSTWy8vJPnNbNM02WZDS8AW3McEOb7wW+nPChLKf3WG2aG7fhaUmfX5FKw9zhsF5+MBwArGyNM7NA==",
+      "cpu": [
+        "arm64"
+      ],
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.9.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.9.4.tgz",
+      "integrity": "sha512-Gc6cukkF38RcYQ6uPdiXi70JB0f29CwcQ7+r4QpfNpQFVHXRd0DfWFidoGxjSx1DwOETM97JPz1RXL5ISSB0pA==",
+      "cpu": [
+        "x64"
+      ],
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.9.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.9.4.tgz",
+      "integrity": "sha512-g21RTeFzoTl8GxosHbnQZ0/JkuFIB13C3T7Y0HtKzOXmoHhewLbVTFBQZu+z5m9STH6FZ7L/oPgU4Nm5ErN2fw==",
+      "cpu": [
+        "arm"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.9.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.4.tgz",
+      "integrity": "sha512-TVYVWD/SYwWzGGnbfTkrNpdE4HON46orgMNHCivlXmlsSGQOx/OHHYiQcMIOx38/GWgwr/po2LBn7wypkWw/Mg==",
+      "cpu": [
+        "arm64"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.9.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.9.4.tgz",
+      "integrity": "sha512-XcKvuendwizYYhFxpvQ3xVpzje2HHImzg33wL9zvxtj77HvPStbSGI9czrdbfrf8DGMcNNReH9pVZv8qejAQ5A==",
+      "cpu": [
+        "arm64"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.9.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.9.4.tgz",
+      "integrity": "sha512-LFHS/8Q+I9YA0yVETyjonMJ3UA+DczeBd/MqNEzsGSTdNvSJa1OJZcSH8GiXLvcizgp9AlHs2walqRcqzjOi3A==",
+      "cpu": [
+        "riscv64"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.9.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.4.tgz",
+      "integrity": "sha512-dIYgo+j1+yfy81i0YVU5KnQrIJZE8ERomx17ReU4GREjGtDW4X+nvkBak2xAUpyqLs4eleDSj3RrV72fQos7zw==",
+      "cpu": [
+        "x64"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.9.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.4.tgz",
+      "integrity": "sha512-RoaYxjdHQ5TPjaPrLsfKqR3pakMr3JGqZ+jZM0zP2IkDtsGa4CqYaWSfQmZVgFUCgLrTnzX+cnHS3nfl+kB6ZQ==",
+      "cpu": [
+        "x64"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.9.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.9.4.tgz",
+      "integrity": "sha512-T8Q3XHV+Jjf5e49B4EAaLKV74BbX7/qYBRQ8Wop/+TyyU0k+vSjiLVSHNWdVd1goMjZcbhDmYZUYW5RFqkBNHQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.9.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.9.4.tgz",
+      "integrity": "sha512-z+JQ7JirDUHAsMecVydnBPWLwJjbppU+7LZjffGf+Jvrxq+dVjIE7By163Sc9DKc3ADSU50qPVw0KonBS+a+HQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.9.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.4.tgz",
+      "integrity": "sha512-LfdGXCV9rdEify1oxlN9eamvDSjv9md9ZVMAbNHA87xqIfFCxImxan9qZ8+Un54iK2nnqPlbnSi4R54ONtbWBw==",
+      "cpu": [
+        "x64"
+      ],
+      "optional": true,
+      "os": [
+        "win32"
+      ]
     },
     "node_modules/@sinclair/typebox": {
       "version": "0.27.8",
@@ -1614,101 +1737,6 @@
         "node": ">=4"
       }
     },
-    "node_modules/@suid/base": {
-      "version": "0.8.5",
-      "resolved": "https://registry.npmjs.org/@suid/base/-/base-0.8.5.tgz",
-      "integrity": "sha512-34Nu44f71ALOb2GEaiyhzAO6cA2EdjsySLBGaI9cV4vuHZpCXDrYVH0DjqNGD+ryEzURUf/tGu+zSREjJS1KiA==",
-      "dependencies": {
-        "@popperjs/core": "^2.11.8",
-        "@suid/css": "0.4.0",
-        "@suid/system": "0.10.5",
-        "@suid/types": "0.5.3",
-        "@suid/utils": "0.8.0",
-        "clsx": "^2.0.0"
-      },
-      "peerDependencies": {
-        "solid-js": "^1.7.12"
-      }
-    },
-    "node_modules/@suid/css": {
-      "version": "0.4.0",
-      "resolved": "https://registry.npmjs.org/@suid/css/-/css-0.4.0.tgz",
-      "integrity": "sha512-yzHAlf1CVi7n0SvUrMgs8Z49UiS9669+td1w1frekhRQuRbkXhHoyJkvovaDVJlWRmCPA8Q0f1OTr0uDCUg9mQ=="
-    },
-    "node_modules/@suid/icons-material": {
-      "version": "0.6.11",
-      "resolved": "https://registry.npmjs.org/@suid/icons-material/-/icons-material-0.6.11.tgz",
-      "integrity": "sha512-IAhhT5GlLvLISNcROM/pQ2sxV3RvvvA7tzGwC3KwqyItYRuAaY78uNAeT07XQDXOF/jibekhk3+UD4CJcY7tYQ==",
-      "dependencies": {
-        "@suid/material": "0.15.1"
-      },
-      "peerDependencies": {
-        "solid-js": "^1.7.12"
-      }
-    },
-    "node_modules/@suid/material": {
-      "version": "0.15.1",
-      "resolved": "https://registry.npmjs.org/@suid/material/-/material-0.15.1.tgz",
-      "integrity": "sha512-pHX4qmqqsaIcPmTEdJVurqwwbxoiRXpOzXdE+Q6PZ63xqugBn6iGXFvw5AgNTOw9CMCRhfzVwhn5V2sqASLu+Q==",
-      "dependencies": {
-        "@suid/base": "0.8.5",
-        "@suid/css": "0.4.0",
-        "@suid/system": "0.10.5",
-        "@suid/types": "0.5.3",
-        "@suid/utils": "0.8.0",
-        "clsx": "^2.0.0"
-      },
-      "peerDependencies": {
-        "solid-js": "^1.7.12"
-      }
-    },
-    "node_modules/@suid/styled-engine": {
-      "version": "0.6.1",
-      "resolved": "https://registry.npmjs.org/@suid/styled-engine/-/styled-engine-0.6.1.tgz",
-      "integrity": "sha512-2pcqqz3ZS1mPkjaSiQqLgZs6SWBnyH/np8xZHWpdvNwavkZtsiDM9tuFnfi10JtSSAUvbnfyA0QUWRRrw56XjA==",
-      "dependencies": {
-        "@suid/css": "0.4.0",
-        "@suid/utils": "0.8.0"
-      },
-      "peerDependencies": {
-        "solid-js": "^1.7.12"
-      }
-    },
-    "node_modules/@suid/system": {
-      "version": "0.10.5",
-      "resolved": "https://registry.npmjs.org/@suid/system/-/system-0.10.5.tgz",
-      "integrity": "sha512-56Q2MpIrOUoDn+OeN/QuO4Y+u7fhJ9IPJFpXXCG36n7KAzspQnFxTUZM+SdxPdSYuVwjeKfwMrXqhUkvTdrOzw==",
-      "dependencies": {
-        "@suid/css": "0.4.0",
-        "@suid/styled-engine": "0.6.1",
-        "@suid/types": "0.5.3",
-        "@suid/utils": "0.8.0",
-        "clsx": "^2.0.0",
-        "csstype": "^3.1.2"
-      },
-      "peerDependencies": {
-        "solid-js": "^1.7.12"
-      }
-    },
-    "node_modules/@suid/types": {
-      "version": "0.5.3",
-      "resolved": "https://registry.npmjs.org/@suid/types/-/types-0.5.3.tgz",
-      "integrity": "sha512-lawR7jUYuvCjkqNeF+ptCAWCRukh3ocUBz3v+zdP/EzS6OHCmJiAzoYlMUgrqxkEcI3JfSpd6u3atZ8/pkDdpQ==",
-      "peerDependencies": {
-        "solid-js": "^1.7.12"
-      }
-    },
-    "node_modules/@suid/utils": {
-      "version": "0.8.0",
-      "resolved": "https://registry.npmjs.org/@suid/utils/-/utils-0.8.0.tgz",
-      "integrity": "sha512-4HT5Ou6EWZviXQEm3DoX2BJZ3vASEmfKikXMWwSA5TaqKhU1dk7bSVaM0Ol8kch4Eyvl145KYmifKl9PD2cXAg==",
-      "dependencies": {
-        "@suid/types": "0.5.3"
-      },
-      "peerDependencies": {
-        "solid-js": "^1.7.12"
-      }
-    },
     "node_modules/@tokenizer/token": {
       "version": "0.3.0",
       "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
@@ -1771,21 +1799,6 @@
       "integrity": "sha512-dJ9yRry9Olt5GAWlgCtE5dK9d/Dfhn/V7hna86eEO2Pn76+E8Y0S0n61iEUEGhWXXgtKtHxtZLVNwL8X+vLHzg==",
       "dev": true
     },
-    "node_modules/@types/chai": {
-      "version": "4.3.6",
-      "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.6.tgz",
-      "integrity": "sha512-VOVRLM1mBxIRxydiViqPcKn6MIxZytrbMpd6RJLIWKxUNr3zux8no0Oc7kJx0WAPIitgZ0gkrDS+btlqQpubpw==",
-      "dev": true
-    },
-    "node_modules/@types/chai-subset": {
-      "version": "1.3.3",
-      "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.3.tgz",
-      "integrity": "sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==",
-      "dev": true,
-      "dependencies": {
-        "@types/chai": "*"
-      }
-    },
     "node_modules/@types/concat-stream": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/@types/concat-stream/-/concat-stream-2.0.0.tgz",
@@ -1805,10 +1818,9 @@
       }
     },
     "node_modules/@types/estree": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.2.tgz",
-      "integrity": "sha512-VeiPZ9MMwXjO32/Xu7+OwflfmeoRwkE/qzndw42gGtgJwZopBnzy2gD//NN1+go1mADzkDcqf/KnFRSjTJ8xJA==",
-      "dev": true
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
+      "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw=="
     },
     "node_modules/@types/estree-jsx": {
       "version": "1.0.1",
@@ -1898,9 +1910,9 @@
       "dev": true
     },
     "node_modules/@types/node": {
-      "version": "20.10.6",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.6.tgz",
-      "integrity": "sha512-Vac8H+NlRNNlAmDfGUP7b5h/KA+AtWIzuXy0E6OyP8f1tCLYAtPvKRRDJjAPqhpCb0t6U2j7/xqAuLEebW2kiw==",
+      "version": "20.11.0",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.0.tgz",
+      "integrity": "sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==",
       "devOptional": true,
       "dependencies": {
         "undici-types": "~5.26.4"
@@ -1918,9 +1930,9 @@
       "dev": true
     },
     "node_modules/@types/react": {
-      "version": "18.2.46",
-      "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.46.tgz",
-      "integrity": "sha512-nNCvVBcZlvX4NU1nRRNV/mFl1nNRuTuslAJglQsq+8ldXe5Xv0Wd2f7WTE3jOxhLH2BFfiZGC6GCp+kHQbgG+w==",
+      "version": "18.2.47",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.47.tgz",
+      "integrity": "sha512-xquNkkOirwyCgoClNk85BjP+aqnIS+ckAJ8i37gAbDs14jfW/J23f2GItAf33oiUPQnqNMALiFeoM9Y5mbjpVQ==",
       "dev": true,
       "dependencies": {
         "@types/prop-types": "*",
@@ -1974,16 +1986,16 @@
       "dev": true
     },
     "node_modules/@typescript-eslint/eslint-plugin": {
-      "version": "6.16.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.16.0.tgz",
-      "integrity": "sha512-O5f7Kv5o4dLWQtPX4ywPPa+v9G+1q1x8mz0Kr0pXUtKsevo+gIJHLkGc8RxaZWtP8RrhwhSNIWThnW42K9/0rQ==",
+      "version": "6.18.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.18.1.tgz",
+      "integrity": "sha512-nISDRYnnIpk7VCFrGcu1rnZfM1Dh9LRHnfgdkjcbi/l7g16VYRri3TjXi9Ir4lOZSw5N/gnV/3H7jIPQ8Q4daA==",
       "dev": true,
       "dependencies": {
         "@eslint-community/regexpp": "^4.5.1",
-        "@typescript-eslint/scope-manager": "6.16.0",
-        "@typescript-eslint/type-utils": "6.16.0",
-        "@typescript-eslint/utils": "6.16.0",
-        "@typescript-eslint/visitor-keys": "6.16.0",
+        "@typescript-eslint/scope-manager": "6.18.1",
+        "@typescript-eslint/type-utils": "6.18.1",
+        "@typescript-eslint/utils": "6.18.1",
+        "@typescript-eslint/visitor-keys": "6.18.1",
         "debug": "^4.3.4",
         "graphemer": "^1.4.0",
         "ignore": "^5.2.4",
@@ -2009,13 +2021,13 @@
       }
     },
     "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
-      "version": "6.16.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.16.0.tgz",
-      "integrity": "sha512-0N7Y9DSPdaBQ3sqSCwlrm9zJwkpOuc6HYm7LpzLAPqBL7dmzAUimr4M29dMkOP/tEwvOCC/Cxo//yOfJD3HUiw==",
+      "version": "6.18.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.18.1.tgz",
+      "integrity": "sha512-BgdBwXPFmZzaZUuw6wKiHKIovms97a7eTImjkXCZE04TGHysG+0hDQPmygyvgtkoB/aOQwSM/nWv3LzrOIQOBw==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "6.16.0",
-        "@typescript-eslint/visitor-keys": "6.16.0"
+        "@typescript-eslint/types": "6.18.1",
+        "@typescript-eslint/visitor-keys": "6.18.1"
       },
       "engines": {
         "node": "^16.0.0 || >=18.0.0"
@@ -2026,9 +2038,9 @@
       }
     },
     "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": {
-      "version": "6.16.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.16.0.tgz",
-      "integrity": "sha512-hvDFpLEvTJoHutVl87+MG/c5C8I6LOgEx05zExTSJDEVU7hhR3jhV8M5zuggbdFCw98+HhZWPHZeKS97kS3JoQ==",
+      "version": "6.18.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.18.1.tgz",
+      "integrity": "sha512-4TuMAe+tc5oA7wwfqMtB0Y5OrREPF1GeJBAjqwgZh1lEMH5PJQgWgHGfYufVB51LtjD+peZylmeyxUXPfENLCw==",
       "dev": true,
       "engines": {
         "node": "^16.0.0 || >=18.0.0"
@@ -2039,12 +2051,12 @@
       }
     },
     "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
-      "version": "6.16.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.16.0.tgz",
-      "integrity": "sha512-QSFQLruk7fhs91a/Ep/LqRdbJCZ1Rq03rqBdKT5Ky17Sz8zRLUksqIe9DW0pKtg/Z35/ztbLQ6qpOCN6rOC11A==",
+      "version": "6.18.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.18.1.tgz",
+      "integrity": "sha512-/kvt0C5lRqGoCfsbmm7/CwMqoSkY3zzHLIjdhHZQW3VFrnz7ATecOHR7nb7V+xn4286MBxfnQfQhAmCI0u+bJA==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "6.16.0",
+        "@typescript-eslint/types": "6.18.1",
         "eslint-visitor-keys": "^3.4.1"
       },
       "engines": {
@@ -2103,13 +2115,13 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils": {
-      "version": "6.16.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.16.0.tgz",
-      "integrity": "sha512-ThmrEOcARmOnoyQfYkHw/DX2SEYBalVECmoldVuH6qagKROp/jMnfXpAU/pAIWub9c4YTxga+XwgAkoA0pxfmg==",
+      "version": "6.18.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.18.1.tgz",
+      "integrity": "sha512-wyOSKhuzHeU/5pcRDP2G2Ndci+4g653V43gXTpt4nbyoIOAASkGDA9JIAgbQCdCkcr1MvpSYWzxTz0olCn8+/Q==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/typescript-estree": "6.16.0",
-        "@typescript-eslint/utils": "6.16.0",
+        "@typescript-eslint/typescript-estree": "6.18.1",
+        "@typescript-eslint/utils": "6.18.1",
         "debug": "^4.3.4",
         "ts-api-utils": "^1.0.1"
       },
@@ -2130,9 +2142,9 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": {
-      "version": "6.16.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.16.0.tgz",
-      "integrity": "sha512-hvDFpLEvTJoHutVl87+MG/c5C8I6LOgEx05zExTSJDEVU7hhR3jhV8M5zuggbdFCw98+HhZWPHZeKS97kS3JoQ==",
+      "version": "6.18.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.18.1.tgz",
+      "integrity": "sha512-4TuMAe+tc5oA7wwfqMtB0Y5OrREPF1GeJBAjqwgZh1lEMH5PJQgWgHGfYufVB51LtjD+peZylmeyxUXPfENLCw==",
       "dev": true,
       "engines": {
         "node": "^16.0.0 || >=18.0.0"
@@ -2143,13 +2155,13 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": {
-      "version": "6.16.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.16.0.tgz",
-      "integrity": "sha512-VTWZuixh/vr7nih6CfrdpmFNLEnoVBF1skfjdyGnNwXOH1SLeHItGdZDHhhAIzd3ACazyY2Fg76zuzOVTaknGA==",
+      "version": "6.18.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.18.1.tgz",
+      "integrity": "sha512-fv9B94UAhywPRhUeeV/v+3SBDvcPiLxRZJw/xZeeGgRLQZ6rLMG+8krrJUyIf6s1ecWTzlsbp0rlw7n9sjufHA==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "6.16.0",
-        "@typescript-eslint/visitor-keys": "6.16.0",
+        "@typescript-eslint/types": "6.18.1",
+        "@typescript-eslint/visitor-keys": "6.18.1",
         "debug": "^4.3.4",
         "globby": "^11.1.0",
         "is-glob": "^4.0.3",
@@ -2171,12 +2183,12 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": {
-      "version": "6.16.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.16.0.tgz",
-      "integrity": "sha512-QSFQLruk7fhs91a/Ep/LqRdbJCZ1Rq03rqBdKT5Ky17Sz8zRLUksqIe9DW0pKtg/Z35/ztbLQ6qpOCN6rOC11A==",
+      "version": "6.18.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.18.1.tgz",
+      "integrity": "sha512-/kvt0C5lRqGoCfsbmm7/CwMqoSkY3zzHLIjdhHZQW3VFrnz7ATecOHR7nb7V+xn4286MBxfnQfQhAmCI0u+bJA==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "6.16.0",
+        "@typescript-eslint/types": "6.18.1",
         "eslint-visitor-keys": "^3.4.1"
       },
       "engines": {
@@ -2254,17 +2266,17 @@
       }
     },
     "node_modules/@typescript-eslint/utils": {
-      "version": "6.16.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.16.0.tgz",
-      "integrity": "sha512-T83QPKrBm6n//q9mv7oiSvy/Xq/7Hyw9SzSEhMHJwznEmQayfBM87+oAlkNAMEO7/MjIwKyOHgBJbxB0s7gx2A==",
+      "version": "6.18.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.18.1.tgz",
+      "integrity": "sha512-zZmTuVZvD1wpoceHvoQpOiewmWu3uP9FuTWo8vqpy2ffsmfCE8mklRPi+vmnIYAIk9t/4kOThri2QCDgor+OpQ==",
       "dev": true,
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.4.0",
         "@types/json-schema": "^7.0.12",
         "@types/semver": "^7.5.0",
-        "@typescript-eslint/scope-manager": "6.16.0",
-        "@typescript-eslint/types": "6.16.0",
-        "@typescript-eslint/typescript-estree": "6.16.0",
+        "@typescript-eslint/scope-manager": "6.18.1",
+        "@typescript-eslint/types": "6.18.1",
+        "@typescript-eslint/typescript-estree": "6.18.1",
         "semver": "^7.5.4"
       },
       "engines": {
@@ -2279,13 +2291,13 @@
       }
     },
     "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": {
-      "version": "6.16.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.16.0.tgz",
-      "integrity": "sha512-0N7Y9DSPdaBQ3sqSCwlrm9zJwkpOuc6HYm7LpzLAPqBL7dmzAUimr4M29dMkOP/tEwvOCC/Cxo//yOfJD3HUiw==",
+      "version": "6.18.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.18.1.tgz",
+      "integrity": "sha512-BgdBwXPFmZzaZUuw6wKiHKIovms97a7eTImjkXCZE04TGHysG+0hDQPmygyvgtkoB/aOQwSM/nWv3LzrOIQOBw==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "6.16.0",
-        "@typescript-eslint/visitor-keys": "6.16.0"
+        "@typescript-eslint/types": "6.18.1",
+        "@typescript-eslint/visitor-keys": "6.18.1"
       },
       "engines": {
         "node": "^16.0.0 || >=18.0.0"
@@ -2296,9 +2308,9 @@
       }
     },
     "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": {
-      "version": "6.16.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.16.0.tgz",
-      "integrity": "sha512-hvDFpLEvTJoHutVl87+MG/c5C8I6LOgEx05zExTSJDEVU7hhR3jhV8M5zuggbdFCw98+HhZWPHZeKS97kS3JoQ==",
+      "version": "6.18.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.18.1.tgz",
+      "integrity": "sha512-4TuMAe+tc5oA7wwfqMtB0Y5OrREPF1GeJBAjqwgZh1lEMH5PJQgWgHGfYufVB51LtjD+peZylmeyxUXPfENLCw==",
       "dev": true,
       "engines": {
         "node": "^16.0.0 || >=18.0.0"
@@ -2309,13 +2321,13 @@
       }
     },
     "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
-      "version": "6.16.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.16.0.tgz",
-      "integrity": "sha512-VTWZuixh/vr7nih6CfrdpmFNLEnoVBF1skfjdyGnNwXOH1SLeHItGdZDHhhAIzd3ACazyY2Fg76zuzOVTaknGA==",
+      "version": "6.18.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.18.1.tgz",
+      "integrity": "sha512-fv9B94UAhywPRhUeeV/v+3SBDvcPiLxRZJw/xZeeGgRLQZ6rLMG+8krrJUyIf6s1ecWTzlsbp0rlw7n9sjufHA==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "6.16.0",
-        "@typescript-eslint/visitor-keys": "6.16.0",
+        "@typescript-eslint/types": "6.18.1",
+        "@typescript-eslint/visitor-keys": "6.18.1",
         "debug": "^4.3.4",
         "globby": "^11.1.0",
         "is-glob": "^4.0.3",
@@ -2337,12 +2349,12 @@
       }
     },
     "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": {
-      "version": "6.16.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.16.0.tgz",
-      "integrity": "sha512-QSFQLruk7fhs91a/Ep/LqRdbJCZ1Rq03rqBdKT5Ky17Sz8zRLUksqIe9DW0pKtg/Z35/ztbLQ6qpOCN6rOC11A==",
+      "version": "6.18.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.18.1.tgz",
+      "integrity": "sha512-/kvt0C5lRqGoCfsbmm7/CwMqoSkY3zzHLIjdhHZQW3VFrnz7ATecOHR7nb7V+xn4286MBxfnQfQhAmCI0u+bJA==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "6.16.0",
+        "@typescript-eslint/types": "6.18.1",
         "eslint-visitor-keys": "^3.4.1"
       },
       "engines": {
@@ -2402,13 +2414,13 @@
       "dev": true
     },
     "node_modules/@vitest/expect": {
-      "version": "0.34.6",
-      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.34.6.tgz",
-      "integrity": "sha512-QUzKpUQRc1qC7qdGo7rMK3AkETI7w18gTCUrsNnyjjJKYiuUB9+TQK3QnR1unhCnWRC0AbKv2omLGQDF/mIjOw==",
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.1.3.tgz",
+      "integrity": "sha512-MnJqsKc1Ko04lksF9XoRJza0bGGwTtqfbyrsYv5on4rcEkdo+QgUdITenBQBUltKzdxW7K3rWh+nXRULwsdaVg==",
       "dev": true,
       "dependencies": {
-        "@vitest/spy": "0.34.6",
-        "@vitest/utils": "0.34.6",
+        "@vitest/spy": "1.1.3",
+        "@vitest/utils": "1.1.3",
         "chai": "^4.3.10"
       },
       "funding": {
@@ -2416,13 +2428,13 @@
       }
     },
     "node_modules/@vitest/runner": {
-      "version": "0.34.6",
-      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-0.34.6.tgz",
-      "integrity": "sha512-1CUQgtJSLF47NnhN+F9X2ycxUP0kLHQ/JWvNHbeBfwW8CzEGgeskzNnHDyv1ieKTltuR6sdIHV+nmR6kPxQqzQ==",
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.1.3.tgz",
+      "integrity": "sha512-Va2XbWMnhSdDEh/OFxyUltgQuuDRxnarK1hW5QNN4URpQrqq6jtt8cfww/pQQ4i0LjoYxh/3bYWvDFlR9tU73g==",
       "dev": true,
       "dependencies": {
-        "@vitest/utils": "0.34.6",
-        "p-limit": "^4.0.0",
+        "@vitest/utils": "1.1.3",
+        "p-limit": "^5.0.0",
         "pathe": "^1.1.1"
       },
       "funding": {
@@ -2430,15 +2442,15 @@
       }
     },
     "node_modules/@vitest/runner/node_modules/p-limit": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz",
-      "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==",
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz",
+      "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==",
       "dev": true,
       "dependencies": {
         "yocto-queue": "^1.0.0"
       },
       "engines": {
-        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+        "node": ">=18"
       },
       "funding": {
         "url": "https://github.com/sponsors/sindresorhus"
@@ -2457,40 +2469,41 @@
       }
     },
     "node_modules/@vitest/snapshot": {
-      "version": "0.34.6",
-      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-0.34.6.tgz",
-      "integrity": "sha512-B3OZqYn6k4VaN011D+ve+AA4whM4QkcwcrwaKwAbyyvS/NB1hCWjFIBQxAQQSQir9/RtyAAGuq+4RJmbn2dH4w==",
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.1.3.tgz",
+      "integrity": "sha512-U0r8pRXsLAdxSVAyGNcqOU2H3Z4Y2dAAGGelL50O0QRMdi1WWeYHdrH/QWpN1e8juWfVKsb8B+pyJwTC+4Gy9w==",
       "dev": true,
       "dependencies": {
-        "magic-string": "^0.30.1",
+        "magic-string": "^0.30.5",
         "pathe": "^1.1.1",
-        "pretty-format": "^29.5.0"
+        "pretty-format": "^29.7.0"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       }
     },
     "node_modules/@vitest/spy": {
-      "version": "0.34.6",
-      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.34.6.tgz",
-      "integrity": "sha512-xaCvneSaeBw/cz8ySmF7ZwGvL0lBjfvqc1LpQ/vcdHEvpLn3Ff1vAvjw+CoGn0802l++5L/pxb7whwcWAw+DUQ==",
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.1.3.tgz",
+      "integrity": "sha512-Ec0qWyGS5LhATFQtldvChPTAHv08yHIOZfiNcjwRQbFPHpkih0md9KAbs7TfeIfL7OFKoe7B/6ukBTqByubXkQ==",
       "dev": true,
       "dependencies": {
-        "tinyspy": "^2.1.1"
+        "tinyspy": "^2.2.0"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       }
     },
     "node_modules/@vitest/utils": {
-      "version": "0.34.6",
-      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.34.6.tgz",
-      "integrity": "sha512-IG5aDD8S6zlvloDsnzHw0Ut5xczlF+kv2BOTo+iXfPr54Yhi5qbVOgGB1hZaVq4iJ4C/MZ2J0y15IlsV/ZcI0A==",
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.1.3.tgz",
+      "integrity": "sha512-Dyt3UMcdElTll2H75vhxfpZu03uFpXRCHxWnzcrFjZxT1kTbq8ALUYIeBgGolo1gldVdI0YSlQRacsqxTwNqwg==",
       "dev": true,
       "dependencies": {
-        "diff-sequences": "^29.4.3",
-        "loupe": "^2.3.6",
-        "pretty-format": "^29.5.0"
+        "diff-sequences": "^29.6.3",
+        "estree-walker": "^3.0.3",
+        "loupe": "^2.3.7",
+        "pretty-format": "^29.7.0"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
@@ -2566,9 +2579,9 @@
       }
     },
     "node_modules/acorn-walk": {
-      "version": "8.2.0",
-      "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz",
-      "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==",
+      "version": "8.3.1",
+      "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.1.tgz",
+      "integrity": "sha512-TgUZgYvqZprrl7YldZNoa9OciCAyZR+Ejm9eXzKCmjsF5IKp/wgQ7Z/ZpjpGTIUPwrHQIcYeI8qDh4PsEwxMbw==",
       "dev": true,
       "engines": {
         "node": ">=0.4.0"
@@ -3714,9 +3727,9 @@
       }
     },
     "node_modules/chai": {
-      "version": "4.3.10",
-      "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz",
-      "integrity": "sha512-0UXG04VuVbruMUYbJ6JctvH0YnC/4q3/AkT18q4NaITo91CUm0liMS9VqzT9vZhVQ/1eqPanMWjBM+Juhfb/9g==",
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.0.tgz",
+      "integrity": "sha512-x9cHNq1uvkCdU+5xTkNh5WtgD4e4yDFCsp9jVc7N7qVeKeftv3gO/ZrviX5d+3ZfxdYnZXZYujjRInu1RogU6A==",
       "dev": true,
       "dependencies": {
         "assertion-error": "^1.1.0",
@@ -3829,14 +3842,6 @@
         "mimic-response": "^1.0.0"
       }
     },
-    "node_modules/clsx": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz",
-      "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==",
-      "engines": {
-        "node": ">=6"
-      }
-    },
     "node_modules/color-convert": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4105,7 +4110,8 @@
     "node_modules/csstype": {
       "version": "3.1.2",
       "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
-      "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
+      "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==",
+      "dev": true
     },
     "node_modules/currently-unhandled": {
       "version": "0.4.1",
@@ -4742,7 +4748,6 @@
       "version": "0.19.11",
       "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.11.tgz",
       "integrity": "sha512-HJ96Hev2hX/6i5cDVwcqiJBBtuo9+FeIJOtZ9W1kA5M6AMJRHUZlpYZ1/SbEwtO0ioNAW8rUooVpC/WehY2SfA==",
-      "dev": true,
       "hasInstallScript": true,
       "bin": {
         "esbuild": "bin/esbuild"
@@ -4928,14 +4933,13 @@
       }
     },
     "node_modules/eslint-plugin-solid": {
-      "version": "0.13.0",
-      "resolved": "https://registry.npmjs.org/eslint-plugin-solid/-/eslint-plugin-solid-0.13.0.tgz",
-      "integrity": "sha512-Sutd+DxEGu9+Z9ITtHKXRAClxVe1a6C1XQZSuN8iBsMy0IAVEc6Tca1UYgc7tD2ZrRRjZKB9mohBOaZl5NJLgg==",
+      "version": "0.13.1",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-solid/-/eslint-plugin-solid-0.13.1.tgz",
+      "integrity": "sha512-PdNrAylFzeh/SbnLc2pQ432l+bXFGzXj/qNqkh5QNVZCoWIdSs0CJA2D7hqW0DloztwUrzkVZCDWFWc3iRAm/Q==",
       "dev": true,
       "dependencies": {
         "@typescript-eslint/utils": "^6.4.0",
         "is-html": "^2.0.0",
-        "jsx-ast-utils": "^3.3.3",
         "kebab-case": "^1.0.2",
         "known-css-properties": "^0.24.0",
         "style-to-object": "^0.3.0"
@@ -5047,6 +5051,15 @@
         "node": ">=4.0"
       }
     },
+    "node_modules/estree-walker": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+      "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+      "dev": true,
+      "dependencies": {
+        "@types/estree": "^1.0.0"
+      }
+    },
     "node_modules/esutils": {
       "version": "2.0.3",
       "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -7255,10 +7268,14 @@
       }
     },
     "node_modules/local-pkg": {
-      "version": "0.4.3",
-      "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.3.tgz",
-      "integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==",
+      "version": "0.5.0",
+      "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz",
+      "integrity": "sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==",
       "dev": true,
+      "dependencies": {
+        "mlly": "^1.4.2",
+        "pkg-types": "^1.0.3"
+      },
       "engines": {
         "node": ">=14"
       },
@@ -7361,12 +7378,12 @@
       "dev": true
     },
     "node_modules/loupe": {
-      "version": "2.3.6",
-      "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz",
-      "integrity": "sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==",
+      "version": "2.3.7",
+      "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
+      "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==",
       "dev": true,
       "dependencies": {
-        "get-func-name": "^2.0.0"
+        "get-func-name": "^2.0.1"
       }
     },
     "node_modules/lowercase-keys": {
@@ -7406,9 +7423,9 @@
       }
     },
     "node_modules/magic-string": {
-      "version": "0.30.3",
-      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.3.tgz",
-      "integrity": "sha512-B7xGbll2fG/VjP+SWg4sX3JynwIU0mjoTc6MPpKNuIvftk6u6vqhDnk1R80b8C2GBR6ywqy+1DcKBrevBg+bmw==",
+      "version": "0.30.5",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz",
+      "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==",
       "dev": true,
       "dependencies": {
         "@jridgewell/sourcemap-codec": "^1.4.15"
@@ -8241,9 +8258,9 @@
       "dev": true
     },
     "node_modules/nanoid": {
-      "version": "3.3.6",
-      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
-      "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
+      "version": "3.3.7",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
+      "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
       "funding": [
         {
           "type": "github",
@@ -9008,9 +9025,9 @@
       "dev": true
     },
     "node_modules/postcss": {
-      "version": "8.4.31",
-      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
-      "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+      "version": "8.4.33",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.33.tgz",
+      "integrity": "sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==",
       "funding": [
         {
           "type": "opencollective",
@@ -9026,7 +9043,7 @@
         }
       ],
       "dependencies": {
-        "nanoid": "^3.3.6",
+        "nanoid": "^3.3.7",
         "picocolors": "^1.0.0",
         "source-map-js": "^1.0.2"
       },
@@ -10942,6 +10959,9 @@
       "version": "3.29.3",
       "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.3.tgz",
       "integrity": "sha512-T7du6Hum8jOkSWetjRgbwpM6Sy0nECYrYRSmZjayFcOddtKJWU4d17AC3HNUk7HRuqy4p+G7aEZclSHytqUmEg==",
+      "dev": true,
+      "optional": true,
+      "peer": true,
       "bin": {
         "rollup": "dist/bin/rollup"
       },
@@ -11040,9 +11060,9 @@
       }
     },
     "node_modules/sass": {
-      "version": "1.69.6",
-      "resolved": "https://registry.npmjs.org/sass/-/sass-1.69.6.tgz",
-      "integrity": "sha512-qbRr3k9JGHWXCvZU77SD2OTwUlC+gNT+61JOLcmLm+XqH4h/5D+p4IIsxvpkB89S9AwJOyb5+rWNpIucaFxSFQ==",
+      "version": "1.69.7",
+      "resolved": "https://registry.npmjs.org/sass/-/sass-1.69.7.tgz",
+      "integrity": "sha512-rzj2soDeZ8wtE2egyLXgOOHQvaC2iosZrkF6v3EUG+tBwEvhqUCzm0VP3k9gHF9LXbSrRhT5SksoI56Iw8NPnQ==",
       "devOptional": true,
       "dependencies": {
         "chokidar": ">=3.0.0 <4.0.0",
@@ -11125,11 +11145,24 @@
       }
     },
     "node_modules/seroval": {
-      "version": "0.15.1",
-      "resolved": "https://registry.npmjs.org/seroval/-/seroval-0.15.1.tgz",
-      "integrity": "sha512-OPVtf0qmeC7RW+ScVX+7aOS+xoIM7pWcZ0jOWg2aTZigCydgRB04adfteBRbecZnnrO1WuGQ+C3tLeBBzX2zSQ==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.0.3.tgz",
+      "integrity": "sha512-v5Psun3O2OdehTS/BpTC9EezHxyQRM4lqtvOdbngEq8qOZUECqG5XaoFNZgBQrJKXZFgcyZtMH6Dv7G96pKSSQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/seroval-plugins": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.0.4.tgz",
+      "integrity": "sha512-DQ2IK6oQVvy8k+c2V5x5YCtUa/GGGsUwUBNN9UqohrZ0rWdUapBFpNMYP1bCyRHoxOJjdKGl+dieacFIpU/i1A==",
+      "dev": true,
       "engines": {
         "node": ">=10"
+      },
+      "peerDependencies": {
+        "seroval": "^1.0"
       }
     },
     "node_modules/set-blocking": {
@@ -11284,12 +11317,14 @@
       }
     },
     "node_modules/solid-js": {
-      "version": "1.8.7",
-      "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.8.7.tgz",
-      "integrity": "sha512-9dzrSVieh2zj3SnJ02II6xZkonR6c+j/91b7XZUNcC6xSaldlqjjGh98F1fk5cRJ8ZTkzqF5fPIWDxEOs6QZXA==",
+      "version": "1.8.11",
+      "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.8.11.tgz",
+      "integrity": "sha512-WdwmER+TwBJiN4rVQTVBxocg+9pKlOs41KzPYntrC86xO5sek8TzBYozPEZPL1IRWDouf2lMrvSbIs3CanlPvQ==",
+      "dev": true,
       "dependencies": {
         "csstype": "^3.1.0",
-        "seroval": "^0.15.1"
+        "seroval": "^1.0.3",
+        "seroval-plugins": "^1.0.3"
       }
     },
     "node_modules/solid-refresh": {
@@ -11451,9 +11486,9 @@
       "dev": true
     },
     "node_modules/std-env": {
-      "version": "3.4.3",
-      "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.4.3.tgz",
-      "integrity": "sha512-f9aPhy8fYBuMN+sNfakZV18U39PbalgjXG3lLB9WkaYTxijru61wb57V9wxxNthXM5Sd88ETBWi29qLAsHO52Q==",
+      "version": "3.7.0",
+      "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz",
+      "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==",
       "dev": true
     },
     "node_modules/streamx": {
@@ -12395,18 +12430,18 @@
       "dev": true
     },
     "node_modules/tinypool": {
-      "version": "0.7.0",
-      "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.7.0.tgz",
-      "integrity": "sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww==",
+      "version": "0.8.1",
+      "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.1.tgz",
+      "integrity": "sha512-zBTCK0cCgRROxvs9c0CGK838sPkeokNGdQVUUwHAbynHFlmyJYj825f/oRs528HaIJ97lo0pLIlDUzwN+IorWg==",
       "dev": true,
       "engines": {
         "node": ">=14.0.0"
       }
     },
     "node_modules/tinyspy": {
-      "version": "2.1.1",
-      "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.1.1.tgz",
-      "integrity": "sha512-XPJL2uSzcOyBMky6OFrusqWlzfFrXtE0hPuMgW8A2HmaqrPo4ZQHRN/V0QXN3FSjKxpsbRrFc5LI7KOwBsT1/w==",
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.0.tgz",
+      "integrity": "sha512-d2eda04AN/cPOR89F7Xv5bK/jrQEhmcLFe6HFldoeO9AJtps+fqEnh486vnT/8y4bw38pSyxDcTCAq+Ks2aJTg==",
       "dev": true,
       "engines": {
         "node": ">=14.0.0"
@@ -12662,9 +12697,9 @@
       }
     },
     "node_modules/ufo": {
-      "version": "1.3.1",
-      "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.3.1.tgz",
-      "integrity": "sha512-uY/99gMLIOlJPwATcMVYfqDSxUR9//AUcgZMzwfSTJPDKzA1S8mX4VLqa+fiAtveraQUBCz4FFcwVZBGbwBXIw==",
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.3.2.tgz",
+      "integrity": "sha512-o+ORpgGwaYQXgqGDwd+hkS4PuZ3QnmqMMxRuajK/a38L6fTpcE5GPIfrf+L/KemFzfUpeUQc1rRS1iDBozvnFA==",
       "dev": true
     },
     "node_modules/unbox-primitive": {
@@ -13552,28 +13587,28 @@
       }
     },
     "node_modules/vite": {
-      "version": "4.5.1",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.1.tgz",
-      "integrity": "sha512-AXXFaAJ8yebyqzoNB9fu2pHoo/nWX+xZlaRwoeYUxEqBO+Zj4msE5G+BhGBll9lYEKv9Hfks52PAF2X7qDYXQA==",
+      "version": "5.0.11",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.11.tgz",
+      "integrity": "sha512-XBMnDjZcNAw/G1gEiskiM1v6yzM4GE5aMGvhWTlHAYYhxb7S3/V1s3m2LDHa8Vh6yIWYYB0iJwsEaS523c4oYA==",
       "dependencies": {
-        "esbuild": "^0.18.10",
-        "postcss": "^8.4.27",
-        "rollup": "^3.27.1"
+        "esbuild": "^0.19.3",
+        "postcss": "^8.4.32",
+        "rollup": "^4.2.0"
       },
       "bin": {
         "vite": "bin/vite.js"
       },
       "engines": {
-        "node": "^14.18.0 || >=16.0.0"
+        "node": "^18.0.0 || >=20.0.0"
       },
       "funding": {
         "url": "https://github.com/vitejs/vite?sponsor=1"
       },
       "optionalDependencies": {
-        "fsevents": "~2.3.2"
+        "fsevents": "~2.3.3"
       },
       "peerDependencies": {
-        "@types/node": ">= 14",
+        "@types/node": "^18.0.0 || >=20.0.0",
         "less": "*",
         "lightningcss": "^1.21.0",
         "sass": "*",
@@ -13606,23 +13641,22 @@
       }
     },
     "node_modules/vite-node": {
-      "version": "0.34.6",
-      "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-0.34.6.tgz",
-      "integrity": "sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA==",
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.1.3.tgz",
+      "integrity": "sha512-BLSO72YAkIUuNrOx+8uznYICJfTEbvBAmWClY3hpath5+h1mbPS5OMn42lrTxXuyCazVyZoDkSRnju78GiVCqA==",
       "dev": true,
       "dependencies": {
         "cac": "^6.7.14",
         "debug": "^4.3.4",
-        "mlly": "^1.4.0",
         "pathe": "^1.1.1",
         "picocolors": "^1.0.0",
-        "vite": "^3.0.0 || ^4.0.0 || ^5.0.0-0"
+        "vite": "^5.0.0"
       },
       "bin": {
         "vite-node": "vite-node.mjs"
       },
       "engines": {
-        "node": ">=v14.18.0"
+        "node": "^18.0.0 || >=20.0.0"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
@@ -13657,9 +13691,9 @@
       }
     },
     "node_modules/vite-plugin-static-copy": {
-      "version": "0.17.1",
-      "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-0.17.1.tgz",
-      "integrity": "sha512-9h3iaVs0bqnqZOM5YHJXGHqdC5VAVlTZ2ARYsuNpzhEJUHmFqXY7dAK4ZFpjEQ4WLFKcaN8yWbczr81n01U4sQ==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-1.0.0.tgz",
+      "integrity": "sha512-kMlrB3WDtC5GzFedNIPkpjnOAr8M11PfWOiUaONrUZ3AqogTsOmIhTt6w7Fh311wl8pN81ld7sfuOEogFJ9N8A==",
       "dev": true,
       "dependencies": {
         "chokidar": "^3.5.3",
@@ -13668,376 +13702,41 @@
         "picocolors": "^1.0.0"
       },
       "engines": {
-        "node": "^14.18.0 || >=16.0.0"
+        "node": "^18.0.0 || >=20.0.0"
       },
       "peerDependencies": {
-        "vite": "^3.0.0 || ^4.0.0 || ^5.0.0"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/android-arm": {
-      "version": "0.18.20",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz",
-      "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==",
-      "cpu": [
-        "arm"
-      ],
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=12"
+        "vite": "^5.0.0"
       }
     },
-    "node_modules/vite/node_modules/@esbuild/android-arm64": {
-      "version": "0.18.20",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz",
-      "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "android"
-      ],
+    "node_modules/vite/node_modules/rollup": {
+      "version": "4.9.4",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.9.4.tgz",
+      "integrity": "sha512-2ztU7pY/lrQyXSCnnoU4ICjT/tCG9cdH3/G25ERqE3Lst6vl2BCM5hL2Nw+sslAvAf+ccKsAq1SkKQALyqhR7g==",
+      "dependencies": {
+        "@types/estree": "1.0.5"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
       "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/android-x64": {
-      "version": "0.18.20",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz",
-      "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/darwin-arm64": {
-      "version": "0.18.20",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz",
-      "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/darwin-x64": {
-      "version": "0.18.20",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz",
-      "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
-      "version": "0.18.20",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz",
-      "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/freebsd-x64": {
-      "version": "0.18.20",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz",
-      "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-arm": {
-      "version": "0.18.20",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz",
-      "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==",
-      "cpu": [
-        "arm"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-arm64": {
-      "version": "0.18.20",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz",
-      "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-ia32": {
-      "version": "0.18.20",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz",
-      "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==",
-      "cpu": [
-        "ia32"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-loong64": {
-      "version": "0.18.20",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz",
-      "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==",
-      "cpu": [
-        "loong64"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-mips64el": {
-      "version": "0.18.20",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz",
-      "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==",
-      "cpu": [
-        "mips64el"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-ppc64": {
-      "version": "0.18.20",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz",
-      "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==",
-      "cpu": [
-        "ppc64"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-riscv64": {
-      "version": "0.18.20",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz",
-      "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==",
-      "cpu": [
-        "riscv64"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-s390x": {
-      "version": "0.18.20",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz",
-      "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==",
-      "cpu": [
-        "s390x"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-x64": {
-      "version": "0.18.20",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz",
-      "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/netbsd-x64": {
-      "version": "0.18.20",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz",
-      "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/openbsd-x64": {
-      "version": "0.18.20",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz",
-      "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/sunos-x64": {
-      "version": "0.18.20",
-      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz",
-      "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "sunos"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/win32-arm64": {
-      "version": "0.18.20",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz",
-      "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/win32-ia32": {
-      "version": "0.18.20",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz",
-      "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==",
-      "cpu": [
-        "ia32"
-      ],
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/win32-x64": {
-      "version": "0.18.20",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz",
-      "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/esbuild": {
-      "version": "0.18.20",
-      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
-      "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==",
-      "hasInstallScript": true,
-      "bin": {
-        "esbuild": "bin/esbuild"
-      },
-      "engines": {
-        "node": ">=12"
-      },
-      "optionalDependencies": {
-        "@esbuild/android-arm": "0.18.20",
-        "@esbuild/android-arm64": "0.18.20",
-        "@esbuild/android-x64": "0.18.20",
-        "@esbuild/darwin-arm64": "0.18.20",
-        "@esbuild/darwin-x64": "0.18.20",
-        "@esbuild/freebsd-arm64": "0.18.20",
-        "@esbuild/freebsd-x64": "0.18.20",
-        "@esbuild/linux-arm": "0.18.20",
-        "@esbuild/linux-arm64": "0.18.20",
-        "@esbuild/linux-ia32": "0.18.20",
-        "@esbuild/linux-loong64": "0.18.20",
-        "@esbuild/linux-mips64el": "0.18.20",
-        "@esbuild/linux-ppc64": "0.18.20",
-        "@esbuild/linux-riscv64": "0.18.20",
-        "@esbuild/linux-s390x": "0.18.20",
-        "@esbuild/linux-x64": "0.18.20",
-        "@esbuild/netbsd-x64": "0.18.20",
-        "@esbuild/openbsd-x64": "0.18.20",
-        "@esbuild/sunos-x64": "0.18.20",
-        "@esbuild/win32-arm64": "0.18.20",
-        "@esbuild/win32-ia32": "0.18.20",
-        "@esbuild/win32-x64": "0.18.20"
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.9.4",
+        "@rollup/rollup-android-arm64": "4.9.4",
+        "@rollup/rollup-darwin-arm64": "4.9.4",
+        "@rollup/rollup-darwin-x64": "4.9.4",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.9.4",
+        "@rollup/rollup-linux-arm64-gnu": "4.9.4",
+        "@rollup/rollup-linux-arm64-musl": "4.9.4",
+        "@rollup/rollup-linux-riscv64-gnu": "4.9.4",
+        "@rollup/rollup-linux-x64-gnu": "4.9.4",
+        "@rollup/rollup-linux-x64-musl": "4.9.4",
+        "@rollup/rollup-win32-arm64-msvc": "4.9.4",
+        "@rollup/rollup-win32-ia32-msvc": "4.9.4",
+        "@rollup/rollup-win32-x64-msvc": "4.9.4",
+        "fsevents": "~2.3.2"
       }
     },
     "node_modules/vitefu": {
@@ -14055,59 +13754,57 @@
       }
     },
     "node_modules/vitest": {
-      "version": "0.34.6",
-      "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.34.6.tgz",
-      "integrity": "sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q==",
-      "dev": true,
-      "dependencies": {
-        "@types/chai": "^4.3.5",
-        "@types/chai-subset": "^1.3.3",
-        "@types/node": "*",
-        "@vitest/expect": "0.34.6",
-        "@vitest/runner": "0.34.6",
-        "@vitest/snapshot": "0.34.6",
-        "@vitest/spy": "0.34.6",
-        "@vitest/utils": "0.34.6",
-        "acorn": "^8.9.0",
-        "acorn-walk": "^8.2.0",
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.1.3.tgz",
+      "integrity": "sha512-2l8om1NOkiA90/Y207PsEvJLYygddsOyr81wLQ20Ra8IlLKbyQncWsGZjnbkyG2KwwuTXLQjEPOJuxGMG8qJBQ==",
+      "dev": true,
+      "dependencies": {
+        "@vitest/expect": "1.1.3",
+        "@vitest/runner": "1.1.3",
+        "@vitest/snapshot": "1.1.3",
+        "@vitest/spy": "1.1.3",
+        "@vitest/utils": "1.1.3",
+        "acorn-walk": "^8.3.1",
         "cac": "^6.7.14",
         "chai": "^4.3.10",
         "debug": "^4.3.4",
-        "local-pkg": "^0.4.3",
-        "magic-string": "^0.30.1",
+        "execa": "^8.0.1",
+        "local-pkg": "^0.5.0",
+        "magic-string": "^0.30.5",
         "pathe": "^1.1.1",
         "picocolors": "^1.0.0",
-        "std-env": "^3.3.3",
-        "strip-literal": "^1.0.1",
-        "tinybench": "^2.5.0",
-        "tinypool": "^0.7.0",
-        "vite": "^3.1.0 || ^4.0.0 || ^5.0.0-0",
-        "vite-node": "0.34.6",
+        "std-env": "^3.5.0",
+        "strip-literal": "^1.3.0",
+        "tinybench": "^2.5.1",
+        "tinypool": "^0.8.1",
+        "vite": "^5.0.0",
+        "vite-node": "1.1.3",
         "why-is-node-running": "^2.2.2"
       },
       "bin": {
         "vitest": "vitest.mjs"
       },
       "engines": {
-        "node": ">=v14.18.0"
+        "node": "^18.0.0 || >=20.0.0"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       },
       "peerDependencies": {
         "@edge-runtime/vm": "*",
-        "@vitest/browser": "*",
-        "@vitest/ui": "*",
+        "@types/node": "^18.0.0 || >=20.0.0",
+        "@vitest/browser": "^1.0.0",
+        "@vitest/ui": "^1.0.0",
         "happy-dom": "*",
-        "jsdom": "*",
-        "playwright": "*",
-        "safaridriver": "*",
-        "webdriverio": "*"
+        "jsdom": "*"
       },
       "peerDependenciesMeta": {
         "@edge-runtime/vm": {
           "optional": true
         },
+        "@types/node": {
+          "optional": true
+        },
         "@vitest/browser": {
           "optional": true
         },
@@ -14119,18 +13816,131 @@
         },
         "jsdom": {
           "optional": true
-        },
-        "playwright": {
-          "optional": true
-        },
-        "safaridriver": {
-          "optional": true
-        },
-        "webdriverio": {
-          "optional": true
         }
       }
     },
+    "node_modules/vitest/node_modules/execa": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
+      "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
+      "dev": true,
+      "dependencies": {
+        "cross-spawn": "^7.0.3",
+        "get-stream": "^8.0.1",
+        "human-signals": "^5.0.0",
+        "is-stream": "^3.0.0",
+        "merge-stream": "^2.0.0",
+        "npm-run-path": "^5.1.0",
+        "onetime": "^6.0.0",
+        "signal-exit": "^4.1.0",
+        "strip-final-newline": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=16.17"
+      },
+      "funding": {
+        "url": "https://github.com/sindresorhus/execa?sponsor=1"
+      }
+    },
+    "node_modules/vitest/node_modules/get-stream": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
+      "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
+      "dev": true,
+      "engines": {
+        "node": ">=16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/vitest/node_modules/human-signals": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
+      "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=16.17.0"
+      }
+    },
+    "node_modules/vitest/node_modules/is-stream": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
+      "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
+      "dev": true,
+      "engines": {
+        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/vitest/node_modules/mimic-fn": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
+      "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
+      "dev": true,
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/vitest/node_modules/npm-run-path": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.2.0.tgz",
+      "integrity": "sha512-W4/tgAXFqFA0iL7fk0+uQ3g7wkL8xJmx3XdK0VGb4cHW//eZTtKGvFBBoRKVTpY7n6ze4NL9ly7rgXcHufqXKg==",
+      "dev": true,
+      "dependencies": {
+        "path-key": "^4.0.0"
+      },
+      "engines": {
+        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/vitest/node_modules/onetime": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
+      "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==",
+      "dev": true,
+      "dependencies": {
+        "mimic-fn": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/vitest/node_modules/path-key": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
+      "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/vitest/node_modules/strip-final-newline": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
+      "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
+      "dev": true,
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/walk-up-path": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-3.0.1.tgz",
diff --git a/package.json b/package.json
index 92bff33b..66190301 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "web-scrobbler",
-  "version": "3.4.0",
+  "version": "3.5.0",
   "author": "David Šabata <posta@davidsabata.cz>",
   "contributors": [
     "@mountainash (https://mountainash.id.au/)"
@@ -28,11 +28,10 @@
   },
   "homepage": "https://web-scrobbler.com/",
   "dependencies": {
-    "@suid/icons-material": "0.6.11",
     "@web-scrobbler/metadata-filter": "3.1.0",
     "blueimp-md5": "2.19.0",
     "stylelint": "15.11.0",
-    "vite": "4.5.1",
+    "vite": "5.0.11",
     "webextension-polyfill": "0.10.0"
   },
   "devDependencies": {
@@ -41,10 +40,10 @@
     "@types/fs-extra": "11.0.3",
     "@types/imagemin": "8.0.5",
     "@types/imagemin-jpegtran": "5.0.4",
-    "@types/node": "20.10.6",
-    "@types/react": "18.2.46",
+    "@types/node": "20.11.0",
+    "@types/react": "18.2.47",
     "@types/webextension-polyfill": "0.10.7",
-    "@typescript-eslint/eslint-plugin": "6.16.0",
+    "@typescript-eslint/eslint-plugin": "6.18.1",
     "@web-scrobbler/eslint-config": "3.0.0",
     "@web-scrobbler/stylelint-config": "1.0.1",
     "archiver": "6.0.1",
@@ -53,7 +52,7 @@
     "esbuild": "0.19.11",
     "eslint": "8.56.0",
     "eslint-plugin-react": "7.33.2",
-    "eslint-plugin-solid": "0.13.0",
+    "eslint-plugin-solid": "0.13.1",
     "eslint-plugin-tsdoc": "0.2.17",
     "fs-extra": "11.1.1",
     "glob": "10.3.10",
@@ -65,14 +64,14 @@
     "remark": "15.0.1",
     "remark-cli": "12.0.0",
     "remark-preset-lint-recommended": "6.1.3",
-    "sass": "1.69.6",
-    "solid-js": "1.8.7",
+    "sass": "1.69.7",
+    "solid-js": "1.8.11",
     "tsx": "4.7.0",
     "typescript": "5.3.3",
     "vite-plugin-conditional-compiler": "0.2.1",
     "vite-plugin-solid": "2.8.0",
-    "vite-plugin-static-copy": "0.17.1",
-    "vitest": "0.34.6"
+    "vite-plugin-static-copy": "1.0.0",
+    "vitest": "1.1.3"
   },
   "engines": {
     "node": ">=18.0.0"
diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json
index 1b8a6da9..45700787 100644
--- a/src/_locales/en/messages.json
+++ b/src/_locales/en/messages.json
@@ -155,6 +155,14 @@
         "message": "Dark",
         "description": "Option label"
     },
+    "optionThemeHighContrastDark": {
+        "message": "High Contrast Dark",
+        "description": "Option label"
+    },
+    "optionThemeHighContrastLight": {
+        "message": "High Contrast Light",
+        "description": "Option label"
+    },
     "optionThemeSystem": {
         "message": "System",
         "description": "Option label"
@@ -203,6 +211,14 @@
         "message": "Custom API URL",
         "description": "Input label"
     },
+    "instanceDomain": {
+        "message": "Instance Domain",
+        "description": "Input label"
+    },
+    "instanceDomainPlaceholder": {
+        "message": "just domain: example.com",
+        "description": "Input label"
+    },
     "accountsUserToken": {
         "message": "Token",
         "description": "Input label"
@@ -255,6 +271,18 @@
         "message": "$1 regex/bulk edits",
         "description": "'Regex/Bulk edits' header"
     },
+    "optionsBlockedSources": {
+        "message": "Blocked sources",
+        "description": "'Edited tracks' header"
+    },
+    "optionsBlockedSourcesDesc": {
+        "message": "You can block scrobbling from specific sources such as channels on this website.",
+        "description": "Description of blocked sources"
+    },
+    "optionsBlockedSourcesPopupTitle": {
+        "message": "$1 blocked sources",
+        "description": "'Blocked sources' header"
+    },
     "optionsViewEdited": {
         "message": "View",
         "description": "Button to view edited tracks"
@@ -416,7 +444,7 @@
         "description": "'About' section text"
     },
     "aboutExtensionDesc": {
-        "message": "Web Scrobbler is a browser extension created for people who listen to music online through their browser, and would like to keep an updated playback history using scrobbling services, such as Last.fm, Libre.fm and ListenBrainz.",
+        "message": "Web Scrobbler is a browser extension created for people who listen to music online through their browser, and would like to keep an updated playback history using scrobbling services, such as Last.fm, Libre.fm, ListenBrainz, and Pleroma.",
         "description": "'About' section text"
     },
     "contributorsTitle": {
@@ -831,6 +859,14 @@
         "message": "Disable $1 connector until tab is closed",
         "description": "Context menu item"
     },
+    "menuDisableChannel": {
+        "message": "Disable scrobbling from $1",
+        "description": "Context menu item"
+    },
+    "menuEnableChannel": {
+        "message": "Enable scrobbling from $1",
+        "description": "Context menu item"
+    },
     "notificationConnectAccounts": {
         "message": "Connect your accounts",
         "description": "Notification title"
@@ -879,4 +915,4 @@
         "message": "The track is not recognized. Click web scrobbler extension icon to correct info.",
         "description": "Infobox track is not recognized"
     }
-}
\ No newline at end of file
+}
diff --git a/src/connectors/bilibili.ts b/src/connectors/bilibili.ts
new file mode 100644
index 00000000..c6528875
--- /dev/null
+++ b/src/connectors/bilibili.ts
@@ -0,0 +1,506 @@
+export {};
+
+/*
+	Since BiliBili is a video site, accurately scrobbling song information from the title can be challenging.
+	However, people often include the artist's name and track title in the video tags.
+
+	Therefore, this connector uses tags to identify artist and track information and determines whether we should scrobble this video.
+	The video title is then used to determine which tag is most likely to contain the artist or track name.
+*/
+
+/**
+ * Quick link to debug and test the connector: https://www.bilibili.com/video/BV1LW4y1g7kN
+ *
+ * the scrobble info will shown beside the video info, under the video title.
+ *
+ * special behaviors:
+ * if the video has bgm tag(a tag with music symbol in front of it), it'll be recognized as track name
+ * if the video has no track or artist related tag, this connector will try grepping it from video title
+ */
+
+/**
+ * selectors used by this connector
+ */
+const videoTitleSelector = '.video-title';
+const videoSelector = 'video';
+const tagPanelSelector = '.tag-panel';
+const tagLinkSelector = '.tag-link';
+const uploaderSelector = '.up-name';
+const bgmTagSelector = '.bgm-tag .tag-link .tag-txt';
+
+/**
+ * selectors used by Connector core
+ */
+const PlayerSelector = '.bpx-player-container';
+const currentTimeSelector = '.bpx-player-ctrl-time-current';
+const durationSelector = '.bpx-player-ctrl-time-duration';
+/**
+ * patterns that used to match possible song info
+ */
+const trackPattern = /[《『「](.*?)[》』」]/g;
+const artistPattern = /[【](.*?)[】]/g;
+const decorationPattern = /[(（](.*?)[)）]/g;
+// do not use lookbehind pattern like this, firefox does not support it
+// const coveredByPattern = /(?<=covered by).*/i;
+const coveredByPattern = /by\s*(.*)|CV.\s*(.*)/i;
+
+/**
+ * symbols that usually used in title to determine artist and track
+ */
+const artistLeftSeparators = ['-', '—'];
+const artistRightSeparators = ['/', '／'];
+/**
+ * The last track title. Used for detecting new songs.
+ */
+let lastVideoTitle: string | null = null;
+
+/**
+ * Object that holds information about song.
+ */
+let songInfo: {
+	artist: string | null;
+	track: string | null;
+} | null = null;
+
+/**
+ * Object that holds information about current video.
+ */
+let videoInfo: {
+	title: string;
+	upLoader: string;
+	tags: string[];
+	bgmTag: string | null;
+} = {
+	title: '',
+	upLoader: '',
+	tags: [],
+	bgmTag: null,
+};
+
+/*
+	only scrobbling if the video is tagged '音乐', which means 'music'
+*/
+const onlyScrobbleContainsMusicTag = true;
+
+/**
+ * don't scrobble a video if a video's tags contains text in tagFilterKeyWords
+ */
+const useScrobbleTagFilter = true;
+
+/**
+ * used when useScrobbleTagFilter is enabled.
+ * if a video's tags contains text in this array, it won't be scrobbled
+ */
+const tagFilterKeyWords = ['教学'];
+
+/**
+ * tags that constantly shown in tag but not related to the track info
+ */
+const filterKeyWords = [
+	'4K',
+	'MV',
+	'音乐',
+	'hires',
+	'无损',
+	'hdr',
+	'翻唱',
+	'原创',
+	'弹唱',
+	'cover',
+	'live',
+	'动画',
+	'综合',
+	'演唱',
+	'Hi-Res',
+	'中字',
+	'双语',
+	'vocaloid',
+	'字幕',
+	'女声',
+	'男声',
+	'现场',
+	'完整版',
+	'开口跪',
+	'op',
+	'ed',
+	'片尾曲',
+	'片头曲',
+];
+
+/**
+ * get current video info
+ */
+function getVideoInfo() {
+	videoInfo.title = Util.getTextFromSelectors(videoTitleSelector) ?? '';
+	videoInfo.upLoader = Util.getTextFromSelectors(uploaderSelector) ?? '';
+
+	const tagPanel = document.querySelector(tagPanelSelector);
+	if (tagPanel) {
+		const tagElements = tagPanel.querySelectorAll(tagLinkSelector);
+		const tags = Array.from(tagElements).map(
+			(tagElement) => tagElement.textContent || '',
+		);
+		videoInfo.tags = tags;
+	}
+
+	videoInfo.bgmTag =
+		Util.getTextFromSelectors(bgmTagSelector)?.replace(
+			decorationPattern,
+			'',
+		) ?? null;
+}
+
+Connector.playerSelector = PlayerSelector;
+
+Connector.currentTimeSelector = currentTimeSelector;
+
+Connector.durationSelector = durationSelector;
+
+// scrobble info is shown beside the video info
+Connector.scrobbleInfoLocationSelector = '.video-info-detail';
+Connector.scrobbleInfoStyle = {
+	...Connector.scrobbleInfoStyle,
+	fontSize: '13px',
+	fontWeight: '400',
+	marginLeft: '10px',
+};
+
+Connector.isPlaying = () => {
+	const video = document.querySelector(videoSelector) as HTMLVideoElement;
+	if (!video) {
+		return false;
+	}
+	return video.currentTime > 0 && !video.paused && !video.ended;
+};
+
+// using BV number(BiliBili's video unique identifier) as unique ID
+Connector.getUniqueID = () => {
+	const currentURL = Connector.getOriginUrl();
+	const match = currentURL
+		? currentURL.match(/\/video\/(BV[0-9A-Za-z]+)/)
+		: null;
+	if (match) {
+		const bvNumber = match[1];
+		return bvNumber;
+	}
+	return null;
+};
+
+// href is like this: href="//space.bilibili.com/1459104794"
+Connector.getChannelId = () =>
+	new URL(
+		(document.querySelector(uploaderSelector) as HTMLAnchorElement)?.href ??
+			'https://bilibili.com/',
+	).pathname
+		.split('/')
+		.pop();
+
+Connector.channelLabelSelector = '.up-name';
+
+Connector.isScrobblingAllowed = () => {
+	const tags = videoInfo.tags;
+
+	if (onlyScrobbleContainsMusicTag) {
+		if (!tags.includes('音乐')) {
+			return false;
+		}
+	} else if (useScrobbleTagFilter) {
+		if (tags.some((tag) => isIncludeElems(tag, tagFilterKeyWords))) {
+			return false;
+		}
+	}
+	return true;
+};
+
+Connector.getArtistTrack = () => {
+	getSongInfo();
+	return songInfo;
+};
+
+/*
+	helper functions
+*/
+
+/**
+ * Check if song is changed.
+ * @returns true if new song is playing; false otherwise
+ */
+function isNewSongPlaying() {
+	const title = videoInfo.title;
+
+	if (lastVideoTitle !== title) {
+		lastVideoTitle = title;
+		return true;
+	}
+
+	return false;
+}
+
+function resetSongInfo() {
+	songInfo = null;
+}
+
+function resetVideoInfo() {
+	videoInfo = {
+		title: '',
+		upLoader: '',
+		tags: [],
+		bgmTag: null,
+	};
+}
+
+/**
+ * if the song changed, flash the songInfo object and reset state(useful when change video on the same page)
+ */
+function getSongInfo() {
+	getVideoInfo();
+	if (isNewSongPlaying()) {
+		try {
+			Connector.resetState();
+			songInfo = grepSongInfo();
+		} catch (err) {
+			const error = err as string;
+			Util.debugLog(`Error:${error}`, 'error');
+			resetSongInfo();
+			resetVideoInfo();
+		}
+	}
+}
+
+/**
+ * case insensitive filter
+ * @param items - tags to be filtered
+ * @param filterKeyWords - words that any tag contains word in this array will be removed from output
+ * @returns an array of tags does not contain any keywords defined in filterKeyWords
+ */
+function itemFilter(items: string[], filterKeyWords: string[]) {
+	return items.filter(
+		(item) =>
+			!filterKeyWords.some((keyword) => {
+				return item.toLowerCase().includes(keyword.toLowerCase());
+			}),
+	);
+}
+/**
+ * main function used to get song info.
+ * @returns \{artist, track\}
+ */
+function grepSongInfo() {
+	let track: string | null = null;
+	let artist: string | null = null;
+	const possibleArtist: string[] = [];
+	const possibleTrack: string[] = [];
+
+	const filteredTags = itemFilter(videoInfo.tags, filterKeyWords);
+	const title = videoInfo.title;
+
+	// possibleArtist array and possibleTrack array is ordered by priority
+	let matched: string[] = [];
+	// artists
+	// if the video title contained 'covered by', the string after it has the biggest chance to be the artist
+	matched = getMatchedTextArray(title, coveredByPattern);
+	possibleArtist.push(...matched);
+
+	// if the video title contains something warped by 【】 and does not contain filter key words, it has the second biggest chance to be the artist name
+	matched = getMatchedTextArray(title, artistPattern);
+	possibleArtist.push(...itemFilter(matched, filterKeyWords));
+
+	// the uploader's name has third large chance to be the artist name
+	const uploaderName = videoInfo.upLoader;
+	possibleArtist.push(uploaderName);
+
+	// tracks
+	// if the video title contains something warped by 《》or『』or「」 and does not contain any filter key words in it, it has the largest chance to be the track name
+	matched = getMatchedTextArray(title, trackPattern);
+	possibleTrack.push(...itemFilter(matched, filterKeyWords));
+
+	// get plain title without any decoration, and analyze it using separator patterns
+	const titleWithoutDecoration = title
+		.replace(trackPattern, '')
+		.replace(artistPattern, '')
+		.replace(decorationPattern, '');
+
+	// both
+	// the title without any decoration also has a big chance to be track name
+	// needs to exclude title that contains separators, otherwise it's repeated
+	if (
+		!(
+			isIncludeElems(title, artistLeftSeparators) ||
+			isIncludeElems(title, artistRightSeparators)
+		)
+	) {
+		possibleTrack.push(titleWithoutDecoration);
+	}
+
+	artistLeftSeparators.forEach((separator) => {
+		const result = extractText(titleWithoutDecoration, separator);
+		if (result) {
+			const [leftPart, rightPart] = result;
+			possibleTrack.push(rightPart);
+			possibleArtist.push(leftPart);
+		}
+	});
+	artistRightSeparators.forEach((separator) => {
+		const result = extractText(titleWithoutDecoration, separator);
+		if (result) {
+			const [leftPart, rightPart] = result;
+			possibleTrack.push(leftPart);
+			possibleArtist.push(rightPart);
+		}
+	});
+
+	// select
+	Util.debugLog(
+		`PossibleTrack: ${possibleTrack.toString()}
+PossibleArtist: ${possibleArtist.toString()}
+FilteredTags: ${filteredTags.toString()}`,
+		'log',
+	);
+
+	// have tags related to the song, grep info from tag
+	if (filteredTags.length !== 0) {
+		Util.debugLog(`Filtered tags: ${filteredTags.toString()}`, 'log');
+
+		const matchedArtist = getMatchedTags(possibleArtist, filteredTags);
+		artist = getFirstNonNull(matchedArtist);
+
+		const matchedTrack = getMatchedTags(possibleTrack, filteredTags);
+		track = getFirstNonNull(matchedTrack);
+
+		Util.debugLog(
+			`Matched artist: ${matchedArtist.toString()}
+Matched track: ${matchedTrack.toString()}
+Filtered tags: ${filteredTags.toString()}`,
+		);
+
+		// fallback
+		if (!track) {
+			// if only have one related tag, it has chance to be the track name
+			if (filteredTags.length === 1) {
+				track = filteredTags[0];
+			}
+			// if only have two related tags, it has chance to be the track name and the artist name
+			if (filteredTags.length === 2 && artist !== null) {
+				const tagWithoutArtist = filteredTags.filter(
+					(tag) => tag !== artist,
+				);
+				track = tagWithoutArtist[0];
+			}
+		}
+		// same as above. if only have two related tags, it has chance to be the track name and the artist name
+		if (!artist) {
+			if (filteredTags.length === 2 && track !== null) {
+				const tagWithoutTrack = filteredTags.filter(
+					(tag) => tag !== track,
+				);
+				artist = tagWithoutTrack[0];
+			}
+		}
+	} else {
+		Util.debugLog('have no filtered tag');
+	}
+
+	// ensure
+	// if the video have a specific bgm tag on it, the tag is the track name for sure
+	const bgmTag = videoInfo.bgmTag;
+	track = bgmTag ? bgmTag : track;
+
+	// final fallback, if it's hard to find song info from tags, grep it from title
+	if (!artist) {
+		artist = getFirstNonNull(possibleArtist);
+	}
+	if (!track) {
+		track = getFirstNonNull(possibleTrack);
+	}
+
+	return { artist, track };
+}
+
+/**
+ * extract text using separators
+ * @param text - text that needs to be separated
+ * @param separator - separator that used to separate the text
+ * @returns [left part of the separator, right part of the separator]
+ */
+function extractText(text: string, separator: string): [string, string] | null {
+	const regex = new RegExp(`([^${separator}]+)\\s*${separator}\\s*(.+)`);
+	const match = text.match(regex);
+
+	if (match) {
+		const leftPart = match[1];
+		const rightPart = match[2];
+		return [leftPart, rightPart];
+	}
+
+	return null;
+}
+
+/**
+ * get first non-none elem in the array
+ * @returns the first non-none in arr
+ */
+function getFirstNonNull(arr: (string | null)[]): string | null {
+	for (const element of arr) {
+		if (element) {
+			return element;
+		}
+	}
+	return null;
+}
+
+/**
+ * case insensitive match
+ * @returns tags that be contained in any elem in textFragment array
+ */
+function getMatchedTags(
+	textFragment: (string | null)[],
+	tags: string[],
+): string[] {
+	const matchedTags: string[] = [];
+	// Traversing as textFragment's order
+	if (textFragment) {
+		textFragment.forEach((fragment) => {
+			tags.forEach((tag) => {
+				if (
+					fragment &&
+					fragment.toLowerCase().includes(tag.toLowerCase()) &&
+					!matchedTags.includes(tag)
+				) {
+					matchedTags.push(tag);
+				}
+			});
+		});
+	}
+	return matchedTags;
+}
+
+/**
+ * @returns an array with text matched with regex pattern
+ */
+function getMatchedTextArray(text: string, pattern: RegExp): string[] {
+	let match;
+	const matchedArray: string[] = [];
+
+	if (pattern.global) {
+		while ((match = pattern.exec(text)) !== null) {
+			matchedArray.push(match[1]);
+		}
+	} else {
+		match = text.match(pattern);
+		if (match) {
+			matchedArray.push(match[1]);
+		}
+	}
+
+	return matchedArray;
+}
+
+/**
+ * case insensitive filter
+ * @returns bool, true if the item contains an elem in the elemList
+ */
+function isIncludeElems(item: string, elemList: string[]) {
+	return elemList.some((elem) =>
+		item.toLowerCase().includes(elem.toLowerCase()),
+	);
+}
diff --git a/src/connectors/jam.coop.ts b/src/connectors/jam.coop.ts
new file mode 100644
index 00000000..31eccb41
--- /dev/null
+++ b/src/connectors/jam.coop.ts
@@ -0,0 +1,94 @@
+export {};
+
+const trackListSelector = '[data-player-target="track"]';
+const progressSelector = '[data-player-target="progress"]';
+
+/**
+ * Finds the currently played track (as HTMLElement) in the track list.
+ * Contains track URL and duration.
+ */
+const getCurrentTrackListElement = () => {
+	const playingTrackName = Connector.getTrack();
+	if (!playingTrackName) {
+		return null;
+	}
+	const trackListElements = Util.queryElements(trackListSelector);
+	if (!trackListElements) {
+		return null;
+	}
+	for (const trackListElem of trackListElements) {
+		const trackListElemTitle = trackListElem.querySelector(
+			'span.cursor-pointer',
+		);
+		if (trackListElemTitle?.textContent === playingTrackName) {
+			return trackListElem;
+		}
+	}
+	return null;
+};
+
+/**
+ * While playing, a progress bar is updated frequently.
+ * Its width CSS attribute tells us the current duration percentage.
+ * @returns value between 0 and 100
+ */
+const getProgressPercentage = (): number => {
+	const progressElements = Util.queryElements(progressSelector);
+	if (!progressElements || !progressElements.length) {
+		return 0;
+	}
+	const widthCss = progressElements[0].style.width;
+	return parseFloat(widthCss);
+};
+
+Connector.playerSelector = '[data-controller="player"]';
+
+Connector.playButtonSelector = '[data-action="player#play"]';
+Connector.pauseButtonSelector = '[data-action="player#pause"]';
+
+Connector.artistSelector = `${Connector.playerSelector} h2`;
+
+Connector.trackSelector = `${Connector.playerSelector} h3`;
+
+Connector.albumSelector = `${Connector.playerSelector} h1`;
+
+Connector.trackArtSelector = `${Connector.playerSelector} img`;
+
+Connector.isPlaying = () => {
+	const elements = Util.queryElements(Connector.pauseButtonSelector);
+	if (!elements || !elements.length) {
+		return false;
+	}
+	return !elements[0].classList.contains('hidden');
+};
+
+Connector.getUniqueID = () => {
+	const currentTrackListElem = getCurrentTrackListElement();
+	if (!currentTrackListElem) {
+		return null;
+	}
+	return currentTrackListElem.getAttribute('data-track-url');
+};
+
+Connector.getDuration = () => {
+	const currentTrackListElem = getCurrentTrackListElement();
+	if (!currentTrackListElem) {
+		return null;
+	}
+	const durationElem =
+		currentTrackListElem.querySelector('span:nth-child(2)');
+	if (!durationElem) {
+		return null;
+	}
+	return Util.stringToSeconds(durationElem.textContent);
+};
+
+Connector.getCurrentTime = () => {
+	const duration = Connector.getDuration() ?? 0;
+	const percentage = getProgressPercentage();
+	return (duration * percentage) / 100;
+};
+
+// The player (dis)appears dynamically.
+// A viable approach to detect this is to call Connector.onStateChanged() from time to time
+setInterval(Connector.onStateChanged, 1000);
diff --git a/src/connectors/planetradio.ts b/src/connectors/planetradio.ts
index 4af6b6d7..2aed7d56 100644
--- a/src/connectors/planetradio.ts
+++ b/src/connectors/planetradio.ts
@@ -1,26 +1,11 @@
 export {};
 
-Connector.playerSelector = [
-	'#__next > div > div > div:first-child',
-	'.now-playing-wrapper',
-];
+Connector.playerSelector = '#__next > div > div > div:first-child';
 
-Connector.artistSelector = [
-	'#__next > div > div > div:first-child > div:nth-child(5) > div > div:nth-child(2) > p:nth-child(2)',
-	'.text-right .title',
-];
+Connector.artistSelector = '#playbar-play-button + div p';
 
-Connector.trackSelector = [
-	'#__next > div > div > div:first-child > div:nth-child(5) > div > div:nth-child(2) > p:nth-child(3)',
-	'.text-right .track',
-];
+Connector.trackSelector = '#playbar-play-button + div p:last-child';
 
-Connector.trackArtSelector = [
-	'#__next > div > div > div:first-child > div:nth-child(5) img',
-	'.now-playing-block.right .image',
-];
+Connector.trackArtSelector = '#playbar-play-button + div picture img';
 
-Connector.playButtonSelector = [
-	'#__next > div > div > div:first-child [data-icon="play"]',
-	'.play',
-];
+Connector.playButtonSelector = '#playbar-play-button svg[data-icon="play"]';
diff --git a/src/connectors/siriusxm-player.ts b/src/connectors/siriusxm-player.ts
index 2b490d0c..7948ef44 100644
--- a/src/connectors/siriusxm-player.ts
+++ b/src/connectors/siriusxm-player.ts
@@ -15,18 +15,19 @@ const filter = MetadataFilter.createFilter({
 	track: [removeYear, removeCover, removeExclusive],
 });
 
-Connector.playerSelector = '._playbackControls_1cag9_17';
+Connector.playerSelector = "*[class^='_playbackControls_']";
 
-Connector.artistTrackSelector = '._playbackControls_1cag9_17 ._title_1cag9_286';
+Connector.artistTrackSelector =
+	"*[class^='_playbackControls_'] *[class*='_title_']";
 
 Connector.trackArtSelector =
-	'._playbackControls_1cag9_17 ._trackImage_1cag9_178 ._image-image_2xo9u_188';
+	"*[class^='_playbackControls_'] *[class^='_trackImage_'] img[class^='_image-image']";
 
 Connector.playButtonSelector =
-	'._playbackControls_1cag9_17 button[aria-label="Play"]';
+	"*[class^='_playbackControls_'] button[aria-label='Play']";
 
 Connector.pauseButtonSelector =
-	'._playbackControls_1cag9_17 button[aria-label="Pause"]';
+	"*[class^='_playbackControls_'] button[aria-label='Pause']";
 
 Connector.isScrobblingAllowed = () => {
 	const artist = Connector.getArtist()?.toLowerCase();
diff --git a/src/connectors/youtube.ts b/src/connectors/youtube.ts
index e66a4ab5..6d0731c4 100644
--- a/src/connectors/youtube.ts
+++ b/src/connectors/youtube.ts
@@ -93,6 +93,21 @@ Connector.scrobbleInfoStyle = {
 	fontWeight: '700',
 };
 
+Connector.getChannelId = () =>
+	new URL(
+		(
+			Util.queryElements([
+				'#upload-info .ytd-channel-name .yt-simple-endpoint',
+				'.slim-owner-icon-and-title',
+			]) as NodeListOf<HTMLAnchorElement>
+		)?.[0]?.href ?? 'https://youtube.com/',
+	).pathname.slice(1);
+
+Connector.channelLabelSelector = [
+	'#primary #title+#top-row ytd-channel-name .yt-formatted-string',
+	'.slim-owner-icon-and-title .yt-core-attributed-string',
+];
+
 Connector.getTrackInfo = () => {
 	const trackInfo: TrackInfoWithAlbum = {};
 
diff --git a/src/core/background/action.ts b/src/core/background/action.ts
index fdda163b..c0b4d4cc 100644
--- a/src/core/background/action.ts
+++ b/src/core/background/action.ts
@@ -1,5 +1,10 @@
 import browser from 'webextension-polyfill';
-import { contextMenus, getBrowserPreferredTheme } from './util';
+import {
+	contextMenus,
+	getBrowserPreferredTheme,
+	getChannelDetails,
+	getChannelBlocklistLabel,
+} from './util';
 import { sendPopupMessage } from '@/util/communication';
 import { ManagerTab } from '@/core/storage/wrapper';
 import * as ControllerMode from '@/core/object/controller/controller-mode';
@@ -40,6 +45,12 @@ async function updateMenus(tab: ManagerTab): Promise<void> {
 		browser.contextMenus?.update(contextMenus.DISABLE_UNTIL_CLOSED, {
 			visible: false,
 		});
+		browser.contextMenus?.update(contextMenus.DISABLE_CHANNEL, {
+			visible: false,
+		});
+		browser.contextMenus?.update(contextMenus.ENABLE_CHANNEL, {
+			visible: false,
+		});
 		return;
 	}
 
@@ -56,6 +67,12 @@ async function updateMenus(tab: ManagerTab): Promise<void> {
 		browser.contextMenus?.update(contextMenus.DISABLE_UNTIL_CLOSED, {
 			visible: false,
 		});
+		browser.contextMenus?.update(contextMenus.DISABLE_CHANNEL, {
+			visible: false,
+		});
+		browser.contextMenus?.update(contextMenus.ENABLE_CHANNEL, {
+			visible: false,
+		});
 		return;
 	}
 
@@ -70,6 +87,41 @@ async function updateMenus(tab: ManagerTab): Promise<void> {
 		visible: true,
 		title: t('menuDisableUntilTabClosed', connector?.label),
 	});
+
+	const channelDetails = await getChannelDetails(tab.tabId);
+	if (
+		!channelDetails ||
+		!channelDetails.channelInfo?.id ||
+		!channelDetails.connector
+	) {
+		browser.contextMenus?.update(contextMenus.DISABLE_CHANNEL, {
+			visible: false,
+		});
+		browser.contextMenus?.update(contextMenus.ENABLE_CHANNEL, {
+			visible: false,
+		});
+	} else if (
+		await getChannelBlocklistLabel(
+			channelDetails.channelInfo.id,
+			channelDetails.connector,
+		)
+	) {
+		browser.contextMenus?.update(contextMenus.DISABLE_CHANNEL, {
+			visible: false,
+		});
+		browser.contextMenus?.update(contextMenus.ENABLE_CHANNEL, {
+			visible: true,
+			title: t('menuEnableChannel', channelDetails.channelInfo.label),
+		});
+	} else {
+		browser.contextMenus?.update(contextMenus.DISABLE_CHANNEL, {
+			visible: true,
+			title: t('menuDisableChannel', channelDetails.channelInfo.label),
+		});
+		browser.contextMenus?.update(contextMenus.ENABLE_CHANNEL, {
+			visible: false,
+		});
+	}
 }
 
 /**
diff --git a/src/core/background/main.ts b/src/core/background/main.ts
index b0d21d0c..553eb786 100644
--- a/src/core/background/main.ts
+++ b/src/core/background/main.ts
@@ -21,6 +21,8 @@ import {
 	getCurrentTab,
 	getActiveTabDetails,
 	getCurrentTabId,
+	addToBlocklist,
+	removeFromBlocklist,
 } from './util';
 import {
 	ControllerModeStr,
@@ -529,6 +531,20 @@ function onStartup() {
 		contexts: ['action'],
 		title: 'Error: You should not be seeing this',
 	});
+
+	browser.contextMenus.create({
+		id: contextMenus.ENABLE_CHANNEL,
+		visible: false,
+		contexts: ['action'],
+		title: 'Error: You should not be seeing this',
+	});
+
+	browser.contextMenus.create({
+		id: contextMenus.DISABLE_CHANNEL,
+		visible: false,
+		contexts: ['action'],
+		title: 'Error: You should not be seeing this',
+	});
 }
 
 /**
@@ -561,5 +577,13 @@ async function contextMenuHandler(info: browser.Menus.OnClickData) {
 			disableUntilClosed(tab.tabId);
 			break;
 		}
+		case contextMenus.ENABLE_CHANNEL: {
+			removeFromBlocklist(tab.tabId);
+			break;
+		}
+		case contextMenus.DISABLE_CHANNEL: {
+			addToBlocklist(tab.tabId);
+			break;
+		}
 	}
 }
diff --git a/src/core/background/util.ts b/src/core/background/util.ts
index e4ccfcf0..dc5d6b29 100644
--- a/src/core/background/util.ts
+++ b/src/core/background/util.ts
@@ -6,13 +6,17 @@ import * as BrowserStorage from '@/core/storage/browser-storage';
 import { isPrioritizedMode } from '@/core/object/controller/controller';
 import { performUpdateAction } from './action';
 import { sendBackgroundMessage } from '@/util/communication';
+import { ConnectorMeta } from '../connectors';
 
 const state = BrowserStorage.getStorage(BrowserStorage.STATE_MANAGEMENT);
+const blocklistStorage = BrowserStorage.getStorage(BrowserStorage.BLOCKLISTS);
 
 export const contextMenus = {
 	ENABLE_CONNECTOR: 'enableConnector',
 	DISABLE_CONNECTOR: 'disableConnector',
 	DISABLE_UNTIL_CLOSED: 'disableUntilClosed',
+	ENABLE_CHANNEL: 'enableChannel',
+	DISABLE_CHANNEL: 'disableChannel',
 };
 
 /**
@@ -61,6 +65,35 @@ export async function filterInactiveTabs(activeTabs: ManagerTab[]) {
 	});
 }
 
+/**
+ * @param tabId - tab to get song from
+ * @returns the details about the channel of the currently playing song in tab
+ */
+export async function getChannelDetails(tabId: number) {
+	return sendBackgroundMessage(tabId, {
+		type: 'getChannelDetails',
+		payload: undefined,
+	});
+}
+
+/**
+ * Checks if current channel is blocklisted and returns its label if so
+ *
+ * @param channelId - ID of the channel to check
+ * @param connector - Details about the connector to check
+ * @returns string label of channel if current channel is blocklisted; null otherwise
+ */
+export async function getChannelBlocklistLabel(
+	channelId: string,
+	connector: ConnectorMeta,
+): Promise<string | null> {
+	const blocklist = (await blocklistStorage.get())?.[connector.id];
+	if (!blocklist || !blocklist[channelId]) {
+		return null;
+	}
+	return blocklist[channelId];
+}
+
 /**
  * Unlock state management storage.
  */
@@ -236,6 +269,30 @@ export function enableConnector(tabId: number) {
 	});
 }
 
+/**
+ * Disables scrobbling current channel for a tab
+ *
+ * @param tabId - tab id of tab to disable scrobbling channel for
+ */
+export function addToBlocklist(tabId: number) {
+	sendBackgroundMessage(tabId, {
+		type: 'addToBlocklist',
+		payload: undefined,
+	});
+}
+
+/**
+ * Enables scrobbling current channel for a tab
+ *
+ * @param tabId - tab id of tab to enable scrobbling channel for
+ */
+export function removeFromBlocklist(tabId: number) {
+	sendBackgroundMessage(tabId, {
+		type: 'removeFromBlocklist',
+		payload: undefined,
+	});
+}
+
 /**
  * Disables connector of a tab until the tab is closed
  *
diff --git a/src/core/connectors.ts b/src/core/connectors.ts
index bbae475f..e29247fd 100644
--- a/src/core/connectors.ts
+++ b/src/core/connectors.ts
@@ -4,6 +4,11 @@ export interface ConnectorMeta {
 	js: string;
 	id: string;
 	allFrames?: true;
+
+	/**
+	 * true if connector uses blocklist. Connector must implement {@link Connector.getChannelId}
+	 */
+	usesBlocklist?: true;
 }
 
 export default <ConnectorMeta[]>[
@@ -12,6 +17,7 @@ export default <ConnectorMeta[]>[
 		matches: ['*://www.youtube.com/*', '*://m.youtube.com/*'],
 		js: 'youtube.js',
 		id: 'youtube',
+		usesBlocklist: true,
 	},
 	{
 		label: 'MySpace',
@@ -2323,4 +2329,17 @@ export default <ConnectorMeta[]>[
 		js: 'radiostudent.js',
 		id: 'radiostudent',
 	},
+	{
+		label: 'BiliBili',
+		matches: ['*://www.bilibili.com/*'],
+		js: 'bilibili.js',
+		id: 'bilibili',
+		usesBlocklist: true,
+	},
+	{
+		label: 'jam.coop',
+		matches: ['*://jam.coop/*'],
+		js: 'jam.coop.js',
+		id: 'jam.coop',
+	},
 ];
diff --git a/src/core/content/connector.ts b/src/core/content/connector.ts
index 710f9d83..84b74b34 100644
--- a/src/core/content/connector.ts
+++ b/src/core/content/connector.ts
@@ -144,6 +144,52 @@ export default class BaseConnector {
 		alignItems: 'center',
 	};
 
+	/**
+	 * Function that gets a unique ID for channel/user blocklist.
+	 *
+	 * Has to be specified if usesBlocklist is set to true in connectors.ts.
+	 * If connectors.ts does not have usesBlocklist set to true, this should be null.
+	 */
+	public getChannelId: (() => string | null | undefined) | null = null;
+
+	/**
+	 * Selector of an element containing channel label.
+	 *
+	 * Only applies when default implementation of
+	 * {@link getChannelLabel} is used.
+	 */
+	public channelLabelSelector: string | string[] | null = null;
+
+	/**
+	 * Function that gets a label for the channel of ID fetched by {@link getChannelId}.
+	 *
+	 * This is the name that will be displayed to the user, and has no bearing on internal logic.
+	 * If not specified, {@link getChannelId} will be used.
+	 */
+	public getChannelLabel: (() => string | null | undefined) | null = () =>
+		Util.getTextFromSelectors(this.channelLabelSelector);
+
+	/**
+	 * Function that gets ID and label for a channel.
+	 *
+	 * A connector can specify this in lieu of {@link getChannelId}, as this is basically combo of
+	 * both {@link getChannelId} and {@link getChannelLabel}
+	 *
+	 * You may return null in this function, but if you return a result for one property,
+	 * you must return a result for both properties, even if the label is just
+	 * a duplicate of the id.
+	 */
+	public getChannelInfo: () => Util.ChannelInfo | null | undefined = () => {
+		const id = this.getChannelId?.();
+		if (!id) {
+			return null;
+		}
+		return {
+			id,
+			label: this.getChannelLabel?.() || id,
+		};
+	};
+
 	/**
 	 * Selector of element contains a track art of now playing song.
 	 * Default implementation looks for track art URL in `src` attribute or
diff --git a/src/core/content/util.ts b/src/core/content/util.ts
index d103e873..27560766 100644
--- a/src/core/content/util.ts
+++ b/src/core/content/util.ts
@@ -1067,3 +1067,8 @@ export function getInfoBoxText(
 			return t(`pageAction${mode}`, trackInfo);
 	}
 }
+
+export interface ChannelInfo {
+	id: string;
+	label: string;
+}
diff --git a/src/core/object/cloned-song.ts b/src/core/object/cloned-song.ts
index 0baf97bb..70e7581a 100644
--- a/src/core/object/cloned-song.ts
+++ b/src/core/object/cloned-song.ts
@@ -18,6 +18,7 @@ export default class ClonedSong extends BaseSong {
 	public flags: Flags;
 	public metadata: Metadata;
 	public connectorLabel: string;
+	public connectorId: string;
 	public controllerTabId: number;
 
 	constructor(song: CloneableSong, controllerTabId: number) {
@@ -28,6 +29,7 @@ export default class ClonedSong extends BaseSong {
 		this.flags = song.flags;
 		this.metadata = song.metadata;
 		this.connectorLabel = song.connectorLabel;
+		this.connectorId = song.connectorId;
 		this.controllerTabId = controllerTabId;
 	}
 
diff --git a/src/core/object/controller/controller.ts b/src/core/object/controller/controller.ts
index 3382c562..256efdf3 100644
--- a/src/core/object/controller/controller.ts
+++ b/src/core/object/controller/controller.ts
@@ -25,6 +25,7 @@ import * as BrowserStorage from '@/core/storage/browser-storage';
 import { debugLog } from '@/core/content/util';
 import browser from 'webextension-polyfill';
 import BaseConnector from '@/core/content/connector';
+import Blocklist from '@/core/storage/blocklist';
 
 /**
  * List of song fields used to check if song is changed. If any of
@@ -72,15 +73,10 @@ export default class Controller {
 	private currentSong: Song | null = null;
 	private isReplayingSong = false;
 	private shouldScrobblePodcasts = true;
+	private blocklist: Blocklist;
 
 	private forceScrobble = false;
 	private shouldHaveScrobbled = false;
-	private shouldScrobble = () => {
-		if (this.forceScrobble) {
-			return true;
-		}
-		return this.currentSong?.parsed.isScrobblingAllowed;
-	};
 
 	private isEditing = false;
 	private setNotEditingTimeout = setTimeout(() => {
@@ -92,6 +88,21 @@ export default class Controller {
 		payload: undefined,
 	});
 
+	/**
+	 * @returns true if song should scrobble; false if disallowed.
+	 */
+	private async shouldScrobble() {
+		if (this.forceScrobble) {
+			return true;
+		}
+		return (
+			this.currentSong?.parsed.isScrobblingAllowed &&
+			(await this.blocklist.shouldScrobbleChannel(
+				this.connector.getChannelInfo?.()?.id,
+			))
+		);
+	}
+
 	/**
 	 * Function that handles updating the scrobble info box
 	 */
@@ -192,6 +203,7 @@ export default class Controller {
 	 */
 	constructor(connector: BaseConnector, isEnabled: boolean) {
 		this.connector = connector;
+		this.blocklist = new Blocklist(this.connector.meta.id);
 		this.isEnabled = isEnabled;
 		this.mode = isEnabled ? ControllerMode.Base : ControllerMode.Disabled;
 		this.setMode(this.mode);
@@ -285,6 +297,35 @@ export default class Controller {
 					}
 				},
 			}),
+			contentListener({
+				type: 'getChannelDetails',
+				fn: () => ({
+					connector: this.connector.meta,
+					channelInfo: this.connector.getChannelInfo?.(),
+				}),
+			}),
+			contentListener({
+				type: 'addToBlocklist',
+				fn: async () => {
+					await this.blocklist.addToBlocklist(
+						this.connector.getChannelInfo?.() ?? null,
+					);
+					this.setMode(ControllerMode.Disallowed);
+				},
+			}),
+			contentListener({
+				type: 'removeFromBlocklist',
+				fn: async () => {
+					await this.blocklist.removeFromBlocklist(
+						this.connector.getChannelInfo?.()?.id ?? null,
+					);
+					if (this.shouldHaveScrobbled) {
+						void this.scrobbleSong();
+					} else {
+						void this.setSongNowPlaying();
+					}
+				},
+			}),
 		);
 	}
 
@@ -321,7 +362,11 @@ export default class Controller {
 		switch (event) {
 			case ControllerEvents.SongNowPlaying: {
 				const song = this.getCurrentSong();
-				if (!song || song.flags.isReplaying || !this.shouldScrobble()) {
+				if (
+					!song ||
+					song.flags.isReplaying ||
+					!(await this.shouldScrobble())
+				) {
 					break;
 				}
 				const id = await this.tabId;
@@ -352,7 +397,7 @@ export default class Controller {
 			}
 			case ControllerEvents.SongUnrecognized: {
 				const song = this.getCurrentSong();
-				if (!song || !this.shouldScrobble()) {
+				if (!song || !(await this.shouldScrobble())) {
 					break;
 				}
 				const id = await this.tabId;
@@ -649,7 +694,7 @@ export default class Controller {
 	 * Process connector state as current one.
 	 * @param newState - Connector state
 	 */
-	private processCurrentState(newState: State): void {
+	private async processCurrentState(newState: State): Promise<void> {
 		if (!assertSongNotNull(this.currentSong)) {
 			return;
 		}
@@ -680,7 +725,7 @@ export default class Controller {
 			this.onPlayingStateChanged(isPlaying);
 		} else if (
 			this.mode === ControllerMode.Disallowed &&
-			this.shouldScrobble() &&
+			(await this.shouldScrobble()) &&
 			isPlaying
 		) {
 			// we need to unset disallowed whenever needed.
@@ -709,7 +754,7 @@ export default class Controller {
 			return;
 		}
 
-		if (this.shouldScrobble()) {
+		if (await this.shouldScrobble()) {
 			this.setMode(ControllerMode.Loading);
 		} else {
 			this.setMode(ControllerMode.Disallowed);
@@ -738,7 +783,7 @@ export default class Controller {
 			 * If the song is playing, mark it immediately;
 			 * otherwise will be flagged in isPlaying binding.
 			 */
-			if (!this.shouldScrobble()) {
+			if (!(await this.shouldScrobble())) {
 				this.setMode(ControllerMode.Disallowed);
 			} else if (this.currentSong.parsed.isPlaying) {
 				/*
@@ -784,7 +829,7 @@ export default class Controller {
 	 * Called when playing state is changed.
 	 * @param value - New playing state
 	 */
-	private onPlayingStateChanged(value: boolean | null): void {
+	private async onPlayingStateChanged(value: boolean | null): Promise<void> {
 		this.debugLog(`isPlaying state changed to ${String(value)}`);
 
 		if (value && this.currentSong) {
@@ -798,7 +843,7 @@ export default class Controller {
 			if (
 				!isMarkedAsPlaying &&
 				this.currentSong.isValid() &&
-				this.shouldScrobble()
+				(await this.shouldScrobble())
 			) {
 				void this.setSongNowPlaying();
 			} else {
@@ -906,7 +951,10 @@ export default class Controller {
 	 * now playing.
 	 */
 	private async setSongNowPlaying(): Promise<void> {
-		if (!assertSongNotNull(this.currentSong) || !this.shouldScrobble()) {
+		if (
+			!assertSongNotNull(this.currentSong) ||
+			!(await this.shouldScrobble())
+		) {
 			return;
 		}
 		this.currentSong.flags.isMarkedAsPlaying = true;
@@ -930,7 +978,10 @@ export default class Controller {
 	}
 
 	private async setPaused(): Promise<void> {
-		if (!assertSongNotNull(this.currentSong) || !this.shouldScrobble()) {
+		if (
+			!assertSongNotNull(this.currentSong) ||
+			!(await this.shouldScrobble())
+		) {
 			return;
 		}
 		await sendContentMessage({
@@ -942,7 +993,10 @@ export default class Controller {
 	}
 
 	private async setResumedPlaying(): Promise<void> {
-		if (!assertSongNotNull(this.currentSong) || !this.shouldScrobble()) {
+		if (
+			!assertSongNotNull(this.currentSong) ||
+			!(await this.shouldScrobble())
+		) {
 			return;
 		}
 		await sendContentMessage({
@@ -997,7 +1051,7 @@ export default class Controller {
 			return;
 		}
 
-		if (!this.shouldScrobble()) {
+		if (!(await this.shouldScrobble())) {
 			this.shouldHaveScrobbled = true;
 			return;
 		}
diff --git a/src/core/object/pipeline/coverartarchive/coverartarchive.ts b/src/core/object/pipeline/coverartarchive/coverartarchive.ts
index 85b9be0a..19c14d47 100644
--- a/src/core/object/pipeline/coverartarchive/coverartarchive.ts
+++ b/src/core/object/pipeline/coverartarchive/coverartarchive.ts
@@ -1,6 +1,12 @@
 import Song from '@/core/object/song';
 import { MusicBrainzSearch } from './coverartarchive.types';
 import { debugLog } from '@/core/content/util';
+import { timeoutPromise } from '@/util/util';
+
+/**
+ * How long to wait before giving up on musicbrainz request.
+ */
+const REQUEST_TIMEOUT = 5000;
 
 /**
  * Fetch coverart from MusicBrainz archive.
@@ -65,7 +71,7 @@ async function getMusicBrainzId(endpoint: string, song: Song) {
 		`title:+"${track ?? ''}"^3 ${track ?? ''} artistname:+"${
 			artist ?? ''
 		}"^4${artist ?? ''}`;
-	const response = await fetch(url);
+	const response = await timeoutPromise(REQUEST_TIMEOUT, fetch(url));
 	if (!response.ok) {
 		throw new Error('Unable to fetch MusicBrainz ID');
 	}
@@ -91,7 +97,10 @@ async function getMusicBrainzId(endpoint: string, song: Song) {
  */
 async function checkCoverArt(mbid: string) {
 	const coverArtUrl = `https://coverartarchive.org/release/${mbid}/front-500`;
-	const response = await fetch(coverArtUrl, { method: 'HEAD' });
+	const response = await timeoutPromise(
+		REQUEST_TIMEOUT,
+		fetch(coverArtUrl, { method: 'HEAD' }),
+	);
 	if (response.ok) {
 		return coverArtUrl;
 	}
diff --git a/src/core/object/scrobble-service.ts b/src/core/object/scrobble-service.ts
index 8117a410..d4d35fe1 100644
--- a/src/core/object/scrobble-service.ts
+++ b/src/core/object/scrobble-service.ts
@@ -4,6 +4,7 @@ import LibreFmScrobbler from '@/core/scrobbler/librefm-scrobbler';
 import ListenBrainzScrobbler from '@/core/scrobbler/listenbrainz/listenbrainz-scrobbler';
 import MalojaScrobbler from '@/core/scrobbler/maloja/maloja-scrobbler';
 import WebhookScrobbler from '@/core/scrobbler/webhook-scrobbler';
+import PleromaScrobbler from '@/core/scrobbler/pleroma/pleroma-scrobbler';
 import { ServiceCallResult } from '@/core/object/service-call-result';
 import { BaseSong } from '@/core/object/song';
 import { ScrobblerSongInfo } from '@/core/scrobbler/base-scrobbler';
@@ -19,7 +20,8 @@ export type Scrobbler =
 	| LibreFmScrobbler
 	| ListenBrainzScrobbler
 	| MalojaScrobbler
-	| WebhookScrobbler;
+	| WebhookScrobbler
+	| PleromaScrobbler;
 
 /**
  * Scrobblers that are registered and that can be bound.
@@ -30,6 +32,7 @@ const registeredScrobblers = [
 	new ListenBrainzScrobbler(),
 	new MalojaScrobbler(),
 	new WebhookScrobbler(),
+	new PleromaScrobbler(),
 ];
 
 export type ScrobblerLabel =
@@ -37,7 +40,8 @@ export type ScrobblerLabel =
 	| 'ListenBrainz'
 	| 'Libre.fm'
 	| 'Maloja'
-	| 'Webhook';
+	| 'Webhook'
+	| 'Pleroma';
 
 /**
  * Check if scrobbler is in given array of scrobblers.
diff --git a/src/core/object/song.ts b/src/core/object/song.ts
index a7e94e23..b5d36351 100644
--- a/src/core/object/song.ts
+++ b/src/core/object/song.ts
@@ -60,6 +60,7 @@ export interface CloneableSong {
 	flags: Flags;
 	metadata: Metadata;
 	connectorLabel: string;
+	connectorId: string;
 }
 
 export abstract class BaseSong {
@@ -69,6 +70,7 @@ export abstract class BaseSong {
 	public abstract flags: Flags;
 	public abstract metadata: Metadata;
 	public abstract connectorLabel: string;
+	public abstract connectorId: string;
 
 	/**
 	 * Get song artist.
@@ -226,6 +228,7 @@ export abstract class BaseSong {
 			metadata: this.metadata,
 			flags: this.flags,
 			connectorLabel: this.connectorLabel,
+			connectorId: this.connectorId,
 		};
 	}
 
@@ -302,6 +305,7 @@ export default class Song extends BaseSong {
 	public flags: Flags;
 	public metadata: Metadata;
 	public connectorLabel: string;
+	public connectorId: string;
 	/**
 	 * @param parsedData - Current state received from connector
 	 * @param connector - Connector match object
@@ -365,6 +369,7 @@ export default class Song extends BaseSong {
 		};
 
 		this.connectorLabel = connector.label;
+		this.connectorId = connector.id;
 
 		this.initSongData();
 	}
diff --git a/src/core/scrobbler/base-scrobbler.ts b/src/core/scrobbler/base-scrobbler.ts
index ae47247f..725ecb10 100644
--- a/src/core/scrobbler/base-scrobbler.ts
+++ b/src/core/scrobbler/base-scrobbler.ts
@@ -378,7 +378,8 @@ export default abstract class BaseScrobbler<K extends keyof ScrobblerModels> {
 		| 'ListenBrainz'
 		| 'Maloja'
 		| 'Libre.fm'
-		| 'Webhook';
+		| 'Webhook'
+		| 'Pleroma';
 
 	/**
 	 * Get URL to profile page.
diff --git a/src/core/scrobbler/pleroma/pleroma-scrobbler.ts b/src/core/scrobbler/pleroma/pleroma-scrobbler.ts
new file mode 100644
index 00000000..fc3801d6
--- /dev/null
+++ b/src/core/scrobbler/pleroma/pleroma-scrobbler.ts
@@ -0,0 +1,174 @@
+'use strict';
+
+import { ServiceCallResult } from '@/core/object/service-call-result';
+import { BaseSong } from '@/core/object/song';
+import { timeoutPromise } from '@/util/util';
+import BaseScrobbler, { SessionData } from '@/core/scrobbler/base-scrobbler';
+import {
+	PleromaTrackMetadata,
+	PleromaUser,
+} from '@/core/scrobbler/pleroma/pleroma.types';
+
+/**
+ * Module for communication with Pleroma
+ */
+
+export default class PleromaScrobbler extends BaseScrobbler<'Pleroma'> {
+	public userToken!: string;
+	public userApiUrl!: string;
+	public isLocalOnly = false;
+
+	/** @override */
+	protected getStorageName(): 'Pleroma' {
+		return 'Pleroma';
+	}
+
+	/** @override */
+	public getLabel(): 'Pleroma' {
+		return 'Pleroma';
+	}
+
+	/** @override */
+	public getStatusUrl(): string {
+		return '';
+	}
+
+	/** @override */
+	public getUserDefinedProperties(): string[] {
+		return ['userApiUrl', 'userToken'];
+	}
+
+	/** @override */
+	public async getProfileUrl(): Promise<string> {
+		return Promise.resolve('');
+	}
+
+	/** @override */
+	public async getAuthUrl(): Promise<string> {
+		return Promise.resolve('');
+	}
+	/** @override */
+	protected getBaseProfileUrl(): string {
+		return '';
+	}
+
+	/** @override */
+	public getSongInfo(): Promise<Record<string, never>> {
+		return Promise.resolve({});
+	}
+
+	public toggleLove(): Promise<ServiceCallResult> {
+		return Promise.resolve(ServiceCallResult.ERROR_OTHER);
+	}
+
+	/** @override */
+	public async getSession(): Promise<SessionData> {
+		if (!this.userToken) {
+			throw ServiceCallResult.ERROR_AUTH;
+		}
+
+		const requestInfo = {
+			method: 'GET',
+			headers: {
+				Authorization: `Bearer ${this.userToken}`,
+			},
+		};
+
+		const verifyCredentialsUrl = `https://${this.userApiUrl}/api/v1/accounts/verify_credentials`;
+		const promise = fetch(verifyCredentialsUrl, requestInfo);
+		const timeout = this.REQUEST_TIMEOUT;
+		let response = null;
+		try {
+			response = await timeoutPromise(timeout, promise);
+			if (response.status !== 200) {
+				throw ServiceCallResult.ERROR_AUTH;
+			}
+
+			const json = (await response.json()) as PleromaUser | null;
+			const fqn = json ? json.fqn : '';
+			return { sessionID: this.userToken, sessionName: fqn };
+		} catch (e) {
+			this.debugLog('Error while sending request', 'error');
+			throw ServiceCallResult.ERROR_AUTH;
+		}
+	}
+
+	/** @override */
+	public isReadyForGrantAccess(): Promise<boolean> {
+		return Promise.resolve(false);
+	}
+
+	/** @override */
+	public sendNowPlaying(): Promise<ServiceCallResult> {
+		// Pleroma does not support "now playing" scrobbles.
+		return Promise.resolve(ServiceCallResult.RESULT_OK);
+	}
+
+	/** @override */
+	async sendResumedPlaying(): Promise<ServiceCallResult> {
+		return Promise.resolve(ServiceCallResult.RESULT_OK);
+	}
+
+	/** @override */
+	async sendPaused(): Promise<ServiceCallResult> {
+		return Promise.resolve(ServiceCallResult.RESULT_OK);
+	}
+
+	/** @override */
+	public async scrobble(song: BaseSong): Promise<ServiceCallResult> {
+		const songData = this.makeTrackMetadata(song);
+
+		return this.sendRequest(songData, this.userToken);
+	}
+
+	/** Private methods */
+
+	async sendRequest(
+		params: PleromaTrackMetadata,
+		userToken: string,
+	): Promise<ServiceCallResult> {
+		const requestInfo = {
+			method: 'POST',
+			headers: {
+				'Content-Type': 'application/json',
+				Authorization: `Bearer ${userToken}`,
+			},
+			body: JSON.stringify(params),
+		};
+
+		const scrobbleUrl = `https://${this.userApiUrl}/api/v1/pleroma/scrobble`;
+
+		const promise = fetch(scrobbleUrl, requestInfo);
+
+		const timeout = this.REQUEST_TIMEOUT;
+
+		let response = null;
+		try {
+			response = await timeoutPromise(timeout, promise);
+			if (response.status !== 200) {
+				return ServiceCallResult.ERROR_OTHER;
+			}
+		} catch (e) {
+			this.debugLog('Error while sending request', 'error');
+			return ServiceCallResult.ERROR_OTHER;
+		}
+
+		return ServiceCallResult.RESULT_OK;
+	}
+
+	private makeTrackMetadata(song: BaseSong) {
+		const trackMeta: PleromaTrackMetadata = {
+			artist: song.getArtist() ?? '',
+			title: song.getTrack() ?? '',
+			length: song.getDuration(),
+			url: song.parsed.originUrl ?? '',
+		};
+
+		const album = song.getAlbum();
+		if (album) {
+			trackMeta.album = album;
+		}
+
+		return trackMeta;
+	}
+}
diff --git a/src/core/scrobbler/pleroma/pleroma.types.ts b/src/core/scrobbler/pleroma/pleroma.types.ts
new file mode 100644
index 00000000..c74ecdb8
--- /dev/null
+++ b/src/core/scrobbler/pleroma/pleroma.types.ts
@@ -0,0 +1,15 @@
+export interface PleromaTrackMetadata {
+	artist: string;
+	title: string;
+	url: string;
+	length?: number | null;
+
+	key?: string;
+	album?: string;
+	albumartists?: string[];
+}
+
+export interface PleromaUser {
+	id: string;
+	fqn: string;
+}
diff --git a/src/core/storage/blocklist.ts b/src/core/storage/blocklist.ts
new file mode 100644
index 00000000..1bbc1a12
--- /dev/null
+++ b/src/core/storage/blocklist.ts
@@ -0,0 +1,109 @@
+import * as BrowserStorage from '@/core/storage/browser-storage';
+import type { ChannelInfo } from '../content/util';
+
+export default class Blocklist {
+	private storage = BrowserStorage.getStorage(BrowserStorage.BLOCKLISTS);
+	private connectorId: string;
+	private isReady: Promise<true>;
+
+	constructor(connectorId: string) {
+		this.connectorId = connectorId;
+		this.isReady = this.init();
+	}
+
+	/**
+	 * Sets default values to blocklist if necessary
+	 */
+	private async setupDefaultBlocklist() {
+		let data = await this.storage.getLocking();
+		if (!data) {
+			data = {};
+		}
+		if (!(this.connectorId in data) || !data[this.connectorId]) {
+			data[this.connectorId] = {};
+		}
+		await this.storage.setLocking(data);
+	}
+
+	/**
+	 * Initializes the blocklist with default values
+	 */
+	private async init(): Promise<true> {
+		await this.setupDefaultBlocklist();
+		return true;
+	}
+
+	/**
+	 * Adds a channel ID to blocklist
+	 *
+	 * @param id - ID of channel to add
+	 */
+	public async addToBlocklist(
+		channelInfo: ChannelInfo | null,
+	): Promise<void> {
+		if (!channelInfo?.id) {
+			return;
+		}
+		await this.isReady;
+		const data = await this.storage.getLocking();
+		if (!data || !(this.connectorId in data) || !data[this.connectorId]) {
+			this.storage.unlock();
+			return;
+		}
+		data[this.connectorId] = {
+			...data[this.connectorId],
+			[channelInfo.id]: channelInfo.label || channelInfo.id,
+		};
+		await this.storage.setLocking(data);
+	}
+
+	/**
+	 * Removes a channel ID from blocklist
+	 *
+	 * @param id - ID of channel to remove
+	 */
+	public async removeFromBlocklist(id: string | null) {
+		if (!id) {
+			return;
+		}
+		await this.isReady;
+		const data = await this.storage.getLocking();
+		if (!data || !(this.connectorId in data) || !data[this.connectorId]) {
+			this.storage.unlock();
+			return;
+		}
+		delete data[this.connectorId][id];
+		this.storage.setLocking(data);
+	}
+
+	/**
+	 * @param id - ID of channel to check
+	 *
+	 * @returns label if channel is blocklisted; null if it isn't
+	 */
+	public async getChannelLabel(
+		id: string | undefined | null,
+	): Promise<string | null> {
+		if (!id) {
+			return null;
+		}
+		await this.isReady;
+		const data = await this.storage.get();
+		if (!data || !(this.connectorId in data) || !data[this.connectorId]) {
+			return null;
+		}
+
+		return data[this.connectorId][id];
+	}
+
+	/**
+	 * @param id - ID of channel to check
+	 *
+	 * @returns true if channel isn't blocklisted; false if it is.
+	 */
+	public async shouldScrobbleChannel(
+		id: string | undefined | null,
+	): Promise<boolean> {
+		return !(await this.getChannelLabel(id));
+	}
+}
diff --git a/src/core/storage/browser-storage.ts b/src/core/storage/browser-storage.ts
index 21de20c1..2572dc23 100644
--- a/src/core/storage/browser-storage.ts
+++ b/src/core/storage/browser-storage.ts
@@ -62,6 +62,12 @@ export const NOTIFICATIONS = 'Notifications';
  */
 export const LOCAL_CACHE = 'LocalCache';
 
+/**
+ * This storage contains the blocklist of each connector.
+ * Each blocklist is an object with channel IDs as keys.
+ */
+export const BLOCKLISTS = 'Blocklists';
+
 /**
  * This storage contains the options values.
  * @see DEFAULT_OPTIONS_MAP object in `config` module.
@@ -127,7 +133,9 @@ const storageTypeMap = {
 	ListenBrainz: LOCAL,
 	Maloja: LOCAL,
 	Webhook: LOCAL,
+	Pleroma: LOCAL,
 
+	[BLOCKLISTS]: LOCAL,
 	[LOCAL_CACHE]: LOCAL,
 	[REGEX_EDITS]: LOCAL,
 	[CORE]: LOCAL,
diff --git a/src/core/storage/wrapper.ts b/src/core/storage/wrapper.ts
index 44585f3d..64981336 100644
--- a/src/core/storage/wrapper.ts
+++ b/src/core/storage/wrapper.ts
@@ -12,6 +12,7 @@ import {
 	REGEX_EDITS,
 	STATE_MANAGEMENT,
 	StorageNamespace,
+	BLOCKLISTS,
 } from '@/core/storage/browser-storage';
 import {
 	ConnectorOptions,
@@ -70,6 +71,7 @@ export interface ScrobblerModels {
 	ListenBrainz?: ListenBrainzModel;
 	Maloja?: Properties;
 	Webhook?: WebhookModel;
+	Pleroma?: Properties;
 }
 
 export interface ManagerTab {
@@ -83,6 +85,10 @@ export interface StateManagement {
 	browserPreferredTheme: 'light' | 'dark';
 }
 
+export type Blocklists = Record<string, Blocklist>;
+
+export type Blocklist = Record<string, string>;
+
 export interface DataModels extends ScrobblerModels {
 	/* sync options */
 	[CUSTOM_PATTERNS]: CustomPatterns;
@@ -95,6 +101,7 @@ export interface DataModels extends ScrobblerModels {
 	[CORE]: { appVersion: string };
 	[LOCAL_CACHE]: { [key: string]: SavedEdit };
 	[REGEX_EDITS]: RegexEdit[];
+	[BLOCKLISTS]: Blocklists;
 
 	/* state management */
 	[STATE_MANAGEMENT]: StateManagement;
diff --git a/src/theme/themes.scss b/src/theme/themes.scss
index 600a6f54..0e2b1bbf 100644
--- a/src/theme/themes.scss
+++ b/src/theme/themes.scss
@@ -5,16 +5,21 @@ $themes: (
 		always-last-fm-hover: #fc3434,
 		background: #fff,
 		checkbox-inactive: #8e8e8e,
+		checkbox-active: #d82323,
 		sidebar-bg: rgb(250 235 235 / 70%),
 		context-bg: rgb(250 235 235 / 95%),
 		modal-bg: rgb(255 255 255 / 95%),
 		last-fm-bg: #faebeb,
 		muted-text: #555,
+		active-text: #fff,
 		text: #222,
 		last-fm: #d82323,
 		confirm: #00b300,
+		reject: #d82323,
+		indeterminate: #8e8e8e,
 		site-image: url('../img/main/site-light.jpg'),
 		invert-filter: invert(0%),
+		interactable-border: none,
 	),
 	dark: (
 		always-light: #fff,
@@ -22,16 +27,65 @@ $themes: (
 		always-last-fm-hover: #fc3434,
 		background: #1e1e1e,
 		checkbox-inactive: #8e8e8e,
+		checkbox-active: #d82323,
 		sidebar-bg: rgb(194 57 55 / 70%),
 		context-bg: rgb(194 57 55 / 95%),
 		modal-bg: rgb(40 40 40 / 95%),
 		last-fm-bg: #c23937,
 		muted-text: #bbb,
+		active-text: #fff,
 		text: #eee,
 		last-fm: #fc3434,
 		confirm: #00b300,
+		reject: #fc3434,
+		indeterminate: #8e8e8e,
 		site-image: url('../img/main/site-dark.jpg'),
 		invert-filter: invert(100%),
+		interactable-border: none,
+	),
+	high-contrast-dark: (
+		always-light: yellow,
+		always-last-fm: black,
+		always-last-fm-hover: lavender,
+		background: black,
+		checkbox-inactive: black,
+		checkbox-active: #8e8e8e,
+		sidebar-bg: black,
+		context-bg: black,
+		modal-bg: black,
+		last-fm-bg: black,
+		muted-text: yellow,
+		text: yellow,
+		active-text: black,
+		last-fm: lavender,
+		confirm: lime,
+		reject: red,
+		indeterminate: #8e8e8e,
+		site-image: black,
+		invert-filter: invert(100%),
+		interactable-border: 1px solid yellow,
+	),
+	high-contrast-light: (
+		always-light: black,
+		always-last-fm: seashell,
+		always-last-fm-hover: saddlebrown,
+		background: seashell,
+		checkbox-inactive: seashell,
+		checkbox-active: #8e8e8e,
+		sidebar-bg: seashell,
+		context-bg: seashell,
+		modal-bg: seashell,
+		last-fm-bg: seashell,
+		muted-text: black,
+		text: black,
+		active-text: seashell,
+		last-fm: saddlebrown,
+		confirm: lime,
+		reject: red,
+		indeterminate: #8e8e8e,
+		site-image: seashell,
+		invert-filter: invert(0%),
+		interactable-border: 1px solid black,
 	),
 );
 
@@ -43,7 +97,21 @@ $themes: (
 	}
 }
 
+// stylelint-disable-next-line
+.theme-high-contrast-dark a {
+	border: var(--interactable-border);
+}
+
+// stylelint-disable-next-line
+.theme-high-contrast-light a {
+	border: var(--interactable-border);
+}
+
 body {
 	// Avoid animation flickering in iOS
 	-webkit-transform: translate3d(0, 0, 0);
 }
+
+* {
+	-ms-high-contrast-adjust: none;
+}
diff --git a/src/theme/themes.ts b/src/theme/themes.ts
index aa889d65..1ba297ee 100644
--- a/src/theme/themes.ts
+++ b/src/theme/themes.ts
@@ -35,6 +35,20 @@ export async function updateTheme(theme: ModifiedTheme) {
 	});
 }
 
+/**
+ * @returns true if user has dark theme enabled on system/browser level; false otherwise
+ */
+const prefersDarkTheme = () =>
+	window.matchMedia('(prefers-color-scheme: dark)').matches;
+
+/**
+ * @returns true if user has high contrast mode enabled on system/browser level; false otherwise
+ */
+const usesHighContrast = () =>
+	window.matchMedia(
+		'(forced-colors: active), (prefers-contrast: more), (-ms-high-contrast: active)',
+	).matches;
+
 /**
  * Process the current theme, to get the correct display theme for themes that have multiple possibilities
  *
@@ -43,18 +57,28 @@ export async function updateTheme(theme: ModifiedTheme) {
  */
 function processTheme(theme: string) {
 	if (theme === 'theme-system') {
-		if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
-			return 'theme-dark';
-		}
-		return 'theme-light';
+		const themeColor = prefersDarkTheme() ? 'dark' : 'light';
+		const highContrastString = usesHighContrast() ? 'high-contrast-' : '';
+		return `theme-${highContrastString}${themeColor}`;
 	}
 	return theme;
 }
 
-type Theme = 'system' | 'dark' | 'light';
+type Theme =
+	| 'system'
+	| 'dark'
+	| 'light'
+	| 'high-contrast-dark'
+	| 'high-contrast-light';
 export type ModifiedTheme = `theme-${Theme}`;
 
-export const themeList: Theme[] = ['system', 'dark', 'light'];
+export const themeList: Theme[] = [
+	'system',
+	'dark',
+	'light',
+	'high-contrast-dark',
+	'high-contrast-light',
+];
 export const modifiedThemeList = themeList.map(
 	(theme) => `theme-${theme}`,
 ) as ModifiedTheme[];
diff --git a/src/ui/components/context-menu/context-menu.module.scss b/src/ui/components/context-menu/context-menu.module.scss
index ff635817..2aa9e1fe 100644
--- a/src/ui/components/context-menu/context-menu.module.scss
+++ b/src/ui/components/context-menu/context-menu.module.scss
@@ -4,6 +4,7 @@
 	display: flex;
 
 	.contextMenuItem {
+		border: var(--interactable-border);
 		flex-grow: 1;
 		padding: 0.5em 0;
 		position: relative;
@@ -11,7 +12,7 @@
 		@supports (-webkit-touch-callout: none) {
 			&.activeDialogButton {
 				background-color: var(--always-last-fm-hover);
-				color: var(--always-light);
+				color: var(--active-text);
 			}
 		}
 
@@ -34,6 +35,7 @@
 
 .contextMenu,
 .contextMenuDialog {
+	border: var(--interactable-border);
 	z-index: 1000000;
 
 	// stylelint-disable-next-line no-duplicate-selectors
@@ -45,7 +47,6 @@
 
 	.contextMenuItem {
 		background-color: transparent;
-		border: none;
 		color: var(--text);
 		cursor: pointer;
 		display: flex;
@@ -62,7 +63,7 @@
 		@supports not (-webkit-touch-callout: none) {
 			&:hover {
 				background-color: var(--always-last-fm-hover);
-				color: var(--always-light);
+				color: var(--active-text);
 			}
 		}
 	}
@@ -71,7 +72,6 @@
 .contextMenuDialog {
 	animation: 0s ease-in-out forwards hide;
 	background-color: var(--context-bg);
-	border: none;
 	border-radius: 0.5em;
 	color: var(--text);
 	display: flex;
@@ -111,6 +111,7 @@
 	}
 
 	.contextMenuItem {
+		border: none;
 		padding: 1em;
 
 		&:not(:last-child) {
diff --git a/src/ui/components/context-menu/context-menu.tsx b/src/ui/components/context-menu/context-menu.tsx
index 63daa89c..908d47aa 100644
--- a/src/ui/components/context-menu/context-menu.tsx
+++ b/src/ui/components/context-menu/context-menu.tsx
@@ -130,7 +130,9 @@ function ContextMenuItem(props: {
 				}}
 			>
 				<props.item.icon />
-				<label>{t(props.item.namei18n)}</label>
+				<label>
+					{t(props.item.namei18n, props.item.i18nSubstitution)}
+				</label>
 			</button>
 			<Show when={!itemIsSingular(props.item)}>
 				<dialog
diff --git a/src/ui/components/icons.module.scss b/src/ui/components/icons.module.scss
new file mode 100644
index 00000000..d3b24b9c
--- /dev/null
+++ b/src/ui/components/icons.module.scss
@@ -0,0 +1,10 @@
+.materialIcon {
+	display: inline-block;
+	fill: currentcolor;
+	flex-shrink: 0;
+	font-size: 1.5rem;
+	height: 1em;
+	transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
+	user-select: none;
+	width: 1em;
+}
diff --git a/src/ui/components/icons.tsx b/src/ui/components/icons.tsx
new file mode 100644
index 00000000..ba295331
--- /dev/null
+++ b/src/ui/components/icons.tsx
@@ -0,0 +1,249 @@
+import { JSXElement, createMemo } from 'solid-js';
+import styles from './icons.module.scss';
+
+function createMaterialIcon(Path: () => JSXElement) {
+	const Component = (props: { class?: string }) => {
+		const className = createMemo(() =>
+			props.class
+				? `${styles.materialIcon} ${props.class}`
+				: styles.materialIcon,
+		);
+
+		return (
+			<svg
+				aria-hidden="true"
+				{...{
+					['focusable']: 'false',
+				}}
+				viewBox="0 0 24 24"
+				{...props}
+				class={className()}
+			>
+				<Path />
+			</svg>
+		);
+	};
+	return Component;
+}
+
+export const AddOutlined = createMaterialIcon(() => (
+	<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
+));
+
+export const AutorenewOutlined = createMaterialIcon(() => (
+	<path d="M12 6v3l4-4-4-4v3c-4.42 0-8 3.58-8 8 0 1.57.46 3.03 1.24 4.26L6.7 14.8c-.45-.83-.7-1.79-.7-2.8 0-3.31 2.69-6 6-6zm6.76 1.74L17.3 9.2c.44.84.7 1.79.7 2.8 0 3.31-2.69 6-6 6v-3l-4 4 4 4v-3c4.42 0 8-3.58 8-8 0-1.57-.46-3.03-1.24-4.26z" />
+));
+
+export const BlockOutlined = createMaterialIcon(() => (
+	<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69 16.9C4.63 15.55 4 13.85 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1C19.37 8.45 20 10.15 20 12c0 4.42-3.58 8-8 8z" />
+));
+
+export const CancelScheduleSendOutlined = createMaterialIcon(() => (
+	<g>
+		<g>
+			<path d="M16.5,9c-0.42,0-0.83,0.04-1.24,0.11L1.01,3L1,10l10.06,1.34c-0.42,0.44-0.78,0.93-1.09,1.46L1,14l0.01,7l8.07-3.46 C9.59,21.19,12.71,24,16.5,24c4.14,0,7.5-3.36,7.5-7.5S20.64,9,16.5,9z M3,8.25l0.01-2.22l7.51,3.22L3,8.25z M9.1,15.36L3,17.97 v-2.22l6.17-0.82C9.14,15.07,9.12,15.21,9.1,15.36z M16.5,22c-3.03,0-5.5-2.47-5.5-5.5s2.47-5.5,5.5-5.5s5.5,2.47,5.5,5.5 S19.53,22,16.5,22z" />
+			<polygon points="18.27,14.03 16.5,15.79 14.73,14.03 14.03,14.73 15.79,16.5 14.03,18.27 14.73,18.97 16.5,17.21 18.27,18.97 18.97,18.27 17.21,16.5 18.97,14.73" />
+		</g>
+	</g>
+));
+
+export const CheckOutlined = createMaterialIcon(() => (
+	<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
+));
+
+export const CloseOutlined = createMaterialIcon(() => (
+	<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" />
+));
+
+export const CodeOutlined = createMaterialIcon(() => (
+	<path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z" />
+));
+
+export const ContactsOutlined = createMaterialIcon(() => (
+	<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4V6h16v12zM4 0h16v2H4zm0 22h16v2H4zm8-10c1.38 0 2.5-1.12 2.5-2.5S13.38 7 12 7 9.5 8.12 9.5 9.5 10.62 12 12 12zm0-3.5c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm5 7.49C17 13.9 13.69 13 12 13s-5 .9-5 2.99V17h10v-1.01zm-8.19-.49c.61-.52 2.03-1 3.19-1 1.17 0 2.59.48 3.2 1H8.81z" />
+));
+
+export const DeleteOutlined = createMaterialIcon(() => (
+	<path d="M16 9v10H8V9h8m-1.5-6h-5l-1 1H5v2h14V4h-3.5l-1-1zM18 7H6v12c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7z" />
+));
+
+export const DownloadOutlined = createMaterialIcon(() => (
+	<path d="M19 9h-4V3H9v6H5l7 7 7-7zm-8 2V5h2v6h1.17L12 13.17 9.83 11H11zm-6 7h14v2H5z" />
+));
+
+export const EditOutlined = createMaterialIcon(() => (
+	<path d="M14.06 9.02l.92.92L5.92 19H5v-.92l9.06-9.06M17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75z" />
+));
+
+export const ErrorOutlined = createMaterialIcon(() => (
+	<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
+));
+
+export const ExpandMoreOutlined = createMaterialIcon(() => (
+	<>
+		<path d="M24 24H0V0h24v24z" fill="none" opacity=".87" />
+		<path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6-1.41-1.41z" />
+	</>
+));
+
+export const ExtensionOutlined = createMaterialIcon(() => (
+	<path d="M10.5 4.5c.28 0 .5.22.5.5v2h6v6h2c.28 0 .5.22.5.5s-.22.5-.5.5h-2v6h-2.12c-.68-1.75-2.39-3-4.38-3s-3.7 1.25-4.38 3H4v-2.12c1.75-.68 3-2.39 3-4.38 0-1.99-1.24-3.7-2.99-4.38L4 7h6V5c0-.28.22-.5.5-.5m0-2C9.12 2.5 8 3.62 8 5H4c-1.1 0-1.99.9-1.99 2v3.8h.29c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-.3c0-1.49 1.21-2.7 2.7-2.7s2.7 1.21 2.7 2.7v.3H17c1.1 0 2-.9 2-2v-4c1.38 0 2.5-1.12 2.5-2.5S20.38 11 19 11V7c0-1.1-.9-2-2-2h-4c0-1.38-1.12-2.5-2.5-2.5z" />
+));
+
+export const FavoriteOutlined = createMaterialIcon(() => (
+	<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
+));
+
+export const HeartBrokenOutlined = createMaterialIcon(() => (
+	<g>
+		<path d="M16.5,3c-0.96,0-1.9,0.25-2.73,0.69L12,9h3l-3,10l1-9h-3l1.54-5.39C10.47,3.61,9.01,3,7.5,3C4.42,3,2,5.42,2,8.5 c0,4.13,4.16,7.18,10,12.5c5.47-4.94,10-8.26,10-12.5C22,5.42,19.58,3,16.5,3z M10.24,16.73C6.45,13.34,4,11,4,8.5 C4,6.54,5.54,5,7.5,5c0.59,0,1.19,0.15,1.73,0.42L7.35,12h3.42L10.24,16.73z M15.13,15.53L17.69,7h-2.91l0.61-1.82 C15.75,5.06,16.13,5,16.5,5C18.46,5,20,6.54,20,8.5C20,10.71,17.98,12.93,15.13,15.53z" />
+	</g>
+));
+
+export const HelpOutlined = createMaterialIcon(() => (
+	<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z" />
+));
+
+export const IndeterminateCheckBoxOutlined = createMaterialIcon(() => (
+	<>
+		<path d="M0 0h24v24H0z" fill="none" />
+		<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM7 11h10v2H7z" />
+	</>
+));
+
+export const InfoOutlined = createMaterialIcon(() => (
+	<path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" />
+));
+
+export const LockOutlined = createMaterialIcon(() => (
+	<>
+		<g fill="none">
+			<path d="M0 0h24v24H0V0z" />
+			<path d="M0 0h24v24H0V0z" opacity=".87" />
+		</g>
+		<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z" />
+	</>
+));
+
+export const ManageAccountsOutlined = createMaterialIcon(() => (
+	<>
+		<g>
+			<path d="M0,0h24v24H0V0z" fill="none" />
+		</g>
+		<g>
+			<g>
+				<path d="M4,18v-0.65c0-0.34,0.16-0.66,0.41-0.81C6.1,15.53,8.03,15,10,15c0.03,0,0.05,0,0.08,0.01c0.1-0.7,0.3-1.37,0.59-1.98 C10.45,13.01,10.23,13,10,13c-2.42,0-4.68,0.67-6.61,1.82C2.51,15.34,2,16.32,2,17.35V20h9.26c-0.42-0.6-0.75-1.28-0.97-2H4z" />
+				<path d="M10,12c2.21,0,4-1.79,4-4s-1.79-4-4-4C7.79,4,6,5.79,6,8S7.79,12,10,12z M10,6c1.1,0,2,0.9,2,2s-0.9,2-2,2 c-1.1,0-2-0.9-2-2S8.9,6,10,6z" />
+				<path d="M20.75,16c0-0.22-0.03-0.42-0.06-0.63l1.14-1.01l-1-1.73l-1.45,0.49c-0.32-0.27-0.68-0.48-1.08-0.63L18,11h-2l-0.3,1.49 c-0.4,0.15-0.76,0.36-1.08,0.63l-1.45-0.49l-1,1.73l1.14,1.01c-0.03,0.21-0.06,0.41-0.06,0.63s0.03,0.42,0.06,0.63l-1.14,1.01 l1,1.73l1.45-0.49c0.32,0.27,0.68,0.48,1.08,0.63L16,21h2l0.3-1.49c0.4-0.15,0.76-0.36,1.08-0.63l1.45,0.49l1-1.73l-1.14-1.01 C20.72,16.42,20.75,16.22,20.75,16z M17,18c-1.1,0-2-0.9-2-2s0.9-2,2-2s2,0.9,2,2S18.1,18,17,18z" />
+			</g>
+		</g>
+	</>
+));
+
+export const MusicNoteOutlined = createMaterialIcon(() => (
+	<path d="M12 3l.01 10.55c-.59-.34-1.27-.55-2-.55C7.79 13 6 14.79 6 17s1.79 4 4.01 4S14 19.21 14 17V7h4V3h-6zm-1.99 16c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z" />
+));
+
+export const MusicOffOutlined = createMaterialIcon(() => (
+	<path d="M14 7h4V3h-6v4.61l2 2zm-2 3.44L4.41 2.86 3 4.27l9 9v.28c-.94-.54-2.1-.75-3.33-.32-1.34.48-2.37 1.67-2.61 3.07-.46 2.74 1.86 5.08 4.59 4.65 1.96-.31 3.35-2.11 3.35-4.1v-1.58L19.73 21l1.41-1.41L12 10.44zM10 19c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z" />
+));
+
+export const PersonOffOutlined = createMaterialIcon(() => (
+	<>
+		<rect fill="none" height="24" width="24" />
+		<path d="M20,17.17l-3.37-3.38c0.64,0.22,1.23,0.48,1.77,0.76C19.37,15.06,19.98,16.07,20,17.17z M21.19,21.19l-1.41,1.41L17.17,20H4 v-2.78c0-1.12,0.61-2.15,1.61-2.66c1.29-0.66,2.87-1.22,4.67-1.45L1.39,4.22l1.41-1.41L21.19,21.19z M15.17,18l-3-3 c-0.06,0-0.11,0-0.17,0c-2.37,0-4.29,0.73-5.48,1.34C6.2,16.5,6,16.84,6,17.22V18H15.17z M12,6c1.1,0,2,0.9,2,2 c0,0.86-0.54,1.59-1.3,1.87l1.48,1.48C15.28,10.64,16,9.4,16,8c0-2.21-1.79-4-4-4c-1.4,0-2.64,0.72-3.35,1.82l1.48,1.48 C10.41,6.54,11.14,6,12,6z" />
+	</>
+));
+
+export const PersonOutlined = createMaterialIcon(() => (
+	<path d="M12 6c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2m0 10c2.7 0 5.8 1.29 6 2H6c.23-.72 3.31-2 6-2m0-12C9.79 4 8 5.79 8 8s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm0 10c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
+));
+
+export const PublishedWithChangesOutlined = createMaterialIcon(() => (
+	<>
+		<rect fill="none" height="24" width="24" />
+		<path d="M18.6,19.5H21v2h-6v-6h2v2.73c1.83-1.47,3-3.71,3-6.23c0-4.07-3.06-7.44-7-7.93V2.05c5.05,0.5,9,4.76,9,9.95 C22,14.99,20.68,17.67,18.6,19.5z M4,12c0-2.52,1.17-4.77,3-6.23V8.5h2v-6H3v2h2.4C3.32,6.33,2,9.01,2,12c0,5.19,3.95,9.45,9,9.95 v-2.02C7.06,19.44,4,16.07,4,12z M16.24,8.11l-5.66,5.66l-2.83-2.83l-1.41,1.41l4.24,4.24l7.07-7.07L16.24,8.11z" />
+	</>
+));
+
+export const RestartAltOutlined = createMaterialIcon(() => (
+	<>
+		<g>
+			<path d="M0,0h24v24H0V0z" fill="none" />
+		</g>
+		<g>
+			<g>
+				<path d="M6,13c0-1.65,0.67-3.15,1.76-4.24L6.34,7.34C4.9,8.79,4,10.79,4,13c0,4.08,3.05,7.44,7,7.93v-2.02 C8.17,18.43,6,15.97,6,13z M20,13c0-4.42-3.58-8-8-8c-0.06,0-0.12,0.01-0.18,0.01l1.09-1.09L11.5,2.5L8,6l3.5,3.5l1.41-1.41 l-1.08-1.08C11.89,7.01,11.95,7,12,7c3.31,0,6,2.69,6,6c0,2.97-2.17,5.43-5,5.91v2.02C16.95,20.44,20,17.08,20,13z" />
+			</g>
+		</g>
+	</>
+));
+
+export const SaveOutlined = createMaterialIcon(() => (
+	<path d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm2 16H5V5h11.17L19 7.83V19zm-7-7c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3zM6 6h9v4H6z" />
+));
+
+export const Send = createMaterialIcon(() => (
+	<>
+		<path d="M0 0h24v24H0z" fill="none" />
+		<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
+	</>
+));
+
+export const SentimentDissatisfiedOutlined = createMaterialIcon(() => (
+	<>
+		<circle cx="15.5" cy="9.5" r="1.5" />
+		<circle cx="8.5" cy="9.5" r="1.5" />
+		<path d="M12 14c-2.33 0-4.32 1.45-5.12 3.5h1.67c.69-1.19 1.97-2 3.45-2s2.75.81 3.45 2h1.67c-.8-2.05-2.79-3.5-5.12-3.5zm-.01-12C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" />
+	</>
+));
+
+export const SettingsOutlined = createMaterialIcon(() => (
+	<path d="M19.43 12.98c.04-.32.07-.64.07-.98 0-.34-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.09-.16-.26-.25-.44-.25-.06 0-.12.01-.17.03l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.06-.02-.12-.03-.18-.03-.17 0-.34.09-.43.25l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98 0 .33.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.09.16.26.25.44.25.06 0 .12-.01.17-.03l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.06.02.12.03.18.03.17 0 .34-.09.43-.25l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zm-1.98-1.71c.04.31.05.52.05.73 0 .21-.02.43-.05.73l-.14 1.13.89.7 1.08.84-.7 1.21-1.27-.51-1.04-.42-.9.68c-.43.32-.84.56-1.25.73l-1.06.43-.16 1.13-.2 1.35h-1.4l-.19-1.35-.16-1.13-1.06-.43c-.43-.18-.83-.41-1.23-.71l-.91-.7-1.06.43-1.27.51-.7-1.21 1.08-.84.89-.7-.14-1.13c-.03-.31-.05-.54-.05-.74s.02-.43.05-.73l.14-1.13-.89-.7-1.08-.84.7-1.21 1.27.51 1.04.42.9-.68c.43-.32.84-.56 1.25-.73l1.06-.43.16-1.13.2-1.35h1.39l.19 1.35.16 1.13 1.06.43c.43.18.83.41 1.23.71l.91.7 1.06-.43 1.27-.51.7 1.21-1.07.85-.89.7.14 1.13zM12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm0 6c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z" />
+));
+
+export const TimerOutlined = createMaterialIcon(() => (
+	<g>
+		<g>
+			<g>
+				<path d="M15,1H9v2h6V1z M11,14h2V8h-2V14z M19.03,7.39l1.42-1.42c-0.43-0.51-0.9-0.99-1.41-1.41l-1.42,1.42 C16.07,4.74,14.12,4,12,4c-4.97,0-9,4.03-9,9s4.02,9,9,9s9-4.03,9-9C21,10.88,20.26,8.93,19.03,7.39z M12,20c-3.87,0-7-3.13-7-7 s3.13-7,7-7s7,3.13,7,7S15.87,20,12,20z" />
+			</g>
+		</g>
+	</g>
+));
+
+export const ToggleOffOutlined = createMaterialIcon(() => (
+	<path d="M17 6H7c-3.31 0-6 2.69-6 6s2.69 6 6 6h10c3.31 0 6-2.69 6-6s-2.69-6-6-6zm0 10H7c-2.21 0-4-1.79-4-4s1.79-4 4-4h10c2.21 0 4 1.79 4 4s-1.79 4-4 4zM7 9c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" />
+));
+
+export const ToggleOnOutlined = createMaterialIcon(() => (
+	<>
+		<path d="M0 0h24v24H0z" fill="none" />
+		<path d="M17 6H7c-3.31 0-6 2.69-6 6s2.69 6 6 6h10c3.31 0 6-2.69 6-6s-2.69-6-6-6zm0 10H7c-2.21 0-4-1.79-4-4s1.79-4 4-4h10c2.21 0 4 1.79 4 4s-1.79 4-4 4zm0-7c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" />
+	</>
+));
+
+export const TuneOutlined = createMaterialIcon(() => (
+	<path d="M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z" />
+));
+
+export const UploadOutlined = createMaterialIcon(() => (
+	<path d="M9 16h6v-6h4l-7-7-7 7h4v6zm3-10.17L14.17 8H13v6h-2V8H9.83L12 5.83zM5 18h14v2H5z" />
+));
+
+export const VisibilityOutlined = createMaterialIcon(() => (
+	<path d="M12 6c3.79 0 7.17 2.13 8.82 5.5C19.17 14.87 15.79 17 12 17s-7.17-2.13-8.82-5.5C4.83 8.13 8.21 6 12 6m0-2C7 4 2.73 7.11 1 11.5 2.73 15.89 7 19 12 19s9.27-3.11 11-7.5C21.27 7.11 17 4 12 4zm0 5c1.38 0 2.5 1.12 2.5 2.5S13.38 14 12 14s-2.5-1.12-2.5-2.5S10.62 9 12 9m0-2c-2.48 0-4.5 2.02-4.5 4.5S9.52 16 12 16s4.5-2.02 4.5-4.5S14.48 7 12 7z" />
+));
+
+export const VolunteerActivismOutlined = createMaterialIcon(() => (
+	<g>
+		<g>
+			<g>
+				<g>
+					<path d="M16,13c3.09-2.81,6-5.44,6-7.7C22,3.45,20.55,2,18.7,2c-1.04,0-2.05,0.49-2.7,1.25C15.34,2.49,14.34,2,13.3,2 C11.45,2,10,3.45,10,5.3C10,7.56,12.91,10.19,16,13z M13.3,4c0.44,0,0.89,0.21,1.18,0.55L16,6.34l1.52-1.79 C17.81,4.21,18.26,4,18.7,4C19.44,4,20,4.56,20,5.3c0,1.12-2.04,3.17-4,4.99c-1.96-1.82-4-3.88-4-4.99C12,4.56,12.56,4,13.3,4z" />
+					<path d="M19,16h-2c0-1.2-0.75-2.28-1.87-2.7L8.97,11H1v11h6v-1.44l7,1.94l8-2.5v-1C22,17.34,20.66,16,19,16z M3,20v-7h2v7H3z M13.97,20.41L7,18.48V13h1.61l5.82,2.17C14.77,15.3,15,15.63,15,16c0,0-1.99-0.05-2.3-0.15l-2.38-0.79l-0.63,1.9l2.38,0.79 c0.51,0.17,1.04,0.26,1.58,0.26H19c0.39,0,0.74,0.23,0.9,0.56L13.97,20.41z" />
+				</g>
+			</g>
+		</g>
+	</g>
+));
diff --git a/src/ui/options/components/accounts.tsx b/src/ui/options/components/accounts.tsx
index 626210c4..d294c2be 100644
--- a/src/ui/options/components/accounts.tsx
+++ b/src/ui/options/components/accounts.tsx
@@ -15,10 +15,12 @@ import {
 } from 'solid-js';
 import styles from './components.module.scss';
 import browser from 'webextension-polyfill';
-import Delete from '@suid/icons-material/DeleteOutlined';
-import Save from '@suid/icons-material/SaveOutlined';
-import AutoRenew from '@suid/icons-material/AutorenewOutlined';
-import Check from '@suid/icons-material/CheckOutlined';
+import {
+	DeleteOutlined,
+	SaveOutlined,
+	AutorenewOutlined,
+	CheckOutlined,
+} from '@/ui/components/icons';
 import { debugLog } from '@/util/util';
 import { sendContentMessage } from '@/util/communication';
 import { Dynamic } from 'solid-js/web';
@@ -51,6 +53,18 @@ const scrobblerPropertiesMap = {
 			placeholder: 'accountsUserTokenPlaceholder',
 		},
 	},
+	Pleroma: {
+		userApiUrl: {
+			type: 'text',
+			title: 'instanceDomain',
+			placeholder: 'instanceDomainPlaceholder',
+		},
+		userToken: {
+			type: 'password',
+			title: 'accountsUserToken',
+			placeholder: 'accountsUserTokenPlaceholder',
+		},
+	},
 };
 
 /**
@@ -83,6 +97,7 @@ export default function Accounts() {
 			<ScrobblerDisplay label="ListenBrainz" />
 			<ScrobblerDisplay label="Maloja" />
 			<ScrobblerDisplay label="Webhook" />
+			<ScrobblerDisplay label="Pleroma" />
 		</>
 	);
 }
@@ -244,9 +259,9 @@ function SaveButton() {
 	const [state, setState] = createSignal(SaveState.BASE);
 
 	const icons = {
-		[SaveState.BASE]: Save,
-		[SaveState.SAVING]: AutoRenew,
-		[SaveState.SAVED]: Check,
+		[SaveState.BASE]: SaveOutlined,
+		[SaveState.SAVING]: AutorenewOutlined,
+		[SaveState.SAVED]: CheckOutlined,
 	};
 
 	return (
@@ -391,7 +406,7 @@ function ArrayProperties(props: { scrobbler: Scrobbler | null }) {
 											});
 										}}
 									>
-										<Delete />
+										<DeleteOutlined />
 									</button>
 									<For each={Object.values(item)}>
 										{(val) => (
diff --git a/src/ui/options/components/components.module.scss b/src/ui/options/components/components.module.scss
index b8058d3b..90c98e0e 100644
--- a/src/ui/options/components/components.module.scss
+++ b/src/ui/options/components/components.module.scss
@@ -11,13 +11,14 @@
 	}
 
 	.tripleCheckboxWrapper {
+		border: var(--interactable-border);
 		border-radius: $size;
 		display: flex;
 		overflow: hidden;
 
 		.tripleCheckboxLabel {
 			--btn-color: var(--last-fm);
-			--btn-active-color: var(--always-light);
+			--btn-active-color: var(--active-text);
 			--btn-active-background-color: var(--last-fm);
 			align-items: center;
 			background-color: var(--background);
@@ -35,6 +36,11 @@
 				color: var(--btn-active-color);
 			}
 
+			&.unchecked {
+				--btn-color: var(--reject);
+				--btn-active-background-color: var(--reject);
+			}
+
 			&.checked {
 				--btn-color: var(--confirm);
 				--btn-active-background-color: var(--confirm);
@@ -42,7 +48,12 @@
 
 			&.indeterminate {
 				--btn-color: var(--text);
-				--btn-active-background-color: var(--checkbox-inactive);
+				--btn-active-background-color: var(--indeterminate);
+			}
+
+			&:nth-child(2) {
+				border-left: var(--interactable-border);
+				border-right: var(--interactable-border);
 			}
 		}
 
@@ -77,7 +88,7 @@
 .button {
 	align-items: center;
 	background-color: var(--always-last-fm);
-	border: none;
+	border: var(--interactable-border);
 	border-radius: 3px;
 	color: var(--always-light);
 	cursor: pointer;
@@ -93,6 +104,7 @@
 
 	&:hover {
 		background-color: var(--always-last-fm-hover);
+		color: var(--active-text);
 	}
 
 	span {
@@ -139,6 +151,7 @@
 		--entry-color: var(--text);
 		--entry-margin-top: 5px;
 		align-items: center;
+		border: var(--interactable-border);
 		color: var(--entry-color);
 		display: flex;
 		padding: 0.5em 0;
@@ -201,7 +214,7 @@
 
 	select {
 		background-color: var(--background);
-		border: none;
+		border: var(--interactable-border);
 		border-radius: $size;
 		color: var(--text);
 		display: inline-block;
@@ -228,6 +241,7 @@
 
 	.checkboxWrapper {
 		background-color: var(--checkbox-background);
+		border: var(--interactable-border);
 		border-radius: $checkbox-size;
 		display: inline-block;
 		height: $checkbox-size;
@@ -250,7 +264,7 @@
 	}
 
 	input:checked ~ .checkboxWrapper {
-		--checkbox-background: var(--always-last-fm);
+		--checkbox-background: var(--checkbox-active);
 		--checkbox-offset: calc(
 			#{$tick-width} - #{$tick-offset} - #{$tick-size}
 		);
@@ -286,6 +300,7 @@
 }
 
 summary {
+	border: var(--interactable-border);
 	list-style: none;
 
 	&::-webkit-details-marker {
@@ -306,6 +321,7 @@ summary {
 
 	.radioLabel {
 		background-color: var(--background);
+		border: var(--interactable-border);
 		border-radius: 0.5em;
 		color: var(--text);
 		cursor: pointer;
@@ -317,7 +333,7 @@ summary {
 	.radioLabel:hover,
 	input:checked + .radioLabel {
 		background-color: var(--last-fm);
-		color: var(--always-light);
+		color: var(--active-text);
 	}
 }
 
@@ -353,6 +369,7 @@ summary {
 
 .patternWrapper {
 	$size: 0.5em;
+	border: var(--interactable-border);
 	border-radius: $size;
 	display: flex;
 	margin-bottom: $size;
@@ -365,6 +382,11 @@ summary {
 		color: var(--text);
 		font-size: 1.2em;
 	}
+
+	.button {
+		border: none;
+		border-left: var(--interactable-border);
+	}
 }
 
 .propLabel {
@@ -377,7 +399,7 @@ summary {
 .propInput {
 	$size: 0.5em;
 	background-color: var(--background);
-	border: none;
+	border: var(--interactable-border);
 	border-radius: $size;
 	color: var(--text);
 	font-size: 1em;
@@ -387,13 +409,14 @@ summary {
 
 .permissionsPopup {
 	background-color: var(--sidebar-bg);
-	color: var(--text);
+	border-bottom: var(--interactable-border);
 	margin: 0;
 	text-align: center;
 
 	button {
 		background: none;
 		border: none;
+		color: var(--text);
 		cursor: pointer;
 		font-size: 1.25em;
 		margin: 0;
@@ -403,6 +426,7 @@ summary {
 
 .attentionWindow {
 	background-color: var(--sidebar-bg);
+	border: var(--interactable-border);
 	color: var(--text);
 	font-size: 1.2em;
 	line-height: 1.25;
@@ -532,19 +556,21 @@ summary {
 
 		&::-webkit-slider-runnable-track {
 			background: var(--checkbox-inactive);
+			border: var(--interactable-border);
 			border-radius: 0.5rem;
 			height: 0.5rem;
 		}
 
 		&::-moz-range-track {
 			background: var(--checkbox-inactive);
+			border: var(--interactable-border);
 			border-radius: 0.5rem;
 			height: 0.5rem;
 		}
 
 		&::-moz-range-thumb {
 			background-color: var(--always-last-fm);
-			border: none;
+			border: var(--interactable-border);
 			border-radius: 0.25rem;
 			height: 1.5rem;
 			width: 1rem;
@@ -554,6 +580,7 @@ summary {
 			-webkit-appearance: none;
 			appearance: none;
 			background-color: var(--always-last-fm);
+			border: var(--interactable-border);
 			border-radius: 0.25rem;
 			height: 1.5rem;
 			margin-top: -0.5rem;
@@ -604,7 +631,7 @@ summary {
 			-moz-appearance: textfield;
 			appearance: textfield;
 			background-color: var(--background);
-			border: none;
+			border: var(--interactable-border);
 			border-radius: 0.5em;
 			color: var(--text);
 			font-size: 1.2em;
diff --git a/src/ui/options/components/connector-override.tsx b/src/ui/options/components/connector-override.tsx
index 9abd2ef8..525a5bfd 100644
--- a/src/ui/options/components/connector-override.tsx
+++ b/src/ui/options/components/connector-override.tsx
@@ -3,6 +3,7 @@ import * as Options from '@/core/storage/options';
 import {
 	Accessor,
 	For,
+	Setter,
 	Show,
 	Suspense,
 	createEffect,
@@ -10,11 +11,13 @@ import {
 	createSignal,
 } from 'solid-js';
 import styles from './components.module.scss';
-import Settings from '@suid/icons-material/SettingsOutlined';
-import ExpandMore from '@suid/icons-material/ExpandMoreOutlined';
-import Delete from '@suid/icons-material/DeleteOutlined';
+import {
+	SettingsOutlined,
+	ExpandMoreOutlined,
+	DeleteOutlined,
+	AddOutlined,
+} from '@/ui/components/icons';
 import connectors, { ConnectorMeta } from '@/core/connectors';
-import Add from '@suid/icons-material/AddOutlined';
 import {
 	Checkbox,
 	ConnectorTripleCheckbox,
@@ -23,6 +26,8 @@ import {
 } from './inputs';
 import browser from 'webextension-polyfill';
 import { t } from '@/util/i18n';
+import BlockedChannels from './edit-options/blocked-channels';
+import { ModalType } from './navigator';
 
 const globalOptions = BrowserStorage.getStorage(BrowserStorage.OPTIONS);
 const connectorOverrideOptions = BrowserStorage.getStorage(
@@ -45,7 +50,10 @@ const [customPatternOptions, setCustomPatternOptions] = createResource(
 /**
  * Component that shows the override options for all connectors
  */
-export default function ConnectorOverrideOptions() {
+export default function ConnectorOverrideOptions(props: {
+	setActiveModal: Setter<ModalType>;
+	modal: HTMLDialogElement | undefined;
+}) {
 	return (
 		<>
 			<h1>{t('optionsSupportedWebsites')}</h1>
@@ -54,7 +62,7 @@ export default function ConnectorOverrideOptions() {
 			<p innerHTML={t('optionsCustomPatternsHint')} />
 			<ul class={`${styles.connectorOptionsList} ${styles.optionList}`}>
 				<li>
-					<Settings />
+					<SettingsOutlined />
 					<Checkbox
 						title={t('optionsToggle')}
 						label={t('optionsToggle')}
@@ -95,7 +103,10 @@ export default function ConnectorOverrideOptions() {
 					/>
 				</li>
 
-				<ConnectorOptions />
+				<ConnectorOptions
+					setActiveModal={props.setActiveModal}
+					modal={props.modal}
+				/>
 			</ul>
 		</>
 	);
@@ -104,11 +115,20 @@ export default function ConnectorOverrideOptions() {
 /**
  * Connector Override Options list
  */
-function ConnectorOptions() {
+function ConnectorOptions(props: {
+	setActiveModal: Setter<ModalType>;
+	modal: HTMLDialogElement | undefined;
+}) {
 	return (
 		<Suspense fallback={<p>{t('optionsLoadingConnectorOptions')}</p>}>
 			<For each={connectors}>
-				{(connector) => <ConnectorOption connector={connector} />}
+				{(connector) => (
+					<ConnectorOption
+						setActiveModal={props.setActiveModal}
+						modal={props.modal}
+						connector={connector}
+					/>
+				)}
 			</For>
 		</Suspense>
 	);
@@ -117,7 +137,11 @@ function ConnectorOptions() {
 /**
  * The connector override options for one connector
  */
-function ConnectorOption(props: { connector: ConnectorMeta }) {
+function ConnectorOption(props: {
+	connector: ConnectorMeta;
+	setActiveModal: Setter<ModalType>;
+	modal: HTMLDialogElement | undefined;
+}) {
 	const [ref, setRef] = createSignal<HTMLDetailsElement>();
 	const [active, setActive] = createSignal(false);
 	createEffect(() => {
@@ -129,7 +153,7 @@ function ConnectorOption(props: { connector: ConnectorMeta }) {
 		<li>
 			<details ref={setRef}>
 				<summary>
-					<ExpandMore class={styles.expandVector} />
+					<ExpandMoreOutlined class={styles.expandVector} />
 					<SummaryCheckbox
 						title={props.connector.label}
 						label={props.connector.label}
@@ -167,6 +191,8 @@ function ConnectorOption(props: { connector: ConnectorMeta }) {
 					/>
 				</summary>
 				<ConnectorOverrideOptionDetails
+					setActiveModal={props.setActiveModal}
+					modal={props.modal}
 					connector={props.connector}
 					active={active}
 				/>
@@ -181,6 +207,8 @@ function ConnectorOption(props: { connector: ConnectorMeta }) {
 function ConnectorOverrideOptionDetails(props: {
 	connector: ConnectorMeta;
 	active: Accessor<boolean>;
+	setActiveModal: Setter<ModalType>;
+	modal: HTMLDialogElement | undefined;
 }) {
 	return (
 		<Show when={props.active()}>
@@ -313,6 +341,13 @@ function ConnectorOverrideOptionDetails(props: {
 			<h3>{t('customPatterns')}</h3>
 			<p>{t('customPatternsHint')}</p>
 			<EditCustomPatterns connector={props.connector} />
+			<Show when={props.connector.usesBlocklist}>
+				<BlockedChannels
+					setActiveModal={props.setActiveModal}
+					modal={props.modal}
+					connector={props.connector}
+				/>
+			</Show>
 		</Show>
 	);
 }
@@ -367,7 +402,7 @@ function EditCustomPatterns(props: { connector: ConnectorMeta }) {
 								});
 							}}
 						>
-							<Delete />
+							<DeleteOutlined />
 						</button>
 					</div>
 				)}
@@ -390,7 +425,7 @@ function EditCustomPatterns(props: { connector: ConnectorMeta }) {
 					});
 				}}
 			>
-				<Add />
+				<AddOutlined />
 				{t('customPatternsAdd')}
 			</button>
 		</>
diff --git a/src/ui/options/components/contact.tsx b/src/ui/options/components/contact.tsx
index 4f60e75d..e52ab07f 100644
--- a/src/ui/options/components/contact.tsx
+++ b/src/ui/options/components/contact.tsx
@@ -1,7 +1,7 @@
 import browser from 'webextension-polyfill';
 import githubIcon from './icons/github-mark.svg';
 import twitterBird from './icons/twitter-logo.svg';
-import Lock from '@suid/icons-material/LockOutlined';
+import { LockOutlined } from '@/ui/components/icons';
 import { t } from '@/util/i18n';
 import styles from './components.module.scss';
 
@@ -68,7 +68,7 @@ export default function ContactComponent() {
 					<li
 						class={`${styles.contactEntry} ${styles.contactPrivacy}`}
 					>
-						<Lock />
+						<LockOutlined />
 						<span class={styles.brandText}>Privacy</span>
 						<span class={styles.desc}>
 							{t('contactPrivacyPolicyDesc')}
diff --git a/src/ui/options/components/edit-options/blocked-channels.tsx b/src/ui/options/components/edit-options/blocked-channels.tsx
new file mode 100644
index 00000000..bc8d73e4
--- /dev/null
+++ b/src/ui/options/components/edit-options/blocked-channels.tsx
@@ -0,0 +1,129 @@
+import { t } from '@/util/i18n';
+import {
+	For,
+	Setter,
+	createEffect,
+	createResource,
+	createSignal,
+} from 'solid-js';
+import * as BrowserStorage from '@/core/storage/browser-storage';
+import styles from '../components.module.scss';
+import { DeleteOutlined } from '@/ui/components/icons';
+import { ExportBlocklist, ImportBlocklist, ViewBlocklist } from './util';
+import { ModalType } from '../navigator';
+import { ConnectorMeta } from '@/core/connectors';
+import { Blocklists } from '@/core/storage/wrapper';
+
+const blocklistStorage = BrowserStorage.getStorage(BrowserStorage.BLOCKLISTS);
+const [blocklists, { mutate }] = createResource(
+	blocklistStorage.get.bind(blocklistStorage),
+);
+const [connector, setConnector] = createSignal<ConnectorMeta>();
+
+/**
+ * Component that allows the user to see, import, and export connector blocklists.
+ */
+export default function BlockedChannels(props: {
+	setActiveModal: Setter<ModalType>;
+	modal: HTMLDialogElement | undefined;
+	connector: ConnectorMeta;
+}) {
+	createEffect(() => {
+		setConnector(props.connector);
+	});
+
+	return (
+		<>
+			<h3>{t('optionsBlockedSources')}</h3>
+			<p>{t('optionsBlockedSourcesDesc')}</p>
+			<div class={styles.buttonContainer}>
+				<ViewBlocklist
+					setActiveModal={props.setActiveModal}
+					modal={props.modal}
+					type={'blocklist'}
+				/>
+				<ExportBlocklist
+					blocklistWrapper={blocklistStorage}
+					connector={props.connector}
+					filename={`${props.connector.id}-blocklist.json`}
+				/>
+				<ImportBlocklist
+					blocklistWrapper={blocklistStorage}
+					connector={props.connector}
+					mutate={mutate}
+				/>
+			</div>
+		</>
+	);
+}
+
+/**
+ * Component that shows all the currently registered blocked channels and allows the user to unblock them.
+ * To be displayed in a modal.
+ */
+export function BlocklistModal() {
+	return (
+		<>
+			<h1>
+				{t(
+					'optionsBlockedSourcesPopupTitle',
+					Object.keys(
+						blocklists()?.[connector()?.id ?? ''] ?? {},
+					).length.toString(),
+				)}
+			</h1>
+			<ul class={styles.optionList}>
+				<For
+					each={Object.entries(
+						blocklists()?.[connector()?.id ?? ''] ?? {},
+					)}
+				>
+					{([channelId, channelLabel]) => (
+						<ChannelInfo
+							channelId={channelId}
+							channelLabel={channelLabel}
+							connector={connector()}
+							mutate={mutate}
+						/>
+					)}
+				</For>
+			</ul>
+		</>
+	);
+}
+
+/**
+ * Component that shows a blocked channel and allows the user to delete it.
+ */
+function ChannelInfo(props: {
+	channelId: string;
+	channelLabel: string;
+	connector: ConnectorMeta | undefined;
+	mutate: Setter<Blocklists | null | undefined>;
+}) {
+	return (
+		<li class={styles.deleteListing}>
+			<button
+				class={`${styles.button} ${styles.small} ${styles.marginRight}`}
+				onClick={(event) => {
+					event.stopPropagation();
+					const channelId = props.channelId;
+					const connector = props.connector;
+					props.mutate((e) => {
+						if (!e || !connector || !e[connector.id]) {
+							return e;
+						}
+						delete e[connector.id][channelId];
+						blocklistStorage.set(e);
+						return {
+							...e,
+						};
+					});
+				}}
+			>
+				<DeleteOutlined />
+			</button>
+			<span>{props.channelLabel}</span>
+		</li>
+	);
+}
diff --git a/src/ui/options/components/edit-options/edited-tracks.tsx b/src/ui/options/components/edit-options/edited-tracks.tsx
index ae645994..4466b6e9 100644
--- a/src/ui/options/components/edit-options/edited-tracks.tsx
+++ b/src/ui/options/components/edit-options/edited-tracks.tsx
@@ -3,7 +3,7 @@ import { For, Setter, createResource } from 'solid-js';
 import * as Options from '@/core/storage/options';
 import * as BrowserStorage from '@/core/storage/browser-storage';
 import styles from '../components.module.scss';
-import Delete from '@suid/icons-material/DeleteOutlined';
+import { DeleteOutlined } from '@/ui/components/icons';
 import { ExportEdits, ImportEdits, ViewEdits } from './util';
 import { ModalType } from '../navigator';
 
@@ -94,7 +94,7 @@ function TrackInfo(props: {
 					});
 				}}
 			>
-				<Delete />
+				<DeleteOutlined />
 			</button>
 			<span>
 				{props.track.artist} - {props.track.track}
diff --git a/src/ui/options/components/edit-options/regex-edits.tsx b/src/ui/options/components/edit-options/regex-edits.tsx
index f5c6827b..c1872f1c 100644
--- a/src/ui/options/components/edit-options/regex-edits.tsx
+++ b/src/ui/options/components/edit-options/regex-edits.tsx
@@ -2,7 +2,7 @@ import { t } from '@/util/i18n';
 import { For, Setter, createResource } from 'solid-js';
 import * as BrowserStorage from '@/core/storage/browser-storage';
 import styles from '../components.module.scss';
-import Delete from '@suid/icons-material/DeleteOutlined';
+import { DeleteOutlined } from '@/ui/components/icons';
 import { FieldType, RegexEdit, pascalCaseField } from '@/util/regex';
 import { ExportEdits, ImportEdits, ViewEdits } from './util';
 import { ModalType } from '../navigator';
@@ -86,7 +86,7 @@ function EditInfo(props: {
 					});
 				}}
 			>
-				<Delete />
+				<DeleteOutlined />
 			</button>
 			<div class={styles.regexDeleteContent}>
 				<span class={styles.regexDeleteSearchLabel}>
diff --git a/src/ui/options/components/edit-options/util.tsx b/src/ui/options/components/edit-options/util.tsx
index 6813d80c..4d55525e 100644
--- a/src/ui/options/components/edit-options/util.tsx
+++ b/src/ui/options/components/edit-options/util.tsx
@@ -1,14 +1,17 @@
-import StorageWrapper from '@/core/storage/wrapper';
+import StorageWrapper, { Blocklists } from '@/core/storage/wrapper';
 import * as Options from '@/core/storage/options';
 import * as BrowserStorage from '@/core/storage/browser-storage';
-import Visibility from '@suid/icons-material/VisibilityOutlined';
-import Upload from '@suid/icons-material/UploadOutlined';
-import Download from '@suid/icons-material/DownloadOutlined';
+import {
+	VisibilityOutlined,
+	UploadOutlined,
+	DownloadOutlined,
+} from '@/ui/components/icons';
 import styles from '../components.module.scss';
 import { t } from '@/util/i18n';
 import { Setter, createSignal } from 'solid-js';
 import { RegexEdit } from '@/util/regex';
 import { ModalType } from '../navigator';
+import { ConnectorMeta } from '@/core/connectors';
 
 type EditWrapper = StorageWrapper<
 	typeof BrowserStorage.REGEX_EDITS | typeof BrowserStorage.LOCAL_CACHE
@@ -39,7 +42,30 @@ export function ViewEdits(props: {
 				props.modal?.showModal();
 			}}
 		>
-			<Visibility />
+			<VisibilityOutlined />
+			{t('optionsViewEdited')}
+		</button>
+	);
+}
+
+/**
+ * Button that allows the user to open the modal that shows them their track edits
+ */
+export function ViewBlocklist(props: {
+	setActiveModal: Setter<ModalType>;
+	modal: HTMLDialogElement | undefined;
+	type: ModalType;
+}) {
+	return (
+		<button
+			class={`${styles.button} ${styles.shiftLeft}`}
+			onClick={(e) => {
+				e.stopImmediatePropagation();
+				props.setActiveModal(props.type);
+				props.modal?.showModal();
+			}}
+		>
+			<VisibilityOutlined />
 			{t('optionsViewEdited')}
 		</button>
 	);
@@ -59,12 +85,58 @@ export function ExportEdits(props: {
 				void downloadEdits(props.editWrapper, props.filename)
 			}
 		>
-			<Upload />
+			<UploadOutlined />
+			{t('optionsExportEdited')}
+		</button>
+	);
+}
+
+/**
+ * Button that exports blocklist for the user
+ */
+export function ExportBlocklist(props: {
+	blocklistWrapper: StorageWrapper<typeof BrowserStorage.BLOCKLISTS>;
+	connector: ConnectorMeta;
+	filename: string;
+}) {
+	return (
+		<button
+			class={`${styles.button} ${styles.shiftLeft}`}
+			onClick={() =>
+				void downloadBlocklist(
+					props.blocklistWrapper,
+					props.connector,
+					props.filename,
+				)
+			}
+		>
+			<UploadOutlined />
 			{t('optionsExportEdited')}
 		</button>
 	);
 }
 
+/**
+ * Compiles all the users track edits from storage and downloads them
+ */
+async function downloadBlocklist(
+	blocklistWrapper: StorageWrapper<typeof BrowserStorage.BLOCKLISTS>,
+	connector: ConnectorMeta,
+	filename: string,
+) {
+	const blocklists = await blocklistWrapper.get();
+	if (!blocklists || !blocklists[connector.id]) {
+		return;
+	}
+	const data = `data:text/json;charset=UTF-8,${encodeURIComponent(
+		JSON.stringify(blocklists[connector.id]),
+	)}`;
+	const a = document.createElement('a');
+	a.download = filename;
+	a.href = data;
+	a.click();
+}
+
 /**
  * Compiles all the users track edits from storage and downloads them
  */
@@ -82,6 +154,40 @@ async function downloadEdits(editWrapper: EditWrapper, filename: string) {
 	a.click();
 }
 
+/**
+ * Button that allows the user to upload a .json file and get blocklist from it.
+ */
+export function ImportBlocklist(props: {
+	blocklistWrapper: StorageWrapper<typeof BrowserStorage.BLOCKLISTS>;
+	connector: ConnectorMeta;
+	mutate: Setter<Blocklists | null | undefined>;
+}) {
+	const [ref, setRef] = createSignal<HTMLInputElement>();
+	return (
+		<button
+			class={`${styles.button} ${styles.shiftLeft}`}
+			onClick={() => ref()?.click()}
+		>
+			<DownloadOutlined />
+			{t('optionsImportEdited')}
+			<input
+				hidden={true}
+				ref={setRef}
+				type="file"
+				accept=".json"
+				onChange={(e) =>
+					pushBlocklist(
+						e,
+						props.blocklistWrapper,
+						props.connector,
+						props.mutate,
+					)
+				}
+			/>
+		</button>
+	);
+}
+
 /**
  * Button that allows the user to upload a .json file and get edits from it.
  */
@@ -95,7 +201,7 @@ export function ImportEdits(props: {
 			class={`${styles.button} ${styles.shiftLeft}`}
 			onClick={() => ref()?.click()}
 		>
-			<Download />
+			<DownloadOutlined />
 			{t('optionsImportEdited')}
 			<input
 				hidden={true}
@@ -145,3 +251,45 @@ function pushEdits(
 	);
 	reader.readAsText(file);
 }
+
+/**
+ * Reads an imported .json file and import the blocklist to storage
+ */
+function pushBlocklist(
+	e: Event & {
+		currentTarget: HTMLInputElement;
+		target: Element;
+	},
+	blocklistWrapper: StorageWrapper<typeof BrowserStorage.BLOCKLISTS>,
+	connector: ConnectorMeta,
+	mutate: Setter<Blocklists | null | undefined>,
+) {
+	const file = e.currentTarget.files?.[0];
+	if (!file) {
+		return;
+	}
+	const reader = new FileReader();
+	reader.addEventListener(
+		'load',
+		(arg) =>
+			void (async (e) => {
+				const newBlocklist = JSON.parse(
+					e.target?.result as string,
+				) as Record<string, string>;
+				let blocklists = await blocklistWrapper.get();
+				if (!blocklists) {
+					blocklists = {};
+				}
+				if (!blocklists[connector.id]) {
+					blocklists[connector.id] = {};
+				}
+				blocklists[connector.id] = {
+					...blocklists[connector.id],
+					...newBlocklist,
+				};
+				blocklistWrapper.set(blocklists);
+				mutate(blocklists);
+			})(arg),
+	);
+	reader.readAsText(file);
+}
diff --git a/src/ui/options/components/inputs.tsx b/src/ui/options/components/inputs.tsx
index 957108e5..2be74d01 100644
--- a/src/ui/options/components/inputs.tsx
+++ b/src/ui/options/components/inputs.tsx
@@ -4,10 +4,12 @@ import * as Options from '@/core/storage/options';
 import { t } from '@/util/i18n';
 import StorageWrapper from '@/core/storage/wrapper';
 import * as BrowserStorage from '@/core/storage/browser-storage';
-import Check from '@suid/icons-material/CheckOutlined';
-import Close from '@suid/icons-material/CloseOutlined';
-import RestartAlt from '@suid/icons-material/RestartAltOutlined';
-import IndeterminateCheckBox from '@suid/icons-material/IndeterminateCheckBoxOutlined';
+import {
+	CheckOutlined,
+	CloseOutlined,
+	RestartAltOutlined,
+	IndeterminateCheckBoxOutlined,
+} from '@/ui/components/icons';
 import { ConnectorMeta } from '@/core/connectors';
 import { clamp } from '@/util/util';
 
@@ -152,7 +154,7 @@ export function RadioButtons(props: {
 							props.reset?.(e);
 						}}
 					>
-						<RestartAlt />
+						<RestartAltOutlined />
 						{t('buttonReset')}
 					</button>
 				</li>
@@ -396,7 +398,7 @@ export function TripleCheckbox(props: {
 								props.onInput(TripleCheckboxState.Unchecked)
 							}
 						/>
-						<Close />
+						<CloseOutlined />
 					</label>
 					<label
 						class={`${styles.tripleCheckboxLabel} ${
@@ -420,7 +422,7 @@ export function TripleCheckbox(props: {
 								props.onInput(TripleCheckboxState.Indeterminate)
 							}
 						/>
-						<IndeterminateCheckBox />
+						<IndeterminateCheckBoxOutlined />
 					</label>
 					<label
 						class={`${styles.tripleCheckboxLabel} ${
@@ -443,7 +445,7 @@ export function TripleCheckbox(props: {
 								props.onInput(TripleCheckboxState.Checked)
 							}
 						/>
-						<Check />
+						<CheckOutlined />
 					</label>
 				</div>
 			</span>
diff --git a/src/ui/options/components/navigator.ts b/src/ui/options/components/navigator.ts
index 83f8924b..4be79002 100644
--- a/src/ui/options/components/navigator.ts
+++ b/src/ui/options/components/navigator.ts
@@ -1,30 +1,39 @@
 import { Component, Setter } from 'solid-js';
-import Info from '@suid/icons-material/InfoOutlined';
-import Help from '@suid/icons-material/HelpOutlined';
-import Contacts from '@suid/icons-material/ContactsOutlined';
-import Settings from '@suid/icons-material/SettingsOutlined';
-import Edit from '@suid/icons-material/EditOutlined';
-import Extension from '@suid/icons-material/ExtensionOutlined';
-import ManageAccounts from '@suid/icons-material/ManageAccountsOutlined';
+import {
+	InfoOutlined,
+	HelpOutlined,
+	ContactsOutlined,
+	SettingsOutlined,
+	EditOutlined,
+	ExtensionOutlined,
+	ManageAccountsOutlined,
+	TuneOutlined,
+	ToggleOnOutlined,
+	ToggleOffOutlined,
+	TimerOutlined,
+	FavoriteOutlined,
+	PersonOutlined,
+	PersonOffOutlined,
+} from '@/ui/components/icons';
+
 import InfoComponent from '@/ui/options/components/info';
 import ShowSomeLove from '@/ui/options/components/showSomeLove';
 import FAQ from '@/ui/options/components/faq';
 import ContactComponent from '@/ui/options/components/contact';
 import OptionsComponent from '@/ui/options/components/options/options';
 import Accounts from '@/ui/options/components/accounts';
-import Tune from '@suid/icons-material/TuneOutlined';
-import ToggleOn from '@suid/icons-material/ToggleOnOutlined';
-import ToggleOff from '@suid/icons-material/ToggleOffOutlined';
-import Timer from '@suid/icons-material/TimerOutlined';
 import ConnectorOverrideOptions from '@/ui/options/components/connector-override';
 import EditOptions from '@/ui/options/components/edit-options/edit-options';
-import Favorite from '@suid/icons-material/FavoriteOutlined';
 import browser from 'webextension-polyfill';
 import {
+	addToBlocklist,
 	disableConnector,
 	disableUntilClosed,
 	enableConnector,
+	getChannelBlocklistLabel,
+	getChannelDetails,
 	getCurrentTab,
+	removeFromBlocklist,
 } from '@/core/background/util';
 import * as ControllerMode from '@/core/object/controller/controller-mode';
 import AdvancedOptionsComponent from './advanced-settings';
@@ -32,39 +41,42 @@ import AdvancedOptionsComponent from './advanced-settings';
 /**
  * Type indicating possible states for modal
  */
-export type ModalType = 'savedEdits' | 'regexEdits' | '';
+export type ModalType = 'savedEdits' | 'regexEdits' | 'blocklist' | '';
 
 /**
- * Singular navigator button
+ * Mutual base button
  */
-export type NavigatorNavigationButton = {
+interface NavigatorButtonBase {
 	namei18n: string;
-	icon: typeof ManageAccounts;
+	i18nSubstitution?: string | string[];
+	icon: typeof ManageAccountsOutlined;
+}
+
+/**
+ * Singular navigator button
+ */
+export interface NavigatorNavigationButton extends NavigatorButtonBase {
 	element: Component<{
 		setActiveModal: Setter<ModalType>;
 		modal: HTMLDialogElement | undefined;
 	}>;
-};
+}
 
 /**
  * Singular navigator button that performs an action rather than opening a page
  */
-export type NavigatorActionButton = {
-	namei18n: string;
-	icon: typeof ManageAccounts;
+export interface NavigatorActionButton extends NavigatorButtonBase {
 	action: () => void;
-};
+}
 
 export type NavigatorButton = NavigatorNavigationButton | NavigatorActionButton;
 
 /**
  * Group of navigator buttons
  */
-export type NavigatorButtonGroup = {
-	namei18n: string;
-	icon: typeof ManageAccounts;
+export interface NavigatorButtonGroup extends NavigatorButtonBase {
 	group: NavigatorButton[];
-};
+}
 
 /**
  * A navigator consisting of a list of buttons and button groups
@@ -73,37 +85,37 @@ export type Navigator = (NavigatorButton | NavigatorButtonGroup)[];
 
 export const accountItem: NavigatorNavigationButton = {
 	namei18n: 'optionsAccounts',
-	icon: ManageAccounts,
+	icon: ManageAccountsOutlined,
 	element: Accounts,
 };
 
 export const optionsItem: NavigatorNavigationButton = {
 	namei18n: 'optionsOptions',
-	icon: Settings,
+	icon: SettingsOutlined,
 	element: OptionsComponent,
 };
 
 export const editOptionsItem: NavigatorNavigationButton = {
 	namei18n: 'optionsEdits',
-	icon: Edit,
+	icon: EditOutlined,
 	element: EditOptions,
 };
 
 export const connectorOverrideOptionsItem: NavigatorNavigationButton = {
 	namei18n: 'optionsConnectors',
-	icon: Extension,
+	icon: ExtensionOutlined,
 	element: ConnectorOverrideOptions,
 };
 
 export const advancedOptionsItem: NavigatorNavigationButton = {
 	namei18n: 'optionsAdvanced',
-	icon: Tune,
+	icon: TuneOutlined,
 	element: AdvancedOptionsComponent,
 };
 
 export const optionsGroup: NavigatorButtonGroup = {
 	namei18n: 'optionsOptions',
-	icon: Settings,
+	icon: SettingsOutlined,
 	group: [
 		optionsItem,
 		editOptionsItem,
@@ -114,31 +126,31 @@ export const optionsGroup: NavigatorButtonGroup = {
 
 export const contactItem: NavigatorNavigationButton = {
 	namei18n: 'contactTitle',
-	icon: Contacts,
+	icon: ContactsOutlined,
 	element: ContactComponent,
 };
 
 export const faqItem: NavigatorNavigationButton = {
 	namei18n: 'faqTitle',
-	icon: Help,
+	icon: HelpOutlined,
 	element: FAQ,
 };
 
 export const aboutItem: NavigatorNavigationButton = {
 	namei18n: 'optionsAbout',
-	icon: Info,
+	icon: InfoOutlined,
 	element: InfoComponent,
 };
 
 export const aboutGroup: NavigatorButtonGroup = {
 	namei18n: 'optionsAbout',
-	icon: Info,
+	icon: InfoOutlined,
 	group: [contactItem, faqItem, aboutItem],
 };
 
 export const showSomeLoveItem: NavigatorNavigationButton = {
 	namei18n: 'showSomeLoveTitle',
-	icon: Favorite,
+	icon: FavoriteOutlined,
 	element: ShowSomeLove,
 };
 
@@ -160,45 +172,77 @@ async function getToggleNavigators(): Promise<NavigatorActionButton[]> {
 	if (tab.mode === ControllerMode.Unsupported) {
 		return [];
 	}
+	const navigators: NavigatorActionButton[] = [];
 
 	if (tab.mode === ControllerMode.Disabled) {
-		return [
+		navigators.push({
+			namei18n: 'menuEnableConnector',
+			icon: ToggleOnOutlined,
+			action: () => {
+				enableConnector(tab.tabId);
+			},
+		});
+	} else {
+		navigators.push(
 			{
-				namei18n: 'menuEnableConnector',
-				icon: ToggleOn,
+				namei18n: 'menuDisableConnector',
+				icon: ToggleOffOutlined,
 				action: () => {
-					enableConnector(tab.tabId);
+					disableConnector(tab.tabId);
 				},
 			},
-		];
+			{
+				namei18n: 'menuDisableUntilTabClosed',
+				icon: TimerOutlined,
+				action: () => {
+					disableUntilClosed(tab.tabId);
+				},
+			},
+		);
 	}
 
-	return [
-		{
-			namei18n: 'menuDisableConnector',
-			icon: ToggleOff,
+	const channelDetails = await getChannelDetails(tab.tabId);
+	if (
+		!channelDetails ||
+		!channelDetails.channelInfo?.id ||
+		!channelDetails.connector
+	) {
+		return navigators;
+	} else if (
+		await getChannelBlocklistLabel(
+			channelDetails.channelInfo.id,
+			channelDetails.connector,
+		)
+	) {
+		navigators.push({
+			namei18n: 'menuEnableChannel',
+			i18nSubstitution: channelDetails.channelInfo.label,
+			icon: PersonOutlined,
 			action: () => {
-				disableConnector(tab.tabId);
+				removeFromBlocklist(tab.tabId);
 			},
-		},
-		{
-			namei18n: 'menuDisableUntilTabClosed',
-			icon: Timer,
+		});
+	} else {
+		navigators.push({
+			namei18n: 'menuDisableChannel',
+			i18nSubstitution: channelDetails.channelInfo.label,
+			icon: PersonOffOutlined,
 			action: () => {
-				disableUntilClosed(tab.tabId);
+				addToBlocklist(tab.tabId);
 			},
-		},
-	];
+		});
+	}
+	return navigators;
 }
 
 export async function getMobileNavigatorGroup(): Promise<NavigatorButtonGroup> {
 	const group: NavigatorButtonGroup = {
 		namei18n: 'optionsOptions',
-		icon: Settings,
+		icon: SettingsOutlined,
 		group: [
 			{
 				namei18n: 'optionsOptions',
-				icon: Settings,
+				icon: SettingsOutlined,
 				action: () => {
 					browser.tabs.create({
 						url: browser.runtime.getURL(
diff --git a/src/ui/options/components/options/global-options.tsx b/src/ui/options/components/options/global-options.tsx
index 915b8255..c7ac84b4 100644
--- a/src/ui/options/components/options/global-options.tsx
+++ b/src/ui/options/components/options/global-options.tsx
@@ -5,7 +5,7 @@ import browser from 'webextension-polyfill';
 import { GlobalOptionEntry } from '../inputs';
 import styles from '../components.module.scss';
 import { t } from '@/util/i18n';
-import { capitalizeFirstLetter, debugLog } from '@/util/util';
+import { debugLog, kebabCaseToPascalCase } from '@/util/util';
 import {
 	ModifiedTheme,
 	getTheme,
@@ -105,7 +105,7 @@ function ThemeSelector() {
 						{(themeName) => (
 							<option value={`theme-${themeName}`}>
 								{t(
-									`optionTheme${capitalizeFirstLetter(
+									`optionTheme${kebabCaseToPascalCase(
 										themeName,
 									)}`,
 								)}
diff --git a/src/ui/options/components/showSomeLove.tsx b/src/ui/options/components/showSomeLove.tsx
index 45da4079..13e50dd9 100644
--- a/src/ui/options/components/showSomeLove.tsx
+++ b/src/ui/options/components/showSomeLove.tsx
@@ -1,5 +1,5 @@
 import { t } from '@/util/i18n';
-import VolunteerActivism from '@suid/icons-material/VolunteerActivismOutlined';
+import { VolunteerActivismOutlined } from '@/ui/components/icons';
 import styles from './components.module.scss';
 
 /**
@@ -20,7 +20,7 @@ export default function ShowSomeLove() {
 				target="_blank"
 				rel="noopener noreferrer"
 			>
-				<VolunteerActivism />
+				<VolunteerActivismOutlined />
 				<span>{t('donationButtonLabel')}</span>
 			</a>
 		</>
diff --git a/src/ui/options/main.tsx b/src/ui/options/main.tsx
index 71bb5a98..819af045 100644
--- a/src/ui/options/main.tsx
+++ b/src/ui/options/main.tsx
@@ -1,9 +1,9 @@
-import { render } from 'solid-js/web';
+import { Dynamic, render } from 'solid-js/web';
 import styles from './settings.module.scss';
 import { initializeThemes } from '@/theme/themes';
 import '@/theme/themes.scss';
-import { Match, Show, Switch, createSignal, onCleanup } from 'solid-js';
-import Close from '@suid/icons-material/CloseOutlined';
+import { Show, createSignal, onCleanup } from 'solid-js';
+import { CloseOutlined } from '@/ui/components/icons';
 import Sidebar from './sidebar/sidebar';
 import { EditsModal } from './components/edit-options/edited-tracks';
 import Permissions from './components/permissions';
@@ -18,6 +18,7 @@ import {
 	showSomeLoveItem,
 } from './components/navigator';
 import ContextMenu from '../components/context-menu/context-menu';
+import { BlocklistModal } from './components/edit-options/blocked-channels';
 
 /**
  * Media query for detecting whether to use context menu or sidebar
@@ -49,6 +50,13 @@ function getDefaultSetting(): NavigatorNavigationButton {
 
 const defaultSetting = getDefaultSetting();
 
+const modalMap = {
+	savedEdits: EditsModal,
+	regexEdits: RegexEditsModal,
+	blocklist: BlocklistModal,
+	'': () => <div>Loading...</div>,
+};
+
 /**
  * Preferences component, with a sidebar and several different options and info pages
  */
@@ -111,20 +119,13 @@ function Options() {
 				onClose={() => setActiveModal('')}
 			>
 				<div class={styles.modalContent}>
-					<Switch fallback={<div>Loading...</div>}>
-						<Match when={activeModal() === 'savedEdits'}>
-							<EditsModal />
-						</Match>
-						<Match when={activeModal() === 'regexEdits'}>
-							<RegexEditsModal />
-						</Match>
-					</Switch>
+					<Dynamic component={modalMap[activeModal()]} />
 				</div>
 				<button
 					class={styles.modalClose}
 					onClick={() => modal?.close()}
 				>
-					<Close />
+					<CloseOutlined />
 				</button>
 			</dialog>
 		</>
diff --git a/src/ui/options/settings.module.scss b/src/ui/options/settings.module.scss
index 9b11bd8c..bc4cbeb2 100644
--- a/src/ui/options/settings.module.scss
+++ b/src/ui/options/settings.module.scss
@@ -86,7 +86,7 @@ a {
 
 .modal {
 	background-color: var(--modal-bg);
-	border: none;
+	border: var(--interactable-border);
 	border-radius: 3em;
 	color: var(--text);
 	height: 80vh;
@@ -116,7 +116,10 @@ a {
 
 	.modalClose {
 		background-color: var(--always-last-fm);
-		border: none;
+		border-bottom: var(--interactable-border);
+		border-left: var(--interactable-border);
+		border-right: none;
+		border-top: none;
 		color: var(--always-light);
 		cursor: pointer;
 		padding: 0.75em 1.75em 0.5em 1em;
diff --git a/src/ui/options/sidebar/sidebar.module.scss b/src/ui/options/sidebar/sidebar.module.scss
index ace5b544..0a893625 100644
--- a/src/ui/options/sidebar/sidebar.module.scss
+++ b/src/ui/options/sidebar/sidebar.module.scss
@@ -8,7 +8,7 @@
 		.sidebarButton {
 			align-items: center;
 			background-color: transparent;
-			border: none;
+			border: var(--interactable-border);
 			color: var(--text);
 			cursor: pointer;
 			display: flex;
@@ -24,7 +24,7 @@
 			&.active,
 			&:hover {
 				background-color: var(--last-fm);
-				color: var(--always-light);
+				color: var(--active-text);
 			}
 		}
 	}
diff --git a/src/ui/popup/base.tsx b/src/ui/popup/base.tsx
index c1ed0ed4..530d764c 100644
--- a/src/ui/popup/base.tsx
+++ b/src/ui/popup/base.tsx
@@ -1,7 +1,6 @@
 import { t } from '@/util/i18n';
-import MusicNote from '@suid/icons-material/MusicNoteOutlined';
+import { ExpandMoreOutlined, MusicNoteOutlined } from '@/ui/components/icons';
 import styles from './popup.module.scss';
-import ExpandMore from '@suid/icons-material/ExpandMoreOutlined';
 import { TPopupAnchor } from '../components/util';
 
 /**
@@ -10,11 +9,11 @@ import { TPopupAnchor } from '../components/util';
 export default function Base() {
 	return (
 		<div class={styles.alertPopup}>
-			<MusicNote class={styles.bigIcon} />
+			<MusicNoteOutlined class={styles.bigIcon} />
 			<h1>{t('getStartedHeader')}</h1>
 			<details class={styles.alreadyPlaying}>
 				<summary>
-					<ExpandMore class={styles.expandVector} />
+					<ExpandMoreOutlined class={styles.expandVector} />
 					{t('getStartedSubheader')}
 				</summary>
 				<p>{t('getStartedSiteChanged')}</p>
diff --git a/src/ui/popup/disabled.tsx b/src/ui/popup/disabled.tsx
index fc9611d4..783c6255 100644
--- a/src/ui/popup/disabled.tsx
+++ b/src/ui/popup/disabled.tsx
@@ -1,8 +1,7 @@
 import { t } from '@/util/i18n';
-import MusicOff from '@suid/icons-material/MusicOffOutlined';
+import { MusicOffOutlined, SettingsOutlined } from '@/ui/components/icons';
 import styles from './popup.module.scss';
 import browser from 'webextension-polyfill';
-import Settings from '@suid/icons-material/SettingsOutlined';
 import optionComponentStyles from '../options/components/components.module.scss';
 import { PopupAnchor } from '../components/util';
 
@@ -12,7 +11,7 @@ import { PopupAnchor } from '../components/util';
 export default function Disabled() {
 	return (
 		<div class={styles.alertPopup}>
-			<MusicOff class={styles.bigIcon} />
+			<MusicOffOutlined class={styles.bigIcon} />
 			<h1>{t('disabledSiteHeader')}</h1>
 			<p>{t('disabledSiteDesc')}</p>
 			<PopupAnchor
@@ -21,7 +20,7 @@ export default function Disabled() {
 				)}
 				class={`${optionComponentStyles.button} ${optionComponentStyles.centered}`}
 			>
-				<Settings />
+				<SettingsOutlined />
 				{t('disabledSiteButton')}
 			</PopupAnchor>
 		</div>
diff --git a/src/ui/popup/disallowed.tsx b/src/ui/popup/disallowed.tsx
index b3e80aa0..8d5d672d 100644
--- a/src/ui/popup/disallowed.tsx
+++ b/src/ui/popup/disallowed.tsx
@@ -4,8 +4,7 @@ import { Resource, Show, createMemo, createSignal } from 'solid-js';
 import styles from './popup.module.scss';
 import optionComponentStyles from '../options/components/components.module.scss';
 import { t } from '@/util/i18n';
-import MusicOff from '@suid/icons-material/MusicOffOutlined';
-import Send from '@suid/icons-material/Send';
+import { MusicOffOutlined, Send } from '@/ui/components/icons';
 import { sendBackgroundMessage } from '@/util/communication';
 
 export default function Disallowed(props: { tab: Resource<ManagerTab> }) {
@@ -25,7 +24,7 @@ export default function Disallowed(props: { tab: Resource<ManagerTab> }) {
 	return (
 		<Show when={!isLoading()}>
 			<div class={styles.widePopup}>
-				<MusicOff class={styles.bigIcon} />
+				<MusicOffOutlined class={styles.bigIcon} />
 				<h1>{t('disallowedHeader')}</h1>
 				<p>
 					{t('disallowedDesc1', [
diff --git a/src/ui/popup/edit.tsx b/src/ui/popup/edit.tsx
index f303b08e..58cf21d8 100644
--- a/src/ui/popup/edit.tsx
+++ b/src/ui/popup/edit.tsx
@@ -16,9 +16,11 @@ import {
 import { ManagerTab } from '@/core/storage/wrapper';
 import browser from 'webextension-polyfill';
 import ClonedSong from '@/core/object/cloned-song';
-import Check from '@suid/icons-material/CheckOutlined';
-import Code from '@suid/icons-material/CodeOutlined';
-import PublishedWithChanges from '@suid/icons-material/PublishedWithChangesOutlined';
+import {
+	CheckOutlined,
+	CodeOutlined,
+	PublishedWithChangesOutlined,
+} from '@/ui/components/icons';
 import { sendBackgroundMessage } from '@/util/communication';
 import savedEdits from '@/core/storage/saved-edits';
 import Regex, { RegexEditContextMenu } from './regex';
@@ -237,7 +239,7 @@ export default function Edit(props: { tab: Resource<ManagerTab> }) {
 											});
 										}}
 									>
-										<Check />
+										<CheckOutlined />
 									</button>
 									<button
 										class={styles.controlButton}
@@ -257,14 +259,14 @@ export default function Edit(props: { tab: Resource<ManagerTab> }) {
 											});
 										}}
 									>
-										<PublishedWithChanges />
+										<PublishedWithChangesOutlined />
 									</button>
 									<button
 										class={styles.controlButton}
 										title={t('infoRegexTitle')}
 										onClick={() => setIsRegex(true)}
 									>
-										<Code />
+										<CodeOutlined />
 									</button>
 								</div>
 							</Show>
@@ -293,7 +295,7 @@ function EditContextMenu(props: {
 					!props.track() || !props.artist()
 						? 'infoSubmitUnableTitleShort'
 						: 'infoSubmitTitleShort',
-				icon: Check,
+				icon: CheckOutlined,
 				action: () =>
 					void saveEdit(props.tab, props.clonedSong, {
 						artist: props.artist(),
@@ -307,7 +309,7 @@ function EditContextMenu(props: {
 					!props.track() || !props.artist()
 						? 'infoSwapUnableTitleShort'
 						: 'infoSwapTitleShort',
-				icon: PublishedWithChanges,
+				icon: PublishedWithChangesOutlined,
 				action: () =>
 					void saveEdit(props.tab, props.clonedSong, {
 						artist: props.track(),
@@ -318,7 +320,7 @@ function EditContextMenu(props: {
 			},
 			{
 				namei18n: 'infoRegexTitleShort',
-				icon: Code,
+				icon: CodeOutlined,
 				action: () => props.setIsRegex(true),
 			},
 		];
diff --git a/src/ui/popup/err.tsx b/src/ui/popup/err.tsx
index 3936c514..60c10ff2 100644
--- a/src/ui/popup/err.tsx
+++ b/src/ui/popup/err.tsx
@@ -1,8 +1,7 @@
 import { t } from '@/util/i18n';
-import Error from '@suid/icons-material/ErrorOutlined';
+import { ErrorOutlined, SettingsOutlined } from '@/ui/components/icons';
 import styles from './popup.module.scss';
 import browser from 'webextension-polyfill';
-import Settings from '@suid/icons-material/SettingsOutlined';
 import optionComponentStyles from '../options/components/components.module.scss';
 import { PopupAnchor, TPopupAnchor } from '../components/util';
 
@@ -12,7 +11,7 @@ import { PopupAnchor, TPopupAnchor } from '../components/util';
 export default function Err() {
 	return (
 		<div class={styles.alertPopup}>
-			<Error class={styles.bigIcon} />
+			<ErrorOutlined class={styles.bigIcon} />
 			<h1>{t('serviceErrorHeader')}</h1>
 			<p>
 				<TPopupAnchor messageName="serviceErrorDesc" />
@@ -23,7 +22,7 @@ export default function Err() {
 				)}
 				class={`${optionComponentStyles.button} ${optionComponentStyles.centered}`}
 			>
-				<Settings />
+				<SettingsOutlined />
 				{t('disabledSiteButton')}
 			</PopupAnchor>
 		</div>
diff --git a/src/ui/popup/ignored.tsx b/src/ui/popup/ignored.tsx
index b7d51668..e6ba7ee9 100644
--- a/src/ui/popup/ignored.tsx
+++ b/src/ui/popup/ignored.tsx
@@ -3,7 +3,7 @@ import { Resource, createMemo } from 'solid-js';
 import styles from './popup.module.scss';
 import { t } from '@/util/i18n';
 import { PopupAnchor } from '../components/util';
-import CancelScheduleSend from '@suid/icons-material/CancelScheduleSendOutlined';
+import { CancelScheduleSendOutlined } from '@/ui/components/icons';
 import ClonedSong from '@/core/object/cloned-song';
 
 export default function Ignored(props: { tab: Resource<ManagerTab> }) {
@@ -15,7 +15,7 @@ export default function Ignored(props: { tab: Resource<ManagerTab> }) {
 	});
 	return (
 		<div class={styles.alertPopup}>
-			<CancelScheduleSend class={styles.bigIcon} />
+			<CancelScheduleSendOutlined class={styles.bigIcon} />
 			<h1>{t('ignoredHeader')}</h1>
 			<p>
 				{t('ignoredDesc', [
diff --git a/src/ui/popup/main.tsx b/src/ui/popup/main.tsx
index ab20b864..904c21c5 100644
--- a/src/ui/popup/main.tsx
+++ b/src/ui/popup/main.tsx
@@ -18,7 +18,7 @@ import Disabled from './disabled';
 import Err from './err';
 import NowPlaying from './nowplaying';
 import Edit from './edit';
-import Settings from '@suid/icons-material/SettingsOutlined';
+import { SettingsOutlined } from '@/ui/components/icons';
 import browser from 'webextension-polyfill';
 import styles from './popup.module.scss';
 import { t } from '@/util/i18n';
@@ -131,7 +131,7 @@ function Popup() {
 					class={styles.settingsIcon}
 					title={t('disabledSiteButton')}
 				>
-					<Settings />
+					<SettingsOutlined />
 				</PopupAnchor>
 			</Show>
 		</>
diff --git a/src/ui/popup/nowplaying.tsx b/src/ui/popup/nowplaying.tsx
index a2d785a7..255dc898 100644
--- a/src/ui/popup/nowplaying.tsx
+++ b/src/ui/popup/nowplaying.tsx
@@ -20,11 +20,13 @@ import browser from 'webextension-polyfill';
 import ClonedSong from '@/core/object/cloned-song';
 import Base from './base';
 import { LastFMIcon } from '@/util/icons';
-import Edit from '@suid/icons-material/EditOutlined';
-import Block from '@suid/icons-material/BlockOutlined';
-import Favorite from '@suid/icons-material/FavoriteOutlined';
-import HeartBroken from '@suid/icons-material/HeartBrokenOutlined';
-import RestartAlt from '@suid/icons-material/RestartAltOutlined';
+import {
+	EditOutlined,
+	BlockOutlined,
+	FavoriteOutlined,
+	HeartBrokenOutlined,
+	RestartAltOutlined,
+} from '@/ui/components/icons';
 import { sendBackgroundMessage } from '@/util/communication';
 import * as ControllerMode from '@/core/object/controller/controller-mode';
 import EditComponent from './edit';
@@ -168,7 +170,7 @@ function NowPlayingContextMenu(props: {
 					props.tab()?.mode === ControllerMode.Playing
 						? 'infoEditTitleShort'
 						: 'infoEditUnableTitleShort',
-				icon: Edit,
+				icon: EditOutlined,
 				action: () => props.setIsEditing(true),
 			},
 		];
@@ -178,13 +180,13 @@ function NowPlayingContextMenu(props: {
 					props.tab()?.mode === ControllerMode.Playing
 						? 'infoRevertTitleShort'
 						: 'infoRevertUnableTitleShort',
-				icon: RestartAlt,
+				icon: RestartAltOutlined,
 				action: () => actionResetSongData(props.tab),
 			});
 		}
 		items.push({
 			namei18n: getSkipLabel(props.tab, true),
-			icon: Block,
+			icon: BlockOutlined,
 			action: () => actionSkipCurrentSong(props.tab),
 		});
 		if (!navigatorResource.loading) {
@@ -244,7 +246,7 @@ function IOSLoveTrack(props: {
 					: t('infoLove')
 			}
 		>
-			<Favorite />
+			<FavoriteOutlined />
 		</button>
 	);
 }
@@ -345,7 +347,7 @@ function TrackControls(props: {
 				}
 				onClick={() => props.setIsEditing(true)}
 			>
-				<Edit />
+				<EditOutlined />
 			</button>
 			<Show when={props.song()?.flags.isCorrectedByUser}>
 				<button
@@ -358,7 +360,7 @@ function TrackControls(props: {
 					}
 					onClick={() => actionResetSongData(props.tab)}
 				>
-					<RestartAlt />
+					<RestartAltOutlined />
 				</button>
 			</Show>
 			<button
@@ -375,7 +377,7 @@ function TrackControls(props: {
 				onClick={() => actionSkipCurrentSong(props.tab)}
 				title={t(getSkipLabel(props.tab, false))}
 			>
-				<Block />
+				<BlockOutlined />
 			</button>
 			<button
 				class={`${styles.controlButton}${
@@ -389,14 +391,14 @@ function TrackControls(props: {
 				}
 			>
 				<span class={styles.nonHover}>
-					<Favorite />
+					<FavoriteOutlined />
 				</span>
 				<span class={styles.hover}>
 					<Show
 						when={props.song()?.metadata.userloved}
-						fallback={<Favorite />}
+						fallback={<FavoriteOutlined />}
 					>
-						<HeartBroken />
+						<HeartBrokenOutlined />
 					</Show>
 				</span>
 			</button>
diff --git a/src/ui/popup/popup.module.scss b/src/ui/popup/popup.module.scss
index 328f8c41..0a1c59a0 100644
--- a/src/ui/popup/popup.module.scss
+++ b/src/ui/popup/popup.module.scss
@@ -1,7 +1,6 @@
 @mixin ios-text-input {
 	appearance: none;
 	background-color: var(--last-fm-bg);
-	border: none;
 	box-sizing: border-box;
 	color: var(--text);
 	font-size: 1.2em;
@@ -107,6 +106,10 @@ a {
 			width: fit-content;
 		}
 
+		.editField {
+			border: var(--interactable-border);
+		}
+
 		@supports (-webkit-touch-callout: none) {
 			.editField {
 				@include ios-text-input;
@@ -125,7 +128,7 @@ a {
 		gap: 0.25em;
 
 		svg {
-			fill: var(--always-light);
+			fill: var(--active-text);
 			height: 1.2em;
 			width: 1.2em;
 		}
@@ -134,7 +137,7 @@ a {
 	.label {
 		background-color: var(--last-fm);
 		border-radius: 0.25em;
-		color: var(--always-light);
+		color: var(--active-text);
 		padding: 0.25em 0.5em;
 	}
 
@@ -144,7 +147,7 @@ a {
 
 		.controlButton {
 			background-color: transparent;
-			border: none;
+			border: var(--interactable-border);
 			color: var(--text);
 			cursor: pointer;
 			padding: 0.25em 0.5em;
@@ -213,6 +216,7 @@ a {
 		position: relative;
 
 		.searchField {
+			border: var(--interactable-border);
 			box-sizing: border-box;
 			width: 100%;
 		}
@@ -238,6 +242,7 @@ a {
 	}
 
 	input {
+		border: var(--interactable-border);
 		padding: 0.25em 0.5em;
 	}
 
@@ -389,7 +394,7 @@ a {
 
 		.iosLoveButton {
 			background-color: transparent;
-			border: none;
+			border: var(--interactable-border);
 			color: var(--text);
 			cursor: pointer;
 			padding: 0.25em 0.5em;
@@ -418,8 +423,9 @@ a {
 		}
 
 		input {
-			@include ios-text-input;
 			width: 100%;
+
+			@include ios-text-input;
 		}
 
 		.entryWrapper {
diff --git a/src/ui/popup/regex.tsx b/src/ui/popup/regex.tsx
index 09c90c7e..0a58f7f5 100644
--- a/src/ui/popup/regex.tsx
+++ b/src/ui/popup/regex.tsx
@@ -1,7 +1,6 @@
 import ClonedSong from '@/core/object/cloned-song';
 import styles from './popup.module.scss';
-import Check from '@suid/icons-material/CheckOutlined';
-import Close from '@suid/icons-material/CloseOutlined';
+import { CheckOutlined, CloseOutlined } from '@/ui/components/icons';
 
 import { t } from '@/util/i18n';
 import {
@@ -172,7 +171,7 @@ function SearchField(props: {
 			/>
 			<Switch
 				fallback={
-					<Close
+					<CloseOutlined
 						class={`${styles.regexTest} ${styles.regexFailure}`}
 					/>
 				}
@@ -186,7 +185,7 @@ function SearchField(props: {
 						getSongFieldNoRegex(props.clonedSong, props.type),
 					)}
 				>
-					<Check
+					<CheckOutlined
 						class={`${styles.regexTest} ${styles.regexSuccess}`}
 					/>
 				</Match>
@@ -269,7 +268,7 @@ function Footer(props: {
 							saveEdit(props.tab);
 						}}
 					>
-						<Check />
+						<CheckOutlined />
 					</button>
 				</div>
 			</Show>
@@ -286,7 +285,7 @@ export function RegexEditContextMenu(props: { tab: Resource<ManagerTab> }) {
 		const items: Navigator = [
 			{
 				namei18n: 'infoSubmitTitleShort',
-				icon: Check,
+				icon: CheckOutlined,
 				action: () => void saveEdit(props.tab),
 			},
 		];
diff --git a/src/ui/popup/unsupported.tsx b/src/ui/popup/unsupported.tsx
index 824710b9..89c54c53 100644
--- a/src/ui/popup/unsupported.tsx
+++ b/src/ui/popup/unsupported.tsx
@@ -1,5 +1,5 @@
 import { t } from '@/util/i18n';
-import SentimentDissatisfied from '@suid/icons-material/SentimentDissatisfiedOutlined';
+import { SentimentDissatisfiedOutlined } from '@/ui/components/icons';
 import styles from './popup.module.scss';
 import { PopupAnchor } from '../components/util';
 
@@ -9,7 +9,7 @@ import { PopupAnchor } from '../components/util';
 export default function Unsupported() {
 	return (
 		<div class={styles.alertPopup}>
-			<SentimentDissatisfied class={styles.bigIcon} />
+			<SentimentDissatisfiedOutlined class={styles.bigIcon} />
 			<h1>{t('unsupportedWebsiteHeader')}</h1>
 			<p>{t('unsupportedWebsiteDesc')}</p>
 			<p>{t('unsupportedWebsiteUpdateNote')}</p>
diff --git a/src/util/communication.ts b/src/util/communication.ts
index 9f42945e..9cd8e05f 100644
--- a/src/util/communication.ts
+++ b/src/util/communication.ts
@@ -1,4 +1,5 @@
 import { ConnectorMeta } from '@/core/connectors';
+import type { ChannelInfo } from '@/core/content/util';
 import { ControllerModeStr } from '@/core/object/controller/controller';
 import { ServiceCallResult } from '@/core/object/service-call-result';
 import { CloneableSong } from '@/core/object/song';
@@ -156,6 +157,21 @@ interface BackgroundCommunications {
 		payload: undefined;
 		response: void;
 	};
+	addToBlocklist: {
+		payload: undefined;
+		response: Promise<void>;
+	};
+	removeFromBlocklist: {
+		payload: undefined;
+		response: Promise<void>;
+	};
+	getChannelDetails: {
+		payload: undefined;
+		response: {
+			connector: ConnectorMeta;
+			channelInfo: ChannelInfo | null | undefined;
+		};
+	};
 }
 
 /**
diff --git a/src/util/util.ts b/src/util/util.ts
index 22905884..278a8766 100644
--- a/src/util/util.ts
+++ b/src/util/util.ts
@@ -10,6 +10,7 @@ import {
 	WebhookModel,
 	Properties,
 	StateManagement,
+	Blocklists,
 } from '@/core/storage/wrapper';
 import { RegexEdit } from './regex';
 
@@ -137,7 +138,8 @@ export function hideObjectValue(
 		| ListenBrainzModel
 		| WebhookModel
 		| StateManagement
-		| RegexEdit[],
+		| RegexEdit[]
+		| Blocklists,
 ): string {
 	if (!keyValue) {
 		if (keyValue === null) {
@@ -228,8 +230,11 @@ export function areAllResults<T>(results: T[], result: T): boolean {
  * @param text - The string to capitalize the first letter of
  * @returns The string with the first letter capitalized
  */
-export function capitalizeFirstLetter(text: string): string {
-	return text[0].toUpperCase() + text.slice(1);
+export function kebabCaseToPascalCase(text: string): string {
+	return text
+		.split('-')
+		.map((s) => s[0].toUpperCase() + s.slice(1))
+		.join('');
 }
 
 /**
diff --git a/tsconfig.json b/tsconfig.json
index da971ddd..ac01de54 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -3,7 +3,7 @@
     "allowSyntheticDefaultImports": true,
     "baseUrl": ".",
     "module": "ES2020",
-    "moduleResolution": "Node",
+    "moduleResolution": "Bundler",
     "isolatedModules": true,
     "outDir": "build",
     "jsx": "preserve",


Test patch:
diff --git a/tests/background/connectors.test.ts b/tests/background/connectors.test.ts
index 5b22f4b6..e9374038 100644
--- a/tests/background/connectors.test.ts
+++ b/tests/background/connectors.test.ts
@@ -7,12 +7,14 @@ import connectors, { ConnectorMeta } from '@/core/connectors';
 import * as UrlMatch from '@/util/url-match';
 
 const PROP_TYPES: {
+	usesBlocklist: 'boolean';
 	allFrames: 'boolean';
 	matches: 'array';
 	label: 'string';
 	js: 'string';
 	id: 'string';
 } = {
+	usesBlocklist: 'boolean',
 	allFrames: 'boolean',
 	matches: 'array',
 	label: 'string',