Browse Source

Merge branch 'staging'

Owen Diffey 2 years ago
parent
commit
4cfb80c29a
100 changed files with 8673 additions and 6879 deletions
  1. 13 7
      .github/workflows/build-lint.yml
  2. 3 3
      .github/workflows/codeql-analysis.yml
  3. 3 2
      .gitignore
  4. 7 0
      .markdownlint.json
  5. 7 2
      .wiki/Backend_Commands.md
  6. 25 9
      .wiki/Configuration.md
  7. 89 30
      .wiki/Installation.md
  8. 10 4
      .wiki/Technical_Overview.md
  9. 30 13
      .wiki/Upgrading.md
  10. 15 4
      .wiki/Utility_Script.md
  11. 62 55
      .wiki/Value_Formats.md
  12. 227 65
      CHANGELOG.md
  13. 62 55
      README.md
  14. 7 3
      SECURITY.md
  15. 2 1
      backend/.eslintignore
  16. 9 4
      backend/.eslintrc
  17. 12 5
      backend/Dockerfile
  18. 2 1
      backend/config/template.json
  19. 1 1
      backend/index.js
  20. 23 12
      backend/logic/actions/dataRequests.js
  21. 2 2
      backend/logic/actions/media.js
  22. 34 13
      backend/logic/actions/playlists.js
  23. 46 1
      backend/logic/actions/punishments.js
  24. 1 1
      backend/logic/actions/songs.js
  25. 41 44
      backend/logic/actions/users.js
  26. 39 1
      backend/logic/actions/youtube.js
  27. 11 5
      backend/logic/api.js
  28. 341 321
      backend/logic/app.js
  29. 96 105
      backend/logic/cache/index.js
  30. 1 1
      backend/logic/db/index.js
  31. 1 1
      backend/logic/db/schemas/report.js
  32. 63 0
      backend/logic/migration/migrations/migration22.js
  33. 72 102
      backend/logic/notifications.js
  34. 65 1
      backend/logic/punishments.js
  35. 10 1
      backend/logic/songs.js
  36. 3 1
      backend/logic/ws.js
  37. 11 4
      backend/logic/youtube.js
  38. 735 58
      backend/package-lock.json
  39. 20 14
      backend/package.json
  40. 103 0
      backend/tsconfig.json
  41. 4 2
      docker-compose.yml
  42. 0 9
      frontend/.babelrc
  43. 2 1
      frontend/.dockerignore
  44. 15 7
      frontend/.eslintrc
  45. 13 9
      frontend/Dockerfile
  46. 5 4
      frontend/dist/config/template.json
  47. 0 56
      frontend/dist/index.tpl.html
  48. 0 0
      frontend/dist/vendor/lofig.1.3.4.min.js
  49. 8 1
      frontend/entrypoint.sh
  50. 1683 1906
      frontend/package-lock.json
  51. 27 41
      frontend/package.json
  52. 8 8
      frontend/prod.nginx.conf
  53. 283 294
      frontend/src/App.vue
  54. 0 9
      frontend/src/api/admin/index.js
  55. 0 100
      frontend/src/api/auth.js
  56. 0 0
      frontend/src/auth.ts
  57. 0 0
      frontend/src/aw.ts
  58. 7 0
      frontend/src/classes/ListenerHandler.class.ts
  59. 154 157
      frontend/src/components/ActivityItem.vue
  60. 103 120
      frontend/src/components/AddToPlaylistDropdown.vue
  61. 1183 0
      frontend/src/components/AdvancedTable.vue
  62. 74 87
      frontend/src/components/AutoSuggest.vue
  63. 13 20
      frontend/src/components/ChristmasLights.vue
  64. 149 139
      frontend/src/components/FloatingBox.vue
  65. 6 11
      frontend/src/components/InfoIcon.vue
  66. 18 20
      frontend/src/components/InputHelpBox.vue
  67. 44 76
      frontend/src/components/LineChart.vue
  68. 68 68
      frontend/src/components/LongJobs.vue
  69. 45 52
      frontend/src/components/MainFooter.vue
  70. 80 89
      frontend/src/components/MainHeader.vue
  71. 31 48
      frontend/src/components/Modal.vue
  72. 34 38
      frontend/src/components/ModalManager.vue
  73. 35 36
      frontend/src/components/PlaylistItem.vue
  74. 314 335
      frontend/src/components/PlaylistTabBase.vue
  75. 33 36
      frontend/src/components/ProfilePicture.vue
  76. 35 36
      frontend/src/components/PunishmentItem.vue
  77. 143 185
      frontend/src/components/Queue.vue
  78. 68 0
      frontend/src/components/QuickConfirm.vue
  79. 17 19
      frontend/src/components/ReportInfoItem.vue
  80. 122 146
      frontend/src/components/Request.vue
  81. 42 46
      frontend/src/components/RunJobDropdown.vue
  82. 66 66
      frontend/src/components/SaveButton.vue
  83. 6 11
      frontend/src/components/SearchQueryItem.vue
  84. 6 8
      frontend/src/components/Sidebar.vue
  85. 119 114
      frontend/src/components/SongItem.vue
  86. 131 0
      frontend/src/components/SongThumbnail.vue
  87. 66 90
      frontend/src/components/StationInfoBox.vue
  88. 53 0
      frontend/src/components/UserLink.vue
  89. 0 72
      frontend/src/components/global/QuickConfirm.vue
  90. 0 141
      frontend/src/components/global/SongThumbnail.vue
  91. 0 53
      frontend/src/components/global/UserLink.vue
  92. 102 100
      frontend/src/components/modals/BulkActions.vue
  93. 37 30
      frontend/src/components/modals/Confirm.vue
  94. 60 68
      frontend/src/components/modals/CreatePlaylist.vue
  95. 85 97
      frontend/src/components/modals/CreateStation.vue
  96. 150 163
      frontend/src/components/modals/EditNews.vue
  97. 100 86
      frontend/src/components/modals/EditPlaylist/Tabs/AddSongs.vue
  98. 68 69
      frontend/src/components/modals/EditPlaylist/Tabs/ImportPlaylists.vue
  99. 64 78
      frontend/src/components/modals/EditPlaylist/Tabs/Settings.vue
  100. 390 470
      frontend/src/components/modals/EditPlaylist/index.vue

+ 13 - 7
.github/workflows/build-eslint.yml → .github/workflows/build-lint.yml

@@ -1,4 +1,4 @@
-name: Musare Build and ESLint
+name: Musare Build and Lint
 
 on: [ push, pull_request, workflow_dispatch ]
 
@@ -24,10 +24,10 @@ env:
     REDIS_DATA_LOCATION: .redis
 
 jobs:
-    build-eslint:
+    build-lint:
         runs-on: ubuntu-latest
         steps:
-            - uses: actions/checkout@v2
+            - uses: actions/checkout@v3
             - name: Build Musare
               run: |
                   cp .env.example .env
@@ -36,7 +36,13 @@ jobs:
                   ./musare.sh build
             - name: Start Musare
               run: ./musare.sh start
-            - name: ESlint Backend
-              run: ./musare.sh eslint backend
-            - name: ESLint Frontend
-              run: ./musare.sh eslint frontend
+            - name: Backend Lint
+              run: ./musare.sh lint backend
+            - name: Backend Typescript
+              run: ./musare.sh typescript backend
+            - name: Frontend Lint
+              run: ./musare.sh lint frontend
+            - name: Frontend Typescript
+              run: ./musare.sh typescript frontend
+            - name: Docs Lint
+              run: ./musare.sh lint docs

+ 3 - 3
.github/workflows/codeql-analysis.yml

@@ -18,12 +18,12 @@ jobs:
 
     steps:
     - name: Checkout repository
-      uses: actions/checkout@v2
+      uses: actions/checkout@v3
 
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@v1
+      uses: github/codeql-action/init@v2
       with:
         languages: ${{ matrix.language }}
 
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@v1
+      uses: github/codeql-action/analyze@v2

+ 3 - 2
.gitignore

@@ -21,18 +21,19 @@ lerna-debug.log
 # Backend
 backend/node_modules/
 backend/config/default.json
+backend/build
 
 # Frontend
 frontend/bundle-stats.json
 frontend/bundle-report.html
 frontend/node_modules/
-frontend/dist/build/
-frontend/dist/index.html
+frontend/build/
 frontend/dist/config/default.json
 
 npm
 node_modules
 package-lock.json
+.eslintcache
 
 # Logs
 log/

+ 7 - 0
.markdownlint.json

@@ -0,0 +1,7 @@
+{
+    "MD013": {
+        "tables": false
+    },
+    "MD024": false,
+    "MD041": false
+}

+ 7 - 2
.wiki/Backend_Commands.md

@@ -1,7 +1,10 @@
 # Backend Commands
-Backend commands are inputted via STDIN or if using the Utility Script by using `./musare.sh attach backend`.
+
+Backend commands are inputted via STDIN or if using the Utility Script by using
+`./musare.sh attach backend`.
 
 ## Commands
+
 | Command | Parameters | Description |
 | --- | --- | --- |
 | `rs` | | Restart backend. |
@@ -17,7 +20,9 @@ Backend commands are inputted via STDIN or if using the Utility Script by using
 | `stats` | `module` | Returns job statistics for a specified module. |
 
 ## Modules
-When specifying a module please use all lowercase. The available modules are as follows:
+
+When specifying a module please use all lowercase.
+The available modules are as follows:
 
 - Cache
 - DB

+ 25 - 9
.wiki/Configuration.md

@@ -1,6 +1,7 @@
 # Configuration
 
 ## Backend
+
 Location: `backend/config/default.json`
 
 | Property | Description |
@@ -23,6 +24,7 @@ Location: `backend/config/default.json`
 | `apis.youtube.quotas.limit` | YouTube API quota limit. |
 | `apis.recaptcha.secret` | ReCaptcha Site v3 secret, obtained from [here](https://www.google.com/recaptcha/admin). |
 | `apis.recaptcha.enabled` | Whether to enable ReCaptcha at email registration. |
+| `apis.github.enabled` | Whether to enable GitHub authentication. |
 | `apis.github.client` | GitHub OAuth Application client, obtained from [here](https://github.com/settings/developers). |
 | `apis.github.secret` | GitHub OAuth Application secret, obtained with client. |
 | `apis.github.redirect_uri` | The authorization callback url is the backend url with `/auth/github/authorize/callback` appended, for example `http://localhost/backend/auth/github/authorize/callback`. |
@@ -43,11 +45,11 @@ Location: `backend/config/default.json`
 | `cookie.domain` | The ip or address you use to access the site, without protocols (http/https), so for example `localhost`. |
 | `cookie.secure` | Should be `true` for SSL connections, and `false` for normal http connections. |
 | `cookie.SIDname` | Name of the cookie stored for sessions. |
-| `blacklistedCommunityStationNames ` | Array of blacklisted community station names. |
-| `featuredPlaylists ` | Array of featured playlist id's. Playlist privacy must be public. |
+| `blacklistedCommunityStationNames` | Array of blacklisted community station names. |
+| `featuredPlaylists` | Array of featured playlist id's. Playlist privacy must be public. |
 | `skipConfigVersionCheck` | Skips checking if the config version is outdated or not. Should almost always be set to false. |
 | `skipDbDocumentsVersionCheck` | Skips checking if there are any DB documents outdated or not. Should almost always be set to false. |
-| `debug.stationIssue` | If set to `true` it will enable the `/debug_station` API endpoint on the backend, which provides information useful to debugging stations not skipping, as well as capure all jobs specified in `debug.captureJobs`. 
+| `debug.stationIssue` | If set to `true` it will enable the `/debug_station` API endpoint on the backend, which provides information useful to debugging stations not skipping, as well as capure all jobs specified in `debug.captureJobs`.
 | `debug.traceUnhandledPromises` | Enables the trace-unhandled package, which provides detailed information when a promise is unhandled. |
 | `debug.captureJobs` | Array of jobs to capture for `debug.stationIssue`. |
 | `defaultLogging.hideType` | Filters out specified message types from log, for example `INFO`, `SUCCESS`, `ERROR` and `STATION_ISSUE`. |
@@ -57,6 +59,7 @@ Location: `backend/config/default.json`
 | `configVersion` | Version of the config. Every time the template changes, you should change your config accordingly and update the configVersion. |
 
 ## Frontend
+
 Location: `frontend/dist/config/default.json`
 
 | Property | Description |
@@ -64,8 +67,8 @@ Location: `frontend/dist/config/default.json`
 | `mode` | Should be either `development` or `production`. |
 | `backend.apiDomain` | Should be the url where the backend will be accessible from, usually `http://localhost/backend` for docker or `http://localhost:8080` for non-Docker. |
 | `backend.websocketsDomain` | Should be the same as the `apiDomain`, except using the `ws://` protocol instead of `http://` and with `/ws` at the end. |
-| `devServer.webSocketURL` | Should be the webpack-dev-server websocket URL, usually `ws://localhost/ws`. |
-| `devServer.port` | Should be the port where webpack-dev-server will be accessible from, should always be port `81` for Docker since nginx listens on port 80, and is recommended to be port `80` for non-Docker. |
+| `devServer.hmrClientPort` | Should be the port on which the frontend will be accessible from, usually port `80`, or `443` if using SSL. Only used when running in dev mode. |
+| `devServer.port` | Should be the port where Vite's dev server will be accessible from, should always be port `81` for Docker since nginx listens on port 80, and is recommended to be port `80` for non-Docker. Only used when running in dev mode. |
 | `frontendDomain` | Should be the url where the frontend will be accessible from, usually `http://localhost` for docker or `http://localhost:80` for non-Docker. |
 | `recaptcha.key` | ReCaptcha Site v3 key, obtained from [here](https://www.google.com/recaptcha/admin). |
 | `recaptcha.enabled` | Whether to enable ReCaptcha at email registration. |
@@ -94,11 +97,19 @@ Location: `frontend/dist/config/default.json`
 [^1]: Requires a frontend restart to update. The data will be available from the frontend console and by the frontend code.
 
 ## Docker Environment
+
 Location: `.env`
 
-In the table below the container host refers to the IP address that the docker container listens on, setting this to `127.0.0.1` for example will only expose the configured port to localhost, whereas setting to `0.0.0.0` will expose the port on all interfaces.
+In the table below the container host refers to the IP address that the docker
+container listens on, setting this to `127.0.0.1` for example will only expose
+the configured port to localhost, whereas setting to `0.0.0.0` will expose the
+port on all interfaces.
 
-The container port refers to the external docker container port, used to access services within the container. Changing this does not require any changes to configuration within container. For example setting the `MONGO_PORT` to `21018` will allow you to access the mongo service through that port, even though the application within the container is listening on `21017`.
+The container port refers to the external docker container port, used to access
+services within the container. Changing this does not require any changes to
+configuration within container. For example setting the `MONGO_PORT` to `21018`
+will allow you to access the mongo service through that port, even though the
+application within the container is listening on `21017`.
 
 | Property | Description |
 | --- | --- |
@@ -126,9 +137,14 @@ The container port refers to the external docker container port, used to access
 | `BACKUP_NAME` | Name of musare.sh backup files. Defaults to `musare-$(date +"%Y-%m-%d-%s").dump`. |
 
 ## Docker-compose override
-You may want to override the docker-compose files in some specific cases. For this, you can create a `docker-compose.override.yml` file.  
+
+You may want to override the docker-compose files in some specific cases.
+For this, you can create a `docker-compose.override.yml` file.
+
 ### Run backend on its own domain
-One example usecase for the override is to expose the backend port so you can run it separately from the frontend. An example file for this is as follows:
+
+One example usecase for the override is to expose the backend port so you can
+run it separately from the frontend. An example file for this is as follows:
 
 ```yml
 services:

+ 89 - 30
.wiki/Installation.md

@@ -1,66 +1,85 @@
 # Installation
-Musare can be installed with Docker (recommended) or without, guides for both installations can be found below.
+
+Musare can be installed with Docker (recommended) or without, guides for both
+installations can be found below.
 
 To update an existing installation please see [Upgrading](./Upgrading.md).
 
 ## Docker
 
 ### Dependencies
+
 - [Git](https://github.com/git-guides/install-git)
 - [Docker](https://docs.docker.com/get-docker/)
 - [docker-compose](https://docs.docker.com/compose/install/)
 
 ### Instructions
+
 1. `git clone https://github.com/Musare/Musare.git`
 2. `cd Musare`
-3. `cp backend/config/template.json backend/config/default.json` and configure as per [Configuration](./Configuration.md#Backend)
-4. `cp frontend/dist/config/template.json frontend/dist/config/default.json` and configure as per [Configuration](./Configuration.md#Frontend)
-5. `cp .env.example .env` and configure as per [Configuration](./Configuration.md#Docker-Environment).
+3. `cp backend/config/template.json backend/config/default.json` and configure
+as per [Configuration](./Configuration.md#Backend)
+4. `cp frontend/dist/config/template.json frontend/dist/config/default.json`
+and configure as per [Configuration](./Configuration.md#Frontend)
+5. `cp .env.example .env` and configure as per
+[Configuration](./Configuration.md#Docker-Environment).
 6. `./musare.sh build`
 7. `./musare.sh start`
-8. **(optional)** Register a new user on the website and grant the admin role by running `./musare.sh admin add USERNAME`.
+8. **(optional)** Register a new user on the website and grant the admin role
+by running `./musare.sh admin add USERNAME`.
 
 ### Fixing the "couldn't connect to docker daemon" error
 
-**Windows Only**
+- **Windows Only**
 
-Some people have had issues while trying to execute the `docker-compose` command.
-To fix this, you will have to run `docker-machine env default`.
-This command will print various variables.
-At the bottom, it will say something similar to `@FOR /f "tokens=*" %i IN ('docker-machine env default') DO @%i`.
-Run this command in your shell. You will have to do this command for every shell you want to run `docker-compose` in (every session).
+    Some people have had issues while trying to execute the `docker-compose` command.
+    To fix this, you will have to run `docker-machine env default`.
+    This command will print various variables.
+    At the bottom, it will say something similar to
+    `@FOR /f "tokens=*" %i IN ('docker-machine env default') DO @%i`.
+    Run this command in your shell. You will have to do this command for every
+    shell you want to run `docker-compose` in (every session).
 
 ---
 
 ## Non-Docker
 
 ### Dependencies
+
 - [Git](https://github.com/git-guides/install-git)
 - [Redis](http://redis.io/download)
 - [MongoDB](https://www.mongodb.com/try/download/community)
 - [NodeJS](https://nodejs.org/en/download/)
-    - [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)
-    - [nodemon](https://github.com/remy/nodemon#installation)
-    - [node-gyp](https://github.com/nodejs/node-gyp#installation)
-    - [webpack](https://webpack.js.org/guides/installation/#global-installation)
+  - [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)
+  - [nodemon](https://github.com/remy/nodemon#installation)
 
 ### Instructions
+
 1. `git clone https://github.com/Musare/Musare.git`
 2. `cd Musare`
-3. [Setup MongoDB](#Setting-up-MongoDB)
-4. [Setup Redis](#Setting-up-Redis)
-5. `cp backend/config/template.json backend/config/default.json` and configure as per [Configuration](./Configuration.md#Backend)
-6. `cp frontend/dist/config/template.json frontend/dist/config/default.json` and configure as per [Configuration](./Configuration.md#Frontend)
-7. Start services
+3. [Setup MongoDB](#setting-up-mongodb)
+4. [Setup Redis](#setting-up-redis)
+5. `cp backend/config/template.json backend/config/default.json` and configure
+as per [Configuration](./Configuration.md#Backend)
+6. `cp frontend/dist/config/template.json frontend/dist/config/default.json`
+and configure as per [Configuration](./Configuration.md#Frontend)
+7. `cd frontend && npm install && cd ..`
+8. `cd backend && npm install && cd ..`
+9. Start services
     - **Linux**
         1. Execute `systemctl start redis mongod`
-        2. Execute `cd frontend && npm run dev` and `cd backend && npm run dev` separately.
+        2. Execute `cd frontend && npm run dev` and
+        `cd backend && npm run dev` separately.
     - **Windows**
-        - **Automatic** Run `windows-start.cmd` or just double click the `windows-start.cmd` file and all servers will automatically start up.
+        - **Automatic** Run `windows-start.cmd` or just double click the
+        `windows-start.cmd` file and all servers will automatically start up.
         - **Manual**
             1. Run `startRedis.cmd` and `startMongo.cmd` to start Redis and Mongo.
-            2. Execute `cd frontend && npm run dev` and `cd backend && npm run dev` separately.
-8. **(optional)** Register a new user on the website and grant the admin role by running the following in the mongodb shell.
+            2. Execute `cd frontend && npm run dev` and
+            `cd backend && npm run dev` separately.
+10. **(optional)** Register a new user on the website and grant the admin role
+by running the following in the mongodb shell.
+
     ```bash
     use musare
     db.auth("MUSAREDBUSER","MUSAREDBPASSWORD")
@@ -68,33 +87,65 @@ Run this command in your shell. You will have to do this command for every shell
     ```
 
 ### Setting up MongoDB
+
 - **Windows Only**
+
     1. In the root directory, create a folder called `.database`
     2. Create a file called `startMongo.cmd` in the root directory with the contents:
 
         `"C:\Program Files\MongoDB\Server\4.0\bin\mongod.exe" --dbpath "C:\Path\To\Musare\.database"`
 
         Make sure to adjust your paths accordingly.
+
     3. Start the database by executing the script `startMongo.cmd` you just made
+
 - Set up the MongoDB database itself
+
     1. Start MongoDB
         - **Linux** Execute `systemctl start mongod`
         - **Windows** Execute the `startMongo.cmd` script you just made
     2. Connect to Mongo from a command prompt
 
         `mongo admin`
+
     3. Create an admin user
 
-        `db.createUser({user: 'admin', pwd: 'PASSWORD_HERE', roles: [{role: 'userAdminAnyDatabase', db: 'admin'}]})`
+        ```javascript
+        db.createUser({
+            user: "admin",
+            pwd: "PASSWORD_HERE",
+            roles: [
+                {
+                    role: "userAdminAnyDatabase",
+                    db: "admin"
+                }
+            ]
+        })
+        ```
+
     4. Connect to the Musare database
 
         `use musare`
+
     5. Create the "musare" user
 
-        `db.createUser({user: 'musare', pwd: 'OTHER_PASSWORD_HERE', roles: [{role: 'readWrite', db: 'musare'}]})`
+        ```javascript
+        db.createUser({
+            user: "musare",
+            pwd: "OTHER_PASSWORD_HERE",
+            roles: [
+                {
+                    role: "readWrite",
+                    db: "musare"
+                }
+            ]
+        })
+        ```
+
     6. Exit
 
         `exit`
+
     7. Add the authentication
         - **Linux**
             1. Add `auth=true` to `/etc/mongod.conf`
@@ -104,19 +155,27 @@ Run this command in your shell. You will have to do this command for every shell
             2. Restart MongoDB
 
 ### Setting up Redis
+
 - **Windows**
+
     1. In the folder where you installed Redis, edit the `redis.windows.conf` file
+
         1. In there, look for the property `notify-keyspace-events`.
         2. Make sure that property is uncommented and has the value `Ex`.
-            
+
             It should look like `notify-keyspace-events Ex` when done.
+
     2. Create a file called `startRedis.cmd` in the main folder with the contents:
 
-        `"C:\Path\To\Redis\redis-server.exe" "C:\Path\To\Redis\redis.windows.conf" "--requirepass" "PASSWORD"`
+        `"C:\Path\To\Redis\redis-server.exe" "C:\Path\To\Redis\redis.windows.conf"
+        "--requirepass" "PASSWORD"`
+
+        And again, make sure that the paths lead to the proper config and
+        executable. Replace `PASSWORD` with your Redis password.
 
-        And again, make sure that the paths lead to the proper config and executable. Replace `PASSWORD` with your Redis password.
 - **Linux**
     1. In `/etc/redis/redis.conf`
         1. Uncomment `notify-keyspace-events` and set its value to `Ex`.
-        2. Uncomment `requirepass foobared` and replace foobared with your Redis password.
+        2. Uncomment `requirepass foobared` and replace foobared with your
+        Redis password.
     2. Restart Redis `systemctl restart redis`

+ 10 - 4
.wiki/Technical_Overview.md

@@ -10,11 +10,17 @@
 
 ### Frontend
 
-The frontend is a [vue-cli](https://github.com/vuejs/vue-cli) generated, [vue-loader](https://github.com/vuejs/vue-loader) single page app, that's served over Nginx or Express. The Nginx server not only serves the frontend, but can also serve as a load balancer for requests going to the backend.
+The frontend is a [vue-cli](https://github.com/vuejs/vue-cli) generated,
+[vue-loader](https://github.com/vuejs/vue-loader) single page app, that's
+served over Nginx or Express. The Nginx server not only serves the frontend,
+but can also serve as a load balancer for requests going to the backend.
 
 ### Backend
 
-The backend is a scalable NodeJS / Redis / MongoDB app. User sessions are stored in a central Redis server. All data is stored in a central MongoDB server. The Redis and MongoDB servers are replicated to several secondary nodes, which can become the primary node if the current primary node goes down.
-
-We currently only utilize 1 backend, 1 MongoDB server and 1 Redis server running for production, though it is relatively easy to expand.
+The backend is a scalable NodeJS / Redis / MongoDB app. User sessions are stored
+in a central Redis server. All data is stored in a central MongoDB server.
+TheRedis and MongoDB servers are replicated to several secondary nodes, which
+can become the primary node if the current primary node goes down.
 
+We currently only utilize 1 backend, 1 MongoDB server and 1 Redis server running
+for production, though it is relatively easy to expand.

+ 30 - 13
.wiki/Upgrading.md

@@ -1,4 +1,5 @@
 # Upgrading
+
 Musare upgrade process.
 
 To install a new instance please see [Installation](./Installation.md).
@@ -6,17 +7,25 @@ To install a new instance please see [Installation](./Installation.md).
 ## Docker
 
 ### Instructions
+
 1. Make a backup! `./musare.sh backup`
-2. Execute `./musare.sh update`. If an update requires any configuration changes or database migrations, you will be notified.
-    - To update configuration compare example configs against your own and add/update/remove any properties as needed. For more information on properties see [Configuration](./Configuration.md). Frontend and backend configuration updates always update the `configVersion` property.
-        - Backend, compare `backend/config/template.json` against `backend/config/default.json`.
-        - Frontend, compare `frontend/dist/config/template.json` against `frontend/dist/config/default.json`.
+2. Execute `./musare.sh update`. If an update requires any configuration changes
+or database migrations, you will be notified.
+    - To update configuration compare example configs against your own and
+    add/update/remove any properties as needed. For more information on
+    properties see [Configuration](./Configuration.md). Frontend and backend
+    configuration updates always update the `configVersion` property.
+        - Backend, compare `backend/config/template.json` against
+        `backend/config/default.json`.
+        - Frontend, compare `frontend/dist/config/template.json` against
+        `frontend/dist/config/default.json`.
         - Environment, compare `.env.example` against `.env`.
     - To migrate database;
         - `./musare.sh stop backend`
         - Set `migration` to `true` in  `backend/config/default.json`
         - `./musare.sh start backend`.
-        - Follow backend logs and await migration completion notice `./musare.sh attach backend`.
+        - Follow backend logs and await migration completion notice
+        `./musare.sh attach backend`.
         - `./musare.sh stop backend`
         - Set `migration` to `false` in  `backend/config/default.json`
         - `./musare.sh start backend`.
@@ -26,12 +35,15 @@ To install a new instance please see [Installation](./Installation.md).
 ## Non-Docker
 
 ### Instructions
+
 1. Make a backup!
 2. Stop all services
 3. `git pull`
 4. `cd frontend && npm install`
 5. `cd ../backend && npm install`
-6. Compare example configs against your own and add/update/remove any properties as needed. For more information on properties see [Configuration](./Configuration.md). Frontend and backend configuration updates always update the `configVersion` property.
+6. Compare example configs against your own and add/update/remove any properties
+as needed. For more information on properties see [Configuration](./Configuration.md).
+Frontend and backend configuration updates always update the `configVersion` property.
     - Backend, compare `backend/config/template.json` against `backend/config/default.json`.
     - Frontend, compare `frontend/dist/config/template.json` against `frontend/dist/config/default.json`.
 7. Start MongoDB and Redis services.
@@ -43,25 +55,30 @@ To install a new instance please see [Installation](./Installation.md).
     - Set `migration` to `false` in  `backend/config/default.json`
 9. Start backend and frontend services.
 
-# Upgrade/downgrade MongoDB
+## Upgrade/downgrade MongoDB
 
-Make sure to always look at the upgrade/downgrade instructions in the [MongoDB release notes](https://docs.mongodb.com/manual/release-notes) before, and always make a full backup of your data before proceeding.
+Make sure to always look at the upgrade/downgrade instructions in the
+[MongoDB release notes](https://docs.mongodb.com/manual/release-notes) before,
+and always make a full backup of your data before proceeding.
 
-## Docker
+### Docker
+
+#### Instructions
 
-### Instructions
 1. Stop the backend (`./musare.sh stop backend`)
 2. Make a backup of MongoDB (`./musare.sh backup`)
-3. Stop and reset the mongo container and delete the database folder (`./musare.sh reset mongo`)
+3. Stop and reset the mongo container and delete the database folder
+(`./musare.sh reset mongo`)
 4. Change the MongoDB version inside your .env file.
 5. Start the mongo container (`./musare.sh start mongo`)
 6. Import your backup of MongoDB (`./musare.sh restore`)
     - Note: backups are stored inside the backups folder by default.
 7. Start the backend (`./musare.sh start backend`)
 
-## Non-Docker
+### Non-Docker
+
+#### Instructions
 
-### Instructions
 1. Stop your backend
 2. Make a backup of MongoDB
 3. Stop and reset MongoDB

+ 15 - 4
.wiki/Utility_Script.md

@@ -1,15 +1,21 @@
 # Utility Script
-The utility script is a tool that allows for the simple management of a Musare Docker instance.
 
-Please follow the [Docker Installation Guide](./Installation.md#Docker) before using this script.
+The utility script is a tool that allows for the simple management of a Musare
+Docker instance.
+
+Please follow the [Docker Installation Guide](./Installation.md#Docker) before
+using this script.
 
 ## Usage
+
 Linux (Bash):
+
 ```bash
 ./musare.sh command [parameters]
 ```
 
 ## Commands
+
 | Command | Parameters | Description |
 | --- | --- | --- |
 | `start` | `[frontend backend redis mongo]` | Start service(s). |
@@ -20,11 +26,16 @@ Linux (Bash):
 | `update` | `[auto]` | Update Musare. When auto is specified the update will be cancelled if there are any changes requiring manual intervention, allowing you to run this unattended. |
 | `attach` | `<backend,mongo,redis>` | Attach to backend server, mongodb or redis shell. |
 | `build` | `[frontend backend]` | Build service(s). |
-| `eslint` | `[frontend backend] [fix]` | Run eslint on frontend and/or backend. Specify fix to auto fix issues where possible. |
+| `lint` | `[frontend backend docs] [fix]` | Run lint on frontend, backend and/or docs. Specify fix to auto fix issues where possible. |
 | `backup` | | Backup database data to file. Configured in .env file. |
 | `restore` | `[file]` | Restore database from file. If file is not specified you will be prompted. |
 | `reset` | `[frontend backend redis mongo]` | Reset all data for service(s). |
 | `admin` | `<add,remove> [username]` | Assign/unassign admin role to/from user. If the username is not specified you will be prompted. |
+| `typescript` | `[frontend backend]` | Run TypeScript checks on frontend and/or backend. |
 
 ### Services
-There are currently 4 services; frontend, backend, redis and mongo. Where services is a parameter you can specify any of these, or multiple seperated by spaces, for example `./musare.sh restart frontend backend` to restart the frontend and backend. If no services are specified all will be selected.
+
+There are currently 4 services; frontend, backend, redis and mongo. Where
+services is a parameter you can specify any of these, or multiple seperated by
+spaces, for example `./musare.sh restart frontend backend` to restart the
+frontend and backend. If no services are specified all will be selected.

+ 62 - 55
.wiki/Value_Formats.md

@@ -3,61 +3,68 @@
 Every input needs validation, below is the required formatting of each value.
 
 - **User**
-    - Username
-        - Description: Any letter from a-z in any case, numbers, underscores and dashes. Must contain at least 1 letter or number.
-        - Length: From 2 to 32 characters.
-        - Regex: ```/^[A-Za-z0-9_]+$/```
-    - Name
-        - Description: Any letter from any language in any case, numbers, underscores, dashes, periods, apostrophes and spaces. Must contain at least 1 letter or number.
-        - Length: From 2 to 64 characters.
-        - Regex: ```/^[\p{L}0-9 .'_-]+$/u```
-    - Email
-        - Description: Standard email address.
-        - Length: From 3 to 254 characters.
-        - Regex: ```/^[\x00-\x7F]+@[a-z0-9]+\.[a-z0-9]+(\.[a-z0-9]+)?$/```
-    - Password
-        - Description: Must include at least one lowercase letter, one uppercase letter, one number and one special character.
-        - Length: From 6 to 200 characters.
-        - Regex: ```/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~])[A-Za-z\d!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~]/```
-    - Ban Reason
-        - Description: Any ASCII character.
-        - Length: From 1 to 64 characters.
-        - Regex: ```/^[\x00-\x7F]+$/```
+  - Username
+    - Description: Any letter from a-z in any case, numbers, underscores and
+    dashes. Must contain at least 1 letter or number.
+    - Length: From 2 to 32 characters.
+    - Regex: ```/^[A-Za-z0-9_]+$/```
+  - Name
+    - Description: Any letter from any language in any case, numbers, underscores,
+    dashes, periods, apostrophes and spaces. Must contain at least 1 letter or number.
+    - Length: From 2 to 64 characters.
+    - Regex: ```/^[\p{L}0-9 .'_-]+$/u```
+  - Email
+    - Description: Standard email address.
+    - Length: From 3 to 254 characters.
+    - Regex: ```/^[\x00-\x7F]+@[a-z0-9]+\.[a-z0-9]+(\.[a-z0-9]+)?$/```
+  - Password
+    - Description: Must include at least one lowercase letter, one uppercase
+    letter, one number and one special character.
+    - Length: From 6 to 200 characters.
+    - Regex: ```/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~])[A-Za-z\d!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~]/```
+  - Ban Reason
+    - Description: Any ASCII character.
+    - Length: From 1 to 64 characters.
+    - Regex: ```/^[\x00-\x7F]+$/```
 - **Station**
-    - Name
-        - Description: Any letter from a-z lowercase, numbers and underscores.
-        - Length: From 2 to 16 characters.
-        - Regex: ```/^[a-z0-9_]+$/```
-    - Display Name
-        - Description: Any ASCII character.
-        - Length: From 2 to 32 characters.
-        - Regex: ```/^[\x00-\x7F]+$/```
-    - Description
-        - Description: Any character.
-        - Length: From 2 to 200 characters.
+  - Name
+    - Description: Any letter from a-z lowercase, numbers and underscores.
+    - Length: From 2 to 16 characters.
+    - Regex: ```/^[a-z0-9_]+$/```
+  - Display Name
+    - Description: Any ASCII character.
+    - Length: From 2 to 32 characters.
+    - Regex: ```/^[\x00-\x7F]+$/```
+  - Description
+    - Description: Any character.
+    - Length: From 2 to 200 characters.
 - **Playlist**
-    - Display Name
-        - Description: Any ASCII character.
-        - Length: From 1 to 32 characters.
-        - Regex: ```/^[\x00-\x7F]+$/```
+  - Display Name
+    - Description: Any ASCII character.
+    - Length: From 1 to 32 characters.
+    - Regex: ```/^[\x00-\x7F]+$/```
 - **Song**
-    - Title
-        - Description: Any ASCII character.
-        - Length: From 1 to 32 characters.
-        - Regex: ```/^[\x00-\x7F]+$/```
-    - Artists
-        - Description: Any character and not NONE.
-        - Length: From 1 to 64 characters.
-        - Quantity: Min 1, max 10.
-    - Genres
-        - Description: Any ASCII character.
-        - Length: From 1 to 32 characters.
-        - Quantity: Min 1, max 16.
-        - Regex: ```/^[\x00-\x7F]+$/```
-    - Tags
-        - Description: Any letter, numbers and underscores. Can be with out without data in square brackets. The base tag and data between brackets follow the same styling. If there's no data in between square brackets, there are no square brackets.
-        - Length: From 1 to 64 characters for the base part, 1 to 64 characters for data in square brackets.
-        - Regex: ```/^[a-zA-Z0-9_]{1,64}$|^[a-zA-Z0-9_]{1,64}\[[a-zA-Z0-9_]{1,64}\]$/```
-    - Thumbnail
-        - Description: Valid url. If site is secure only https prepended urls are valid.
-        - Length: From 1 to 256 characters.
+  - Title
+    - Description: Any ASCII character.
+    - Length: From 1 to 32 characters.
+    - Regex: ```/^[\x00-\x7F]+$/```
+  - Artists
+    - Description: Any character and not NONE.
+    - Length: From 1 to 64 characters.
+    - Quantity: Min 1, max 10.
+  - Genres
+    - Description: Any ASCII character.
+    - Length: From 1 to 32 characters.
+    - Quantity: Min 1, max 16.
+    - Regex: ```/^[\x00-\x7F]+$/```
+  - Tags
+    - Description: Any letter, numbers and underscores. Can be with out without
+    data in square brackets. The base tag and data between brackets follow the
+    same styling. If there's no data in between square brackets, there are no
+    square brackets.
+    - Length: From 1 to 64 characters for the base part, 1 to 64 characters for
+    data in square brackets.
+    - Regex: ```/^[a-zA-Z0-9_]{1,64}$|^[a-zA-Z0-9_]{1,64}\[[a-zA-Z0-9_]{1,64}\]$/```
+  - Thumbnail
+    - Description: Valid url. If site is secure only https prepended urls are valid.
+    - Length: From 1 to 256 characters.

+ 227 - 65
CHANGELOG.md

@@ -1,10 +1,83 @@
 # Changelog
 
+## [v3.7.0] - 2022-08-27
+
+This release contains mostly internal refactors, and includes all
+changes from v3.7.0-rc1 and v3.7.0-rc2, in addition to the following.
+Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
+
+### Fixed
+
+- fix: Unable to login with username if it contains uppercase characters
+- fix: Profile page not responsive
+- fix: Don't use npx within package scripts
+
+## [v3.7.0-rc2] - 2022-08-21
+
+This release includes all changes from v3.7.0-rc1, in addition to the following.
+Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
+
+### Changed
+
+- refactor: Migrated from sortablejs-vue3 to vue-draggable-list
+- refactor: Disabled user preference activity items
+- refactor: Allowed for YouTube channel imports in to playlists
+
+### Fixed
+
+- fix: Invalid settings store getters
+- fix: EditSong song items scrollIntoView not functioning
+- fix: Cache/notifications module falsely reporting readiness
+
+## [v3.7.0-rc1] - 2022-08-07
+
+This release contains mostly internal refactors.
+Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
+
+### Added
+
+- feat: Added TypeScript to frontend and backend
+- feat: Added TypeScript check command to musare.sh
+- feat: Added markdown lint command to musare.sh
+- feat: Added config option to enable/disable GitHub authentication
+- feat: Added resolved column, filter and update event
+to Data Requests admin page
+- feat: Added ability to deactivate punishments
+- feat: Added songId column and filter to YouTube Videos admin page
+
+### Changed
+
+- refactor: Started migrating frontend to TypeScript, with inferred types
+- refactor: Migrated from Vue Options to Vue Composition API
+- refactor: Migrated from Webpack to Vite
+- refactor: Migrated from VueX to Pinia
+- refactor: Migrated from vue-draggable to sortablejs-vue3
+- refactor: Enabled eslint cache
+- refactor: Split docker npm install build steps
+- refactor: Merged Edit Songs into Edit Song modal
+- refactor: Converted global components back to normal components
+
+### Fixed
+
+- fix: toGMTString deprecated
+- fix: First letter of Activity Item title duplicated
+- fix: getRatings not available to logged out users
+- fix: Opening station with active floating box breaks styling
+- fix: GET_SONGS returns out-of-order array
+- fix: Previous migration didn't properly migrate reports
+- fix: Banning users causing backend crash and continuous reconnection attempts
+- fix: Edit Songs does not work in production
+- fix: Close modal keyboard shortcut does not work in Edit Song modal
+- fix: Station and modal playback toggles not handled properly
+- fix: MediaSession breaks station if a YouTube video plays instead of a song
+
 ## [v3.6.0] - 2022-06-12
 
-This release includes all changes from v3.6.0-rc1, in addition to the following. Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
+This release includes all changes from v3.6.0-rc1, in addition to the following.
+Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
 
 ### Fixed
+
 - fix: Removed tag="transition-group" from draggable components
 
 ## [v3.6.0-rc1] - 2022-06-05
@@ -12,6 +85,7 @@ This release includes all changes from v3.6.0-rc1, in addition to the following.
 Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
 
 ### Added
+
 - feat: Added tab-completion to backend commands
 - feat: Added YouTube quota usage tracking
 - feat: Added YouTube API requests tracking, caching and management
@@ -26,24 +100,33 @@ Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
 - feat: Added long jobs handling and monitoring
 
 ### Changed
-- refactor: Display user display names instead of usernames in links and station user list
+
+- refactor: Display user display names instead of usernames in links and station
+user list
 - refactor: Use YouTube thumbnail as a fallback to song thumbnails
-- refactor: Use song thumbnail component in Edit Song modal, with fallback disabled
+- refactor: Use song thumbnail component in Edit Song modal, with fallback
+disabled
 - refactor: Edit Song positioning and styling tweaks
 - refactor: Moved vote skip processing to dedicated job
 - refactor: Prevent auto vote to skip if locally paused
 - refactor: Added info header card to admin pages
-- refactor: Allowed for song style usage of YouTube videos in playlists and station queues
-- refactor: Moved ratings to dedicated model within media module, with YouTube video support
-- refactor: Replace songs with YouTube videos in playlists, station queues and ratings on removal
+- refactor: Allowed for song style usage of YouTube videos in playlists and
+station queues
+- refactor: Moved ratings to dedicated model within media module, with YouTube
+video support
+- refactor: Replace songs with YouTube videos in playlists, station queues and
+ratings on removal
 - refactor: Moved drag box handling to mixin
 - refactor: Floating box logic and styling improvements
-- refactor: Added support for creation of songs from YouTube videos in Edit Song(s) modals
+- refactor: Added support for creation of songs from YouTube videos in
+Edit Song(s) modals
 - refactor: Compile production frontend as part of docker image build
 - refactor: Changed default frontend docker mode to prod
-- refactor: Import Album can now use a selection of songs or YouTube videos in addition to YouTube playlist importing.
+- refactor: Import Album can now use a selection of songs or YouTube videos in
+addition to YouTube playlist importing.
 
 ### Fixed
+
 - fix: musare.sh attach not working with podman-compose
 - fix: Station autofill not run after removal from queue
 - fix: AdvancedTable multi-row select with left ctrl/shift doesnt work
@@ -54,6 +137,7 @@ Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
 Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
 
 ### Fixed
+
 - fix: Assert package.json import as json
 - fix: Limited NodeJS version to v16.15
 - fix: Temporarily disabled eslint for moduleManager import
@@ -63,6 +147,7 @@ Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
 Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
 
 ### Fixed
+
 - fix: Songs requestSet could return null songs
 - fix: Prevent adding duplicate items with bulk actions
 - fix: Throw error if unknown job is called
@@ -78,12 +163,15 @@ Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
 
 ## [v3.5.0] - 2022-04-28
 
-This release includes all changes from v3.5.0-rc1 and v3.5.0-rc2, in addition to the following. Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
+This release includes all changes from v3.5.0-rc1 and v3.5.0-rc2, in addition to
+the following. Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
 
 ### Changed
+
 - refactor: close all modals upon route change
 
 ### Fixed
+
 - fix: Autofilling station queue would reset requestedAt
 - fix: Station time elapsed would show false if 0
 
@@ -92,9 +180,11 @@ This release includes all changes from v3.5.0-rc1 and v3.5.0-rc2, in addition to
 This release includes all changes from v3.5.0-rc1, in addition to the following.
 
 ### Added
+
 - chore: Added docker-compose.override.yml documentation
 
 ### Fixed
+
 - fix: Unable to compile frontend in production mode
 - fix: Homepage admin filter keyboard shortcut not always registering
 - fix: Docker will create folders if default.json files do not exist
@@ -107,6 +197,7 @@ This release includes all changes from v3.5.0-rc1, in addition to the following.
 ## [v3.5.0-rc1] - 2022-04-14
 
 ### Added
+
 - feat: Station autofill configurable limit
 - feat: Station requests configurable access level
 - feat: Station requests configurable per user request limit
@@ -116,18 +207,24 @@ This release includes all changes from v3.5.0-rc1, in addition to the following.
 - feat: Added info icon component
 
 ### Changed
-- refactor: No longer showing unlisted stations on homepage if not owned by user unless toggled by admin
+
+- refactor: No longer showing unlisted stations on homepage if not owned by user
+unless toggled by admin
 - refactor: Renamed station excludedPlaylists to blacklist
 - refactor: Unified station update functions and events
 - refactor: Replaced Manage Station settings dropdowns with select elements
 - refactor: Use a local object to edit stations before saving
-- refactor: Replace station modes with 2 modules which are independently toggleable and configurable on every station
-    - Requests: Replaces party mode, users can request songs or auto request from playlists
-    - Autofill: Replaces playlist mode, owners select songs to autofill queue. Also includes old playMode and includedPlaylist functionality
+- refactor: Replace station modes with 2 modules which are independently
+toggleable and configurable on every station
+  - Requests: Replaces party mode, users can request songs or auto request from
+  playlists
+  - Autofill: Replaces playlist mode, owners select songs to autofill queue.
+  Also includes old playMode and includedPlaylist functionality
 - refactor: Update active team
 - refactor: Separate docker container modes
 - refactor: Improve musare.sh exit code usage and other tweaks
-- refactor: Made Main Header/Footer, Modal, QuickConfirm and UserIdToUsername global components
+- refactor: Made Main Header/Footer, Modal, QuickConfirm and UserIdToUsername
+global components
 - refactor: Use crypto random values instead of math.random to create UUID
 - refactor: Added trailing slash to URL startsWith check
 - chore: Updated frontend package-lock.json version from 1 to 2
@@ -137,21 +234,29 @@ This release includes all changes from v3.5.0-rc1, in addition to the following.
 - refactor: Made station info box a component for Station and Manage Station
 
 ### Fixed
+
 - fix: Changing station privacy does not kick out newly-unauthorized users
 
 ### Removed
+
 - refactor: Removed station queue lock
 
 ## [v3.4.0] - 2022-03-27
 
 ### **Breaking Changes**
-This release makes the MongoDB version configurable in the .env file. Prior to this release, the MongoDB version was 4.0. We recommend upgrading to 5.0 or 4.4. Upgrade instructions can be found in [.wiki/Upgrading](.wiki/Upgrading.md#Upgrade/downgradeMongoDB).
 
-Please run the Update All Songs job after upgrading to ensure playlist and station song data accuracy.
+This release makes the MongoDB version configurable in the .env file. Prior to
+this release, the MongoDB version was 4.0. We recommend upgrading to 5.0 or 4.4.
+Upgrade instructions can be found in [.wiki/Upgrading](.wiki/Upgrading.md#Upgrade/downgradeMongoDB).
+
+Please run the Update All Songs job after upgrading to ensure playlist and
+station song data accuracy.
 
 ### Added
+
 - feat: Scroll to next song item in Edit Songs queue
-- feat: Reset Advanced Table bulk actions popup position on screen resize if in initial position
+- feat: Reset Advanced Table bulk actions popup position on screen resize if in
+initial position
 - feat: Global LESS variables
 - refactor: Configurable Main Footer links
 - feat: Configurable Docker container restart policy
@@ -174,11 +279,15 @@ Please run the Update All Songs job after upgrading to ensure playlist and stati
 - feat: Configurable MongoDB container image version
 
 ### Changed
+
 - refactor: Replaced night mode toggle slider in Main Header with day/night icons
 - refactor: Replaced SASS/SCCS with LESS
-- refactor: Hide registration buttons and prevent opening register modal if registration is disabled
-- refactor: Trim certain user modifiable strings in playlists, songs, reports and stations
-- refactor: Allow title to wrap to a 2nd line if no there are no artists in Song Item
+- refactor: Hide registration buttons and prevent opening register modal if
+registration is disabled
+- refactor: Trim certain user modifiable strings in playlists, songs, reports
+and stations
+- refactor: Allow title to wrap to a 2nd line if no there are no artists in
+Song Item
 - refactor: Consistent border-radius
 - refactor: Consistent box-shadow
 - refactor: Replace deprecated /deep/ selector with :deep()
@@ -197,14 +306,18 @@ Please run the Update All Songs job after upgrading to ensure playlist and stati
 - refactor: Delete user sessions when account is deleted
 
 ### Fixed
-- fix: Relative homepage header height causing overlay of content on non-standard resolutions
+
+- fix: Relative homepage header height causing overlay of content on
+non-standard resolutions
 - fix: Unable to toggle nightmode on mobile logged out on homepage
 - fix: Station card top row should not wrap
 - fix: Advanced Table CTRL/SHIFT select rows does not work
-- fix: Station not automatically removed from favorite stations on homepage on deletion
+- fix: Station not automatically removed from favorite stations on homepage on
+deletion
 - fix: Playlist songs do not contain verified attribute
 - fix: Newest news should only fetch published items
-- fix: Deleting a song as an admin adds activity item that you deleted a song from genre playlists
+- fix: Deleting a song as an admin adds activity item that you deleted a song
+from genre playlists
 - fix: News item divider has no top/bottom margin
 - fix: Edit Song failing to fetch song reports
 - fix: Station refill can include current song
@@ -219,7 +332,8 @@ Please run the Update All Songs job after upgrading to ensure playlist and stati
 - fix: Playlists could sometimes not be created due to restrictive MongoDB index
 - fix: Add tags to songs doesn't give any feedback to the user
 - fix: AdvancedTable checkboxes overlay mobile navbar dropdown
-- fix: Nightmode -> EditSong -> Discogs API Result release on hover style is messed up
+- fix: Nightmode -> EditSong -> Discogs API Result release on hover style is
+messed up
 - fix: Station creation validation always failing
 - fix: Station info display name and description overflow horizontally
 - fix: Volume slider incorrect sensitivity
@@ -231,18 +345,22 @@ Please run the Update All Songs job after upgrading to ensure playlist and stati
 - fix: Add To Playlist Dropdown create playlist button not full width
 
 ### Removed
+
 - refactor: Removed skip to last 10s button from Edit Song player
 - refactor: Removed Request Song modal
 
 ## [v3.4.0-rc2] - 2022-03-19
 
 ### Added
+
 - feat: Re-added ability to hard stop player in Edit Song
 
 ### Changed
+
 - refactor: Delete user sessions when account is deleted
 
 ### Fixed
+
 - fix: Changing password in Settings does not create success toast
 - fix: Invalid user sessions could sometimes break actions
 - fix: Add To Playlist Dropdown create playlist button not full width
@@ -250,13 +368,19 @@ Please run the Update All Songs job after upgrading to ensure playlist and stati
 ## [v3.4.0-rc1] - 2022-03-06
 
 ### **Breaking Changes**
-This release makes the MongoDB version configurable in the .env file. Prior to this release, the MongoDB version was 4.0. We recommend upgrading to 5.0 or 4.4. Upgrade instructions can be found in [.wiki/Upgrading](.wiki/Upgrading.md#Upgrade/downgradeMongoDB).
 
-Please run the Update All Songs job after upgrading to ensure playlist and station song data accuracy.
+This release makes the MongoDB version configurable in the .env file. Prior to
+this release, the MongoDB version was 4.0. We recommend upgrading to 5.0 or 4.4.
+Upgrade instructions can be found in [.wiki/Upgrading](.wiki/Upgrading.md#Upgrade/downgradeMongoDB).
+
+Please run the Update All Songs job after upgrading to ensure playlist and
+station song data accuracy.
 
 ### Added
+
 - feat: Scroll to next song item in Edit Songs queue
-- feat: Reset Advanced Table bulk actions popup position on screen resize if in initial position
+- feat: Reset Advanced Table bulk actions popup position on screen resize if in
+initial position
 - feat: Global LESS variables
 - refactor: Configurable Main Footer links
 - feat: Configurable Docker container restart policy
@@ -279,11 +403,15 @@ Please run the Update All Songs job after upgrading to ensure playlist and stati
 - feat: Configurable MongoDB container image version
 
 ### Changed
+
 - refactor: Replaced night mode toggle slider in Main Header with day/night icons
 - refactor: Replaced SASS/SCCS with LESS
-- refactor: Hide registration buttons and prevent opening register modal if registration is disabled
-- refactor: Trim certain user modifiable strings in playlists, songs, reports and stations
-- refactor: Allow title to wrap to a 2nd line if no there are no artists in Song Item
+- refactor: Hide registration buttons and prevent opening register modal if
+registration is disabled
+- refactor: Trim certain user modifiable strings in playlists, songs, reports
+and stations
+- refactor: Allow title to wrap to a 2nd line if no there are no artists in
+Song Item
 - refactor: Consistent border-radius
 - refactor: Consistent box-shadow
 - refactor: Replace deprecated /deep/ selector with :deep()
@@ -301,14 +429,18 @@ Please run the Update All Songs job after upgrading to ensure playlist and stati
 - refactor: Pull images in musare.sh build command
 
 ### Fixed
-- fix: Relative homepage header height causing overlay of content on non-standard resolutions
+
+- fix: Relative homepage header height causing overlay of content on
+non-standard resolutions
 - fix: Unable to toggle nightmode on mobile logged out on homepage
 - fix: Station card top row should not wrap
 - fix: Advanced Table CTRL/SHIFT select rows does not work
-- fix: Station not automatically removed from favorite stations on homepage on deletion
+- fix: Station not automatically removed from favorite stations on homepage on
+deletion
 - fix: Playlist songs do not contain verified attribute
 - fix: Newest news should only fetch published items
-- fix: Deleting a song as an admin adds activity item that you deleted a song from genre playlists
+- fix: Deleting a song as an admin adds activity item that you deleted a song
+from genre playlists
 - fix: News item divider has no top/bottom margin
 - fix: Edit Song failing to fetch song reports
 - fix: Station refill can include current song
@@ -323,7 +455,8 @@ Please run the Update All Songs job after upgrading to ensure playlist and stati
 - fix: Playlists could sometimes not be created due to restrictive MongoDB index
 - fix: Add tags to songs doesn't give any feedback to the user
 - fix: AdvancedTable checkboxes overlay mobile navbar dropdown
-- fix: Nightmode -> EditSong -> Discogs API Result release on hover style is messed up
+- fix: Nightmode -> EditSong -> Discogs API Result release on hover style is
+messed up
 - fix: Station creation validation always failing
 - fix: Station info display name and description overflow horizontally
 - fix: Volume slider incorrect sensitivity
@@ -332,19 +465,23 @@ Please run the Update All Songs job after upgrading to ensure playlist and stati
 - fix: Edit Song seekTo does not apply if video is stopped
 
 ### Removed
+
 - refactor: Removed skip to last 10s button from Edit Song player
 - refactor: Removed Request Song modal
 
 ## [v3.3.1] - 2022-02-03
 
 ### Fixes
+
 - fix: migration18 doesnt migrate playlist and queue songs
 
 ## [v3.3.0] - 2022-01-22
 
 ### Added
+
 - feat: Admin ability to edit another users playlist
-- feat: Admin/Users ability to delete user, resend verify email and resend reset password email
+- feat: Admin/Users ability to delete user, resend verify email and resend reset
+password email
 - feat: Bulk Actions modal for admin/songs bulk editing tags, genres and artists.
 - feat: Button and job to recalculate all song likes and dislikes
 - feat: Confirm modal, for more detailed confirmation of actions
@@ -354,48 +491,58 @@ Please run the Update All Songs job after upgrading to ensure playlist and stati
 - feat: Import Album styling improvements and prefill Discogs data option
 - feat: MediaSession controls (experimental)
 - feat: New admin area advanced table
-    - Advanced filter/search functionality with autocomplete for certain attributes
-    - Bulk update actions popup for songs. Ability to bulk edit, verify, unverify, delete, update tags, genres and artists.
-    - Hide columns
-    - Keyboard shortcuts
-    - Local and query storage of table configuration
-    - Manage columns dropdown
-    - Pagination and configurable page size
-    - Reorder columns
-    - Resize columns
-    - Row update and removed event handling
-    - Select rows with checkboxes
-    - Sort by column
+  - Advanced filter/search functionality with autocomplete for certain attributes
+  - Bulk update actions popup for songs. Ability to bulk edit, verify, unverify,
+  delete, update tags, genres and artists.
+  - Hide columns
+  - Keyboard shortcuts
+  - Local and query storage of table configuration
+  - Manage columns dropdown
+  - Pagination and configurable page size
+  - Reorder columns
+  - Resize columns
+  - Row update and removed event handling
+  - Select rows with checkboxes
+  - Sort by column
 - feat: Open Manage Station from homepage
 - feat: Open Station Queue from homepage
 - feat: Redirect /admin to tab route
 - feat: Run jobs dropdown in admin area pages to replace buttons
 - feat: Song tagging
-- feat: Store the latest admin tab in localStorage and reopen that tab next time you go to admin
+- feat: Store the latest admin tab in localStorage and reopen that tab next time
+you go to admin
 - feat: View Musare version and Git info from backend/frontend
 - chore: Security.md file
 
 ### Changed
+
 - refactor: Auto suggest component
 - refactor: Renamed confirm component to quick confirm
-- refactor: Song status is now a verified boolean, with hidden songs migrated to unverified with a hidden tag
-- refactor: Treat liked/disliked playlists more like normal user playlists, except the ability to rename and delete
+- refactor: Song status is now a verified boolean, with hidden songs migrated to
+unverified with a hidden tag
+- refactor: Treat liked/disliked playlists more like normal user playlists,
+except the ability to rename and delete
 - refactor: Unify song update socket events
 - refactor: web-kit scrollbars and support Firefox scrollbar styling
 - chore: Update material icons font
 - chore: Use npm for can-autoplay and lofig packages
 
 ### Fixed
+
 - fix: Any logged in user can perform certain actions on any playlist
 - fix: Changing your username does not update your username stored locally
-- fix: Clicking outside of the edit song modal whilst its loading, or attempt to close in any other way, will prevent you from closing the modal
+- fix: Clicking outside of the edit song modal whilst its loading, or attempt to
+close in any other way, will prevent you from closing the modal
 - fix: Data request emails are always sent from musare.com
-- fix: Frontend ws.js, when onConnect is called right after the socket connects (within 150ms), the onConnect callback is called twice
+- fix: Frontend ws.js, when onConnect is called right after the socket connects
+(within 150ms), the onConnect callback is called twice
 - fix: Header logo and modal close icon does not have user-select: none;
 - fix: Home header min-height not set
 - fix: Importing YouTube playlist has errors
 - fix: Indexing reports prints "string" in backend logs
-- fix: Memory leak on the frontend, where every time the backend restarts the homepage tries to index the stations X times the server has restarted whilst the homepage has been active
+- fix: Memory leak on the frontend, where every time the backend restarts the
+homepage tries to index the stations X times the server has restarted whilst the
+homepage has been active
 - fix: Modal footer overflow cropped
 - fix: Move song to bottom of queue does not work on occasion
 - fix: News items on news page overflow horizontally on mobile
@@ -409,51 +556,66 @@ Please run the Update All Songs job after upgrading to ensure playlist and stati
 ## [v3.2.2] - 2021-12-11
 
 ### Changed
+
 - refactor: Self host santa seeker icon
 
 ## [v3.2.1] - 2021-12-01
 
 ### Fixed
+
 - fix: Jumpy candy cane seeker bar
-- fix: Christmas lights on home header when logged out and on mobile aren't on bottom of element
+- fix: Christmas lights on home header when logged out and on mobile aren't on
+bottom of element
 - fix: Christmas lights hover just below main header
 - fix: Christmas lights box shadow cropped
 
 ## [v3.2.0] - 2021-11-20
 
 ### Added
+
 - feat: Added christmas theme
-    - Enable with frontend config option
-    - Red primary color
-    - Candycane station seekerbar
-    - Santa on sleigh seeker icon
-    - Christmas lights below main and modal header
-    - Snow falling in the background
-- feat: Added new featured playlist feature to manage station, specify with backend config option
+  - Enable with frontend config option
+  - Red primary color
+  - Candycane station seekerbar
+  - Santa on sleigh seeker icon
+  - Christmas lights below main and modal header
+  - Snow falling in the background
+- feat: Added new featured playlist feature to manage station, specify with
+backend config option
 - feat: Added red station theme
 
 ### Changed
-- refactor: Replaced standard red with darker red, except for christmas and red station themes.
+
+- refactor: Replaced standard red with darker red, except for christmas and red
+station themes.
 
 ## [v3.1.1] - 2021-11-15
 
 ### Fixed
+
 - fix: Not logging in other open tabs automatically
 - fix: blacklistedCommunityStationNames issues
 
 ## [v3.1.0] - 2021-11-14
 
 ### Added
+
 - feat: New config option for blacklisted station names
 
 ### Changed
+
 - refactor: Removed bulma dependency
 - refactor: Patched missing styling after removing bulma
-- refactor: Refactored createStation modal to allow for official station creation from admin area
-- refactor: Refactored login and register modals to open on top of homepage from route
+- refactor: Refactored createStation modal to allow for official station
+creation from admin area
+- refactor: Refactored login and register modals to open on top of homepage from
+route
 
 ### Fixed
+
 - Various bug fixes
 
 ## [v3.0.0] - 2021-10-31
-Major update including feature changes, improvements and bug fixes. Changelog not completed for this release.
+
+Major update including feature changes, improvements and bug fixes.
+Changelog not completed for this release.

+ 62 - 55
README.md

@@ -2,13 +2,15 @@
 
 # Musare
 
-Musare is an open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.
+Musare is an open-source collaborative music listening and catalogue curation
+application. Currently supporting YouTube based content.
 
 A production demonstration instance of Musare can be found at [demo.musare.com](https://demo.musare.com).
 
 ---
 
 ## Documentation
+
 - [Installation](./.wiki/Installation.md)
 - [Upgrading](./.wiki/Upgrading.md)
 - [Configuration](./.wiki/Configuration.md)
@@ -20,69 +22,74 @@ A production demonstration instance of Musare can be found at [demo.musare.com](
 ---
 
 ## Features
+
 - **Playlists**
-    - User created playlists
-    - Automatically generated playlists for genres
-    - Privacy configuration
-    - Liked and Disliked songs playlists per user
-    - Bulk import videos from YouTube playlist
-    - Add songs from verified catalogue or YouTube videos
-    - Ability to download in JSON format
+  - User created playlists
+  - Automatically generated playlists for genres
+  - Privacy configuration
+  - Liked and Disliked songs playlists per user
+  - Bulk import videos from YouTube playlist
+  - Add songs from verified catalogue or YouTube videos
+  - Ability to download in JSON format
 - **Stations**
-    - Requests - Toggleable module to allow users to add songs to the queue
-        - Configurable access level and per user request limit
-        - Automatically request songs from selected playlists
-        - Ability to search for songs from verified catalogue or YouTube videos
-    - Autofill - Toggleable module to allow owners to configure automatic filling of the queue from selected playlists
-        - Configurable song limit
-        - Play mode option to randomly play many playlists, or sequentially play one playlist
-        - Ability to search for playlists on Musare
-    - Ability to blacklist playlists to prevent songs within from playing
-    - Themes
-    - Privacy configuration
-    - Favoriting
-    - Official stations controlled by admins
-    - User created and controlled stations
-    - Pause playback just in local session
-    - Station-wide pausing by admins or owners
-    - Vote to skip songs
-    - Force skipping song by admins or owners
+  - Requests - Toggleable module to allow users to add songs to the queue
+    - Configurable access level and per user request limit
+    - Automatically request songs from selected playlists
+    - Ability to search for songs from verified catalogue or YouTube videos
+  - Autofill - Toggleable module to allow owners to configure automatic filling
+  of the queue from selected playlists
+    - Configurable song limit
+    - Play mode option to randomly play many playlists, or sequentially play one
+    playlist
+    - Ability to search for playlists on Musare
+  - Ability to blacklist playlists to prevent songs within from playing
+  - Themes
+  - Privacy configuration
+  - Favoriting
+  - Official stations controlled by admins
+  - User created and controlled stations
+  - Pause playback just in local session
+  - Station-wide pausing by admins or owners
+  - Vote to skip songs
+  - Force skipping song by admins or owners
 - **Song Management**
-    - Verify songs to allow them to be searched for and added to automatically generated genre playlists
-    - Discogs integration to import metadata
-    - Ability for users to report issues with songs and admins to resolve
-    - Configurable skip duration and song duration to cut intros and outros
-    - Import YouTube playlists or channels from admin area
-    - Import Album to associate Discogs album data with media in bulk
-    - Bulk admin management of songs
-    - Create songs from scratch or from YouTube videos
+  - Verify songs to allow them to be searched for and added to automatically
+  generated genre playlists
+  - Discogs integration to import metadata
+  - Ability for users to report issues with songs and admins to resolve
+  - Configurable skip duration and song duration to cut intros and outros
+  - Import YouTube playlists or channels from admin area
+  - Import Album to associate Discogs album data with media in bulk
+  - Bulk admin management of songs
+  - Create songs from scratch or from YouTube videos
 - **YouTube**
-    - Monitor and manage API requests and quota usage
-    - Configure API quota limits
-    - YouTube video management
+  - Monitor and manage API requests and quota usage
+  - Configure API quota limits
+  - YouTube video management
 - **Users**
-    - Activity logs
-    - Profile page showing public playlists and activity logs
-    - Text or gravatar profile pictures
-    - Email or Github login/registration
-    - Preferences to tailor site usage
-    - Password reset
-    - Data deletion management
-    - ActivityWatch integration
+  - Activity logs
+  - Profile page showing public playlists and activity logs
+  - Text or gravatar profile pictures
+  - Email or Github login/registration
+  - Preferences to tailor site usage
+  - Password reset
+  - Data deletion management
+  - ActivityWatch integration
 - **Punishments**
-    - Ban users
-    - Ban IPs
+  - Ban users
+  - Ban IPs
 - **News**
-    - Admins can add/edit/remove news items
-    - Markdown editor
+  - Admins can add/edit/remove news items
+  - Markdown editor
 - **Night Mode**
 - **Administration**
-    - Admin area to manage instance
-    - Configurable data tables
-        - Reorder, resize, sort by and toggle visibilty of columns
-        - Advanced queries
-    - Bulk management
-    - View backend statistics
+  - Admin area to manage instance
+  - Configurable data tables
+    - Reorder, resize, sort by and toggle visibilty of columns
+    - Advanced queries
+  - Bulk management
+  - View backend statistics
+
 ---
 
 ## Contact

+ 7 - 3
SECURITY.md

@@ -6,8 +6,12 @@ Only the latest published production version is supported.
 
 ## Reporting a Vulnerability
 
-To report a vulnerability with a supported version please get in touch with us via email at [core@musare.com](mailto:core@musare.com).
+To report a vulnerability with a supported version please get in touch with us
+via email at [core@musare.com](mailto:core@musare.com).
 
-We endeavour to respond to reports as soon as possible, this may however take a few days. Please refrain from reporting security issues in public forums such as GitHub issues.
+We endeavour to respond to reports as soon as possible, this may however take a
+few days. Please refrain from reporting security issues in public forums such as
+GitHub issues.
 
-Reports will be disclosed via a security advisory once fixes are included in a production release.
+Reports will be disclosed via a security advisory once fixes are included in a
+production release.

+ 2 - 1
backend/.eslintignore

@@ -1 +1,2 @@
-node_modules
+node_modules
+build

+ 9 - 4
backend/.eslintrc

@@ -6,15 +6,18 @@
 	},
 	"parserOptions": {
 		"ecmaVersion": 2021,
-		"sourceType": "module"
+		"sourceType": "module",
+		"parser": "@typescript-eslint/parser"
 	},
 	"extends": [
 		"eslint:recommended",
 		"airbnb-base",
 		"prettier",
-		"plugin:jsdoc/recommended"
+		"plugin:jsdoc/recommended",
+		"plugin:@typescript-eslint/eslint-recommended",
+        "plugin:@typescript-eslint/recommended"
     ],
-    "plugins": [ "prettier", "jsdoc" ],
+    "plugins": [ "prettier", "jsdoc", "@typescript-eslint" ],
 	"rules": {
 		"no-console": 0,
 		"no-control-regex": 0,
@@ -40,6 +43,8 @@
 				"ArrowFunctionExpression": false,
 				"FunctionExpression": false
 			}
-		}]
+		}],
+		"@typescript-eslint/no-empty-function": 0,
+		"@typescript-eslint/no-this-alias": 0
     }
 }

+ 12 - 5
backend/Dockerfile

@@ -1,6 +1,4 @@
-FROM node:16.15 AS musare_backend
-
-RUN npm install -g nodemon
+FROM node:16.15 AS backend_node_modules
 
 RUN mkdir -p /opt/app
 WORKDIR /opt/app
@@ -8,11 +6,20 @@ WORKDIR /opt/app
 COPY package.json /opt/app/package.json
 COPY package-lock.json /opt/app/package-lock.json
 
-RUN npm install
+RUN npm install --silent
+
+FROM node:16.15 AS musare_backend
+
+ARG CONTAINER_MODE=prod
+ENV CONTAINER_MODE=${CONTAINER_MODE}
+
+RUN mkdir -p /opt/app
+WORKDIR /opt/app
 
 COPY . /opt/app
+COPY --from=backend_node_modules /opt/app/node_modules node_modules
 
-ENTRYPOINT npm run docker:dev
+ENTRYPOINT bash -c '([[ "${CONTAINER_MODE}" == "dev" ]] && npm install --silent); npm run docker:dev'
 
 EXPOSE 8080/tcp
 EXPOSE 8080/udp

+ 2 - 1
backend/config/template.json

@@ -37,6 +37,7 @@
 			"enabled": false
 		},
 		"github": {
+			"enabled": false,
 			"client": "",
 			"secret": "",
 			"redirect_uri": ""
@@ -113,5 +114,5 @@
 			]
 		}
 	},
-	"configVersion": 10
+	"configVersion": 11
 }

+ 1 - 1
backend/index.js

@@ -6,7 +6,7 @@ import fs from "fs";
 
 import package_json from "./package.json" assert { type: "json" };
 
-const REQUIRED_CONFIG_VERSION = 10;
+const REQUIRED_CONFIG_VERSION = 11;
 
 // eslint-disable-next-line
 Array.prototype.remove = function (item) {

+ 23 - 12
backend/logic/actions/dataRequests.js

@@ -11,11 +11,17 @@ const WSModule = moduleManager.modules.ws;
 const CacheModule = moduleManager.modules.cache;
 
 CacheModule.runJob("SUB", {
-	channel: "dataRequest.resolve",
-	cb: dataRequestId => {
-		WSModule.runJob("EMIT_TO_ROOM", {
-			room: "admin.users",
-			args: ["event:admin.dataRequests.resolved", { data: { dataRequestId } }]
+	channel: "dataRequest.update",
+	cb: async dataRequestId => {
+		const dataRequestModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "dataRequest"
+		});
+
+		dataRequestModel.findOne({ _id: dataRequestId }, (err, dataRequest) => {
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: "admin.users",
+				args: ["event:admin.dataRequests.updated", { data: { dataRequest } }]
+			});
 		});
 	}
 });
@@ -81,10 +87,11 @@ export default {
 	 * Resolves a data request
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {object} dataRequestId - the id of the data request to resolve
+	 * @param {string} dataRequestId - the id of the data request to resolve
+	 * @param {boolean} resolved - whether to set to resolved to true or false
 	 * @param {Function} cb - gets called with the result
 	 */
-	resolve: isAdminRequired(async function update(session, dataRequestId, cb) {
+	resolve: isAdminRequired(async function resolve(session, dataRequestId, resolved, cb) {
 		const dataRequestModel = await DBModule.runJob("GET_MODEL", { modelName: "dataRequest" }, this);
 
 		async.waterfall(
@@ -96,7 +103,7 @@ export default {
 				},
 
 				next => {
-					dataRequestModel.updateOne({ _id: dataRequestId }, { resolved: true }, { upsert: true }, err =>
+					dataRequestModel.updateOne({ _id: dataRequestId }, { resolved }, { upsert: true }, err =>
 						next(err)
 					);
 				}
@@ -107,22 +114,26 @@ export default {
 					this.log(
 						"ERROR",
 						"DATA_REQUESTS_RESOLVE",
-						`Resolving data request ${dataRequestId} failed for user "${session.userId}". "${err}"`
+						`${resolved ? "R" : "Unr"}esolving data request ${dataRequestId} failed for user "${
+							session.userId
+						}". "${err}"`
 					);
 					return cb({ status: "error", message: err });
 				}
 
-				CacheModule.runJob("PUB", { channel: "dataRequest.resolve", value: dataRequestId });
+				CacheModule.runJob("PUB", { channel: "dataRequest.update", value: dataRequestId });
 
 				this.log(
 					"SUCCESS",
 					"DATA_REQUESTS_RESOLVE",
-					`Resolving data request "${dataRequestId}" successful for user ${session.userId}".`
+					`${resolved ? "R" : "Unr"}esolving data request "${dataRequestId}" successful for user ${
+						session.userId
+					}".`
 				);
 
 				return cb({
 					status: "success",
-					message: "Successfully resolved data request."
+					message: `Successfully ${resolved ? "" : "un"}resolved data request.`
 				});
 			}
 		);

+ 2 - 2
backend/logic/actions/media.js

@@ -704,7 +704,7 @@ export default {
 	 * @param cb
 	 */
 
-	getRatings: isLoginRequired(async function getRatings(session, youtubeId, cb) {
+	async getRatings(session, youtubeId, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -742,7 +742,7 @@ export default {
 				});
 			}
 		);
-	}),
+	},
 
 	/**
 	 * Gets user's own ratings

+ 34 - 13
backend/logic/actions/playlists.js

@@ -1280,20 +1280,41 @@ export default {
 		async.waterfall(
 			[
 				next => {
-					this.publishProgress({ status: "update", message: `Importing YouTube playlist (stage 1)` });
-					YouTubeModule.runJob("GET_PLAYLIST", { url, musicOnly }, this)
-						.then(res => {
-							if (res.filteredSongs) {
-								videosInPlaylistTotal = res.songs.length;
-								songsInPlaylistTotal = res.filteredSongs.length;
-							} else {
-								songsInPlaylistTotal = videosInPlaylistTotal = res.songs.length;
-							}
-							next(null, res.songs);
-						})
-						.catch(err => {
-							next(err);
+					DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
+						userModel.findOne({ _id: session.userId }, (err, user) => {
+							if (user && user.role === "admin") return next(null, true);
+							return next(null, false);
 						});
+					});
+				},
+
+				(isAdmin, next) => {
+					this.publishProgress({ status: "update", message: `Importing YouTube playlist (stage 1)` });
+					const playlistRegex = /[\\?&]list=([^&#]*)/;
+					const channelRegex =
+						/\.[\w]+\/(?:(?:channel\/(UC[0-9A-Za-z_-]{21}[AQgw]))|(?:user\/?([\w-]+))|(?:c\/?([\w-]+))|(?:\/?([\w-]+)))/;
+
+					if (playlistRegex.exec(url) || channelRegex.exec(url))
+						YouTubeModule.runJob(
+							playlistRegex.exec(url) ? "GET_PLAYLIST" : "GET_CHANNEL",
+							{
+								url,
+								musicOnly,
+								disableSearch: !isAdmin
+							},
+							this
+						)
+							.then(res => {
+								if (res.filteredSongs) {
+									videosInPlaylistTotal = res.songs.length;
+									songsInPlaylistTotal = res.filteredSongs.length;
+								} else {
+									songsInPlaylistTotal = videosInPlaylistTotal = res.songs.length;
+								}
+								next(null, res.songs);
+							})
+							.catch(next);
+					else next("Invalid YouTube URL.");
 				},
 				(youtubeIds, next) => {
 					this.publishProgress({ status: "update", message: `Importing YouTube playlist (stage 2)` });

+ 46 - 1
backend/logic/actions/punishments.js

@@ -21,7 +21,7 @@ CacheModule.runJob("SUB", {
 
 		WSModule.runJob("SOCKETS_FROM_IP", { ip: data.ip }, this).then(sockets => {
 			sockets.forEach(socket => {
-				socket.disconnect(true);
+				socket.close();
 			});
 		});
 	}
@@ -347,5 +347,50 @@ export default {
 				});
 			}
 		);
+	}),
+
+	/**
+	 * Deactivates a punishment
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} punishmentId - the MongoDB id of the punishment
+	 * @param {Function} cb - gets called with the result
+	 */
+	deactivatePunishment: isAdminRequired(function deactivatePunishment(session, punishmentId, cb) {
+		async.waterfall(
+			[
+				next => {
+					PunishmentsModule.runJob("DEACTIVATE_PUNISHMENT", { punishmentId }, this)
+						.then(punishment => next(null, punishment._doc))
+						.catch(next);
+				}
+			],
+			async (err, punishment) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"DEACTIVATE_PUNISHMENT",
+						`Deactivating punishment ${punishmentId} failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "DEACTIVATE_PUNISHMENT", `Deactivated punishment ${punishmentId} successful.`);
+
+				WSModule.runJob("EMIT_TO_ROOM", {
+					room: `admin.punishments`,
+					args: [
+						"event:admin.punishment.updated",
+						{
+							data: {
+								punishment: { ...punishment, status: "Inactive" }
+							}
+						}
+					]
+				});
+
+				return cb({ status: "success" });
+			}
+		);
 	})
 };

+ 1 - 1
backend/logic/actions/songs.js

@@ -289,7 +289,7 @@ export default {
 
 	/**
 	 * Gets multiple songs from the Musare song ids
-	 * At this time only used in EditSongs
+	 * At this time only used in bulk EditSong
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {Array} youtubeIds - the song ids

+ 41 - 44
backend/logic/actions/users.js

@@ -131,7 +131,7 @@ CacheModule.runJob("SUB", {
 		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
 			sockets.forEach(socket => {
 				socket.dispatch("keep.event:user.banned", { data: { ban: data.punishment } });
-				socket.disconnect(true);
+				socket.close();
 			});
 		});
 	}
@@ -775,13 +775,8 @@ export default {
 				next => {
 					const query = {};
 					if (identifier.indexOf("@") !== -1) query["email.address"] = identifier;
-					else query.username = identifier;
-					userModel.findOne(
-						{
-							$or: [query]
-						},
-						next
-					);
+					else query.username = { $regex: `^${identifier}$`, $options: "i" };
+					userModel.findOne(query, next);
 				},
 
 				// if the user doesn't exist, respond with a failure
@@ -1184,7 +1179,8 @@ export default {
 		return async.waterfall(
 			[
 				next => {
-					userModel.findOne({ _id: session.userId }, (err, user) => next(err, user));
+					if (!config.get("apis.github.enabled")) return next("GitHub authentication is disabled.");
+					return userModel.findOne({ _id: session.userId }, (err, user) => next(err, user));
 				},
 
 				(user, next) => {
@@ -1466,7 +1462,7 @@ export default {
 					userModel.findByIdAndUpdate(session.userId, { $set }, { new: false, upsert: true }, next);
 				}
 			],
-			async (err, user) => {
+			async err => {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 
@@ -1489,40 +1485,40 @@ export default {
 					}
 				});
 
-				if (preferences.nightmode !== undefined && preferences.nightmode !== user.preferences.nightmode)
-					ActivitiesModule.runJob("ADD_ACTIVITY", {
-						userId: session.userId,
-						type: "user__toggle_nightmode",
-						payload: { message: preferences.nightmode ? "Enabled nightmode" : "Disabled nightmode" }
-					});
-
-				if (
-					preferences.autoSkipDisliked !== undefined &&
-					preferences.autoSkipDisliked !== user.preferences.autoSkipDisliked
-				)
-					ActivitiesModule.runJob("ADD_ACTIVITY", {
-						userId: session.userId,
-						type: "user__toggle_autoskip_disliked_songs",
-						payload: {
-							message: preferences.autoSkipDisliked
-								? "Enabled the autoskipping of disliked songs"
-								: "Disabled the autoskipping of disliked songs"
-						}
-					});
-
-				if (
-					preferences.activityWatch !== undefined &&
-					preferences.activityWatch !== user.preferences.activityWatch
-				)
-					ActivitiesModule.runJob("ADD_ACTIVITY", {
-						userId: session.userId,
-						type: "user__toggle_activity_watch",
-						payload: {
-							message: preferences.activityWatch
-								? "Enabled ActivityWatch integration"
-								: "Disabled ActivityWatch integration"
-						}
-					});
+				// if (preferences.nightmode !== undefined && preferences.nightmode !== user.preferences.nightmode)
+				// 	ActivitiesModule.runJob("ADD_ACTIVITY", {
+				// 		userId: session.userId,
+				// 		type: "user__toggle_nightmode",
+				// 		payload: { message: preferences.nightmode ? "Enabled nightmode" : "Disabled nightmode" }
+				// 	});
+
+				// if (
+				// 	preferences.autoSkipDisliked !== undefined &&
+				// 	preferences.autoSkipDisliked !== user.preferences.autoSkipDisliked
+				// )
+				// 	ActivitiesModule.runJob("ADD_ACTIVITY", {
+				// 		userId: session.userId,
+				// 		type: "user__toggle_autoskip_disliked_songs",
+				// 		payload: {
+				// 			message: preferences.autoSkipDisliked
+				// 				? "Enabled the autoskipping of disliked songs"
+				// 				: "Disabled the autoskipping of disliked songs"
+				// 		}
+				// 	});
+
+				// if (
+				// 	preferences.activityWatch !== undefined &&
+				// 	preferences.activityWatch !== user.preferences.activityWatch
+				// )
+				// 	ActivitiesModule.runJob("ADD_ACTIVITY", {
+				// 		userId: session.userId,
+				// 		type: "user__toggle_activity_watch",
+				// 		payload: {
+				// 			message: preferences.activityWatch
+				// 				? "Enabled ActivityWatch integration"
+				// 				: "Disabled ActivityWatch integration"
+				// 		}
+				// 	});
 
 				this.log(
 					"SUCCESS",
@@ -2813,6 +2809,7 @@ export default {
 
 				(user, next) => {
 					if (!user) return next("Not logged in.");
+					if (!config.get("apis.github.enabled")) return next("Unlinking password is disabled.");
 					if (!user.services.github || !user.services.github.id)
 						return next("You can't remove password login without having GitHub login.");
 					return userModel.updateOne({ _id: session.userId }, { $unset: { "services.password": "" } }, next);

+ 39 - 1
backend/logic/actions/youtube.js

@@ -273,7 +273,45 @@ export default {
 							operator,
 							modelName: "youtubeVideo",
 							blacklistedProperties: [],
-							specialProperties: {},
+							specialProperties: {
+								songId: [
+									// Fetch songs from songs collection with a matching youtubeId
+									{
+										$lookup: {
+											from: "songs",
+											localField: "youtubeId",
+											foreignField: "youtubeId",
+											as: "song"
+										}
+									},
+									// Turn the array of songs returned in the last step into one object, since only one song should have been returned maximum
+									{
+										$unwind: {
+											path: "$song",
+											preserveNullAndEmptyArrays: true
+										}
+									},
+									// Add new field songId, which grabs the song object's _id and tries turning it into a string
+									{
+										$addFields: {
+											songId: {
+												$convert: {
+													input: "$song._id",
+													to: "string",
+													onError: "",
+													onNull: ""
+												}
+											}
+										}
+									},
+									// Cleanup, don't return the song object for any further steps
+									{
+										$project: {
+											song: 0
+										}
+									}
+								]
+							},
 							specialQueries: {},
 							specialFilters: {
 								importJob: importJobId => [

+ 11 - 5
backend/logic/api.js

@@ -208,7 +208,10 @@ class _APIModule extends CoreClass {
 									},
 
 									next => {
-										NotificationsModule.pub.keys("*", next);
+										NotificationsModule.pub
+											.KEYS("*")
+											.then(redisKeys => next(null, redisKeys))
+											.catch(next);
 									},
 
 									(redisKeys, next) => {
@@ -220,10 +223,13 @@ class _APIModule extends CoreClass {
 											redisKeys,
 											1,
 											(redisKey, next) => {
-												NotificationsModule.pub.ttl(redisKey, (err, ttl) => {
-													responseObject.redis.ttl[redisKey] = ttl;
-													next(err);
-												});
+												NotificationsModule.pub
+													.TTL(redisKey)
+													.then(ttl => {
+														responseObject.redis.ttl[redisKey] = ttl;
+														next();
+													})
+													.catch(next);
 											},
 											next
 										);

+ 341 - 321
backend/logic/app.js

@@ -62,17 +62,6 @@ class _AppModule extends CoreClass {
 			app.use(cors(corsOptions));
 			app.options("*", cors(corsOptions));
 
-			const oauth2 = new OAuth2(
-				config.get("apis.github.client"),
-				config.get("apis.github.secret"),
-				"https://github.com/",
-				"login/oauth/authorize",
-				"login/oauth/access_token",
-				null
-			);
-
-			const redirectUri = `${config.get("serverDomain")}/auth/github/authorize/callback`;
-
 			/**
 			 * @param {object} res - response object from Express
 			 * @param {string} err - custom error message
@@ -81,358 +70,389 @@ class _AppModule extends CoreClass {
 				res.redirect(`${config.get("domain")}?err=${encodeURIComponent(err)}`);
 			}
 
-			app.get("/auth/github/authorize", async (req, res) => {
-				if (this.getStatus() !== "READY") {
-					this.log(
-						"INFO",
-						"APP_REJECTED_GITHUB_AUTHORIZE",
-						`A user tried to use github authorize, but the APP module is currently not ready.`
-					);
-					return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
-				}
-
-				const params = [
-					`client_id=${config.get("apis.github.client")}`,
-					`redirect_uri=${config.get("serverDomain")}/auth/github/authorize/callback`,
-					`scope=user:email`
-				].join("&");
-				return res.redirect(`https://github.com/login/oauth/authorize?${params}`);
-			});
-
-			app.get("/auth/github/link", async (req, res) => {
-				if (this.getStatus() !== "READY") {
-					this.log(
-						"INFO",
-						"APP_REJECTED_GITHUB_AUTHORIZE",
-						`A user tried to use github authorize, but the APP module is currently not ready.`
-					);
-					return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
-				}
-
-				const params = [
-					`client_id=${config.get("apis.github.client")}`,
-					`redirect_uri=${config.get("serverDomain")}/auth/github/authorize/callback`,
-					`scope=user:email`,
-					`state=${req.cookies[SIDname]}`
-				].join("&");
-				return res.redirect(`https://github.com/login/oauth/authorize?${params}`);
-			});
-
-			app.get("/auth/github/authorize/callback", async (req, res) => {
-				if (this.getStatus() !== "READY") {
-					this.log(
-						"INFO",
-						"APP_REJECTED_GITHUB_AUTHORIZE",
-						`A user tried to use github authorize, but the APP module is currently not ready.`
-					);
-
-					return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
-				}
-
-				const { code } = req.query;
-				let accessToken;
-				let body;
-				let address;
-
-				const { state } = req.query;
-
-				const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 });
-
-				return async.waterfall(
-					[
-						next => {
-							if (req.query.error) return next(req.query.error_description);
-							return next();
-						},
-
-						next => {
-							oauth2.getOAuthAccessToken(code, { redirect_uri: redirectUri }, next);
-						},
+			if (config.get("apis.github.enabled")) {
+				const oauth2 = new OAuth2(
+					config.get("apis.github.client"),
+					config.get("apis.github.secret"),
+					"https://github.com/",
+					"login/oauth/authorize",
+					"login/oauth/access_token",
+					null
+				);
 
-						(_accessToken, refreshToken, results, next) => {
-							if (results.error) return next(results.error_description);
+				const redirectUri = `${config.get("apis.github.redirect_uri")}`;
 
-							accessToken = _accessToken;
+				app.get("/auth/github/authorize", async (req, res) => {
+					if (this.getStatus() !== "READY") {
+						this.log(
+							"INFO",
+							"APP_REJECTED_GITHUB_AUTHORIZE",
+							`A user tried to use github authorize, but the APP module is currently not ready.`
+						);
+						return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
+					}
 
-							const options = {
-								headers: {
-									"User-Agent": "request",
-									Authorization: `token ${accessToken}`
-								}
-							};
+					const params = [
+						`client_id=${config.get("apis.github.client")}`,
+						`redirect_uri=${config.get("apis.github.redirect_uri")}`,
+						`scope=user:email`
+					].join("&");
+					return res.redirect(`https://github.com/login/oauth/authorize?${params}`);
+				});
+
+				app.get("/auth/github/link", async (req, res) => {
+					if (this.getStatus() !== "READY") {
+						this.log(
+							"INFO",
+							"APP_REJECTED_GITHUB_AUTHORIZE",
+							`A user tried to use github authorize, but the APP module is currently not ready.`
+						);
+						return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
+					}
 
-							return axios
-								.get("https://api.github.com/user", options)
-								.then(github => next(null, github))
-								.catch(err => next(err));
-						},
+					const params = [
+						`client_id=${config.get("apis.github.client")}`,
+						`redirect_uri=${config.get("apis.github.redirect_uri")}`,
+						`scope=user:email`,
+						`state=${req.cookies[SIDname]}`
+					].join("&");
+					return res.redirect(`https://github.com/login/oauth/authorize?${params}`);
+				});
+
+				app.get("/auth/github/authorize/callback", async (req, res) => {
+					if (this.getStatus() !== "READY") {
+						this.log(
+							"INFO",
+							"APP_REJECTED_GITHUB_AUTHORIZE",
+							`A user tried to use github authorize, but the APP module is currently not ready.`
+						);
+
+						return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
+					}
 
-						(github, next) => {
-							if (github.status !== 200) return next(github.data.message);
-
-							if (state) {
-								return async.waterfall(
-									[
-										next => {
-											CacheModule.runJob("HGET", {
-												table: "sessions",
-												key: state
-											})
-												.then(session => next(null, session))
-												.catch(next);
-										},
-
-										(session, next) => {
-											if (!session) return next("Invalid session.");
-											return userModel.findOne({ _id: session.userId }, next);
-										},
-
-										(user, next) => {
-											if (!user) return next("User not found.");
-											if (user.services.github && user.services.github.id)
-												return next("Account already has GitHub linked.");
-
-											return userModel.updateOne(
-												{ _id: user._id },
-												{
-													$set: {
-														"services.github": {
-															id: github.data.id,
-															access_token: accessToken
-														}
-													}
-												},
-												{ runValidators: true },
-												err => {
-													if (err) return next(err);
-													return next(null, user, github.data);
-												}
-											);
-										},
-
-										user => {
-											CacheModule.runJob("PUB", {
-												channel: "user.linkGithub",
-												value: user._id
-											});
-
-											CacheModule.runJob("PUB", {
-												channel: "user.updated",
-												value: { userId: user._id }
-											});
-
-											res.redirect(`${config.get("domain")}/settings?tab=security`);
-										}
-									],
-									next
-								);
-							}
+					const { code } = req.query;
+					let accessToken;
+					let body;
+					let address;
 
-							if (!github.data.id) return next("Something went wrong, no id.");
+					const { state } = req.query;
 
-							return userModel.findOne({ "services.github.id": github.data.id }, (err, user) => {
-								next(err, user, github.data);
-							});
-						},
+					const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 });
 
-						(user, _body, next) => {
-							body = _body;
+					return async.waterfall(
+						[
+							next => {
+								if (req.query.error) return next(req.query.error_description);
+								return next();
+							},
 
-							if (user) {
-								user.services.github.access_token = accessToken;
-								return user.save(() => next(true, user._id));
-							}
+							next => {
+								oauth2.getOAuthAccessToken(code, { redirect_uri: redirectUri }, next);
+							},
 
-							return userModel.findOne({ username: new RegExp(`^${body.login}$`, "i") }, (err, user) =>
-								next(err, user)
-							);
-						},
+							(_accessToken, refreshToken, results, next) => {
+								if (results.error) return next(results.error_description);
 
-						(user, next) => {
-							if (user) return next(`An account with that username already exists.`);
+								accessToken = _accessToken;
 
-							return axios
-								.get("https://api.github.com/user/emails", {
+								const options = {
 									headers: {
 										"User-Agent": "request",
 										Authorization: `token ${accessToken}`
 									}
-								})
-								.then(res => next(null, res.data))
-								.catch(err => next(err));
-						},
-
-						(body, next) => {
-							if (!Array.isArray(body)) return next(body.message);
-
-							body.forEach(email => {
-								if (email.primary) address = email.email.toLowerCase();
-							});
+								};
+
+								return axios
+									.get("https://api.github.com/user", options)
+									.then(github => next(null, github))
+									.catch(err => next(err));
+							},
+
+							(github, next) => {
+								if (github.status !== 200) return next(github.data.message);
+
+								if (state) {
+									return async.waterfall(
+										[
+											next => {
+												CacheModule.runJob("HGET", {
+													table: "sessions",
+													key: state
+												})
+													.then(session => next(null, session))
+													.catch(next);
+											},
+
+											(session, next) => {
+												if (!session) return next("Invalid session.");
+												return userModel.findOne({ _id: session.userId }, next);
+											},
+
+											(user, next) => {
+												if (!user) return next("User not found.");
+												if (user.services.github && user.services.github.id)
+													return next("Account already has GitHub linked.");
+
+												return userModel.updateOne(
+													{ _id: user._id },
+													{
+														$set: {
+															"services.github": {
+																id: github.data.id,
+																access_token: accessToken
+															}
+														}
+													},
+													{ runValidators: true },
+													err => {
+														if (err) return next(err);
+														return next(null, user, github.data);
+													}
+												);
+											},
+
+											user => {
+												CacheModule.runJob("PUB", {
+													channel: "user.linkGithub",
+													value: user._id
+												});
+
+												CacheModule.runJob("PUB", {
+													channel: "user.updated",
+													value: { userId: user._id }
+												});
+
+												res.redirect(`${config.get("domain")}/settings?tab=security`);
+											}
+										],
+										next
+									);
+								}
 
-							return userModel.findOne({ "email.address": address }, next);
-						},
+								if (!github.data.id) return next("Something went wrong, no id.");
 
-						(user, next) => {
-							UtilsModule.runJob("GENERATE_RANDOM_STRING", {
-								length: 12
-							}).then(_id => next(null, user, _id));
-						},
+								return userModel.findOne({ "services.github.id": github.data.id }, (err, user) => {
+									next(err, user, github.data);
+								});
+							},
 
-						(user, _id, next) => {
-							if (user) {
-								if (Object.keys(JSON.parse(user.services.github)).length === 0)
-									return next(
-										`An account with that email address exists, but is not linked to GitHub.`
-									);
-								return next(`An account with that email address already exists.`);
-							}
+							(user, _body, next) => {
+								body = _body;
 
-							return next(null, {
-								_id,
-								username: body.login,
-								name: body.name,
-								location: body.location,
-								bio: body.bio,
-								email: {
-									address,
-									verificationToken
-								},
-								services: {
-									github: { id: body.id, access_token: accessToken }
+								if (user) {
+									user.services.github.access_token = accessToken;
+									return user.save(() => next(true, user._id));
 								}
-							});
-						},
 
-						// generate the url for gravatar avatar
-						(user, next) => {
-							UtilsModule.runJob("CREATE_GRAVATAR", {
-								email: user.email.address
-							}).then(url => {
-								user.avatar = { type: "gravatar", url };
-								next(null, user);
-							});
-						},
+								return userModel.findOne(
+									{
+										username: new RegExp(`^${body.login}$`, "i")
+									},
+									(err, user) => next(err, user)
+								);
+							},
 
-						// save the new user to the database
-						(user, next) => {
-							userModel.create(user, next);
-						},
+							(user, next) => {
+								if (user) return next(`An account with that username already exists.`);
 
-						(user, next) => {
-							MailModule.runJob("GET_SCHEMA", {
-								schemaName: "verifyEmail"
-							}).then(verifyEmailSchema => {
-								verifyEmailSchema(address, body.login, user.email.verificationToken, err => {
-									next(err, user._id);
-								});
-							});
-						},
+								return axios
+									.get("https://api.github.com/user/emails", {
+										headers: {
+											"User-Agent": "request",
+											Authorization: `token ${accessToken}`
+										}
+									})
+									.then(res => next(null, res.data))
+									.catch(err => next(err));
+							},
 
-						// create a liked songs playlist for the new user
-						(userId, next) => {
-							PlaylistsModule.runJob("CREATE_USER_PLAYLIST", {
-								userId,
-								displayName: "Liked Songs",
-								type: "user-liked"
-							})
-								.then(likedSongsPlaylist => {
-									next(null, likedSongsPlaylist, userId);
-								})
-								.catch(err => next(err));
-						},
+							(body, next) => {
+								if (!Array.isArray(body)) return next(body.message);
 
-						// create a disliked songs playlist for the new user
-						(likedSongsPlaylist, userId, next) => {
-							PlaylistsModule.runJob("CREATE_USER_PLAYLIST", {
-								userId,
-								displayName: "Disliked Songs",
-								type: "user-disliked"
-							})
-								.then(dislikedSongsPlaylist => {
-									next(null, { likedSongsPlaylist, dislikedSongsPlaylist }, userId);
-								})
-								.catch(err => next(err));
-						},
+								body.forEach(email => {
+									if (email.primary) address = email.email.toLowerCase();
+								});
 
-						// associate liked + disliked songs playlist to the user object
-						({ likedSongsPlaylist, dislikedSongsPlaylist }, userId, next) => {
-							userModel.updateOne(
-								{ _id: userId },
-								{ $set: { likedSongsPlaylist, dislikedSongsPlaylist } },
-								{ runValidators: true },
-								err => {
-									if (err) return next(err);
-									return next(null, userId);
+								return userModel.findOne({ "email.address": address }, next);
+							},
+
+							(user, next) => {
+								UtilsModule.runJob("GENERATE_RANDOM_STRING", {
+									length: 12
+								}).then(_id => next(null, user, _id));
+							},
+
+							(user, _id, next) => {
+								if (user) {
+									if (Object.keys(JSON.parse(user.services.github)).length === 0)
+										return next(
+											`An account with that email address exists, but is not linked to GitHub.`
+										);
+									return next(`An account with that email address already exists.`);
 								}
-							);
-						},
-
-						// add the activity of account creation
-						(userId, next) => {
-							ActivitiesModule.runJob("ADD_ACTIVITY", {
-								userId,
-								type: "user__joined",
-								payload: { message: "Welcome to Musare!" }
-							});
 
-							next(null, userId);
-						}
-					],
-					async (err, userId) => {
-						if (err && err !== true) {
-							err = await UtilsModule.runJob("GET_ERROR", {
-								error: err
-							});
-
-							this.log(
-								"ERROR",
-								"AUTH_GITHUB_AUTHORIZE_CALLBACK",
-								`Failed to authorize with GitHub. "${err}"`
-							);
-
-							return redirectOnErr(res, err);
-						}
+								return next(null, {
+									_id,
+									username: body.login,
+									name: body.name,
+									location: body.location,
+									bio: body.bio,
+									email: {
+										address,
+										verificationToken
+									},
+									services: {
+										github: {
+											id: body.id,
+											access_token: accessToken
+										}
+									}
+								});
+							},
+
+							// generate the url for gravatar avatar
+							(user, next) => {
+								UtilsModule.runJob("CREATE_GRAVATAR", {
+									email: user.email.address
+								}).then(url => {
+									user.avatar = { type: "gravatar", url };
+									next(null, user);
+								});
+							},
+
+							// save the new user to the database
+							(user, next) => {
+								userModel.create(user, next);
+							},
+
+							(user, next) => {
+								MailModule.runJob("GET_SCHEMA", {
+									schemaName: "verifyEmail"
+								}).then(verifyEmailSchema => {
+									verifyEmailSchema(address, body.login, user.email.verificationToken, err => {
+										next(err, user._id);
+									});
+								});
+							},
+
+							// create a liked songs playlist for the new user
+							(userId, next) => {
+								PlaylistsModule.runJob("CREATE_USER_PLAYLIST", {
+									userId,
+									displayName: "Liked Songs",
+									type: "user-liked"
+								})
+									.then(likedSongsPlaylist => {
+										next(null, likedSongsPlaylist, userId);
+									})
+									.catch(err => next(err));
+							},
+
+							// create a disliked songs playlist for the new user
+							(likedSongsPlaylist, userId, next) => {
+								PlaylistsModule.runJob("CREATE_USER_PLAYLIST", {
+									userId,
+									displayName: "Disliked Songs",
+									type: "user-disliked"
+								})
+									.then(dislikedSongsPlaylist => {
+										next(
+											null,
+											{
+												likedSongsPlaylist,
+												dislikedSongsPlaylist
+											},
+											userId
+										);
+									})
+									.catch(err => next(err));
+							},
+
+							// associate liked + disliked songs playlist to the user object
+							({ likedSongsPlaylist, dislikedSongsPlaylist }, userId, next) => {
+								userModel.updateOne(
+									{ _id: userId },
+									{
+										$set: {
+											likedSongsPlaylist,
+											dislikedSongsPlaylist
+										}
+									},
+									{ runValidators: true },
+									err => {
+										if (err) return next(err);
+										return next(null, userId);
+									}
+								);
+							},
+
+							// add the activity of account creation
+							(userId, next) => {
+								ActivitiesModule.runJob("ADD_ACTIVITY", {
+									userId,
+									type: "user__joined",
+									payload: { message: "Welcome to Musare!" }
+								});
 
-						const sessionId = await UtilsModule.runJob("GUID", {});
-						const sessionSchema = await CacheModule.runJob("GET_SCHEMA", {
-							schemaName: "session"
-						});
-
-						return CacheModule.runJob("HSET", {
-							table: "sessions",
-							key: sessionId,
-							value: sessionSchema(sessionId, userId)
-						})
-							.then(() => {
-								const date = new Date();
-								date.setTime(new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000);
-
-								res.cookie(SIDname, sessionId, {
-									expires: date,
-									secure: config.get("cookie.secure"),
-									path: "/",
-									domain: config.get("cookie.domain")
+								next(null, userId);
+							}
+						],
+						async (err, userId) => {
+							if (err && err !== true) {
+								err = await UtilsModule.runJob("GET_ERROR", {
+									error: err
 								});
 
 								this.log(
-									"INFO",
+									"ERROR",
 									"AUTH_GITHUB_AUTHORIZE_CALLBACK",
-									`User "${userId}" successfully authorized with GitHub.`
+									`Failed to authorize with GitHub. "${err}"`
 								);
 
-								res.redirect(`${config.get("domain")}/`);
+								return redirectOnErr(res, err);
+							}
+
+							const sessionId = await UtilsModule.runJob("GUID", {});
+							const sessionSchema = await CacheModule.runJob("GET_SCHEMA", {
+								schemaName: "session"
+							});
+
+							return CacheModule.runJob("HSET", {
+								table: "sessions",
+								key: sessionId,
+								value: sessionSchema(sessionId, userId)
 							})
-							.catch(err => redirectOnErr(res, err.message));
-					}
-				);
-			});
+								.then(() => {
+									const date = new Date();
+									date.setTime(new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000);
+
+									res.cookie(SIDname, sessionId, {
+										expires: date,
+										secure: config.get("cookie.secure"),
+										path: "/",
+										domain: config.get("cookie.domain")
+									});
+
+									this.log(
+										"INFO",
+										"AUTH_GITHUB_AUTHORIZE_CALLBACK",
+										`User "${userId}" successfully authorized with GitHub.`
+									);
+
+									res.redirect(`${config.get("domain")}/`);
+								})
+								.catch(err => redirectOnErr(res, err.message));
+						}
+					);
+				});
+			}
 
 			app.get("/auth/verify_email", async (req, res) => {
 				if (this.getStatus() !== "READY") {
 					this.log(
 						"INFO",
-						"APP_REJECTED_GITHUB_AUTHORIZE",
-						`A user tried to use github authorize, but the APP module is currently not ready.`
+						"APP_REJECTED_VERIFY_EMAIL",
+						`A user tried to use verify email, but the APP module is currently not ready.`
 					);
 					return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
 				}

+ 96 - 105
backend/logic/cache/index.js

@@ -51,29 +51,24 @@ class _CacheModule extends CoreClass {
 			this.client = redis.createClient({
 				url: this.url,
 				password: this.password,
-				retry_strategy: options => {
-					if (this.getStatus() === "LOCKDOWN") return;
-					if (this.getStatus() !== "RECONNECTING") this.setStatus("RECONNECTING");
+				reconnectStrategy: retries => {
+					if (this.getStatus() !== "LOCKDOWN") {
+						if (this.getStatus() !== "RECONNECTING") this.setStatus("RECONNECTING");
 
-					this.log("INFO", `Attempting to reconnect.`);
+						this.log("INFO", `Attempting to reconnect.`);
 
-					if (options.attempt >= 10) {
-						this.log("ERROR", `Stopped trying to reconnect.`);
+						if (retries >= 10) {
+							this.log("ERROR", `Stopped trying to reconnect.`);
 
-						this.setStatus("FAILED");
+							this.setStatus("FAILED");
+							new Error("Stopped trying to reconnect.");
+						} else {
+							Math.min(retries * 50, 500);
+						}
 					}
 				}
 			});
 
-			// TODO move to a better place
-			CacheModule.runJob("KEYS", { pattern: "longJobs.*" }).then(keys => {
-				async.eachLimit(keys, 1, (key, next) => {
-					CacheModule.runJob("DEL", { key }).finally(() => {
-						next();
-					});
-				});
-			});
-
 			this.client.on("error", err => {
 				if (this.getStatus() === "INITIALIZING") reject(err);
 				if (this.getStatus() === "LOCKDOWN") return;
@@ -81,12 +76,24 @@ class _CacheModule extends CoreClass {
 				this.log("ERROR", `Error ${err.message}.`);
 			});
 
-			this.client.on("connect", () => {
-				this.log("INFO", "Connected succesfully.");
-
+			this.client.on("ready", () => {
+				this.log("INFO", "Redis is ready.");
 				if (this.getStatus() === "INITIALIZING") resolve();
 				else if (this.getStatus() === "FAILED" || this.getStatus() === "RECONNECTING") this.setStatus("READY");
 			});
+
+			this.client.connect().then(async () => {
+				this.log("INFO", "Connected succesfully.");
+			});
+
+			// TODO move to a better place
+			CacheModule.runJob("KEYS", { pattern: "longJobs.*" }).then(keys => {
+				async.eachLimit(keys, 1, (key, next) => {
+					CacheModule.runJob("DEL", { key }).finally(() => {
+						next();
+					});
+				});
+			});
 		});
 	}
 
@@ -125,10 +132,10 @@ class _CacheModule extends CoreClass {
 			// automatically stringify objects and arrays into JSON
 			if (["object", "array"].includes(typeof value)) value = JSON.stringify(value);
 
-			CacheModule.client.hset(payload.table, key, value, err => {
-				if (err) return reject(new Error(err));
-				return resolve(JSON.parse(value));
-			});
+			CacheModule.client
+				.HSET(payload.table, key, value)
+				.then(() => resolve(JSON.parse(value)))
+				.catch(err => reject(new Error(err)));
 		});
 	}
 
@@ -155,20 +162,19 @@ class _CacheModule extends CoreClass {
 			}
 			if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
 
-			CacheModule.client.hget(payload.table, key, (err, value) => {
-				if (err) {
-					reject(new Error(err));
-					return;
-				}
-				try {
-					value = JSON.parse(value);
-				} catch (e) {
-					reject(err);
-					return;
-				}
+			CacheModule.client
+				.HGET(payload.table, key, payload.value)
+				.then(value => {
+					let parsedValue;
+					try {
+						parsedValue = JSON.parse(value);
+					} catch (err) {
+						return reject(err);
+					}
 
-				resolve(value);
-			});
+					return resolve(parsedValue);
+				})
+				.catch(err => reject(new Error(err)));
 		});
 	}
 
@@ -195,13 +201,10 @@ class _CacheModule extends CoreClass {
 
 			if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
 
-			CacheModule.client.hdel(payload.table, key, err => {
-				if (err) {
-					reject(new Error(err));
-					return;
-				}
-				resolve();
-			});
+			CacheModule.client
+				.HDEL(payload.table, key)
+				.then(() => resolve())
+				.catch(err => reject(new Error(err)));
 		});
 	}
 
@@ -220,19 +223,17 @@ class _CacheModule extends CoreClass {
 				return;
 			}
 
-			CacheModule.client.hgetall(payload.table, (err, obj) => {
-				if (err) {
-					reject(new Error(err));
-					return;
-				}
-				if (obj)
-					Object.keys(obj).forEach(key => {
-						obj[key] = JSON.parse(obj[key]);
-					});
-				else if (!obj) obj = [];
-
-				resolve(obj);
-			});
+			CacheModule.client
+				.HGETALL(payload.table)
+				.then(obj => {
+					if (obj)
+						Object.keys(obj).forEach(key => {
+							obj[key] = JSON.parse(obj[key]);
+						});
+					else if (!obj) obj = [];
+					resolve(obj);
+				})
+				.catch(err => reject(new Error(err)));
 		});
 	}
 
@@ -254,13 +255,10 @@ class _CacheModule extends CoreClass {
 
 			if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
 
-			CacheModule.client.del(key, err => {
-				if (err) {
-					reject(new Error(err));
-					return;
-				}
-				resolve();
-			});
+			CacheModule.client
+				.DEL(key)
+				.then(() => resolve())
+				.catch(err => reject(new Error(err)));
 		});
 	}
 
@@ -288,10 +286,10 @@ class _CacheModule extends CoreClass {
 
 			if (["object", "array"].includes(typeof value)) value = JSON.stringify(value);
 
-			CacheModule.client.publish(payload.channel, value, err => {
-				if (err) reject(err);
-				else resolve();
-			});
+			CacheModule.client
+				.publish(payload.channel, value)
+				.then(() => resolve())
+				.catch(err => reject(new Error(err)));
 		});
 	}
 
@@ -318,21 +316,20 @@ class _CacheModule extends CoreClass {
 					}),
 					cbs: []
 				};
-
-				subs[payload.channel].client.on("message", (channel, message) => {
-					if (message.startsWith("[") || message.startsWith("{"))
-						try {
-							message = JSON.parse(message);
-						} catch (err) {
-							console.error(err);
-						}
-					else if (message.startsWith('"') && message.endsWith('"'))
-						message = message.substring(1).substring(0, message.length - 2);
-
-					return subs[channel].cbs.forEach(cb => cb(message));
+				subs[payload.channel].client.connect().then(() => {
+					subs[payload.channel].client.subscribe(payload.channel, (message, channel) => {
+						if (message.startsWith("[") || message.startsWith("{"))
+							try {
+								message = JSON.parse(message);
+							} catch (err) {
+								console.error(err);
+							}
+						else if (message.startsWith('"') && message.endsWith('"'))
+							message = message.substring(1).substring(0, message.length - 2);
+
+						subs[channel].cbs.forEach(cb => cb(message));
+					});
 				});
-
-				subs[payload.channel].client.subscribe(payload.channel);
 			}
 
 			subs[payload.channel].cbs.push(payload.cb);
@@ -358,14 +355,10 @@ class _CacheModule extends CoreClass {
 			}
 			if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
 
-			CacheModule.client.LRANGE(key, 0, -1, (err, list) => {
-				if (err) {
-					reject(new Error(err));
-					return;
-				}
-
-				resolve(list);
-			});
+			CacheModule.client
+				.LRANGE(key, 0, -1)
+				.then(list => resolve(list))
+				.catch(err => reject(new Error(err)));
 		});
 	}
 
@@ -380,17 +373,16 @@ class _CacheModule extends CoreClass {
 	 */
 	RPUSH(payload) {
 		return new Promise((resolve, reject) => {
-			let { key } = payload;
-			let { value } = payload;
+			let { key, value } = payload;
 
 			if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
 			// automatically stringify objects and arrays into JSON
 			if (["object", "array"].includes(typeof value)) value = JSON.stringify(value);
 
-			CacheModule.client.RPUSH(key, value, err => {
-				if (err) return reject(new Error(err));
-				return resolve();
-			});
+			CacheModule.client
+				.RPUSH(key, value)
+				.then(() => resolve())
+				.catch(err => reject(new Error(err)));
 		});
 	}
 
@@ -405,17 +397,16 @@ class _CacheModule extends CoreClass {
 	 */
 	LREM(payload) {
 		return new Promise((resolve, reject) => {
-			let { key } = payload;
-			let { value } = payload;
+			let { key, value } = payload;
 
 			if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
 			// automatically stringify objects and arrays into JSON
 			if (["object", "array"].includes(typeof value)) value = JSON.stringify(value);
 
-			CacheModule.client.LREM(key, 1, value, err => {
-				if (err) return reject(new Error(err));
-				return resolve();
-			});
+			CacheModule.client
+				.LREM(key, 1, value)
+				.then(() => resolve())
+				.catch(err => reject(new Error(err)));
 		});
 	}
 
@@ -430,10 +421,10 @@ class _CacheModule extends CoreClass {
 		return new Promise((resolve, reject) => {
 			const { pattern } = payload;
 
-			CacheModule.client.KEYS(pattern, (err, keys) => {
-				if (err) return reject(new Error(err));
-				return resolve(keys);
-			});
+			CacheModule.client
+				.KEYS(pattern)
+				.then(keys => resolve(keys))
+				.catch(err => reject(new Error(err)));
 		});
 	}
 

+ 1 - 1
backend/logic/db/index.js

@@ -11,7 +11,7 @@ const REQUIRED_DOCUMENT_VERSIONS = {
 	playlist: 6,
 	punishment: 1,
 	queueSong: 1,
-	report: 5,
+	report: 6,
 	song: 9,
 	station: 8,
 	user: 3,

+ 1 - 1
backend/logic/db/schemas/report.js

@@ -18,5 +18,5 @@ export default {
 	],
 	createdBy: { type: String, required: true },
 	createdAt: { type: Date, default: Date.now, required: true },
-	documentVersion: { type: Number, default: 5, required: true }
+	documentVersion: { type: Number, default: 6, required: true }
 };

+ 63 - 0
backend/logic/migration/migrations/migration22.js

@@ -0,0 +1,63 @@
+import async from "async";
+
+/**
+ * Migration 22
+ *
+ * Migration to fix issues in a previous migration (12), where report categories were not turned into lowercase
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const reportModel = await MigrationModule.runJob("GET_MODEL", { modelName: "report" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 22. Finding reports with document version 5.`);
+					reportModel.find({ documentVersion: 5 }, (err, reports) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								reports.map(reporti => reporti._doc),
+								1,
+								(reporti, next) => {
+									const issues = reporti.issues.map(issue => ({
+										...issue,
+										category: issue.category.toLowerCase()
+									}));
+
+									reportModel.updateOne(
+										{ _id: reporti._id },
+										{
+											$set: {
+												documentVersion: 6,
+												issues
+											},
+											$unset: {
+												description: ""
+											}
+										},
+										next
+									);
+								},
+								err => {
+									if (err) next(err);
+									else {
+										this.log("INFO", `Migration 22. Reports found: ${reports.length}.`);
+										next();
+									}
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) reject(new Error(err));
+				else resolve();
+			}
+		);
+	});
+}

+ 72 - 102
backend/logic/notifications.js

@@ -6,7 +6,6 @@ import redis from "redis";
 import CoreClass from "../core";
 
 let NotificationsModule;
-let UtilsModule;
 
 class _NotificationsModule extends CoreClass {
 	// eslint-disable-next-line require-jsdoc
@@ -28,48 +27,28 @@ class _NotificationsModule extends CoreClass {
 			const url = (this.url = config.get("redis").url);
 			const password = (this.password = config.get("redis").password);
 
-			UtilsModule = this.moduleManager.modules.utils;
-
 			this.pub = redis.createClient({
 				url,
 				password,
-				retry_strategy: options => {
-					if (this.getStatus() === "LOCKDOWN") return;
-					if (this.getStatus() !== "RECONNECTING") this.setStatus("RECONNECTING");
-
-					this.log("INFO", `Attempting to reconnect.`);
+				reconnectStrategy: retries => {
+					if (this.getStatus() !== "LOCKDOWN") {
+						if (this.getStatus() !== "RECONNECTING") this.setStatus("RECONNECTING");
 
-					if (options.attempt >= 10) {
-						this.log("ERROR", `Stopped trying to reconnect.`);
-
-						this.setStatus("FAILED");
-					}
-				}
-			});
-			this.sub = redis.createClient({
-				url,
-				password,
-				retry_strategy: options => {
-					if (this.getStatus() === "LOCKDOWN") return;
-					if (this.getStatus() !== "RECONNECTING") this.setStatus("RECONNECTING");
+						this.log("INFO", `Attempting to reconnect.`);
 
-					this.log("INFO", `Attempting to reconnect.`);
+						if (retries >= 10) {
+							this.log("ERROR", `Stopped trying to reconnect.`);
 
-					if (options.attempt >= 10) {
-						this.log("ERROR", `Stopped trying to reconnect.`);
+							this.setStatus("FAILED");
 
-						this.setStatus("FAILED");
+							new Error("Stopped trying to reconnect.");
+						} else {
+							Math.min(retries * 50, 500);
+						}
 					}
 				}
 			});
 
-			this.sub.on("error", err => {
-				if (this.getStatus() === "INITIALIZING") reject(err);
-				if (this.getStatus() === "LOCKDOWN") return;
-
-				this.log("ERROR", `Error ${err.message}.`);
-			});
-
 			this.pub.on("error", err => {
 				if (this.getStatus() === "INITIALIZING") reject(err);
 				if (this.getStatus() === "LOCKDOWN") return;
@@ -77,75 +56,73 @@ class _NotificationsModule extends CoreClass {
 				this.log("ERROR", `Error ${err.message}.`);
 			});
 
-			this.sub.on("connect", () => {
-				this.log("INFO", "Sub connected succesfully.");
-
+			this.pub.on("ready", () => {
+				this.log("INFO", "Pub is ready.");
 				if (this.getStatus() === "INITIALIZING") resolve();
 				else if (this.getStatus() === "LOCKDOWN" || this.getStatus() === "RECONNECTING")
-					this.setStatus("READY");
+					this.setStatus("INITIALIZED");
 			});
 
-			this.pub.on("connect", () => {
+			this.pub.connect().then(async () => {
 				this.log("INFO", "Pub connected succesfully.");
 
-				this.pub.config("GET", "notify-keyspace-events", async (err, response) => {
-					if (err) {
-						const formattedErr = await UtilsModule.runJob(
-							"GET_ERROR",
-							{
-								error: err
-							},
-							this
-						);
-						this.log(
-							"ERROR",
-							"NOTIFICATIONS_INITIALIZE",
-							`Getting notify-keyspace-events gave an error. ${formattedErr}`
-						);
-						this.log(
-							"STATION_ISSUE",
-							`Getting notify-keyspace-events gave an error. ${formattedErr}. ${response}`
-						);
-						return;
-					}
-					if (response[1] === "xE") {
-						this.log("INFO", "NOTIFICATIONS_INITIALIZE", `notify-keyspace-events is set correctly`);
-						this.log("STATION_ISSUE", `notify-keyspace-events is set correctly`);
-					} else {
+				this.pub
+					.sendCommand(["CONFIG", "GET", "notify-keyspace-events"])
+					.then(response => {
+						if (response[1] === "xE") {
+							this.log("INFO", "NOTIFICATIONS_INITIALIZE", `notify-keyspace-events is set correctly`);
+							this.log("STATION_ISSUE", `notify-keyspace-events is set correctly`);
+						} else {
+							this.log(
+								"ERROR",
+								"NOTIFICATIONS_INITIALIZE",
+								`notify-keyspace-events is NOT correctly! It is set to: ${response[1]}`
+							);
+							this.log(
+								"STATION_ISSUE",
+								`notify-keyspace-events is NOT correctly! It is set to: ${response[1]}`
+							);
+						}
+					})
+					.catch(err => {
 						this.log(
 							"ERROR",
 							"NOTIFICATIONS_INITIALIZE",
-							`notify-keyspace-events is NOT correctly! It is set to: ${response[1]}`
+							`Getting notify-keyspace-events gave an error. ${err}`
 						);
-						this.log(
-							"STATION_ISSUE",
-							`notify-keyspace-events is NOT correctly! It is set to: ${response[1]}`
-						);
-					}
-				});
+						this.log("STATION_ISSUE", `Getting notify-keyspace-events gave an error. ${err}.`);
+					});
+			});
+
+			this.sub = this.pub.duplicate();
+
+			this.sub.on("error", err => {
+				if (this.getStatus() === "INITIALIZING") reject(err);
+				if (this.getStatus() === "LOCKDOWN") return;
+
+				this.log("ERROR", `Error ${err.message}.`);
+			});
+
+			this.sub.connect().then(async () => {
+				this.log("INFO", "Sub connected succesfully.");
 
 				if (this.getStatus() === "INITIALIZING") resolve();
 				else if (this.getStatus() === "LOCKDOWN" || this.getStatus() === "RECONNECTING")
-					this.setStatus("INITIALIZED");
-			});
+					this.setStatus("READY");
 
-			this.sub.on("pmessage", (pattern, channel, expiredKey) => {
-				this.log(
-					"STATION_ISSUE",
-					`PMESSAGE1 - Pattern: ${pattern}; Channel: ${channel}; ExpiredKey: ${expiredKey}`
-				);
+				this.sub.PSUBSCRIBE(`__keyevent@${this.sub.options.database}__:expired`, (message, channel) => {
+					this.log("STATION_ISSUE", `PMESSAGE1 - Channel: ${channel}; ExpiredKey: ${message}`);
 
-				this.subscriptions.forEach(sub => {
-					this.log(
-						"STATION_ISSUE",
-						`PMESSAGE2 - Sub name: ${sub.name}; Calls cb: ${!(sub.name !== expiredKey)}`
-					);
-					if (sub.name !== expiredKey) return;
-					sub.cb();
+					this.subscriptions.forEach(sub => {
+						this.log(
+							"STATION_ISSUE",
+							`PMESSAGE2 - Sub name: ${sub.name}; Calls cb: ${!(sub.name !== message)}`
+						);
+						if (sub.name !== message) return;
+						sub.cb();
+					});
 				});
 			});
-
-			this.sub.psubscribe(`__keyevent@${this.pub.options.db}__:expired`);
 		});
 	}
 
@@ -172,17 +149,13 @@ class _NotificationsModule extends CoreClass {
 						.update(`_notification:${payload.name}_`)
 						.digest("hex")}; StationId: ${payload.station._id}; StationName: ${payload.station.name}`
 				);
-				NotificationsModule.pub.set(
-					crypto.createHash("md5").update(`_notification:${payload.name}_`).digest("hex"),
-					"",
-					"PX",
-					time,
-					"NX",
-					err => {
-						if (err) reject(err);
-						else resolve();
-					}
-				);
+				NotificationsModule.pub
+					.SET(crypto.createHash("md5").update(`_notification:${payload.name}_`).digest("hex"), "", {
+						PX: time,
+						NX: true
+					})
+					.then(() => resolve())
+					.catch(err => reject(new Error(err)));
 			}
 		});
 	}
@@ -266,13 +239,10 @@ class _NotificationsModule extends CoreClass {
 					.update(`_notification:${payload.name}_`)
 					.digest("hex")}`
 			);
-			NotificationsModule.pub.del(
-				crypto.createHash("md5").update(`_notification:${payload.name}_`).digest("hex"),
-				err => {
-					if (err) reject(err);
-					else resolve();
-				}
-			);
+			NotificationsModule.pub
+				.DEL(crypto.createHash("md5").update(`_notification:${payload.name}_`).digest("hex"))
+				.then(() => resolve())
+				.catch(err => reject(new Error(err)));
 		});
 	}
 }

+ 65 - 1
backend/logic/punishments.js

@@ -6,6 +6,7 @@ let PunishmentsModule;
 let CacheModule;
 let DBModule;
 let UtilsModule;
+let WSModule;
 
 class _PunishmentsModule extends CoreClass {
 	// eslint-disable-next-line require-jsdoc
@@ -26,6 +27,7 @@ class _PunishmentsModule extends CoreClass {
 		CacheModule = this.moduleManager.modules.cache;
 		DBModule = this.moduleManager.modules.db;
 		UtilsModule = this.moduleManager.modules.utils;
+		WSModule = this.moduleManager.modules.ws;
 
 		this.punishmentModel = this.PunishmentModel = await DBModule.runJob("GET_MODEL", { modelName: "punishment" });
 		this.punishmentSchemaCache = await CacheModule.runJob("GET_SCHEMA", { schemaName: "punishment" });
@@ -144,7 +146,19 @@ class _PunishmentsModule extends CoreClass {
 										key: punishment.punishmentId
 									},
 									this
-								).finally(() => next2());
+								).finally(() => {
+									WSModule.runJob(
+										"EMIT_TO_ROOM",
+										{
+											room: `admin.punishments`,
+											args: [
+												"event:admin.punishment.updated",
+												{ data: { punishment: { ...punishment, status: "Inactive" } } }
+											]
+										},
+										this
+									).finally(() => next2());
+								});
 							},
 							() => {
 								next(null, punishments);
@@ -301,6 +315,56 @@ class _PunishmentsModule extends CoreClass {
 			);
 		});
 	}
+
+	/**
+	 * Deactivates a punishment
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.punishmentId - the MongoDB id of the punishment
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	DEACTIVATE_PUNISHMENT(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						PunishmentsModule.punishmentModel.findOne({ _id: payload.punishmentId }, next);
+					},
+
+					(punishment, next) => {
+						if (!punishment) next("Punishment does not exist.");
+						else
+							PunishmentsModule.punishmentModel.updateOne(
+								{ _id: payload.punishmentId },
+								{ $set: { active: false } },
+								next
+							);
+					},
+
+					(res, next) => {
+						CacheModule.runJob(
+							"HDEL",
+							{
+								table: "punishments",
+								key: payload.punishmentId
+							},
+							this
+						)
+							.then(() => next())
+							.catch(next);
+					},
+
+					next => {
+						PunishmentsModule.punishmentModel.findOne({ _id: payload.punishmentId }, next);
+					}
+				],
+				(err, punishment) => {
+					if (err) return reject(new Error(err));
+					return resolve(punishment);
+				}
+			);
+		});
+	}
 }
 
 export default new _PunishmentsModule();

+ 10 - 1
backend/logic/songs.js

@@ -207,7 +207,16 @@ class _SongsModule extends CoreClass {
 											youtubeVideoId: video._id
 										};
 									});
-									next(null, [...songs, ...youtubeVideos]);
+									next(
+										null,
+										payload.youtubeIds
+											.map(
+												youtubeId =>
+													songs.find(song => song.youtubeId === youtubeId) ||
+													youtubeVideos.find(video => video.youtubeId === youtubeId)
+											)
+											.filter(song => !!song)
+									);
 								}
 							}
 						);

+ 3 - 1
backend/logic/ws.js

@@ -561,10 +561,12 @@ class _WSModule extends CoreClass {
 					`A user tried to connect, but is currently banned. IP: ${socket.ip}.${sessionInfo}`
 				);
 
-				socket.dispatch("keep.event:banned", { data: { ban: socket.banishment.ban } });
+				socket.dispatch("keep.event:user.banned", { data: { ban: socket.banishment.ban } });
 
 				socket.close(); // close socket connection
 
+				resolve();
+
 				return;
 			}
 

+ 11 - 4
backend/logic/youtube.js

@@ -587,7 +587,7 @@ class _YouTubeModule extends CoreClass {
 				],
 				(err, channelId) => {
 					if (err) {
-						YouTubeModule.log("ERROR", "GET_CHANNEL_ID_FROM_CUSTOM_URL", `${err.message}`);
+						YouTubeModule.log("ERROR", "GET_CHANNEL_ID_FROM_CUSTOM_URL", `${err.message || err}`);
 						if (err.message === "Request failed with status code 404") {
 							return reject(new Error("Channel not found. Is the channel public/unlisted?"));
 						}
@@ -783,6 +783,7 @@ class _YouTubeModule extends CoreClass {
 	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {boolean} payload.musicOnly - whether to return music videos or all videos in the channel
+	 * @param {boolean} payload.disableSearch - whether to allow searching for custom url/username
 	 * @param {string} payload.url - the url of the YouTube channel
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
@@ -807,6 +808,8 @@ class _YouTubeModule extends CoreClass {
 			console.log(`Channel custom URL: ${channelCustomUrl}`);
 			console.log(`Channel username or custom URL: ${channelUsernameOrCustomUrl}`);
 
+			const disableSearch = payload.disableSearch || false;
+
 			async.waterfall(
 				[
 					next => {
@@ -829,6 +832,10 @@ class _YouTubeModule extends CoreClass {
 					(getUsernameFromCustomUrl, playlistId, next) => {
 						if (!getUsernameFromCustomUrl) return next(null, playlistId);
 
+						if (disableSearch)
+							return next(
+								"Importing with this type of URL is disabled. Please provide a channel URL with the channel ID."
+							);
 						const payload = {};
 						if (channelCustomUrl) payload.customUrl = channelCustomUrl;
 						else if (channelUsernameOrCustomUrl) payload.customUrl = channelUsernameOrCustomUrl;
@@ -890,8 +897,8 @@ class _YouTubeModule extends CoreClass {
 				],
 				(err, response) => {
 					if (err && err !== true) {
-						YouTubeModule.log("ERROR", "GET_CHANNEL", "Some error has occurred.", err.message);
-						reject(new Error(err.message));
+						YouTubeModule.log("ERROR", "GET_CHANNEL", "Some error has occurred.", err.message || err);
+						reject(new Error(err.message || err));
 					} else {
 						resolve({ songs: response.filteredSongs ? response.filteredSongs.videoIds : response.songs });
 					}
@@ -1053,7 +1060,7 @@ class _YouTubeModule extends CoreClass {
 
 				youtubeApiRequest.save();
 
-				const { key, ...keylessParams } = payload.params;
+				const { ...keylessParams } = payload.params;
 				CacheModule.runJob(
 					"HSET",
 					{

File diff suppressed because it is too large
+ 735 - 58
backend/package-lock.json


+ 20 - 14
backend/package.json

@@ -1,7 +1,7 @@
 {
   "name": "musare-backend",
   "private": true,
-  "version": "3.6.0",
+  "version": "3.7.0",
   "type": "module",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "index.js",
@@ -12,10 +12,11 @@
     "dev": "nodemon --es-module-specifier-resolution=node",
     "docker:dev": "nodemon --es-module-specifier-resolution=node --legacy-watch --no-stdin /opt/app",
     "docker:prod": "node --es-module-specifier-resolution=node /opt/app",
-    "lint": "npx eslint logic"
+    "lint": "eslint --cache logic",
+    "typescript": "tsc --noEmit --skipLibCheck"
   },
   "dependencies": {
-    "async": "^3.2.3",
+    "async": "^3.2.4",
     "axios": "^0.27.2",
     "bcrypt": "^5.0.1",
     "bluebird": "^3.7.2",
@@ -25,24 +26,29 @@
     "cors": "^2.8.5",
     "express": "^4.18.1",
     "moment": "^2.29.4",
-    "mongoose": "^6.4.6",
-    "nodemailer": "^6.7.5",
-    "oauth": "^0.9.15",
-    "redis": "^3.1.2",
+    "mongoose": "^6.5.3",
+    "nodemailer": "^6.7.8",
+    "oauth": "^0.10.0",
+    "redis": "^4.3.0",
     "retry-axios": "^3.0.0",
     "sha256": "^0.2.0",
-    "socks": "^2.6.2",
+    "socks": "^2.7.0",
     "underscore": "^1.13.4",
-    "ws": "^8.7.0"
+    "ws": "^8.8.1"
   },
   "devDependencies": {
-    "eslint": "^8.17.0",
+    "@typescript-eslint/eslint-plugin": "^5.35.1",
+    "@typescript-eslint/parser": "^5.35.1",
+    "eslint": "^8.22.0",
     "eslint-config-airbnb-base": "^15.0.0",
     "eslint-config-prettier": "^8.5.0",
     "eslint-plugin-import": "^2.26.0",
-    "eslint-plugin-jsdoc": "^39.3.2",
-    "eslint-plugin-prettier": "^4.0.0",
-    "prettier": "2.6.2",
-    "trace-unhandled": "^2.0.1"
+    "eslint-plugin-jsdoc": "^39.3.6",
+    "eslint-plugin-prettier": "^4.2.1",
+    "nodemon": "^2.0.19",
+    "prettier": "2.7.1",
+    "trace-unhandled": "^2.0.1",
+    "ts-node": "^10.9.1",
+    "typescript": "^4.8.2"
   }
 }

+ 103 - 0
backend/tsconfig.json

@@ -0,0 +1,103 @@
+{
+  "compilerOptions": {
+    /* Visit https://aka.ms/tsconfig to read more about this file */
+
+    /* Projects */
+    // "incremental": true,                              /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
+    // "composite": true,                                /* Enable constraints that allow a TypeScript project to be used with project references. */
+    // "tsBuildInfoFile": "./.tsbuildinfo",              /* Specify the path to .tsbuildinfo incremental compilation file. */
+    // "disableSourceOfProjectReferenceRedirect": true,  /* Disable preferring source files instead of declaration files when referencing composite projects. */
+    // "disableSolutionSearching": true,                 /* Opt a project out of multi-project reference checking when editing. */
+    // "disableReferencedProjectLoad": true,             /* Reduce the number of projects loaded automatically by TypeScript. */
+
+    /* Language and Environment */
+    "target": "es2016",                                  /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
+    // "lib": [],                                        /* Specify a set of bundled library declaration files that describe the target runtime environment. */
+    // "jsx": "preserve",                                /* Specify what JSX code is generated. */
+    // "experimentalDecorators": true,                   /* Enable experimental support for TC39 stage 2 draft decorators. */
+    // "emitDecoratorMetadata": true,                    /* Emit design-type metadata for decorated declarations in source files. */
+    // "jsxFactory": "",                                 /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
+    // "jsxFragmentFactory": "",                         /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
+    // "jsxImportSource": "",                            /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
+    // "reactNamespace": "",                             /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
+    // "noLib": true,                                    /* Disable including any library files, including the default lib.d.ts. */
+    // "useDefineForClassFields": true,                  /* Emit ECMAScript-standard-compliant class fields. */
+    // "moduleDetection": "auto",                        /* Control what method is used to detect module-format JS files. */
+
+    /* Modules */
+    "module": "commonjs",                                /* Specify what module code is generated. */
+    // "rootDir": "./",                                  /* Specify the root folder within your source files. */
+    // "moduleResolution": "node",                       /* Specify how TypeScript looks up a file from a given module specifier. */
+    // "baseUrl": "./",                                  /* Specify the base directory to resolve non-relative module names. */
+    // "paths": {},                                      /* Specify a set of entries that re-map imports to additional lookup locations. */
+    // "rootDirs": [],                                   /* Allow multiple folders to be treated as one when resolving modules. */
+    // "typeRoots": [],                                  /* Specify multiple folders that act like './node_modules/@types'. */
+    // "types": [],                                      /* Specify type package names to be included without being referenced in a source file. */
+    // "allowUmdGlobalAccess": true,                     /* Allow accessing UMD globals from modules. */
+    // "moduleSuffixes": [],                             /* List of file name suffixes to search when resolving a module. */
+    // "resolveJsonModule": true,                        /* Enable importing .json files. */
+    // "noResolve": true,                                /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
+
+    /* JavaScript Support */
+    "allowJs": true,                                  /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
+    // "checkJs": true,                                  /* Enable error reporting in type-checked JavaScript files. */
+    // "maxNodeModuleJsDepth": 1,                        /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
+
+    /* Emit */
+    // "declaration": true,                              /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
+    // "declarationMap": true,                           /* Create sourcemaps for d.ts files. */
+    // "emitDeclarationOnly": true,                      /* Only output d.ts files and not JavaScript files. */
+    // "sourceMap": true,                                /* Create source map files for emitted JavaScript files. */
+    // "outFile": "./",                                  /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
+    "outDir": "./build",                                   /* Specify an output folder for all emitted files. */
+    // "removeComments": true,                           /* Disable emitting comments. */
+    // "noEmit": true,                                   /* Disable emitting files from a compilation. */
+    // "importHelpers": true,                            /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
+    // "importsNotUsedAsValues": "remove",               /* Specify emit/checking behavior for imports that are only used for types. */
+    // "downlevelIteration": true,                       /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
+    // "sourceRoot": "",                                 /* Specify the root path for debuggers to find the reference source code. */
+    // "mapRoot": "",                                    /* Specify the location where debugger should locate map files instead of generated locations. */
+    // "inlineSourceMap": true,                          /* Include sourcemap files inside the emitted JavaScript. */
+    // "inlineSources": true,                            /* Include source code in the sourcemaps inside the emitted JavaScript. */
+    // "emitBOM": true,                                  /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
+    // "newLine": "crlf",                                /* Set the newline character for emitting files. */
+    // "stripInternal": true,                            /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
+    // "noEmitHelpers": true,                            /* Disable generating custom helper functions like '__extends' in compiled output. */
+    // "noEmitOnError": true,                            /* Disable emitting files if any type checking errors are reported. */
+    // "preserveConstEnums": true,                       /* Disable erasing 'const enum' declarations in generated code. */
+    // "declarationDir": "./",                           /* Specify the output directory for generated declaration files. */
+    // "preserveValueImports": true,                     /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
+
+    /* Interop Constraints */
+    // "isolatedModules": true,                          /* Ensure that each file can be safely transpiled without relying on other imports. */
+    // "allowSyntheticDefaultImports": true,             /* Allow 'import x from y' when a module doesn't have a default export. */
+    "esModuleInterop": true,                             /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
+    // "preserveSymlinks": true,                         /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
+    "forceConsistentCasingInFileNames": true,            /* Ensure that casing is correct in imports. */
+
+    /* Type Checking */
+    "strict": true,                                      /* Enable all strict type-checking options. */
+    // "noImplicitAny": true,                            /* Enable error reporting for expressions and declarations with an implied 'any' type. */
+    // "strictNullChecks": true,                         /* When type checking, take into account 'null' and 'undefined'. */
+    // "strictFunctionTypes": true,                      /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
+    // "strictBindCallApply": true,                      /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
+    // "strictPropertyInitialization": true,             /* Check for class properties that are declared but not set in the constructor. */
+    // "noImplicitThis": true,                           /* Enable error reporting when 'this' is given the type 'any'. */
+    // "useUnknownInCatchVariables": true,               /* Default catch clause variables as 'unknown' instead of 'any'. */
+    // "alwaysStrict": true,                             /* Ensure 'use strict' is always emitted. */
+    // "noUnusedLocals": true,                           /* Enable error reporting when local variables aren't read. */
+    // "noUnusedParameters": true,                       /* Raise an error when a function parameter isn't read. */
+    // "exactOptionalPropertyTypes": true,               /* Interpret optional property types as written, rather than adding 'undefined'. */
+    // "noImplicitReturns": true,                        /* Enable error reporting for codepaths that do not explicitly return in a function. */
+    // "noFallthroughCasesInSwitch": true,               /* Enable error reporting for fallthrough cases in switch statements. */
+    // "noUncheckedIndexedAccess": true,                 /* Add 'undefined' to a type when accessed using an index. */
+    // "noImplicitOverride": true,                       /* Ensure overriding members in derived classes are marked with an override modifier. */
+    // "noPropertyAccessFromIndexSignature": true,       /* Enforces using indexed accessors for keys declared using an indexed type. */
+    // "allowUnusedLabels": true,                        /* Disable error reporting for unused labels. */
+    // "allowUnreachableCode": true,                     /* Disable error reporting for unreachable code. */
+
+    /* Completeness */
+    // "skipDefaultLibCheck": true,                      /* Skip type checking .d.ts files that are included with TypeScript. */
+    "skipLibCheck": true                                 /* Skip type checking all .d.ts files. */
+  }
+}

+ 4 - 2
docker-compose.yml

@@ -8,8 +8,9 @@ services:
     restart: ${RESTART_POLICY:-unless-stopped}
     volumes:
       - ./.git:/opt/app/.parent_git:ro
-      - /opt/app/node_modules
       - ./backend/config:/opt/app/config
+    environment:
+      - CONTAINER_MODE=${CONTAINER_MODE:-prod}
     links:
       - mongo
       - redis
@@ -23,14 +24,15 @@ services:
       args:
         FRONTEND_MODE: "${FRONTEND_MODE:-prod}"
     restart: ${RESTART_POLICY:-unless-stopped}
+    user: root
     ports:
       - "${FRONTEND_HOST:-0.0.0.0}:${FRONTEND_PORT:-80}:80"
     volumes:
       - ./.git:/opt/app/.parent_git:ro
-      - /opt/app/node_modules
       - ./frontend/dist/config:/opt/app/dist/config
     environment:
       - FRONTEND_MODE=${FRONTEND_MODE:-prod}
+      - CONTAINER_MODE=${CONTAINER_MODE:-prod}
     links:
       - backend
 

+ 0 - 9
frontend/.babelrc

@@ -1,9 +0,0 @@
-{
-	"presets": [ ["@babel/preset-env", { "modules": false }] ],
-	"plugins": [
-		"@babel/plugin-transform-runtime",
-		"@babel/plugin-syntax-dynamic-import",
-		"@babel/plugin-proposal-object-rest-spread"
-	],
-	"comments": false
-}

+ 2 - 1
frontend/.dockerignore

@@ -1,3 +1,4 @@
 node_modules/
 Dockerfile
-dist/config/default.json
+config/default.json
+build/

+ 15 - 7
frontend/.eslintrc

@@ -6,20 +6,24 @@
 		"node": true,
 		"es6": true
 	},
+	"parser": "vue-eslint-parser",
 	"parserOptions": {
-		"ecmaVersion": 2018,
+		"ecmaVersion": 2021,
 		"sourceType": "module",
-		"parser": "@babel/eslint-parser",
-		"requireConfigFile": false
+		"requireConfigFile": false,
+		"parser": "@typescript-eslint/parser"
 	},
 	"extends": [
 		"airbnb-base",
-		"plugin:vue/strongly-recommended",
+		"plugin:vue/vue3-strongly-recommended",
 		"eslint:recommended",
-		"prettier"
+		"prettier",
+		"plugin:@typescript-eslint/eslint-recommended",
+        "plugin:@typescript-eslint/recommended"
 	],
 	"plugins": [
-		"prettier"
+		"prettier",
+		"@typescript-eslint"
 	],
 	"globals": {
 		"lofig": "writable",
@@ -35,13 +39,17 @@
 		"no-multi-assign": 0,
 		"no-shadow": 0,
 		"no-new": 0,
+		"no-param-reassign": 0,
 		"import/no-unresolved": 0,
 		"import/extensions": 0,
+		"import/prefer-default-export": 0,
 		"prettier/prettier": [
 			"error"
 		],
 		"vue/order-in-components": 2,
 		"vue/no-v-for-template-key": 0,
-		"vue/multi-word-component-names": 0
+		"vue/multi-word-component-names": 0,
+		"@typescript-eslint/no-empty-function": 0,
+		"@typescript-eslint/no-explicit-any": 0
 	}
 }

+ 13 - 9
frontend/Dockerfile

@@ -1,31 +1,35 @@
+FROM node:16.15 AS frontend_node_modules
+
+RUN mkdir -p /opt/app
+WORKDIR /opt/app
+
+COPY package.json /opt/app/package.json
+COPY package-lock.json /opt/app/package-lock.json
+
+RUN npm install --silent
+
 FROM node:16.15 AS musare_frontend
 
 ARG FRONTEND_MODE=prod
 ENV FRONTEND_MODE=${FRONTEND_MODE}
 ENV SUPPRESS_NO_CONFIG_WARNING=1
+ENV NODE_CONFIG_DIR=./dist/config
 
 RUN apt-get update
 RUN apt-get install nginx -y
 
-RUN npm install -g webpack@5.73.0 webpack-cli@4.9.2
-
 RUN mkdir -p /opt/app
 WORKDIR /opt/app
 
-COPY package.json /opt/app/package.json
-COPY package-lock.json /opt/app/package-lock.json
-
-RUN npm install
-
 COPY . /opt/app
+COPY --from=frontend_node_modules /opt/app/node_modules node_modules
 
 RUN mkdir -p /run/nginx
 
-RUN bash -c '[[ "${FRONTEND_MODE}" = "prod" ]] && npm run prod' || exit 0
+RUN bash -c '([[ "${FRONTEND_MODE}" == "dev" ]] && exit 0) || npm run prod'
 
 RUN chmod u+x entrypoint.sh
 
 ENTRYPOINT bash /opt/app/entrypoint.sh
 
 EXPOSE 80/tcp
-EXPOSE 80/udp

+ 5 - 4
frontend/dist/config/template.json

@@ -8,8 +8,8 @@
 		"websocketsDomain": "ws://localhost/backend/ws"
 	},
 	"devServer": {
-		"port": "81",
-		"webSocketURL": "ws://localhost/ws"
+		"port": 81,
+		"hmrClientPort": 80
 	},
 	"frontendDomain": "http://localhost",
 	"mode": "development",
@@ -28,7 +28,8 @@
 		},
 		"mediasession": false,
 		"christmas": false,
-		"registrationDisabled": false
+		"registrationDisabled": false,
+		"githubAuthentication": false
 	},
 	"messages": {
 		"accountRemoval": "Your account will be deactivated instantly and your data will shortly be deleted by an admin."
@@ -53,5 +54,5 @@
 		"version": true
 	},
 	"skipConfigVersionCheck": false,
-	"configVersion": 11
+	"configVersion": 13
 }

+ 0 - 56
frontend/dist/index.tpl.html

@@ -1,56 +0,0 @@
-<!DOCTYPE html>
-<html lang='en'>
-
-<head>
-	<title><%= htmlWebpackPlugin.options.title %></title>
-
-	<meta charset='UTF-8'>
-	<meta http-equiv='X-UA-Compatible' content='IE=edge'>
-	<meta name='viewport' content='width=device-width, initial-scale=1, user-scalable=no'>
-	<meta name='keywords' content='music, <%= htmlWebpackPlugin.options.title %>, musare, songs, song catalogue, listen, station, station, radio, open source'>
-	<meta name='description' content='<%= htmlWebpackPlugin.options.title %> is an open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.'>
-	<meta name='copyright' content='© Copyright Musare 2015-2022 All Right Reserved'>
-
-	<link rel='apple-touch-icon' sizes='57x57' href='/assets/favicon/apple-touch-icon-57x57.png?v=06042016'>
-	<link rel='apple-touch-icon' sizes='60x60' href='/assets/favicon/apple-touch-icon-60x60.png?v=06042016'>
-	<link rel='apple-touch-icon' sizes='72x72' href='/assets/favicon/apple-touch-icon-72x72.png?v=06042016'>
-	<link rel='apple-touch-icon' sizes='76x76' href='/assets/favicon/apple-touch-icon-76x76.png?v=06042016'>
-	<link rel='apple-touch-icon' sizes='114x114' href='/assets/favicon/apple-touch-icon-114x114.png?v=06042016'>
-	<link rel='apple-touch-icon' sizes='120x120' href='/assets/favicon/apple-touch-icon-120x120.png?v=06042016'>
-	<link rel='apple-touch-icon' sizes='144x144' href='/assets/favicon/apple-touch-icon-144x144.png?v=06042016'>
-	<link rel='apple-touch-icon' sizes='152x152' href='/assets/favicon/apple-touch-icon-152x152.png?v=06042016'>
-	<link rel='apple-touch-icon' sizes='180x180' href='/assets/favicon/apple-touch-icon-180x180.png?v=06042016'>
-	<link rel='icon' type='image/png' href='/assets/favicon/favicon-32x32.png?v=06042016' sizes='32x32'>
-	<link rel='icon' type='image/png' href='/assets/favicon/favicon-194x194.png?v=06042016' sizes='194x194'>
-	<link rel='icon' type='image/png' href='/assets/favicon/favicon-96x96.png?v=06042016' sizes='96x96'>
-	<link rel='icon' type='image/png' href='/assets/favicon/android-chrome-192x192.png?v=06042016' sizes='192x192'>
-	<link rel='icon' type='image/png' href='/assets/favicon/favicon-16x16.png?v=06042016' sizes='16x16'>
-	<link rel='manifest' href='/assets/favicon/manifest.json?v=06042016'>
-	<link rel='mask-icon' href='/assets/favicon/safari-pinned-tab.svg?v=06042016' color='#03a9f4'>
-	<link rel='shortcut icon' href='/assets/favicon/favicon.ico?v=06042016'>
-	<meta name='msapplication-TileColor' content='#03a9f4'>
-	<meta name='msapplication-TileImage' content='/assets/favicon/mstile-144x144.png?v=06042016'>
-	<meta name='theme-color' content='#03a9f4'>
-	<meta name='google' content='nositelinkssearchbox' />
-
-	<script src='https://www.youtube.com/iframe_api'></script>
-
-	<!--Musare version: <%= htmlWebpackPlugin.options.debug.version %>-->
-	<script>
-		const MUSARE_VERSION = "<%= htmlWebpackPlugin.options.debug.version %>";
-		const MUSARE_GIT_REMOTE = "<%= htmlWebpackPlugin.options.debug.git.remote %>";
-		const MUSARE_GIT_REMOTE_URL = "<%= htmlWebpackPlugin.options.debug.git.remoteUrl %>";
-		const MUSARE_GIT_BRANCH = "<%= htmlWebpackPlugin.options.debug.git.branch %>";
-		const MUSARE_GIT_LATEST_COMMIT = "<%= htmlWebpackPlugin.options.debug.git.latestCommit %>";
-		const MUSARE_GIT_LATEST_COMMIT_SHORT = "<%= htmlWebpackPlugin.options.debug.git.latestCommitShort %>";
-	</script>
-</head>
-
-<body>
-	<div id="root"></div>
-	<div id="toasts-container" class="position-right position-bottom">
-		<div id="toasts-content"></div>
-	</div>
-</body>
-
-</html>

File diff suppressed because it is too large
+ 0 - 0
frontend/dist/vendor/lofig.1.3.4.min.js


+ 8 - 1
frontend/entrypoint.sh

@@ -1,6 +1,13 @@
 #!/bin/bash
 
-if [[ "${FRONTEND_MODE}" = "prod" ]]; then
+if [[ "${CONTAINER_MODE}" == "dev" ]]; then
+    npm install --silent
+fi
+
+if [[ "${FRONTEND_MODE}" == "prod" ]]; then
+    if [[ "${CONTAINER_MODE}" == "dev" ]]; then
+        npm run prod
+    fi
     nginx -c /opt/app/prod.nginx.conf -g "daemon off;"
 elif [ "${FRONTEND_MODE}" == "dev" ]; then
     nginx -c /opt/app/dev.nginx.conf

File diff suppressed because it is too large
+ 1683 - 1906
frontend/package-lock.json


+ 27 - 41
frontend/package.json

@@ -5,67 +5,53 @@
     "*.vue"
   ],
   "private": true,
-  "version": "3.6.0",
+  "version": "3.7.0",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "main.js",
   "author": "Musare Team",
   "license": "GPL-3.0",
   "repository": "https://github.com/Musare/Musare",
   "scripts": {
-    "lint": "npx eslint src --ext .js,.vue",
-    "dev": "npx webpack serve --config webpack.dev.js",
-    "prod": "npx webpack --config webpack.prod.js"
+    "lint": "eslint --cache src --ext .js,.ts,.vue",
+    "dev": "vite",
+    "prod": "vite build --emptyOutDir",
+    "typescript": "vue-tsc --noEmit --skipLibCheck"
   },
   "devDependencies": {
-    "@babel/core": "^7.18.2",
-    "@babel/eslint-parser": "^7.18.2",
-    "@babel/plugin-proposal-object-rest-spread": "^7.18.0",
-    "@babel/plugin-syntax-dynamic-import": "^7.8.3",
-    "@babel/plugin-transform-runtime": "^7.18.2",
-    "@babel/preset-env": "^7.18.2",
-    "@vue/compiler-sfc": "^3.2.36",
-    "babel-loader": "^8.2.5",
-    "css-loader": "^6.7.1",
-    "eslint": "^8.17.0",
+    "@typescript-eslint/eslint-plugin": "^5.35.1",
+    "@typescript-eslint/parser": "^5.35.1",
+    "eslint": "^8.22.0",
     "eslint-config-prettier": "^8.5.0",
     "eslint-plugin-import": "^2.26.0",
-    "eslint-plugin-prettier": "^4.0.0",
-    "eslint-plugin-vue": "^9.1.0",
-    "eslint-webpack-plugin": "^3.1.1",
-    "fetch": "^1.1.0",
-    "less": "^4.1.2",
-    "less-loader": "^11.0.0",
-    "prettier": "^2.6.2",
-    "style-loader": "^3.3.1",
-    "style-resources-loader": "^1.5.0",
-    "vue-style-loader": "^4.1.3",
-    "webpack-cli": "^4.9.2",
-    "webpack-dev-server": "^4.9.1"
+    "eslint-plugin-prettier": "^4.2.1",
+    "eslint-plugin-vue": "^9.4.0",
+    "less": "^4.1.3",
+    "prettier": "^2.7.1",
+    "vite-plugin-dynamic-import": "^1.1.1",
+    "vue-eslint-parser": "^9.0.3",
+    "vue-tsc": "^0.39.5"
   },
   "dependencies": {
-    "@babel/runtime": "^7.18.3",
+    "@vitejs/plugin-vue": "^3.0.3",
     "can-autoplay": "^3.0.2",
-    "chart.js": "^3.8.0",
+    "chart.js": "^3.9.1",
     "config": "^3.3.7",
-    "date-fns": "^2.28.0",
-    "dompurify": "^2.3.8",
+    "date-fns": "^2.29.2",
+    "dompurify": "^2.4.0",
     "eslint-config-airbnb-base": "^15.0.0",
-    "html-webpack-plugin": "^5.5.0",
     "lofig": "^1.3.4",
-    "marked": "^4.0.16",
+    "marked": "^4.0.19",
     "normalize.css": "^8.0.1",
+    "pinia": "^2.0.21",
     "toasters": "^2.3.1",
+    "typescript": "^4.8.2",
+    "vite": "^3.0.9",
     "vue": "^3.2.36",
     "vue-chartjs": "^4.1.1",
     "vue-content-loader": "^2.0.1",
-    "vue-json-pretty": "^2.1.0",
-    "vue-loader": "^17.0.0",
-    "vue-router": "^4.0.15",
-    "vue-tippy": "^6.0.0-alpha.57",
-    "vuedraggable": "^4.1.0",
-    "vuex": "^4.0.2",
-    "webpack": "^5.73.0",
-    "webpack-bundle-analyzer": "^4.5.0",
-    "webpack-merge": "^5.8.0"
+    "vue-draggable-list": "^0.1.1",
+    "vue-json-pretty": "^2.2.0",
+    "vue-router": "^4.1.5",
+    "vue-tippy": "^6.0.0-alpha.63"
   }
 }

+ 8 - 8
frontend/prod.nginx.conf

@@ -5,25 +5,25 @@ events {
 }
 
 http {
-    include       /etc/nginx/mime.types;
-    default_type  application/octet-stream;
+    include /etc/nginx/mime.types;
+    default_type application/octet-stream;
 
-    sendfile        off;
+    sendfile off;
 
-    keepalive_timeout  65;
+    keepalive_timeout 65;
 
     server {
-        listen       80;
+        listen 80;
         server_name _;
 
-        root /opt/app/dist;
+        root /opt/app/build;
 
         location / {
-            try_files $uri /build/$uri /build/index.html =404;
+            try_files $uri /$uri /index.html =404;
         }
 
         location /backend {
-            proxy_set_header X-Real-IP  $remote_addr;
+            proxy_set_header X-Real-IP $remote_addr;
             proxy_set_header X-Forwarded-For $remote_addr;
             proxy_set_header Host $host;
 

+ 283 - 294
frontend/src/App.vue

@@ -1,325 +1,313 @@
-<template>
-	<div class="upper-container">
-		<banned v-if="banned" />
-		<div v-else class="upper-container">
-			<router-view
-				:key="$route.fullPath"
-				class="main-container"
-				:class="{ 'main-container-modal-active': aModalIsOpen2 }"
-			/>
-		</div>
-		<falling-snow v-if="christmas" />
-		<modal-manager />
-		<long-jobs />
-	</div>
-</template>
-
-<script>
-import { mapState, mapActions, mapGetters } from "vuex";
+<script setup lang="ts">
+import { useRoute, useRouter } from "vue-router";
+import { defineAsyncComponent, ref, computed, watch, onMounted } from "vue";
 import Toast from "toasters";
-import { defineAsyncComponent } from "vue";
-
-import ws from "./ws";
-import aw from "./aw";
-import keyboardShortcuts from "./keyboardShortcuts";
-
-export default {
-	components: {
-		ModalManager: defineAsyncComponent(() =>
-			import("@/components/ModalManager.vue")
-		),
-		LongJobs: defineAsyncComponent(() =>
-			import("@/components/LongJobs.vue")
-		),
-		Banned: defineAsyncComponent(() => import("@/pages/Banned.vue")),
-		FallingSnow: defineAsyncComponent(() =>
-			import("@/components/FallingSnow.vue")
-		)
-	},
-	replace: false,
-	data() {
-		return {
-			apiDomain: "",
-			socketConnected: true,
-			keyIsDown: false,
-			scrollPosition: { y: 0, x: 0 },
-			aModalIsOpen2: false,
-			broadcastChannel: null,
-			christmas: false
-		};
-	},
-	computed: {
-		...mapState({
-			loggedIn: state => state.user.auth.loggedIn,
-			role: state => state.user.auth.role,
-			username: state => state.user.auth.username,
-			userId: state => state.user.auth.userId,
-			banned: state => state.user.auth.banned,
-			modals: state => state.modalVisibility.modals,
-			activeModals: state => state.modalVisibility.activeModals,
-			nightmode: state => state.user.preferences.nightmode,
-			activityWatch: state => state.user.preferences.activityWatch
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		}),
-		aModalIsOpen() {
-			return Object.keys(this.activeModals).length > 0;
-		}
-	},
-	watch: {
-		socketConnected(connected) {
-			if (!connected) this.disconnectedMessage.show();
-			else this.disconnectedMessage.hide();
-		},
-		nightmode(nightmode) {
-			if (nightmode) this.enableNightmode();
-			else this.disableNightmode();
-		},
-		activityWatch(activityWatch) {
-			if (activityWatch) aw.enable();
-			else aw.disable();
-		},
-		aModalIsOpen(aModalIsOpen) {
-			if (aModalIsOpen) {
-				this.scrollPosition = {
-					x: window.scrollX,
-					y: window.scrollY
-				};
-				this.aModalIsOpen2 = true;
-			} else {
-				this.aModalIsOpen2 = false;
-				setTimeout(() => {
-					window.scrollTo(
-						this.scrollPosition.x,
-						this.scrollPosition.y
-					);
-				}, 10);
+import { storeToRefs } from "pinia";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useUserAuthStore } from "@/stores/userAuth";
+import { useUserPreferencesStore } from "@/stores/userPreferences";
+import { useModalsStore } from "@/stores/modals";
+import ws from "@/ws";
+import aw from "@/aw";
+import keyboardShortcuts from "@/keyboardShortcuts";
+
+const ModalManager = defineAsyncComponent(
+	() => import("@/components/ModalManager.vue")
+);
+const LongJobs = defineAsyncComponent(
+	() => import("@/components/LongJobs.vue")
+);
+const BannedPage = defineAsyncComponent(() => import("@/pages/Banned.vue"));
+const FallingSnow = defineAsyncComponent(
+	() => import("@/components/FallingSnow.vue")
+);
+
+const route = useRoute();
+const router = useRouter();
+
+const { socket } = useWebsocketsStore();
+const userAuthStore = useUserAuthStore();
+const userPreferencesStore = useUserPreferencesStore();
+const modalsStore = useModalsStore();
+
+const apiDomain = ref("");
+const socketConnected = ref(true);
+const keyIsDown = ref("");
+const scrollPosition = ref({ y: 0, x: 0 });
+const aModalIsOpen2 = ref(false);
+const broadcastChannel = ref();
+const christmas = ref(false);
+const disconnectedMessage = ref();
+
+const { loggedIn, banned } = storeToRefs(userAuthStore);
+const { nightmode, activityWatch } = storeToRefs(userPreferencesStore);
+const {
+	changeNightmode,
+	changeAutoSkipDisliked,
+	changeActivityLogPublic,
+	changeAnonymousSongRequests,
+	changeActivityWatch
+} = userPreferencesStore;
+const { modals, activeModals } = storeToRefs(modalsStore);
+const { openModal, closeCurrentModal } = modalsStore;
+
+const aModalIsOpen = computed(() => Object.keys(activeModals.value).length > 0);
+
+const toggleNightMode = () => {
+	localStorage.setItem("nightmode", `${!nightmode.value}`);
+
+	if (loggedIn.value) {
+		socket.dispatch(
+			"users.updatePreferences",
+			{ nightmode: !nightmode.value },
+			res => {
+				if (res.status !== "success") new Toast(res.message);
 			}
-		}
-	},
-	async mounted() {
-		window
-			.matchMedia("(prefers-color-scheme: dark)")
-			.addEventListener("change", e => {
-				if (e.matches === !this.nightmode) this.toggleNightMode();
-			});
-
-		if (!this.loggedIn) {
-			lofig.get("cookie.SIDname").then(sid => {
-				this.broadcastChannel = new BroadcastChannel(
-					`${sid}.user_login`
-				);
-				this.broadcastChannel.onmessage = data => {
-					if (data) {
-						this.broadcastChannel.close();
-						window.location.reload();
-					}
-				};
-			});
-		}
+		);
+	}
 
-		document.onkeydown = ev => {
-			const event = ev || window.event;
-			const { keyCode } = event;
-			const shift = event.shiftKey;
-			const ctrl = event.ctrlKey;
-			const alt = event.altKey;
+	changeNightmode(!nightmode.value);
+};
 
-			const identifier = `${keyCode}.${shift}.${ctrl}`;
+const enableNightmode = () => {
+	document.getElementsByTagName("html")[0].classList.add("night-mode");
+};
 
-			if (this.keyIsDown === identifier) return;
-			this.keyIsDown = identifier;
+const disableNightmode = () => {
+	document.getElementsByTagName("html")[0].classList.remove("night-mode");
+};
 
-			keyboardShortcuts.handleKeyDown(event, keyCode, shift, ctrl, alt);
-		};
+const enableChristmasMode = () => {
+	document.getElementsByTagName("html")[0].classList.add("christmas-mode");
+};
 
-		document.onkeyup = () => {
-			this.keyIsDown = "";
+watch(socketConnected, connected => {
+	if (!connected && !userAuthStore.banned) disconnectedMessage.value.show();
+	else disconnectedMessage.value.hide();
+});
+watch(banned, () => {
+	disconnectedMessage.value.hide();
+});
+watch(nightmode, enabled => {
+	if (enabled) enableNightmode();
+	else disableNightmode();
+});
+watch(activityWatch, enabled => {
+	if (enabled) aw.enable();
+	else aw.disable();
+});
+watch(aModalIsOpen, isOpen => {
+	if (isOpen) {
+		scrollPosition.value = {
+			x: window.scrollX,
+			y: window.scrollY
 		};
-
-		// ctrl + alt + n
-		keyboardShortcuts.registerShortcut("nightmode", {
-			keyCode: 78,
-			ctrl: true,
-			alt: true,
-			handler: () => this.toggleNightMode()
-		});
-
-		keyboardShortcuts.registerShortcut("closeModal", {
-			keyCode: 27,
-			shift: false,
-			ctrl: false,
-			handler: () => {
-				if (
-					Object.keys(this.activeModals).length !== 0 &&
-					this.modals[
-						this.activeModals[this.activeModals.length - 1]
-					] !== "editSong" &&
-					this.modals[
-						this.activeModals[this.activeModals.length - 1]
-					] !== "editSongs"
-				)
-					this.closeCurrentModal();
-			}
+		aModalIsOpen2.value = true;
+	} else {
+		aModalIsOpen2.value = false;
+		setTimeout(() => {
+			window.scrollTo(scrollPosition.value.x, scrollPosition.value.y);
+		}, 10);
+	}
+});
+
+onMounted(async () => {
+	window
+		.matchMedia("(prefers-color-scheme: dark)")
+		.addEventListener("change", e => {
+			if (e.matches === !nightmode.value) toggleNightMode();
 		});
 
-		this.disconnectedMessage = new Toast({
-			content: "Could not connect to the server.",
-			persistent: true,
-			interactable: false
+	if (!loggedIn.value) {
+		lofig.get("cookie.SIDname").then(sid => {
+			broadcastChannel.value = new BroadcastChannel(`${sid}.user_login`);
+			broadcastChannel.value.onmessage = data => {
+				if (data) {
+					broadcastChannel.value.close();
+					window.location.reload();
+				}
+			};
 		});
+	}
 
-		this.disconnectedMessage.hide();
-
-		ws.onConnect(() => {
-			this.socketConnected = true;
+	document.onkeydown = (ev: any) => {
+		const event = ev || window.event;
+		const { keyCode } = event;
+		const shift = event.shiftKey;
+		const ctrl = event.ctrlKey;
+		const alt = event.altKey;
+
+		const identifier = `${keyCode}.${shift}.${ctrl}`;
+
+		if (keyIsDown.value === identifier) return;
+		keyIsDown.value = identifier;
+
+		keyboardShortcuts.handleKeyDown(event, keyCode, shift, ctrl, alt);
+	};
+
+	document.onkeyup = () => {
+		keyIsDown.value = "";
+	};
+
+	// ctrl + alt + n
+	keyboardShortcuts.registerShortcut("nightmode", {
+		keyCode: 78,
+		ctrl: true,
+		alt: true,
+		handler: () => toggleNightMode()
+	});
+
+	keyboardShortcuts.registerShortcut("closeModal", {
+		keyCode: 27,
+		shift: false,
+		ctrl: false,
+		handler: () => {
+			if (
+				Object.keys(activeModals.value).length !== 0 &&
+				modals.value[
+					activeModals.value[activeModals.value.length - 1]
+				] !== "editSong"
+			)
+				closeCurrentModal();
+		}
+	});
 
-			this.socket.dispatch("users.getPreferences", res => {
-				if (res.status === "success") {
-					const { preferences } = res.data;
+	disconnectedMessage.value = new Toast({
+		content: "Could not connect to the server.",
+		persistent: true,
+		interactable: false
+	});
 
-					this.changeAutoSkipDisliked(preferences.autoSkipDisliked);
-					this.changeNightmode(preferences.nightmode);
-					this.changeActivityLogPublic(preferences.activityLogPublic);
-					this.changeAnonymousSongRequests(
-						preferences.anonymousSongRequests
-					);
-					this.changeActivityWatch(preferences.activityWatch);
+	disconnectedMessage.value.hide();
 
-					if (this.nightmode) this.enableNightmode();
-					else this.disableNightmode();
-				}
-			});
-
-			this.socket.on("keep.event:user.session.deleted", () =>
-				window.location.reload()
-			);
-
-			const newUser = !localStorage.getItem("firstVisited");
-			this.socket.dispatch("news.newest", newUser, res => {
-				if (res.status !== "success") return;
-
-				const { news } = res.data;
-
-				if (news) {
-					if (newUser) {
-						this.openModal({ modal: "whatIsNew", data: { news } });
-					} else if (localStorage.getItem("whatIsNew")) {
-						if (
-							parseInt(localStorage.getItem("whatIsNew")) <
-							news.createdAt
-						) {
-							this.openModal({
-								modal: "whatIsNew",
-								data: { news }
-							});
-							localStorage.setItem("whatIsNew", news.createdAt);
-						}
-					} else {
-						if (
-							parseInt(localStorage.getItem("firstVisited")) <
-							news.createdAt
-						)
-							this.openModal({
-								modal: "whatIsNew",
-								data: { news }
-							});
-						localStorage.setItem("whatIsNew", news.createdAt);
-					}
-				}
+	ws.onConnect(() => {
+		socketConnected.value = true;
 
-				if (!localStorage.getItem("firstVisited"))
-					localStorage.setItem("firstVisited", Date.now());
-			});
-		});
+		socket.dispatch("users.getPreferences", res => {
+			if (res.status === "success") {
+				const { preferences } = res.data;
 
-		ws.onDisconnect(true, () => {
-			this.socketConnected = false;
-		});
+				changeAutoSkipDisliked(preferences.autoSkipDisliked);
+				changeNightmode(preferences.nightmode);
+				changeActivityLogPublic(preferences.activityLogPublic);
+				changeAnonymousSongRequests(preferences.anonymousSongRequests);
+				changeActivityWatch(preferences.activityWatch);
 
-		this.apiDomain = await lofig.get("backend.apiDomain");
-
-		this.$router.isReady().then(() => {
-			if (this.$route.query.err) {
-				let { err } = this.$route.query;
-				err = err.replace(/</g, "&lt;").replace(/>/g, "&gt;");
-				this.$router.push({ query: {} });
-				new Toast({ content: err, timeout: 20000 });
+				if (nightmode.value) enableNightmode();
+				else disableNightmode();
 			}
+		});
 
-			if (this.$route.query.msg) {
-				let { msg } = this.$route.query;
-				msg = msg.replace(/</g, "&lt;").replace(/>/g, "&gt;");
-				this.$router.push({ query: {} });
-				new Toast({ content: msg, timeout: 20000 });
+		socket.on("keep.event:user.session.deleted", () =>
+			window.location.reload()
+		);
+
+		const newUser = !localStorage.getItem("firstVisited");
+		socket.dispatch("news.newest", newUser, res => {
+			if (res.status !== "success") return;
+
+			const { news } = res.data;
+
+			if (news) {
+				if (newUser) {
+					openModal({ modal: "whatIsNew", data: { news } });
+				} else if (localStorage.getItem("whatIsNew")) {
+					if (
+						parseInt(localStorage.getItem("whatIsNew")) <
+						news.createdAt
+					) {
+						openModal({
+							modal: "whatIsNew",
+							data: { news }
+						});
+						localStorage.setItem("whatIsNew", news.createdAt);
+					}
+				} else {
+					if (
+						parseInt(localStorage.getItem("firstVisited")) <
+						news.createdAt
+					)
+						openModal({
+							modal: "whatIsNew",
+							data: { news }
+						});
+					localStorage.setItem("whatIsNew", news.createdAt);
+				}
 			}
 
-			if (localStorage.getItem("github_redirect")) {
-				this.$router.push(localStorage.getItem("github_redirect"));
-				localStorage.removeItem("github_redirect");
-			}
+			if (!localStorage.getItem("firstVisited"))
+				localStorage.setItem("firstVisited", Date.now().toString());
 		});
+	});
+
+	ws.onDisconnect(true, () => {
+		socketConnected.value = false;
+	});
+
+	apiDomain.value = await lofig.get("backend.apiDomain");
+
+	router.isReady().then(() => {
+		if (route.query.err) {
+			let { err } = route.query;
+			err = JSON.stringify(err)
+				.replace(/</g, "&lt;")
+				.replace(/>/g, "&gt;");
+			router.push({ query: {} });
+			new Toast({ content: err, timeout: 20000 });
+		}
 
-		if (localStorage.getItem("nightmode") === "true") {
-			this.changeNightmode(true);
-			this.enableNightmode();
+		if (route.query.msg) {
+			let { msg } = route.query;
+			msg = JSON.stringify(msg)
+				.replace(/</g, "&lt;")
+				.replace(/>/g, "&gt;");
+			router.push({ query: {} });
+			new Toast({ content: msg, timeout: 20000 });
 		}
 
-		lofig.get("siteSettings.christmas").then(christmas => {
-			if (christmas) {
-				this.christmas = true;
-				this.enableChristmasMode();
+		lofig.get("siteSettings.githubAuthentication").then(enabled => {
+			if (enabled && localStorage.getItem("github_redirect")) {
+				router.push(localStorage.getItem("github_redirect"));
+				localStorage.removeItem("github_redirect");
 			}
 		});
-	},
-	methods: {
-		toggleNightMode() {
-			localStorage.setItem("nightmode", !this.nightmode);
-
-			if (this.loggedIn) {
-				this.socket.dispatch(
-					"users.updatePreferences",
-					{ nightmode: !this.nightmode },
-					res => {
-						if (res.status !== "success") new Toast(res.message);
-					}
-				);
-			}
+	});
 
-			this.changeNightmode(!this.nightmode);
-		},
-		enableNightmode: () => {
-			document
-				.getElementsByTagName("html")[0]
-				.classList.add("night-mode");
-		},
-		disableNightmode: () => {
-			document
-				.getElementsByTagName("html")[0]
-				.classList.remove("night-mode");
-		},
-		enableChristmasMode: () => {
-			document
-				.getElementsByTagName("html")[0]
-				.classList.add("christmas-mode");
-		},
-		...mapActions("modalVisibility", ["closeCurrentModal", "openModal"]),
-		...mapActions("user/preferences", [
-			"changeNightmode",
-			"changeAutoSkipDisliked",
-			"changeActivityLogPublic",
-			"changeAnonymousSongRequests",
-			"changeActivityWatch"
-		])
+	if (localStorage.getItem("nightmode") === "true") {
+		changeNightmode(true);
+		enableNightmode();
 	}
-};
+
+	lofig.get("siteSettings.christmas").then(enabled => {
+		if (enabled) {
+			christmas.value = true;
+			enableChristmasMode();
+		}
+	});
+});
 </script>
 
+<template>
+	<div class="upper-container">
+		<banned-page v-if="banned" />
+		<div v-else class="upper-container">
+			<router-view
+				:key="$route.fullPath"
+				class="main-container"
+				:class="{ 'main-container-modal-active': aModalIsOpen2 }"
+			/>
+		</div>
+		<falling-snow v-if="christmas" />
+		<modal-manager />
+		<long-jobs />
+	</div>
+</template>
+
 <style lang="less">
 @import "normalize.css/normalize.css";
 @import "tippy.js/dist/tippy.css";
 @import "tippy.js/animations/scale.css";
+@import "vue-draggable-list/dist/style.css";
 
 :root {
 	--primary-color: var(--blue);
@@ -590,6 +578,8 @@ body {
 	line-height: 1.4285714;
 	font-size: 1rem;
 	font-family: "Inter", Helvetica, Arial, sans-serif;
+	max-width: 100%;
+	overflow-x: hidden;
 }
 
 .app {
@@ -599,6 +589,7 @@ body {
 
 #root {
 	height: 100%;
+	max-width: 100%;
 }
 
 .content-wrapper {
@@ -769,6 +760,7 @@ textarea {
 
 .upper-container {
 	height: 100%;
+	max-width: 100%;
 }
 
 .main-container {
@@ -776,6 +768,7 @@ textarea {
 	min-height: 100vh;
 	display: flex;
 	flex-direction: column;
+	max-width: 100%;
 
 	&.main-container-modal-active {
 		height: 100% !important;
@@ -1163,10 +1156,6 @@ img {
 					background-color: var(--light-grey-3);
 					transition: 0.2s;
 					border-radius: 34px;
-
-					&.disabled {
-						cursor: not-allowed;
-					}
 				}
 
 				.slider:before {
@@ -1653,10 +1642,6 @@ h4.section-title {
 }
 
 /** Universial items e.g. playlist items, queue items, activity items */
-.item-draggable {
-	cursor: move;
-}
-
 .universal-item {
 	display: flex;
 	flex-direction: row;
@@ -2020,6 +2005,10 @@ h4.section-title {
 		background-color: var(--light-grey-3);
 		transition: 0.2s;
 		border-radius: 34px;
+
+		&.disabled {
+			cursor: not-allowed;
+		}
 	}
 
 	.slider:before {

+ 0 - 9
frontend/src/api/admin/index.js

@@ -1,9 +0,0 @@
-/* eslint-disable import/no-cycle */
-
-import reports from "./reports";
-
-// when Vuex needs to interact with websockets
-
-export default {
-	reports
-};

+ 0 - 100
frontend/src/api/auth.js

@@ -1,100 +0,0 @@
-/* eslint-disable import/no-cycle */
-
-import Toast from "toasters";
-import ws from "@/ws";
-
-// when Vuex needs to interact with websockets
-
-export default {
-	register(user) {
-		return new Promise((resolve, reject) => {
-			const { username, email, password, recaptchaToken } = user;
-
-			ws.socket.dispatch(
-				"users.register",
-				username,
-				email,
-				password,
-				recaptchaToken,
-				res => {
-					if (res.status === "success") {
-						if (res.SID) {
-							return lofig.get("cookie").then(cookie => {
-								const date = new Date();
-								date.setTime(
-									new Date().getTime() +
-										2 * 365 * 24 * 60 * 60 * 1000
-								);
-
-								const secure = cookie.secure
-									? "secure=true; "
-									: "";
-
-								let domain = "";
-								if (cookie.domain !== "localhost")
-									domain = ` domain=${cookie.domain};`;
-
-								document.cookie = `${cookie.SIDname}=${
-									res.SID
-								}; expires=${date.toGMTString()}; ${domain}${secure}path=/`;
-
-								return resolve({
-									status: "success",
-									message: "Account registered!"
-								});
-							});
-						}
-
-						return reject(new Error("You must login"));
-					}
-
-					return reject(new Error(res.message));
-				}
-			);
-		});
-	},
-	login(user) {
-		return new Promise((resolve, reject) => {
-			const { email, password } = user;
-
-			ws.socket.dispatch("users.login", email, password, res => {
-				if (res.status === "success") {
-					return lofig.get("cookie").then(cookie => {
-						const date = new Date();
-						date.setTime(
-							new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000
-						);
-
-						const secure = cookie.secure ? "secure=true; " : "";
-
-						let domain = "";
-						if (cookie.domain !== "localhost")
-							domain = ` domain=${cookie.domain};`;
-
-						document.cookie = `${cookie.SIDname}=${
-							res.data.SID
-						}; expires=${date.toGMTString()}; ${domain}${secure}path=/`;
-
-						return resolve({ status: "success" });
-					});
-				}
-
-				return reject(new Error(res.message));
-			});
-		});
-	},
-	logout() {
-		return new Promise((resolve, reject) => {
-			ws.socket.dispatch("users.logout", res => {
-				if (res.status === "success") {
-					return lofig.get("cookie").then(cookie => {
-						document.cookie = `${cookie.SIDname}=;expires=Thu, 01 Jan 1970 00:00:01 GMT;`;
-						return window.location.reload();
-					});
-				}
-				new Toast(res.message);
-				return reject(new Error(res.message));
-			});
-		});
-	}
-};

+ 0 - 0
frontend/src/auth.js → frontend/src/auth.ts


+ 0 - 0
frontend/src/aw.js → frontend/src/aw.ts


+ 7 - 0
frontend/src/classes/ListenerHandler.class.js → frontend/src/classes/ListenerHandler.class.ts

@@ -1,4 +1,11 @@
 export default class ListenerHandler extends EventTarget {
+	listeners: {
+		[name: string]: Array<{
+			cb: (event: any) => void;
+			options: { replaceable: boolean };
+		}>;
+	};
+
 	constructor() {
 		super();
 		this.listeners = {};

+ 154 - 157
frontend/src/components/ActivityItem.vue

@@ -1,3 +1,105 @@
+<script setup lang="ts">
+import { ref, computed, onMounted } from "vue";
+import { formatDistance, parseISO } from "date-fns";
+import { useModalsStore } from "@/stores/modals";
+
+const props = defineProps({
+	activity: {
+		type: Object,
+		default: () => {}
+	}
+});
+
+const theme = ref("blue");
+
+const { openModal } = useModalsStore();
+
+const messageParts = computed(() => {
+	const { message } = props.activity.payload;
+	const messageParts = message.split(
+		/((?:<youtubeId>.*<\/youtubeId>)|(?:<reportId>.*<\/reportId>)|(?:<playlistId>.*<\/playlistId>)|(?:<stationId>.*<\/stationId>))/g
+	);
+
+	return messageParts;
+});
+const messageStripped = computed(() => {
+	let { message } = props.activity.payload;
+
+	message = message.replace(/<reportId>(.*)<\/reportId>/g, "report");
+	message = message.replace(/<youtubeId>(.*)<\/youtubeId>/g, "$1");
+	message = message.replace(/<playlistId>(.*)<\/playlistId>/g, `$1`);
+	message = message.replace(/<stationId>(.*)<\/stationId>/g, `$1`);
+
+	return message;
+});
+
+const getMessagePartType = messagePart =>
+	messagePart.substring(1, messagePart.indexOf(">"));
+
+const getMessagePartText = messagePart => {
+	let message = messagePart;
+
+	message = message.replace(/<reportId>(.*)<\/reportId>/g, "report");
+	message = message.replace(/<youtubeId>(.*)<\/youtubeId>/g, "$1");
+	message = message.replace(/<playlistId>(.*)<\/playlistId>/g, `$1`);
+	message = message.replace(/<stationId>(.*)<\/stationId>/g, `$1`);
+
+	return message;
+};
+
+const getIcon = () => {
+	const icons = {
+		/** User */
+		user__joined: "account_circle",
+		user__edit_bio: "create",
+		user__edit_avatar: "insert_photo",
+		user__edit_name: "create",
+		user__edit_location: "place",
+		user__toggle_nightmode: "nightlight_round",
+		user__toggle_autoskip_disliked_songs: "thumb_down_alt",
+		user__toggle_activity_watch: "visibility",
+		/** Songs */
+		song__report: "flag",
+		song__like: "thumb_up_alt",
+		song__dislike: "thumb_down_alt",
+		song__unlike: "not_interested",
+		song__undislike: "not_interested",
+		/** Stations */
+		station__favorite: "star",
+		station__unfavorite: "star_border",
+		station__create: "create",
+		station__remove: "delete",
+		station__edit_theme: "color_lens",
+		station__edit_name: "create",
+		station__edit_display_name: "create",
+		station__edit_description: "create",
+		station__edit_privacy: "security",
+		station__edit_genres: "create",
+		station__edit_blacklisted_genres: "create",
+		/** Playlists */
+		playlist__create: "create",
+		playlist__remove: "delete",
+		playlist__remove_song: "not_interested",
+		playlist__remove_songs: "not_interested",
+		playlist__add_song: "library_add",
+		playlist__add_songs: "library_add",
+		playlist__edit_privacy: "security",
+		playlist__edit_display_name: "create",
+		playlist__import_playlist: "publish"
+	};
+
+	return icons[props.activity.type];
+};
+
+onMounted(() => {
+	if (props.activity.type === "station__edit_theme")
+		theme.value = props.activity.payload.message.replace(
+			/to\s(\w+)/g,
+			"$1"
+		);
+});
+</script>
+
 <template>
 	<div class="item activity-item universal-item">
 		<div :class="[theme, 'thumbnail']">
@@ -5,16 +107,62 @@
 				v-if="activity.payload.thumbnail"
 				:src="activity.payload.thumbnail"
 				onerror="this.src='/assets/notes.png'"
-				:alt="textOnlyMessage"
+				:alt="messageStripped"
 			/>
 			<i class="material-icons activity-type-icon">{{ getIcon() }}</i>
 		</div>
 		<div class="left-part">
-			<component
-				class="item-title"
-				:title="textOnlyMessage"
-				:is="formattedMessage"
-			/>
+			<p :title="messageStripped" class="item-title">
+				<span v-for="messagePart in messageParts" :key="messagePart">
+					<span
+						v-if="getMessagePartType(messagePart) === 'youtubeId'"
+						>{{ getMessagePartText(messagePart) }}</span
+					>
+					<a
+						v-else-if="
+							getMessagePartType(messagePart) === 'reportId'
+						"
+						class="activity-item-link"
+						@click="
+							openModal({
+								modal: 'viewReport',
+								data: { reportId: activity.payload.reportId }
+							})
+						"
+						>report</a
+					>
+					<a
+						v-else-if="
+							getMessagePartType(messagePart) === 'playlistId'
+						"
+						class="activity-item-link"
+						@click="
+							openModal({
+								modal: 'editPlaylist',
+								data: {
+									playlistId: activity.payload.playlistId
+								}
+							})
+						"
+						>{{ getMessagePartText(messagePart) }}
+					</a>
+					<router-link
+						v-else-if="
+							getMessagePartType(messagePart) === 'stationId'
+						"
+						class="activity-item-link"
+						:to="{
+							name: 'station',
+							params: { id: activity.payload.stationId }
+						}"
+						>{{ getMessagePartText(messagePart) }}</router-link
+					>
+
+					<span v-else>
+						{{ messagePart }}
+					</span>
+				</span>
+			</p>
 			<p class="item-description">
 				{{
 					formatDistance(parseISO(activity.createdAt), new Date(), {
@@ -29,157 +177,6 @@
 	</div>
 </template>
 
-<script>
-import { mapActions } from "vuex";
-import { formatDistance, parseISO } from "date-fns";
-
-export default {
-	props: {
-		activity: {
-			type: Object,
-			default: () => {}
-		}
-	},
-	data() {
-		return {
-			theme: "blue"
-		};
-	},
-	computed: {
-		formattedMessage() {
-			const { youtubeId, playlistId, stationId, reportId } =
-				this.activity.payload;
-			let { message } = this.activity.payload;
-
-			if (youtubeId) {
-				message = message.replace(
-					/<youtubeId>(.*)<\/youtubeId>/g,
-					"$1"
-				);
-			}
-
-			if (reportId) {
-				message = message.replace(
-					/<reportId>(.*)<\/reportId>/g,
-					`<a href='#' class='activity-item-link' @click='openModal({ modal: "viewReport", data: { reportId: "${reportId}" } })'>report</a>`
-				);
-			}
-
-			if (playlistId) {
-				message = message.replace(
-					/<playlistId>(.*)<\/playlistId>/g,
-					`<a href='#' class='activity-item-link' @click='openModal({ modal: "editPlaylist", data: { playlistId: "${playlistId}" } })'>$1</a>`
-				);
-			}
-
-			if (stationId) {
-				message = message.replace(
-					/<stationId>(.*)<\/stationId>/g,
-					`<router-link class='activity-item-link' :to="{ name: 'station', params: { id: '${stationId}' } }">$1</router-link>`
-				);
-			}
-
-			return {
-				template: `<p>${message}</p>`,
-				methods: {
-					openModal: this.openModal
-				}
-			};
-		},
-		textOnlyMessage() {
-			const { youtubeId, playlistId, stationId, reportId } =
-				this.activity.payload;
-			let { message } = this.activity.payload;
-
-			if (reportId) {
-				message = message.replace(
-					/<reportId>(.*)<\/reportId>/g,
-					"report"
-				);
-			}
-
-			if (youtubeId) {
-				message = message.replace(
-					/<youtubeId>(.*)<\/youtubeId>/g,
-					"$1"
-				);
-			}
-
-			if (playlistId) {
-				message = message.replace(
-					/<playlistId>(.*)<\/playlistId>/g,
-					`$1`
-				);
-			}
-
-			if (stationId) {
-				message = message.replace(
-					/<stationId>(.*)<\/stationId>/g,
-					`$1`
-				);
-			}
-
-			return message;
-		}
-	},
-	mounted() {
-		if (this.activity.type === "station__edit_theme")
-			this.theme = this.activity.payload.message.replace(
-				/to\s(\w+)/g,
-				"$1"
-			);
-	},
-	methods: {
-		getIcon() {
-			const icons = {
-				/** User */
-				user__joined: "account_circle",
-				user__edit_bio: "create",
-				user__edit_avatar: "insert_photo",
-				user__edit_name: "create",
-				user__edit_location: "place",
-				user__toggle_nightmode: "nightlight_round",
-				user__toggle_autoskip_disliked_songs: "thumb_down_alt",
-				user__toggle_activity_watch: "visibility",
-				/** Songs */
-				song__report: "flag",
-				song__like: "thumb_up_alt",
-				song__dislike: "thumb_down_alt",
-				song__unlike: "not_interested",
-				song__undislike: "not_interested",
-				/** Stations */
-				station__favorite: "star",
-				station__unfavorite: "star_border",
-				station__create: "create",
-				station__remove: "delete",
-				station__edit_theme: "color_lens",
-				station__edit_name: "create",
-				station__edit_display_name: "create",
-				station__edit_description: "create",
-				station__edit_privacy: "security",
-				station__edit_genres: "create",
-				station__edit_blacklisted_genres: "create",
-				/** Playlists */
-				playlist__create: "create",
-				playlist__remove: "delete",
-				playlist__remove_song: "not_interested",
-				playlist__remove_songs: "not_interested",
-				playlist__add_song: "library_add",
-				playlist__add_songs: "library_add",
-				playlist__edit_privacy: "security",
-				playlist__edit_display_name: "create",
-				playlist__import_playlist: "publish"
-			};
-
-			return icons[this.activity.type];
-		},
-		formatDistance,
-		parseISO,
-		...mapActions("modalVisibility", ["openModal"])
-	}
-};
-</script>
-
 <style lang="less">
 .activity-item-link {
 	color: var(--primary-color) !important;

+ 103 - 120
frontend/src/components/AddToPlaylistDropdown.vue

@@ -1,3 +1,102 @@
+<script setup lang="ts">
+import { ref, onMounted } from "vue";
+import Toast from "toasters";
+import { storeToRefs } from "pinia";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useUserPlaylistsStore } from "@/stores/userPlaylists";
+import { useModalsStore } from "@/stores/modals";
+import ws from "@/ws";
+
+const props = defineProps({
+	song: {
+		type: Object,
+		default: () => {}
+	},
+	placement: {
+		type: String,
+		default: "left"
+	}
+});
+
+const emit = defineEmits(["showPlaylistDropdown"]);
+
+const dropdown = ref(null);
+
+const { socket } = useWebsocketsStore();
+const userPlaylistsStore = useUserPlaylistsStore();
+
+const { playlists, fetchedPlaylists } = storeToRefs(userPlaylistsStore);
+const { setPlaylists, addPlaylist, removePlaylist } = userPlaylistsStore;
+
+const { openModal } = useModalsStore();
+
+const init = () => {
+	if (!fetchedPlaylists.value)
+		socket.dispatch("playlists.indexMyPlaylists", res => {
+			if (res.status === "success")
+				if (!fetchedPlaylists.value) setPlaylists(res.data.playlists);
+		});
+};
+const hasSong = playlist =>
+	playlist.songs.map(song => song.youtubeId).indexOf(props.song.youtubeId) !==
+	-1;
+const toggleSongInPlaylist = playlistIndex => {
+	const playlist = playlists.value[playlistIndex];
+	if (!hasSong(playlist)) {
+		socket.dispatch(
+			"playlists.addSongToPlaylist",
+			false,
+			props.song.youtubeId,
+			playlist._id,
+			res => new Toast(res.message)
+		);
+	} else {
+		socket.dispatch(
+			"playlists.removeSongFromPlaylist",
+			props.song.youtubeId,
+			playlist._id,
+			res => new Toast(res.message)
+		);
+	}
+};
+const createPlaylist = () => {
+	dropdown.value.tippy.setProps({
+		zIndex: 0,
+		hideOnClick: false
+	});
+
+	window.addToPlaylistDropdown = dropdown.value;
+
+	openModal("createPlaylist");
+};
+
+onMounted(() => {
+	ws.onConnect(init);
+
+	socket.on("event:playlist.created", res => addPlaylist(res.data.playlist), {
+		replaceable: true
+	});
+
+	socket.on(
+		"event:playlist.deleted",
+		res => removePlaylist(res.data.playlistId),
+		{ replaceable: true }
+	);
+
+	socket.on(
+		"event:playlist.displayName.updated",
+		res => {
+			playlists.value.forEach((playlist, index) => {
+				if (playlist._id === res.data.playlistId) {
+					playlists.value[index].displayName = res.data.displayName;
+				}
+			});
+		},
+		{ replaceable: true }
+	);
+});
+</script>
+
 <template>
 	<tippy
 		class="addToPlaylistDropdown"
@@ -8,16 +107,8 @@
 		ref="dropdown"
 		trigger="click"
 		append-to="parent"
-		@show="
-			() => {
-				$parent.showPlaylistDropdown = true;
-			}
-		"
-		@hide="
-			() => {
-				$parent.showPlaylistDropdown = false;
-			}
-		"
+		@show="emit('showPlaylistDropdown', true)"
+		@hide="emit('showPlaylistDropdown', false)"
 	>
 		<slot name="button" ref="trigger" />
 
@@ -34,13 +125,13 @@
 						<label class="switch">
 							<input
 								type="checkbox"
-								:id="index"
+								:id="`${index}`"
 								:checked="hasSong(playlist)"
 								@click="toggleSongInPlaylist(index)"
 							/>
 							<span class="slider round"></span>
 						</label>
-						<label :for="index">
+						<label :for="`${index}`">
 							<span></span>
 							<p>{{ playlist.displayName }}</p>
 						</label>
@@ -63,114 +154,6 @@
 	</tippy>
 </template>
 
-<script>
-import { mapGetters, mapState, mapActions } from "vuex";
-import Toast from "toasters";
-import ws from "@/ws";
-
-export default {
-	props: {
-		song: {
-			type: Object,
-			default: () => {}
-		},
-		placement: {
-			type: String,
-			default: "left"
-		}
-	},
-	computed: {
-		...mapGetters({
-			socket: "websockets/getSocket"
-		}),
-		...mapState({
-			playlists: state => state.user.playlists.playlists,
-			fetchedPlaylists: state => state.user.playlists.fetchedPlaylists
-		})
-	},
-	mounted() {
-		ws.onConnect(this.init);
-
-		this.socket.on(
-			"event:playlist.created",
-			res => this.addPlaylist(res.data.playlist),
-			{ replaceable: true }
-		);
-
-		this.socket.on(
-			"event:playlist.deleted",
-			res => this.removePlaylist(res.data.playlistId),
-			{ replaceable: true }
-		);
-
-		this.socket.on(
-			"event:playlist.displayName.updated",
-			res => {
-				this.playlists.forEach((playlist, index) => {
-					if (playlist._id === res.data.playlistId) {
-						this.playlists[index].displayName =
-							res.data.displayName;
-					}
-				});
-			},
-			{ replaceable: true }
-		);
-	},
-	methods: {
-		init() {
-			if (!this.fetchedPlaylists)
-				this.socket.dispatch("playlists.indexMyPlaylists", res => {
-					if (res.status === "success")
-						if (!this.fetchedPlaylists)
-							this.setPlaylists(res.data.playlists);
-				});
-		},
-		toggleSongInPlaylist(playlistIndex) {
-			const playlist = this.playlists[playlistIndex];
-			if (!this.hasSong(playlist)) {
-				this.socket.dispatch(
-					"playlists.addSongToPlaylist",
-					false,
-					this.song.youtubeId,
-					playlist._id,
-					res => new Toast(res.message)
-				);
-			} else {
-				this.socket.dispatch(
-					"playlists.removeSongFromPlaylist",
-					this.song.youtubeId,
-					playlist._id,
-					res => new Toast(res.message)
-				);
-			}
-		},
-		hasSong(playlist) {
-			return (
-				playlist.songs
-					.map(song => song.youtubeId)
-					.indexOf(this.song.youtubeId) !== -1
-			);
-		},
-		createPlaylist() {
-			this.$refs.dropdown.tippy.setProps({
-				zIndex: 0,
-				hideOnClick: false
-			});
-
-			window.addToPlaylistDropdown = this.$refs.dropdown;
-
-			this.openModal("createPlaylist");
-		},
-		...mapActions("user/playlists", [
-			"setPlaylists",
-			"addPlaylist",
-			"removePlaylist"
-		]),
-		...mapActions("modalVisibility", ["openModal"])
-	}
-};
-</script>
-
 <style lang="less" scoped>
 .no-playlists {
 	text-align: center;

File diff suppressed because it is too large
+ 1183 - 0
frontend/src/components/AdvancedTable.vue


+ 74 - 87
frontend/src/components/AutoSuggest.vue

@@ -1,3 +1,76 @@
+<script setup lang="ts">
+import { ref, computed } from "vue";
+
+const props = defineProps({
+	modelValue: { type: String, default: "" },
+	placeholder: { type: String, default: "Search value" },
+	disabled: { type: Boolean, default: false },
+	allItems: { type: Array, default: () => [] }
+});
+
+const emit = defineEmits(["update:modelValue", "submitted"]);
+
+const inputFocussed = ref(false);
+const containerFocussed = ref(false);
+const itemFocussed = ref(false);
+const keydownInputTimeout = ref();
+const items = ref([]);
+
+const value = computed({
+	get: () => props.modelValue,
+	set: value => emit("update:modelValue", value)
+});
+
+const blurInput = event => {
+	if (
+		event.relatedTarget &&
+		event.relatedTarget.classList.contains("autosuggest-item")
+	)
+		itemFocussed.value = true;
+	inputFocussed.value = false;
+};
+
+const focusInput = () => {
+	inputFocussed.value = true;
+};
+
+const keydownInput = () => {
+	clearTimeout(keydownInputTimeout.value);
+	keydownInputTimeout.value = setTimeout(() => {
+		if (value.value && value.value.length > 1) {
+			items.value = props.allItems.filter((item: string) =>
+				item.toLowerCase().startsWith(value.value.toLowerCase())
+			);
+		} else items.value = [];
+	}, 1000);
+};
+
+const focusAutosuggestContainer = () => {
+	containerFocussed.value = true;
+};
+
+const blurAutosuggestContainer = () => {
+	containerFocussed.value = false;
+};
+
+const selectAutosuggestItem = item => {
+	value.value = item;
+	items.value = [];
+};
+
+const focusAutosuggestItem = () => {
+	itemFocussed.value = true;
+};
+
+const blurAutosuggestItem = event => {
+	if (
+		!event.relatedTarget ||
+		!event.relatedTarget.classList.contains("autosuggest-item")
+	)
+		itemFocussed.value = false;
+};
+</script>
+
 <template>
 	<div>
 		<input
@@ -8,7 +81,7 @@
 			:disabled="disabled"
 			@blur="blurInput($event)"
 			@focus="focusInput()"
-			@keydown.enter="$emit('submitted')"
+			@keydown.enter="emit('submitted')"
 			@keydown="keydownInput()"
 		/>
 		<div
@@ -36,92 +109,6 @@
 	</div>
 </template>
 
-<script>
-export default {
-	props: {
-		modelValue: {
-			type: String,
-			default: ""
-		},
-		placeholder: {
-			type: String,
-			default: "Search value"
-		},
-		disabled: {
-			type: Boolean,
-			default: false
-		},
-		allItems: {
-			type: Array,
-			default: () => []
-		}
-	},
-	emits: ["update:modelValue"],
-	data() {
-		return {
-			inputFocussed: false,
-			containerFocussed: false,
-			itemFocussed: false,
-			keydownInputTimeout: null,
-			items: []
-		};
-	},
-	computed: {
-		value: {
-			get() {
-				return this.modelValue;
-			},
-			set(value) {
-				this.$emit("update:modelValue", value);
-			}
-		}
-	},
-	methods: {
-		blurInput(event) {
-			if (
-				event.relatedTarget &&
-				event.relatedTarget.classList.contains("autosuggest-item")
-			)
-				this.itemFocussed = true;
-			this.inputFocussed = false;
-		},
-		focusInput() {
-			this.inputFocussed = true;
-		},
-		keydownInput() {
-			clearTimeout(this.keydownInputTimeout);
-			this.keydownInputTimeout = setTimeout(() => {
-				if (this.value && this.value.length > 1) {
-					this.items = this.allItems.filter(item =>
-						item.toLowerCase().startsWith(this.value.toLowerCase())
-					);
-				} else this.items = [];
-			}, 1000);
-		},
-		focusAutosuggestContainer() {
-			this.containerFocussed = true;
-		},
-		blurAutosuggestContainer() {
-			this.containerFocussed = false;
-		},
-		selectAutosuggestItem(item) {
-			this.value = item;
-			this.items = [];
-		},
-		focusAutosuggestItem() {
-			this.itemFocussed = true;
-		},
-		blurAutosuggestItem(event) {
-			if (
-				!event.relatedTarget ||
-				!event.relatedTarget.classList.contains("autosuggest-item")
-			)
-				this.itemFocussed = false;
-		}
-	}
-};
-</script>
-
 <style lang="less" scoped>
 .night-mode .autosuggest-container {
 	background-color: var(--dark-grey) !important;

+ 13 - 20
frontend/src/components/ChristmasLights.vue

@@ -1,3 +1,16 @@
+<script setup lang="ts">
+import { storeToRefs } from "pinia";
+import { useUserAuthStore } from "@/stores/userAuth";
+
+defineProps({
+	small: { type: Boolean, default: false },
+	lights: { type: Number, default: 1 }
+});
+
+const userAuthStore = useUserAuthStore();
+const { loggedIn } = storeToRefs(userAuthStore);
+</script>
+
 <template>
 	<div
 		:class="{
@@ -14,26 +27,6 @@
 	</div>
 </template>
 
-<script>
-import { mapState } from "vuex";
-
-export default {
-	props: {
-		small: { type: Boolean, default: false },
-		lights: { type: Number, default: 1 }
-	},
-	computed: {
-		...mapState({
-			loggedIn: state => state.user.auth.loggedIn
-		})
-	},
-
-	async mounted() {
-		this.christmas = await lofig.get("siteSettings.christmas");
-	}
-};
-</script>
-
 <style lang="less" scoped>
 .christmas-mode {
 	.christmas-lights {

+ 149 - 139
frontend/src/components/FloatingBox.vue

@@ -1,3 +1,150 @@
+<script setup lang="ts">
+import { onMounted, onUnmounted, ref, defineExpose, nextTick } from "vue";
+import { useDragBox } from "@/composables/useDragBox";
+
+const props = defineProps({
+	id: { type: String, default: null },
+	column: { type: Boolean, default: true },
+	title: { type: String, default: null },
+	persist: { type: Boolean, default: false },
+	initial: { type: String, default: "align-top" },
+	minWidth: { type: Number, default: 100 },
+	maxWidth: { type: Number, default: 1000 },
+	minHeight: { type: Number, default: 100 },
+	maxHeight: { type: Number, default: 1000 }
+});
+
+const {
+	dragBox,
+	setInitialBox,
+	onDragBox,
+	resetBoxPosition,
+	setOnDragBoxUpdate
+} = useDragBox();
+const debounceTimeout = ref();
+const shown = ref(false);
+const box = ref();
+
+const saveBox = () => {
+	if (props.id === null) return;
+	localStorage.setItem(
+		`box:${props.id}`,
+		JSON.stringify({
+			height: dragBox.value.height,
+			width: dragBox.value.width,
+			top: dragBox.value.top,
+			left: dragBox.value.left,
+			shown: shown.value
+		})
+	);
+	setInitialBox({
+		top:
+			props.initial === "align-bottom"
+				? Math.max(
+						document.body.clientHeight - 10 - dragBox.value.height,
+						0
+				  )
+				: 10,
+		left: 10
+	});
+};
+
+const setBoxDimensions = (width, height) => {
+	dragBox.value.height = Math.min(
+		Math.max(height, props.minHeight),
+		props.maxHeight,
+		document.body.clientHeight
+	);
+	dragBox.value.width = Math.min(
+		Math.max(width, props.minWidth),
+		props.maxWidth,
+		document.body.clientWidth
+	);
+};
+
+const onResizeBox = e => {
+	if (e.target !== box.value) return;
+
+	document.onmouseup = () => {
+		document.onmouseup = null;
+		const { width, height } = e.target.style;
+		setBoxDimensions(
+			width
+				.split("")
+				.splice(0, width.length - 2)
+				.join(""),
+			height
+				.split("")
+				.splice(0, height.length - 2)
+				.join("")
+		);
+		saveBox();
+	};
+};
+
+const toggleBox = () => {
+	shown.value = !shown.value;
+	saveBox();
+};
+
+const resetBox = () => {
+	resetBoxPosition();
+	setBoxDimensions(200, 200);
+	saveBox();
+};
+
+const onWindowResize = () => {
+	if (debounceTimeout.value) clearTimeout(debounceTimeout.value);
+
+	debounceTimeout.value = setTimeout(() => {
+		const { width, height } = dragBox.value;
+		setBoxDimensions(width + 0, height + 0);
+		saveBox();
+	}, 50);
+};
+
+const onDragBoxUpdate = () => {
+	onWindowResize();
+};
+
+setOnDragBoxUpdate(onDragBoxUpdate);
+
+onMounted(async () => {
+	let initial = {
+		top: 10,
+		left: 10,
+		width: 200,
+		height: 400
+	};
+	if (props.id !== null && localStorage[`box:${props.id}`]) {
+		const json = JSON.parse(localStorage.getItem(`box:${props.id}`));
+		initial = { ...initial, ...json };
+		shown.value = json.shown;
+	} else {
+		initial.top =
+			props.initial === "align-bottom"
+				? Math.max(document.body.clientHeight - 10 - initial.height, 0)
+				: 10;
+	}
+	setInitialBox(initial, true);
+
+	await nextTick();
+
+	onWindowResize();
+	window.addEventListener("resize", onWindowResize);
+});
+
+onUnmounted(() => {
+	window.removeEventListener("resize", onWindowResize);
+	if (debounceTimeout.value) clearTimeout(debounceTimeout.value);
+});
+
+defineExpose({
+	resetBox,
+	toggleBox
+});
+</script>
+
 <template>
 	<div
 		ref="box"
@@ -16,7 +163,7 @@
 		@mousedown.left="onResizeBox"
 	>
 		<div class="box-header item-draggable" @mousedown.left="onDragBox">
-			<span class="drag material-icons" @dblclick="resetBoxPosition()"
+			<span class="drag material-icons" @dblclick="resetBox()"
 				>drag_indicator</span
 			>
 			<span v-if="title" class="box-title" :title="title">{{
@@ -35,144 +182,7 @@
 	</div>
 </template>
 
-<script>
-import DragBox from "@/mixins/DragBox.vue";
-
-export default {
-	mixins: [DragBox],
-	props: {
-		id: { type: String, default: null },
-		column: { type: Boolean, default: true },
-		title: { type: String, default: null },
-		persist: { type: Boolean, default: false },
-		initial: { type: String, default: "align-top" },
-		minWidth: { type: Number, default: 100 },
-		maxWidth: { type: Number, default: 1000 },
-		minHeight: { type: Number, default: 100 },
-		maxHeight: { type: Number, default: 1000 }
-	},
-	data() {
-		return {
-			shown: false,
-			debounceTimeout: null
-		};
-	},
-	mounted() {
-		let initial = {
-			top: 10,
-			left: 10,
-			width: 200,
-			height: 400
-		};
-		if (this.id !== null && localStorage[`box:${this.id}`]) {
-			const json = JSON.parse(localStorage.getItem(`box:${this.id}`));
-			initial = { ...initial, ...json };
-			this.shown = json.shown;
-		} else {
-			initial.top =
-				this.initial === "align-bottom"
-					? Math.max(
-							document.body.clientHeight - 10 - initial.height,
-							0
-					  )
-					: 10;
-		}
-		this.setInitialBox(initial, true);
-
-		this.$nextTick(() => {
-			this.onWindowResize();
-			window.addEventListener("resize", this.onWindowResize);
-		});
-	},
-	unmounted() {
-		window.removeEventListener("resize", this.onWindowResize);
-		if (this.debounceTimeout) clearTimeout(this.debounceTimeout);
-	},
-	methods: {
-		setBoxDimensions(width, height) {
-			this.dragBox.height = Math.min(
-				Math.max(height, this.minHeight),
-				this.maxHeight,
-				document.body.clientHeight
-			);
-
-			this.dragBox.width = Math.min(
-				Math.max(width, this.minWidth),
-				this.maxWidth,
-				document.body.clientWidth
-			);
-		},
-		onResizeBox(e) {
-			if (e.target !== this.$refs.box) return;
-
-			document.onmouseup = () => {
-				document.onmouseup = null;
-
-				const { width, height } = e.target.style;
-				this.setBoxDimensions(
-					width
-						.split("")
-						.splice(0, width.length - 2)
-						.join(""),
-					height
-						.split("")
-						.splice(0, height.length - 2)
-						.join("")
-				);
-
-				this.saveBox();
-			};
-		},
-		toggleBox() {
-			this.shown = !this.shown;
-			this.saveBox();
-		},
-		resetBoxDimensions() {
-			this.setBoxDimensions(200, 200);
-			this.saveBox();
-		},
-		saveBox() {
-			if (this.id === null) return;
-			localStorage.setItem(
-				`box:${this.id}`,
-				JSON.stringify({
-					height: this.dragBox.height,
-					width: this.dragBox.width,
-					top: this.dragBox.top,
-					left: this.dragBox.left,
-					shown: this.shown
-				})
-			);
-			this.setInitialBox({
-				top:
-					this.initial === "align-bottom"
-						? Math.max(
-								document.body.clientHeight -
-									10 -
-									this.dragBox.height,
-								0
-						  )
-						: 10,
-				left: 10
-			});
-		},
-		onDragBoxUpdate() {
-			this.onWindowResize();
-		},
-		onWindowResize() {
-			if (this.debounceTimeout) clearTimeout(this.debounceTimeout);
-
-			this.debounceTimeout = setTimeout(() => {
-				const { width, height } = this.dragBox;
-				this.setBoxDimensions(width + 0, height + 0);
-				this.saveBox();
-			}, 50);
-		}
-	}
-};
-</script>
-
-<style lang="less">
+<style lang="less" scoped>
 .night-mode .floating-box {
 	background-color: var(--dark-grey-2) !important;
 	border: 0 !important;

+ 6 - 11
frontend/src/components/global/InfoIcon.vue → frontend/src/components/InfoIcon.vue

@@ -1,20 +1,15 @@
+<script setup lang="ts">
+defineProps({
+	tooltip: { type: String, required: true }
+});
+</script>
+
 <template>
 	<span class="material-icons info-icon" :content="tooltip" v-tippy>
 		info
 	</span>
 </template>
 
-<script>
-export default {
-	props: {
-		tooltip: {
-			type: String,
-			required: true
-		}
-	}
-};
-</script>
-
 <style lang="less" scoped>
 .material-icons.info-icon {
 	font-size: 14px;

+ 18 - 20
frontend/src/components/InputHelpBox.vue

@@ -1,3 +1,21 @@
+<script setup lang="ts">
+defineProps({
+	message: {
+		type: String,
+		required: true
+	},
+	valid: {
+		type: Boolean,
+		required: true
+	},
+	entered: {
+		type: Boolean,
+		default: undefined,
+		required: false
+	}
+});
+</script>
+
 <template>
 	<p
 		class="help"
@@ -13,26 +31,6 @@
 	</p>
 </template>
 
-<script>
-export default {
-	props: {
-		message: {
-			type: String,
-			required: true
-		},
-		valid: {
-			type: Boolean,
-			required: true
-		},
-		entered: {
-			type: Boolean,
-			default: undefined,
-			required: false
-		}
-	}
-};
-</script>
-
 <style lang="less" scoped>
 .help {
 	margin-top: 0 !important;

+ 44 - 76
frontend/src/components/LineChart.vue

@@ -1,19 +1,5 @@
-<template>
-	<Line
-		:ref="`chart-${chartId}`"
-		:chart-options="chartOptions"
-		:chart-data="data"
-		:chart-id="chartId"
-		:dataset-id-key="datasetIdKey"
-		:plugins="plugins"
-		:css-classes="cssClasses"
-		:styles="chartStyles"
-		:width="width"
-		:height="height"
-	/>
-</template>
-
-<script>
+<script setup lang="ts">
+import { PropType, computed } from "vue";
 import { Line } from "vue-chartjs";
 import {
 	Chart as ChartJS,
@@ -24,7 +10,8 @@ import {
 	PointElement,
 	CategoryScale,
 	LinearScale,
-	LineController
+	LineController,
+	Plugin
 } from "chart.js";
 
 ChartJS.register(
@@ -38,64 +25,45 @@ ChartJS.register(
 	LineController
 );
 
-export default {
-	name: "LineChart",
-	// eslint-disable-next-line vue/no-reserved-component-names
-	components: { Line },
-	props: {
-		chartId: {
-			type: String,
-			default: "line-chart"
-		},
-		datasetIdKey: {
-			type: String,
-			default: "label"
-		},
-		width: {
-			type: Number,
-			default: 200
-		},
-		height: {
-			type: Number,
-			default: 200
-		},
-		cssClasses: {
-			default: "",
-			type: String
-		},
-		styles: {
-			type: Object,
-			default: () => {}
-		},
-		plugins: {
-			type: Object,
-			default: () => {}
-		},
-		data: {
-			type: Object,
-			default: () => {}
-		},
-		options: {
-			type: Object,
-			default: () => {}
-		}
+const props = defineProps({
+	chartId: { type: String, default: "line-chart" },
+	datasetIdKey: { type: String, default: "label" },
+	width: { type: Number, default: 200 },
+	height: { type: Number, default: 200 },
+	cssClasses: { default: "", type: String },
+	styles: {
+		type: Object as PropType<Partial<CSSStyleDeclaration>>,
+		default: () => {}
 	},
-	computed: {
-		chartStyles() {
-			return {
-				position: "relative",
-				height: this.height,
-				...this.styles
-			};
-		},
-		chartOptions() {
-			return {
-				responsive: true,
-				maintainAspectRatio: false,
-				resizeDelay: 10,
-				...this.options
-			};
-		}
-	}
-};
+	plugins: { type: Object as PropType<Plugin<"line">[]>, default: () => {} },
+	data: { type: Object as PropType<any>, default: () => {} },
+	options: { type: Object, default: () => {} }
+});
+
+const chartStyles = computed(() => ({
+	position: "relative",
+	height: `${props.height}px`,
+	...props.styles
+}));
+const chartOptions = computed(() => ({
+	responsive: true,
+	maintainAspectRatio: false,
+	resizeDelay: 10,
+	...props.options
+}));
 </script>
+
+<template>
+	<Line
+		:ref="`chart-${chartId}`"
+		:chart-options="chartOptions"
+		:chart-data="data"
+		:chart-id="chartId"
+		:dataset-id-key="datasetIdKey"
+		:plugins="plugins"
+		:css-classes="cssClasses"
+		:styles="chartStyles"
+		:width="width"
+		:height="height"
+	/>
+</template>

+ 68 - 68
frontend/src/components/LongJobs.vue

@@ -1,3 +1,71 @@
+<script setup lang="ts">
+import { defineAsyncComponent, ref, onMounted } from "vue";
+import { storeToRefs } from "pinia";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useLongJobsStore } from "@/stores/longJobs";
+import { useUserAuthStore } from "@/stores/userAuth";
+
+const FloatingBox = defineAsyncComponent(
+	() => import("@/components/FloatingBox.vue")
+);
+
+const body = ref(document.body);
+
+const userAuthStore = useUserAuthStore();
+const { loggedIn } = storeToRefs(userAuthStore);
+
+const { socket } = useWebsocketsStore();
+
+const longJobsStore = useLongJobsStore();
+const { activeJobs } = storeToRefs(longJobsStore);
+const { setJob, setJobs, removeJob } = longJobsStore;
+
+const remove = job => {
+	if (job.status === "success" || job.status === "error") {
+		socket.dispatch("users.removeLongJob", job.id, res => {
+			if (res.status === "success") {
+				removeJob(job.id);
+			} else console.log(res.message);
+		});
+	}
+};
+
+onMounted(() => {
+	if (loggedIn.value) {
+		socket.dispatch("users.getLongJobs", {
+			cb: res => {
+				if (res.status === "success") {
+					setJobs(res.data.longJobs);
+				} else console.log(res.message);
+			},
+			onProgress: res => {
+				setJob(res);
+			}
+		});
+
+		socket.on("keep.event:longJob.removed", ({ data }) => {
+			removeJob(data.jobId);
+		});
+
+		socket.on("keep.event:longJob.added", ({ data }) => {
+			if (
+				!activeJobs.value.find(activeJob => activeJob.id === data.jobId)
+			)
+				socket.dispatch("users.getLongJob", data.jobId, {
+					cb: res => {
+						if (res.status === "success") {
+							setJob(res.data.longJob);
+						} else console.log(res.message);
+					},
+					onProgress: res => {
+						setJob(res);
+					}
+				});
+		});
+	}
+});
+</script>
+
 <template>
 	<floating-box
 		v-if="activeJobs.length > 0"
@@ -84,74 +152,6 @@
 	</floating-box>
 </template>
 
-<script>
-import { mapState, mapActions, mapGetters } from "vuex";
-
-import FloatingBox from "@/components/FloatingBox.vue";
-
-export default {
-	components: {
-		FloatingBox
-	},
-	data() {
-		return {
-			minimise: true,
-			body: document.body
-		};
-	},
-	computed: {
-		...mapState("longJobs", {
-			activeJobs: state => state.activeJobs
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	mounted() {
-		this.socket.dispatch("users.getLongJobs", {
-			cb: res => {
-				if (res.status === "success") {
-					this.setJobs(res.data.longJobs);
-				} else console.log(res.message);
-			},
-			onProgress: res => {
-				this.setJob(res);
-			}
-		});
-
-		this.socket.on("keep.event:longJob.removed", ({ data }) => {
-			this.removeJob(data.jobId);
-		});
-
-		this.socket.on("keep.event:longJob.added", ({ data }) => {
-			if (!this.activeJobs.find(activeJob => activeJob.id === data.jobId))
-				this.socket.dispatch("users.getLongJob", data.jobId, {
-					cb: res => {
-						if (res.status === "success") {
-							this.setJob(res.data.longJob);
-						} else console.log(res.message);
-					},
-					onProgress: res => {
-						this.setJob(res);
-					}
-				});
-		});
-	},
-	methods: {
-		remove(job) {
-			if (job.status === "success" || job.status === "error") {
-				this.socket.dispatch("users.removeLongJob", job.id, res => {
-					if (res.status === "success") {
-						this.removeJob(job.id);
-					} else console.log(res.message);
-				});
-			}
-		},
-		...mapActions("longJobs", ["setJob", "setJobs", "removeJob"])
-	}
-};
-</script>
-
 <style lang="less" scoped>
 .night-mode {
 	#longJobs {

+ 45 - 52
frontend/src/components/global/MainFooter.vue → frontend/src/components/MainFooter.vue

@@ -1,3 +1,46 @@
+<script setup lang="ts">
+import { ref, computed, onMounted } from "vue";
+
+const siteSettings = ref({
+	logo_blue: "/assets/blue_wordmark.png",
+	sitename: "Musare",
+	footerLinks: {}
+});
+
+const filteredFooterLinks = computed(() =>
+	Object.fromEntries(
+		Object.entries(siteSettings.value.footerLinks).filter(
+			([title, url]) =>
+				!(
+					["about", "team", "news"].includes(title.toLowerCase()) &&
+					typeof url === "boolean"
+				)
+		)
+	)
+);
+
+const getLink = title =>
+	siteSettings.value.footerLinks[
+		Object.keys(siteSettings.value.footerLinks).find(
+			key => key.toLowerCase() === title
+		)
+	];
+
+onMounted(async () => {
+	lofig.get("siteSettings").then(settings => {
+		siteSettings.value = {
+			...settings,
+			footerLinks: {
+				about: true,
+				team: true,
+				news: true,
+				...settings.footerLinks
+			}
+		};
+	});
+});
+</script>
+
 <template>
 	<footer class="footer">
 		<div class="container">
@@ -17,9 +60,9 @@
 					<a
 						v-for="(url, title, index) in filteredFooterLinks"
 						:key="`footer-link-${index}`"
-						:href="url"
+						:href="`${url}`"
 						target="_blank"
-						:title="title"
+						:title="`${title}`"
 					>
 						{{ title }}
 					</a>
@@ -47,56 +90,6 @@
 	</footer>
 </template>
 
-<script>
-export default {
-	data() {
-		return {
-			siteSettings: {
-				logo_blue: "/assets/blue_wordmark.png",
-				sitename: "Musare",
-				footerLinks: {}
-			}
-		};
-	},
-	computed: {
-		filteredFooterLinks() {
-			return Object.fromEntries(
-				Object.entries(this.siteSettings.footerLinks).filter(
-					([title, url]) =>
-						!(
-							["about", "team", "news"].includes(
-								title.toLowerCase()
-							) && typeof url === "boolean"
-						)
-				)
-			);
-		}
-	},
-	async mounted() {
-		lofig.get("siteSettings").then(siteSettings => {
-			this.siteSettings = {
-				...siteSettings,
-				footerLinks: {
-					about: true,
-					team: true,
-					news: true,
-					...siteSettings.footerLinks
-				}
-			};
-		});
-	},
-	methods: {
-		getLink(title) {
-			return this.siteSettings.footerLinks[
-				Object.keys(this.siteSettings.footerLinks).find(
-					key => key.toLowerCase() === title
-				)
-			];
-		}
-	}
-};
-</script>
-
 <style lang="less" scoped>
 .night-mode {
 	footer.footer,

+ 80 - 89
frontend/src/components/global/MainHeader.vue → frontend/src/components/MainHeader.vue

@@ -1,3 +1,82 @@
+<script setup lang="ts">
+import { defineAsyncComponent, ref, onMounted, watch, nextTick } from "vue";
+import Toast from "toasters";
+import { storeToRefs } from "pinia";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useUserAuthStore } from "@/stores/userAuth";
+import { useUserPreferencesStore } from "@/stores/userPreferences";
+import { useModalsStore } from "@/stores/modals";
+
+const ChristmasLights = defineAsyncComponent(
+	() => import("@/components/ChristmasLights.vue")
+);
+
+defineProps({
+	hideLogo: { type: Boolean, default: false },
+	transparent: { type: Boolean, default: false },
+	hideLoggedOut: { type: Boolean, default: false }
+});
+
+const userAuthStore = useUserAuthStore();
+
+const localNightmode = ref(false);
+const isMobile = ref(false);
+const frontendDomain = ref("");
+const siteSettings = ref({
+	logo_white: "/assets/white_wordmark.png",
+	sitename: "Musare",
+	christmas: false,
+	registrationDisabled: false
+});
+const windowWidth = ref(0);
+
+const { socket } = useWebsocketsStore();
+
+const { loggedIn, username, role } = storeToRefs(userAuthStore);
+const { logout } = userAuthStore;
+const { changeNightmode } = useUserPreferencesStore();
+
+const { openModal } = useModalsStore();
+
+const toggleNightmode = toggle => {
+	localNightmode.value = toggle || !localNightmode.value;
+
+	localStorage.setItem("nightmode", `${localNightmode.value}`);
+
+	if (loggedIn.value) {
+		socket.dispatch(
+			"users.updatePreferences",
+			{ nightmode: localNightmode.value },
+			res => {
+				if (res.status !== "success") new Toast(res.message);
+			}
+		);
+	}
+
+	changeNightmode(localNightmode.value);
+};
+
+const onResize = () => {
+	windowWidth.value = window.innerWidth;
+};
+
+watch(localNightmode, nightmode => {
+	if (localNightmode.value !== nightmode) toggleNightmode(nightmode);
+});
+
+onMounted(async () => {
+	localNightmode.value = JSON.parse(localStorage.getItem("nightmode"));
+	if (localNightmode.value === null) localNightmode.value = false;
+
+	frontendDomain.value = await lofig.get("frontendDomain");
+	siteSettings.value = await lofig.get("siteSettings");
+
+	await nextTick();
+	onResize();
+	window.addEventListener("resize", onResize);
+});
+</script>
+
 <template>
 	<nav
 		class="nav is-info"
@@ -31,7 +110,7 @@
 			<div
 				class="nav-item"
 				id="nightmode-toggle"
-				@click="toggleNightmode()"
+				@click="toggleNightmode(!localNightmode)"
 			>
 				<span
 					:class="{
@@ -88,94 +167,6 @@
 	</nav>
 </template>
 
-<script>
-import Toast from "toasters";
-import { mapState, mapGetters, mapActions } from "vuex";
-import { defineAsyncComponent } from "vue";
-
-export default {
-	components: {
-		ChristmasLights: defineAsyncComponent(() =>
-			import("@/components/ChristmasLights.vue")
-		)
-	},
-	props: {
-		hideLogo: { type: Boolean, default: false },
-		transparent: { type: Boolean, default: false },
-		hideLoggedOut: { type: Boolean, default: false }
-	},
-	data() {
-		return {
-			localNightmode: false,
-			isMobile: false,
-			frontendDomain: "",
-			siteSettings: {
-				logo_white: "",
-				sitename: "",
-				christmas: false,
-				registrationDisabled: false
-			},
-			windowWidth: 0
-		};
-	},
-	computed: {
-		...mapState({
-			modals: state => state.modalVisibility.modals.header,
-			role: state => state.user.auth.role,
-			loggedIn: state => state.user.auth.loggedIn,
-			username: state => state.user.auth.username,
-			nightmode: state => state.user.preferences.nightmode
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	watch: {
-		nightmode(nightmode) {
-			if (this.localNightmode !== nightmode)
-				this.toggleNightmode(nightmode);
-		}
-	},
-	async mounted() {
-		this.localNightmode = JSON.parse(localStorage.getItem("nightmode"));
-		if (this.localNightmode === null) this.localNightmode = false;
-
-		this.frontendDomain = await lofig.get("frontendDomain");
-		this.siteSettings = await lofig.get("siteSettings");
-
-		this.$nextTick(() => {
-			this.onResize();
-			window.addEventListener("resize", this.onResize);
-		});
-	},
-	methods: {
-		toggleNightmode(toggle) {
-			this.localNightmode = toggle || !this.localNightmode;
-
-			localStorage.setItem("nightmode", this.localNightmode);
-
-			if (this.loggedIn) {
-				this.socket.dispatch(
-					"users.updatePreferences",
-					{ nightmode: this.localNightmode },
-					res => {
-						if (res.status !== "success") new Toast(res.message);
-					}
-				);
-			}
-
-			this.changeNightmode(this.localNightmode);
-		},
-		onResize() {
-			this.windowWidth = window.innerWidth;
-		},
-		...mapActions("modalVisibility", ["openModal"]),
-		...mapActions("user/auth", ["logout"]),
-		...mapActions("user/preferences", ["changeNightmode"])
-	}
-};
-</script>
-
 <style lang="less" scoped>
 .night-mode {
 	.nav {

+ 31 - 48
frontend/src/components/global/Modal.vue → frontend/src/components/Modal.vue

@@ -1,3 +1,34 @@
+<script setup lang="ts">
+import { defineAsyncComponent, ref, onMounted } from "vue";
+import { useModalsStore } from "@/stores/modals";
+
+const ChristmasLights = defineAsyncComponent(
+	() => import("@/components/ChristmasLights.vue")
+);
+
+const props = defineProps({
+	title: { type: String, default: "Modal" },
+	size: { type: String, default: null },
+	split: { type: Boolean, default: false },
+	interceptClose: { type: Boolean, default: false }
+});
+
+const emit = defineEmits(["close"]);
+
+const christmas = ref(false);
+
+const { closeCurrentModal } = useModalsStore();
+
+const closeCurrentModalClick = () => {
+	if (props.interceptClose) emit("close");
+	else closeCurrentModal();
+};
+
+onMounted(async () => {
+	christmas.value = await lofig.get("siteSettings.christmas");
+});
+</script>
+
 <template>
 	<div class="modal is-active">
 		<div class="modal-background" @click="closeCurrentModalClick()" />
@@ -37,54 +68,6 @@
 	</div>
 </template>
 
-<script>
-import { mapState, mapActions } from "vuex";
-import { defineAsyncComponent } from "vue";
-
-export default {
-	components: {
-		ChristmasLights: defineAsyncComponent(() =>
-			import("@/components/ChristmasLights.vue")
-		)
-	},
-	props: {
-		title: { type: String, default: "Modal" },
-		size: { type: String, default: null },
-		split: { type: Boolean, default: false },
-		interceptClose: { type: Boolean, default: false }
-	},
-	emits: ["close"],
-	data() {
-		return {
-			christmas: false
-		};
-	},
-	computed: {
-		...mapState({
-			loggedIn: state => state.user.auth.loggedIn
-		})
-	},
-	async mounted() {
-		this.type = this.toCamelCase(this.title);
-		this.christmas = await lofig.get("siteSettings.christmas");
-	},
-	methods: {
-		closeCurrentModalClick() {
-			if (this.interceptClose) this.$emit("close");
-			else this.closeCurrentModal();
-		},
-		toCamelCase: str =>
-			str
-				.toLowerCase()
-				.replace(/[-_]+/g, " ")
-				.replace(/[^\w\s]/g, "")
-				.replace(/ (.)/g, $1 => $1.toUpperCase())
-				.replace(/ /g, ""),
-		...mapActions("modalVisibility", ["closeCurrentModal"])
-	}
-};
-</script>
-
 <style lang="less">
 .night-mode .modal .modal-card {
 	.modal-card-head,

+ 34 - 38
frontend/src/components/ModalManager.vue

@@ -1,47 +1,43 @@
+<script setup lang="ts">
+import { shallowRef } from "vue";
+import { storeToRefs } from "pinia";
+import { useModalsStore, useModalComponents } from "@/stores/modals";
+
+const modalsStore = useModalsStore();
+const { modals, activeModals } = storeToRefs(modalsStore);
+
+const modalComponents = shallowRef(
+	useModalComponents("components/modals", {
+		editUser: "EditUser.vue",
+		login: "Login.vue",
+		register: "Register.vue",
+		whatIsNew: "WhatIsNew.vue",
+		createStation: "CreateStation.vue",
+		editNews: "EditNews.vue",
+		manageStation: "ManageStation/index.vue",
+		editPlaylist: "EditPlaylist/index.vue",
+		createPlaylist: "CreatePlaylist.vue",
+		report: "Report.vue",
+		viewReport: "ViewReport.vue",
+		bulkActions: "BulkActions.vue",
+		viewApiRequest: "ViewApiRequest.vue",
+		viewPunishment: "ViewPunishment.vue",
+		removeAccount: "RemoveAccount.vue",
+		importAlbum: "ImportAlbum.vue",
+		confirm: "Confirm.vue",
+		editSong: "EditSong/index.vue",
+		viewYoutubeVideo: "ViewYoutubeVideo.vue"
+	})
+);
+</script>
+
 <template>
 	<div>
 		<div v-for="activeModalUuid in activeModals" :key="activeModalUuid">
 			<component
-				:is="this[modals[activeModalUuid]]"
+				:is="modalComponents[modals[activeModalUuid]]"
 				:modal-uuid="activeModalUuid"
 			/>
 		</div>
 	</div>
 </template>
-
-<script>
-import { mapState } from "vuex";
-
-import { mapModalComponents } from "@/vuex_helpers";
-
-export default {
-	computed: {
-		...mapModalComponents("./components/modals", {
-			editUser: "EditUser.vue",
-			login: "Login.vue",
-			register: "Register.vue",
-			whatIsNew: "WhatIsNew.vue",
-			createStation: "CreateStation.vue",
-			editNews: "EditNews.vue",
-			manageStation: "ManageStation/index.vue",
-			editPlaylist: "EditPlaylist/index.vue",
-			createPlaylist: "CreatePlaylist.vue",
-			report: "Report.vue",
-			viewReport: "ViewReport.vue",
-			bulkActions: "BulkActions.vue",
-			viewApiRequest: "ViewApiRequest.vue",
-			viewPunishment: "ViewPunishment.vue",
-			removeAccount: "RemoveAccount.vue",
-			importAlbum: "ImportAlbum.vue",
-			confirm: "Confirm.vue",
-			editSongs: "EditSongs.vue",
-			editSong: "EditSong/index.vue",
-			viewYoutubeVideo: "ViewYoutubeVideo.vue"
-		}),
-		...mapState("modalVisibility", {
-			activeModals: state => state.activeModals,
-			modals: state => state.modals
-		})
-	}
-};
-</script>

+ 35 - 36
frontend/src/components/PlaylistItem.vue

@@ -1,3 +1,38 @@
+<script setup lang="ts">
+import { defineAsyncComponent, ref, computed, onMounted } from "vue";
+import utils from "@/utils";
+
+const UserLink = defineAsyncComponent(
+	() => import("@/components/UserLink.vue")
+);
+
+const props = defineProps({
+	playlist: { type: Object, default: () => {} },
+	showOwner: { type: Boolean, default: false }
+});
+
+const sitename = ref("Musare");
+
+const totalLength = playlist => {
+	let length = 0;
+	playlist.songs.forEach(song => {
+		length += song.duration;
+	});
+	return utils.formatTimeLong(length);
+};
+
+const playlistLength = computed(
+	() =>
+		`${totalLength(props.playlist)} • ${props.playlist.songs.length} ${
+			props.playlist.songs.length === 1 ? "song" : "songs"
+		}`
+);
+
+onMounted(async () => {
+	sitename.value = await lofig.get("siteSettings.sitename");
+});
+</script>
+
 <template>
 	<div class="playlist-item universal-item">
 		<slot name="item-icon">
@@ -36,42 +71,6 @@
 	</div>
 </template>
 
-<script>
-import utils from "../../js/utils";
-
-export default {
-	props: {
-		playlist: { type: Object, default: () => {} },
-		showOwner: { type: Boolean, default: false }
-	},
-	data() {
-		return {
-			utils,
-			sitename: "Musare"
-		};
-	},
-	computed: {
-		playlistLength() {
-			return `${this.totalLength(this.playlist)} • ${
-				this.playlist.songs.length
-			} ${this.playlist.songs.length === 1 ? "song" : "songs"}`;
-		}
-	},
-	async mounted() {
-		this.sitename = await lofig.get("siteSettings.sitename");
-	},
-	methods: {
-		totalLength(playlist) {
-			let length = 0;
-			playlist.songs.forEach(song => {
-				length += song.duration;
-			});
-			return this.utils.formatTimeLong(length);
-		}
-	}
-};
-</script>
-
 <style lang="less" scoped>
 .night-mode {
 	.playlist-item {

+ 314 - 335
frontend/src/components/PlaylistTabBase.vue

@@ -1,3 +1,308 @@
+<script setup lang="ts">
+import { defineAsyncComponent, ref, reactive, computed, onMounted } from "vue";
+import Toast from "toasters";
+import { storeToRefs } from "pinia";
+import ws from "@/ws";
+
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useStationStore } from "@/stores/station";
+import { useUserAuthStore } from "@/stores/userAuth";
+import { useUserPlaylistsStore } from "@/stores/userPlaylists";
+import { useModalsStore } from "@/stores/modals";
+import { useManageStationStore } from "@/stores/manageStation";
+
+import { useSortablePlaylists } from "@/composables/useSortablePlaylists";
+
+const PlaylistItem = defineAsyncComponent(
+	() => import("@/components/PlaylistItem.vue")
+);
+const QuickConfirm = defineAsyncComponent(
+	() => import("@/components/QuickConfirm.vue")
+);
+
+const props = defineProps({
+	modalUuid: { type: String, default: "" },
+	type: {
+		type: String,
+		default: ""
+	},
+	sector: {
+		type: String,
+		default: "manageStation"
+	}
+});
+
+const emit = defineEmits(["selected"]);
+
+const { socket } = useWebsocketsStore();
+const stationStore = useStationStore();
+const userAuthStore = useUserAuthStore();
+
+const tab = ref("current");
+const search = reactive({
+	query: "",
+	searchedQuery: "",
+	page: 0,
+	count: 0,
+	resultsLeft: 0,
+	pageSize: 0,
+	results: []
+});
+const featuredPlaylists = ref([]);
+const tabs = ref({});
+
+const {
+	DraggableList,
+	drag,
+	playlists,
+	savePlaylistOrder,
+	orderOfPlaylists,
+	myUserId,
+	calculatePlaylistOrder
+} = useSortablePlaylists();
+
+const { loggedIn, role, userId } = storeToRefs(userAuthStore);
+const { autoRequest } = storeToRefs(stationStore);
+
+const manageStationStore = useManageStationStore(props);
+const { autofill } = storeToRefs(manageStationStore);
+
+const station = computed({
+	get() {
+		if (props.sector === "manageStation") return manageStationStore.station;
+		return stationStore.station;
+	},
+	set(value) {
+		if (props.sector === "manageStation")
+			manageStationStore.updateStation(value);
+		else stationStore.updateStation(value);
+	}
+});
+
+const blacklist = computed({
+	get() {
+		if (props.sector === "manageStation")
+			return manageStationStore.blacklist;
+		return stationStore.blacklist;
+	},
+	set(value) {
+		if (props.sector === "manageStation")
+			manageStationStore.setBlacklist(value);
+		else stationStore.setBlacklist(value);
+	}
+});
+
+const resultsLeftCount = computed(() => search.count - search.results.length);
+
+const nextPageResultsCount = computed(() =>
+	Math.min(search.pageSize, resultsLeftCount.value)
+);
+
+const { openModal } = useModalsStore();
+
+const { setPlaylists } = useUserPlaylistsStore();
+
+const { addPlaylistToAutoRequest, removePlaylistFromAutoRequest } =
+	stationStore;
+
+const init = () => {
+	socket.dispatch("playlists.indexMyPlaylists", res => {
+		if (res.status === "success") setPlaylists(res.data.playlists);
+		orderOfPlaylists.value = calculatePlaylistOrder(); // order in regards to the database
+	});
+
+	socket.dispatch("playlists.indexFeaturedPlaylists", res => {
+		if (res.status === "success")
+			featuredPlaylists.value = res.data.playlists;
+	});
+
+	if (props.type === "autofill")
+		socket.dispatch(
+			`stations.getStationAutofillPlaylistsById`,
+			station.value._id,
+			res => {
+				if (res.status === "success") {
+					station.value.autofill.playlists = res.data.playlists;
+				}
+			}
+		);
+
+	socket.dispatch(
+		`stations.getStationBlacklistById`,
+		station.value._id,
+		res => {
+			if (res.status === "success") {
+				station.value.blacklist = res.data.playlists;
+			}
+		}
+	);
+};
+
+const showTab = _tab => {
+	tabs.value[`${_tab}-tab`].scrollIntoView({ block: "nearest" });
+	tab.value = _tab;
+};
+
+const isOwner = () =>
+	loggedIn.value && station.value && userId.value === station.value.owner;
+const isAdmin = () => loggedIn.value && role.value === "admin";
+const isOwnerOrAdmin = () => isOwner() || isAdmin();
+
+const label = (tense = "future", typeOverwrite = null, capitalize = false) => {
+	let label = typeOverwrite || props.type;
+
+	if (tense === "past") label = `${label}ed`;
+	if (tense === "present") label = `${label}ing`;
+
+	if (capitalize) label = `${label.charAt(0).toUpperCase()}${label.slice(1)}`;
+
+	return label;
+};
+
+const selectedPlaylists = (typeOverwrite?: string) => {
+	const type = typeOverwrite || props.type;
+
+	if (type === "autofill") return autofill.value;
+	if (type === "blacklist") return blacklist.value;
+	if (type === "autorequest") return autoRequest.value;
+	return [];
+};
+
+const isSelected = (playlistId, typeOverwrite?: string) => {
+	const type = typeOverwrite || props.type;
+	let selected = false;
+
+	selectedPlaylists(type).forEach(playlist => {
+		if (playlist._id === playlistId) selected = true;
+	});
+	return selected;
+};
+
+const deselectPlaylist = (playlistId, typeOverwrite?: string) => {
+	const type = typeOverwrite || props.type;
+
+	if (type === "autofill")
+		return new Promise(resolve => {
+			socket.dispatch(
+				"stations.removeAutofillPlaylist",
+				station.value._id,
+				playlistId,
+				res => {
+					new Toast(res.message);
+					resolve(true);
+				}
+			);
+		});
+	if (type === "blacklist")
+		return new Promise(resolve => {
+			socket.dispatch(
+				"stations.removeBlacklistedPlaylist",
+				station.value._id,
+				playlistId,
+				res => {
+					new Toast(res.message);
+					resolve(true);
+				}
+			);
+		});
+	if (type === "autorequest")
+		return new Promise(resolve => {
+			removePlaylistFromAutoRequest(playlistId);
+			new Toast("Successfully deselected playlist.");
+			resolve(true);
+		});
+	return false;
+};
+
+const selectPlaylist = async (playlist, typeOverwrite?: string) => {
+	const type = typeOverwrite || props.type;
+
+	if (isSelected(playlist._id, type))
+		return new Toast(`Error: Playlist already ${label("past", type)}.`);
+
+	if (type === "autofill")
+		return new Promise(resolve => {
+			socket.dispatch(
+				"stations.autofillPlaylist",
+				station.value._id,
+				playlist._id,
+				res => {
+					new Toast(res.message);
+					emit("selected");
+					resolve(true);
+				}
+			);
+		});
+	if (type === "blacklist") {
+		if (props.type !== "blacklist" && isSelected(playlist._id))
+			await deselectPlaylist(playlist._id);
+
+		return new Promise(resolve => {
+			socket.dispatch(
+				"stations.blacklistPlaylist",
+				station.value._id,
+				playlist._id,
+				res => {
+					new Toast(res.message);
+					emit("selected");
+					resolve(true);
+				}
+			);
+		});
+	}
+	if (type === "autorequest")
+		return new Promise(resolve => {
+			addPlaylistToAutoRequest(playlist);
+			new Toast("Successfully selected playlist to auto request songs.");
+			emit("selected");
+			resolve(true);
+		});
+	return false;
+};
+
+const searchForPlaylists = page => {
+	if (search.page >= page || search.searchedQuery !== search.query) {
+		search.results = [];
+		search.page = 0;
+		search.count = 0;
+		search.resultsLeft = 0;
+		search.pageSize = 0;
+	}
+
+	const { query } = search;
+	const action =
+		station.value.type === "official" && props.type !== "autorequest"
+			? "playlists.searchOfficial"
+			: "playlists.searchCommunity";
+
+	search.searchedQuery = search.query;
+	socket.dispatch(action, query, page, res => {
+		const { data } = res;
+		if (res.status === "success") {
+			const { count, pageSize, playlists } = data;
+			search.results = [...search.results, ...playlists];
+			search.page = page;
+			search.count = count;
+			search.resultsLeft = count - search.results.length;
+			search.pageSize = pageSize;
+		} else if (res.status === "error") {
+			search.results = [];
+			search.page = 0;
+			search.count = 0;
+			search.resultsLeft = 0;
+			search.pageSize = 0;
+			new Toast(res.message);
+		}
+	});
+};
+
+onMounted(() => {
+	showTab("search");
+
+	ws.onConnect(init);
+});
+</script>
+
 <template>
 	<div class="playlist-tab-base">
 		<div v-if="$slots.info" class="top-info has-text-centered">
@@ -7,7 +312,7 @@
 			<div class="tab-selection">
 				<button
 					class="button is-default"
-					ref="search-tab"
+					:ref="el => (tabs['search-tab'] = el)"
 					:class="{ selected: tab === 'search' }"
 					@click="showTab('search')"
 				>
@@ -15,7 +320,7 @@
 				</button>
 				<button
 					class="button is-default"
-					ref="current-tab"
+					:ref="el => (tabs['current-tab'] = el)"
 					:class="{ selected: tab === 'current' }"
 					@click="showTab('current')"
 				>
@@ -26,7 +331,7 @@
 						type === 'autorequest' || station.type === 'community'
 					"
 					class="button is-default"
-					ref="my-playlists-tab"
+					:ref="el => (tabs['my-playlists-tab'] = el)"
 					:class="{ selected: tab === 'my-playlists' }"
 					@click="showTab('my-playlists')"
 				>
@@ -506,22 +811,15 @@
 					class="menu-list scrollable-list"
 					v-if="playlists.length > 0"
 				>
-					<draggable
-						:component-data="{
-							name: !drag ? 'draggable-list-transition' : null
-						}"
+					<draggable-list
+						v-model:list="playlists"
 						item-key="_id"
-						v-model="playlists"
-						v-bind="dragOptions"
 						@start="drag = true"
 						@end="drag = false"
-						@change="savePlaylistOrder"
+						@update="savePlaylistOrder"
 					>
 						<template #item="{ element }">
-							<playlist-item
-								class="item-draggable"
-								:playlist="element"
-							>
+							<playlist-item :playlist="element">
 								<template #item-icon>
 									<i
 										class="material-icons blacklisted-icon"
@@ -669,7 +967,7 @@
 								</template>
 							</playlist-item>
 						</template>
-					</draggable>
+					</draggable-list>
 				</div>
 
 				<p v-else class="has-text-centered scrollable-list">
@@ -679,324 +977,6 @@
 		</div>
 	</div>
 </template>
-<script>
-import { mapActions, mapState, mapGetters } from "vuex";
-import Toast from "toasters";
-import ws from "@/ws";
-
-import { mapModalState } from "@/vuex_helpers";
-
-import PlaylistItem from "@/components/PlaylistItem.vue";
-
-import SortablePlaylists from "@/mixins/SortablePlaylists.vue";
-
-export default {
-	components: {
-		PlaylistItem
-	},
-	mixins: [SortablePlaylists],
-	props: {
-		modalUuid: { type: String, default: "" },
-		type: {
-			type: String,
-			default: ""
-		},
-		sector: {
-			type: String,
-			default: "manageStation"
-		}
-	},
-	emits: ["selected"],
-	data() {
-		return {
-			tab: "current",
-			search: {
-				query: "",
-				searchedQuery: "",
-				page: 0,
-				count: 0,
-				resultsLeft: 0,
-				results: []
-			},
-			featuredPlaylists: []
-		};
-	},
-	computed: {
-		station: {
-			get() {
-				if (this.sector === "manageStation")
-					return this.$store.state.modals.manageStation[
-						this.modalUuid
-					].station;
-				return this.$store.state.station.station;
-			},
-			set(station) {
-				if (this.sector === "manageStation")
-					this.$store.commit(
-						`modals/manageStation/${this.modalUuid}/updateStation`,
-						station
-					);
-				else this.$store.commit("station/updateStation", station);
-			}
-		},
-		blacklist: {
-			get() {
-				if (this.sector === "manageStation")
-					return this.$store.state.modals.manageStation[
-						this.modalUuid
-					].blacklist;
-				return this.$store.state.station.blacklist;
-			},
-			set(blacklist) {
-				if (this.sector === "manageStation")
-					this.$store.commit(
-						`modals/manageStation/${this.modalUuid}/setBlacklist`,
-						blacklist
-					);
-				else this.$store.commit("station/setBlacklist", blacklist);
-			}
-		},
-		resultsLeftCount() {
-			return this.search.count - this.search.results.length;
-		},
-		nextPageResultsCount() {
-			return Math.min(this.search.pageSize, this.resultsLeftCount);
-		},
-		...mapState({
-			loggedIn: state => state.user.auth.loggedIn,
-			role: state => state.user.auth.role,
-			userId: state => state.user.auth.userId
-		}),
-		...mapModalState("modals/manageStation/MODAL_UUID", {
-			autofill: state => state.autofill
-		}),
-		...mapState("station", {
-			autoRequest: state => state.autoRequest
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	mounted() {
-		this.showTab("search");
-
-		ws.onConnect(this.init);
-	},
-	methods: {
-		init() {
-			this.socket.dispatch("playlists.indexMyPlaylists", res => {
-				if (res.status === "success")
-					this.setPlaylists(res.data.playlists);
-				this.orderOfPlaylists = this.calculatePlaylistOrder(); // order in regards to the database
-			});
-
-			this.socket.dispatch("playlists.indexFeaturedPlaylists", res => {
-				if (res.status === "success")
-					this.featuredPlaylists = res.data.playlists;
-			});
-
-			if (this.type === "autofill")
-				this.socket.dispatch(
-					`stations.getStationAutofillPlaylistsById`,
-					this.station._id,
-					res => {
-						if (res.status === "success") {
-							this.station.autofill.playlists =
-								res.data.playlists;
-						}
-					}
-				);
-
-			this.socket.dispatch(
-				`stations.getStationBlacklistById`,
-				this.station._id,
-				res => {
-					if (res.status === "success") {
-						this.station.blacklist = res.data.playlists;
-					}
-				}
-			);
-		},
-		showTab(tab) {
-			this.$refs[`${tab}-tab`].scrollIntoView({ block: "nearest" });
-			this.tab = tab;
-		},
-		isOwner() {
-			return (
-				this.loggedIn &&
-				this.station &&
-				this.userId === this.station.owner
-			);
-		},
-		isAdmin() {
-			return this.loggedIn && this.role === "admin";
-		},
-		isOwnerOrAdmin() {
-			return this.isOwner() || this.isAdmin();
-		},
-		label(tense = "future", typeOverwrite = null, capitalize = false) {
-			let label = typeOverwrite || this.type;
-
-			if (tense === "past") label = `${label}ed`;
-			if (tense === "present") label = `${label}ing`;
-
-			if (capitalize)
-				label = `${label.charAt(0).toUpperCase()}${label.slice(1)}`;
-
-			return label;
-		},
-		selectedPlaylists(typeOverwrite) {
-			const type = typeOverwrite || this.type;
-
-			if (type === "autofill") return this.autofill;
-			if (type === "blacklist") return this.blacklist;
-			if (type === "autorequest") return this.autoRequest;
-			return [];
-		},
-		async selectPlaylist(playlist, typeOverwrite) {
-			const type = typeOverwrite || this.type;
-
-			if (this.isSelected(playlist._id, type))
-				return new Toast(
-					`Error: Playlist already ${this.label("past", type)}.`
-				);
-
-			if (type === "autofill")
-				return new Promise(resolve => {
-					this.socket.dispatch(
-						"stations.autofillPlaylist",
-						this.station._id,
-						playlist._id,
-						res => {
-							new Toast(res.message);
-							this.$emit("selected");
-							resolve();
-						}
-					);
-				});
-			if (type === "blacklist") {
-				if (this.type !== "blacklist" && this.isSelected(playlist._id))
-					await this.deselectPlaylist(playlist._id);
-
-				return new Promise(resolve => {
-					this.socket.dispatch(
-						"stations.blacklistPlaylist",
-						this.station._id,
-						playlist._id,
-						res => {
-							new Toast(res.message);
-							this.$emit("selected");
-							resolve();
-						}
-					);
-				});
-			}
-			if (type === "autorequest")
-				return new Promise(resolve => {
-					this.addPlaylistToAutoRequest(playlist);
-					new Toast(
-						"Successfully selected playlist to auto request songs."
-					);
-					this.$emit("selected");
-					resolve();
-				});
-			return false;
-		},
-		deselectPlaylist(playlistId, typeOverwrite) {
-			const type = typeOverwrite || this.type;
-
-			if (type === "autofill")
-				return new Promise(resolve => {
-					this.socket.dispatch(
-						"stations.removeAutofillPlaylist",
-						this.station._id,
-						playlistId,
-						res => {
-							new Toast(res.message);
-							resolve();
-						}
-					);
-				});
-			if (type === "blacklist")
-				return new Promise(resolve => {
-					this.socket.dispatch(
-						"stations.removeBlacklistedPlaylist",
-						this.station._id,
-						playlistId,
-						res => {
-							new Toast(res.message);
-							resolve();
-						}
-					);
-				});
-			if (type === "autorequest")
-				return new Promise(resolve => {
-					this.removePlaylistFromAutoRequest(playlistId);
-					new Toast("Successfully deselected playlist.");
-					resolve();
-				});
-			return false;
-		},
-		isSelected(playlistId, typeOverwrite) {
-			const type = typeOverwrite || this.type;
-			let selected = false;
-
-			this.selectedPlaylists(type).forEach(playlist => {
-				if (playlist._id === playlistId) selected = true;
-			});
-			return selected;
-		},
-		searchForPlaylists(page) {
-			if (
-				this.search.page >= page ||
-				this.search.searchedQuery !== this.search.query
-			) {
-				this.search.results = [];
-				this.search.page = 0;
-				this.search.count = 0;
-				this.search.resultsLeft = 0;
-				this.search.pageSize = 0;
-			}
-
-			const { query } = this.search;
-			const action =
-				this.station.type === "official" && this.type !== "autorequest"
-					? "playlists.searchOfficial"
-					: "playlists.searchCommunity";
-
-			this.search.searchedQuery = this.search.query;
-			this.socket.dispatch(action, query, page, res => {
-				const { data } = res;
-				if (res.status === "success") {
-					const { count, pageSize, playlists } = data;
-					this.search.results = [
-						...this.search.results,
-						...playlists
-					];
-					this.search.page = page;
-					this.search.count = count;
-					this.search.resultsLeft =
-						count - this.search.results.length;
-					this.search.pageSize = pageSize;
-				} else if (res.status === "error") {
-					this.search.results = [];
-					this.search.page = 0;
-					this.search.count = 0;
-					this.search.resultsLeft = 0;
-					this.search.pageSize = 0;
-					new Toast(res.message);
-				}
-			});
-		},
-		...mapActions("modalVisibility", ["openModal"]),
-		...mapActions("user/playlists", ["setPlaylists"]),
-		...mapActions("station", [
-			"addPlaylistToAutoRequest",
-			"removePlaylistFromAutoRequest"
-		])
-	}
-};
-</script>
 
 <style lang="less" scoped>
 .night-mode {
@@ -1044,8 +1024,7 @@ export default {
 		.tab {
 			padding: 15px 0;
 			border-radius: 0;
-			.playlist-item:not(:last-of-type),
-			.item.item-draggable:not(:last-of-type) {
+			.playlist-item:not(:last-of-type) {
 				margin-bottom: 10px;
 			}
 			.load-more-button {

+ 33 - 36
frontend/src/components/ProfilePicture.vue

@@ -1,3 +1,36 @@
+<script setup lang="ts">
+import { ref, computed, onMounted } from "vue";
+
+const props = defineProps({
+	avatar: {
+		type: Object,
+		default: () => {}
+	},
+	name: {
+		type: String,
+		default: ": )"
+	}
+});
+
+const notes = ref("");
+
+const initials = computed(() =>
+	props.name
+		.replaceAll(/[^A-Za-z ]+/g, "")
+		.replaceAll(/ +/g, " ")
+		.split(" ")
+		.map(word => word.charAt(0))
+		.splice(0, 2)
+		.join("")
+		.toUpperCase()
+);
+
+onMounted(async () => {
+	const frontendDomain = await lofig.get("frontendDomain");
+	notes.value = encodeURI(`${frontendDomain}/assets/notes.png`);
+});
+</script>
+
 <template>
 	<img
 		class="profile-picture using-gravatar"
@@ -12,42 +45,6 @@
 	</div>
 </template>
 
-<script>
-export default {
-	props: {
-		avatar: {
-			type: Object,
-			default: () => {}
-		},
-		name: {
-			type: String,
-			default: ": )"
-		}
-	},
-	data() {
-		return {
-			notes: ""
-		};
-	},
-	computed: {
-		initials() {
-			return this.name
-				.replaceAll(/[^A-Za-z ]+/g, "")
-				.replaceAll(/ +/g, " ")
-				.split(" ")
-				.map(word => word.charAt(0))
-				.splice(0, 2)
-				.join("")
-				.toUpperCase();
-		}
-	},
-	async mounted() {
-		const frontendDomain = await lofig.get("frontendDomain");
-		this.notes = encodeURI(`${frontendDomain}/assets/notes.png`);
-	}
-};
-</script>
-
 <style lang="less" scoped>
 .profile-picture {
 	width: 100px;

+ 35 - 36
frontend/src/components/PunishmentItem.vue

@@ -1,10 +1,42 @@
+<script setup lang="ts">
+import { defineAsyncComponent, computed } from "vue";
+import { format, formatDistance, parseISO } from "date-fns";
+
+const UserLink = defineAsyncComponent(
+	() => import("@/components/UserLink.vue")
+);
+
+const props = defineProps({
+	punishment: { type: Object, default: () => {} }
+});
+
+defineEmits(["deactivate"]);
+
+const active = computed(
+	() =>
+		props.punishment.active &&
+		new Date(props.punishment.expiresAt).getTime() > Date.now()
+);
+</script>
+
 <template>
 	<div class="universal-item punishment-item">
 		<div class="item-icon">
 			<p class="is-expanded checkbox-control">
-				<label class="switch">
-					<input type="checkbox" v-model="active" disabled />
-					<span class="slider round"></span>
+				<label class="switch" :class="{ disabled: !active }">
+					<input
+						type="checkbox"
+						:checked="active"
+						@click="
+							active
+								? $emit('deactivate', $event)
+								: $event.preventDefault()
+						"
+					/>
+					<span
+						class="slider round"
+						:class="{ disabled: !active }"
+					></span>
 				</label>
 			</p>
 			<p>
@@ -70,35 +102,6 @@
 	</div>
 </template>
 
-<script>
-import { mapActions } from "vuex";
-import { format, formatDistance, parseISO } from "date-fns";
-
-export default {
-	props: {
-		punishment: { type: Object, default: () => {} }
-	},
-	data() {
-		return {
-			active: false
-		};
-	},
-	watch: {
-		punishment(punishment) {
-			this.active =
-				punishment.active &&
-				new Date(this.punishment.expiresAt).getTime() > Date.now();
-		}
-	},
-	methods: {
-		formatDistance,
-		format,
-		parseISO,
-		...mapActions("modalVisibility", ["closeModal"])
-	}
-};
-</script>
-
 <style lang="less" scoped>
 .night-mode {
 	.punishment-item {
@@ -123,10 +126,6 @@ export default {
 		justify-content: space-evenly;
 		border: 1px solid var(--light-grey-3);
 		border-radius: @border-radius;
-
-		.checkbox-control .slider {
-			cursor: default;
-		}
 	}
 
 	.item-title {

+ 143 - 185
frontend/src/components/Queue.vue

@@ -1,3 +1,138 @@
+<script setup lang="ts">
+import { defineAsyncComponent, ref, computed, onUpdated } from "vue";
+import Toast from "toasters";
+import { storeToRefs } from "pinia";
+import { DraggableList } from "vue-draggable-list";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useStationStore } from "@/stores/station";
+import { useUserAuthStore } from "@/stores/userAuth";
+import { useManageStationStore } from "@/stores/manageStation";
+
+const SongItem = defineAsyncComponent(
+	() => import("@/components/SongItem.vue")
+);
+const QuickConfirm = defineAsyncComponent(
+	() => import("@/components/QuickConfirm.vue")
+);
+
+const props = defineProps({
+	modalUuid: { type: String, default: "" },
+	sector: { type: String, default: "station" }
+});
+
+const { socket } = useWebsocketsStore();
+const stationStore = useStationStore();
+const userAuthStore = useUserAuthStore();
+const manageStationStore = useManageStationStore(props);
+
+const { loggedIn, userId, role: userRole } = storeToRefs(userAuthStore);
+
+const actionableButtonVisible = ref(false);
+const drag = ref(false);
+const songItems = ref([]);
+
+const station = computed({
+	get: () => {
+		if (props.sector === "manageStation") return manageStationStore.station;
+		return stationStore.station;
+	},
+	set: value => {
+		if (props.sector === "manageStation")
+			manageStationStore.updateStation(value);
+		else stationStore.updateStation(value);
+	}
+});
+
+const queue = computed({
+	get: () => {
+		if (props.sector === "manageStation")
+			return manageStationStore.songsList;
+		return stationStore.songsList;
+	},
+	set: value => {
+		if (props.sector === "manageStation")
+			manageStationStore.updateSongsList(value);
+		else stationStore.updateSongsList(value);
+	}
+});
+
+const isOwnerOnly = () =>
+	loggedIn.value && userId.value === station.value.owner;
+
+const isAdminOnly = () => loggedIn.value && userRole.value === "admin";
+
+const removeFromQueue = youtubeId => {
+	socket.dispatch(
+		"stations.removeFromQueue",
+		station.value._id,
+		youtubeId,
+		res => {
+			if (res.status === "success")
+				new Toast("Successfully removed song from the queue.");
+			else new Toast(res.message);
+		}
+	);
+};
+
+const repositionSongInQueue = ({ moved }) => {
+	const { oldIndex, newIndex } = moved;
+	if (oldIndex === newIndex) return; // we only need to update when song is moved
+	const song = queue.value[newIndex];
+	socket.dispatch(
+		"stations.repositionSongInQueue",
+		station.value._id,
+		{
+			...song,
+			oldIndex,
+			newIndex
+		},
+		res => {
+			new Toast({ content: res.message, timeout: 4000 });
+			if (res.status !== "success")
+				queue.value.splice(
+					oldIndex,
+					0,
+					queue.value.splice(newIndex, 1)[0]
+				);
+		}
+	);
+};
+
+const moveSongToTop = index => {
+	songItems.value[`song-item-${index}`].$refs.songActions.tippy.hide();
+	queue.value.splice(0, 0, queue.value.splice(index, 1)[0]);
+	repositionSongInQueue({
+		moved: {
+			oldIndex: index,
+			newIndex: 0
+		}
+	});
+};
+
+const moveSongToBottom = index => {
+	songItems.value[`song-item-${index}`].$refs.songActions.tippy.hide();
+	queue.value.splice(
+		queue.value.length - 1,
+		0,
+		queue.value.splice(index, 1)[0]
+	);
+	repositionSongInQueue({
+		moved: {
+			oldIndex: index,
+			newIndex: queue.value.length - 1
+		}
+	});
+};
+
+onUpdated(() => {
+	// check if actionable button is visible, if not: set max-height of queue items to 100%
+	actionableButtonVisible.value =
+		document
+			.getElementById("queue")
+			.querySelectorAll(".tab-actionable-button").length > 0;
+});
+</script>
+
 <template>
 	<div id="queue">
 		<div
@@ -7,26 +142,20 @@
 				'scrollable-list': true
 			}"
 		>
-			<draggable
-				:component-data="{
-					name: !drag ? 'draggable-list-transition' : null
-				}"
-				v-model="queue"
+			<draggable-list
+				v-model:list="queue"
 				item-key="_id"
-				v-bind="dragOptions"
 				@start="drag = true"
 				@end="drag = false"
-				@change="repositionSongInQueue"
+				@update="repositionSongInQueue"
+				:disabled="!(isAdminOnly() || isOwnerOnly())"
 			>
 				<template #item="{ element, index }">
 					<song-item
 						:song="element"
 						:requested-by="true"
-						:class="{
-							'item-draggable': isAdminOnly() || isOwnerOnly()
-						}"
 						:disabled-actions="[]"
-						:ref="`song-item-${index}`"
+						:ref="el => (songItems[`song-item-${index}`] = el)"
 					>
 						<template
 							v-if="isAdminOnly() || isOwnerOnly()"
@@ -47,14 +176,14 @@
 							<i
 								class="material-icons"
 								v-if="index > 0"
-								@click="moveSongToTop(element, index)"
+								@click="moveSongToTop(index)"
 								content="Move to top of Queue"
 								v-tippy
 								>vertical_align_top</i
 							>
 							<i
 								v-if="queue.length - 1 !== index"
-								@click="moveSongToBottom(element, index)"
+								@click="moveSongToBottom(index)"
 								class="material-icons"
 								content="Move to bottom of Queue"
 								v-tippy
@@ -63,7 +192,7 @@
 						</template>
 					</song-item>
 				</template>
-			</draggable>
+			</draggable-list>
 		</div>
 		<p class="nothing-here-text has-text-centered" v-else>
 			There are no songs currently queued
@@ -71,173 +200,6 @@
 	</div>
 </template>
 
-<script>
-import { mapActions, mapState, mapGetters } from "vuex";
-import draggable from "vuedraggable";
-import Toast from "toasters";
-
-import SongItem from "@/components/SongItem.vue";
-
-export default {
-	components: { draggable, SongItem },
-	props: {
-		modalUuid: { type: String, default: "" },
-		sector: {
-			type: String,
-			default: "station"
-		}
-	},
-	data() {
-		return {
-			actionableButtonVisible: false,
-			drag: false
-		};
-	},
-	computed: {
-		station: {
-			get() {
-				if (this.sector === "manageStation")
-					return this.$store.state.modals.manageStation[
-						this.modalUuid
-					].station;
-				return this.$store.state.station.station;
-			},
-			set(station) {
-				if (this.sector === "manageStation")
-					this.$store.commit(
-						`modals/manageStation/${this.modalUuid}/updateStation`,
-						station
-					);
-				else this.$store.commit("station/updateStation", station);
-			}
-		},
-		queue: {
-			get() {
-				if (this.sector === "manageStation")
-					return this.$store.state.modals.manageStation[
-						this.modalUuid
-					].songsList;
-				return this.$store.state.station.songsList;
-			},
-			set(queue) {
-				if (this.sector === "manageStation")
-					this.$store.commit(
-						`modals/manageStation/${this.modalUuid}/updateSongsList`,
-						queue
-					);
-				else this.$store.commit("station/updateSongsList", queue);
-			}
-		},
-		dragOptions() {
-			return {
-				animation: 200,
-				group: "queue",
-				disabled: !(this.isAdminOnly() || this.isOwnerOnly()),
-				ghostClass: "draggable-list-ghost"
-			};
-		},
-		...mapState({
-			loggedIn: state => state.user.auth.loggedIn,
-			userId: state => state.user.auth.userId,
-			userRole: state => state.user.auth.role,
-			noSong: state => state.station.noSong
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	updated() {
-		// check if actionable button is visible, if not: set max-height of queue items to 100%
-		if (
-			document
-				.getElementById("queue")
-				.querySelectorAll(".tab-actionable-button").length > 0
-		)
-			this.actionableButtonVisible = true;
-		else this.actionableButtonVisible = false;
-	},
-	methods: {
-		isOwnerOnly() {
-			return this.loggedIn && this.userId === this.station.owner;
-		},
-		isAdminOnly() {
-			return this.loggedIn && this.userRole === "admin";
-		},
-		removeFromQueue(youtubeId) {
-			this.socket.dispatch(
-				"stations.removeFromQueue",
-				this.station._id,
-				youtubeId,
-				res => {
-					if (res.status === "success")
-						new Toast("Successfully removed song from the queue.");
-					else new Toast(res.message);
-				}
-			);
-		},
-		repositionSongInQueue({ moved }) {
-			if (!moved) return; // we only need to update when song is moved
-
-			this.socket.dispatch(
-				"stations.repositionSongInQueue",
-				this.station._id,
-				{
-					...moved.element,
-					oldIndex: moved.oldIndex,
-					newIndex: moved.newIndex
-				},
-				res => {
-					new Toast({ content: res.message, timeout: 4000 });
-					if (res.status !== "success")
-						this.repositionSongInList({
-							...moved.element,
-							newIndex: moved.oldIndex,
-							oldIndex: moved.newIndex
-						});
-				}
-			);
-		},
-		moveSongToTop(song, index) {
-			this.$refs[`song-item-${index}`].$refs.songActions.tippy.hide();
-
-			this.repositionSongInQueue({
-				moved: {
-					element: song,
-					oldIndex: index,
-					newIndex: 0
-				}
-			});
-		},
-		moveSongToBottom(song, index) {
-			this.$refs[`song-item-${index}`].$refs.songActions.tippy.hide();
-
-			this.repositionSongInQueue({
-				moved: {
-					element: song,
-					oldIndex: index,
-					newIndex: this.queue.length
-				}
-			});
-		},
-		...mapActions({
-			repositionSongInList(dispatch, payload) {
-				if (this.sector === "manageStation")
-					return dispatch(
-						"modals/manageStation/repositionSongInList",
-						payload
-					);
-
-				return dispatch("station/repositionSongInList", payload);
-			}
-		}),
-		...mapActions("modalVisibility", ["openModal"]),
-		...mapActions({
-			showManageStationTab: "modals/manageStation/showTab"
-		})
-	}
-};
-</script>
-
 <style lang="less" scoped>
 .night-mode {
 	#queue {
@@ -255,10 +217,6 @@ export default {
 		max-height: 100%;
 	}
 
-	.song-item:not(:last-of-type) {
-		margin-bottom: 10px;
-	}
-
 	#queue-locked {
 		display: flex;
 		justify-content: center;

+ 68 - 0
frontend/src/components/QuickConfirm.vue

@@ -0,0 +1,68 @@
+<script setup lang="ts">
+import { ref } from "vue";
+
+defineProps({
+	placement: { type: String, default: "top" }
+});
+
+const emit = defineEmits(["confirm"]);
+
+const clickedOnce = ref(false);
+const body = ref(document.body);
+const quickConfirm = ref();
+
+const confirm = event => {
+	if (
+		!event ||
+		event.type !== "click" ||
+		event.altKey ||
+		event.ctrlKey ||
+		event.metaKey
+	)
+		return;
+
+	clickedOnce.value = false;
+	emit("confirm");
+	setTimeout(() => {
+		quickConfirm.value.tippy.hide();
+	}, 25);
+};
+
+const click = event => {
+	if (clickedOnce.value) confirm(event);
+	else clickedOnce.value = true;
+};
+
+const shiftClick = event => {
+	confirm(event);
+};
+
+const delayedHide = () => {
+	setTimeout(() => {
+		clickedOnce.value = false;
+	}, 25);
+};
+</script>
+
+<template>
+	<tippy
+		:interactive="true"
+		:touch="true"
+		:placement="placement"
+		theme="quickConfirm"
+		ref="quickConfirm"
+		trigger="click"
+		:append-to="body"
+		@hide="delayedHide()"
+	>
+		<div
+			@click.shift.stop="shiftClick($event)"
+			@click.exact="click($event)"
+		>
+			<slot ref="trigger" />
+		</div>
+		<template #content>
+			<a @click="confirm($event)"> Click to Confirm </a>
+		</template>
+	</tippy>
+</template>

+ 17 - 19
frontend/src/components/ReportInfoItem.vue

@@ -1,3 +1,20 @@
+<script setup lang="ts">
+import { defineAsyncComponent } from "vue";
+import { formatDistance } from "date-fns";
+import { useModalsStore } from "@/stores/modals";
+
+const ProfilePicture = defineAsyncComponent(
+	() => import("@/components/ProfilePicture.vue")
+);
+
+defineProps({
+	createdBy: { type: Object, default: () => {} },
+	createdAt: { type: String, default: "" }
+});
+
+const { closeModal } = useModalsStore();
+</script>
+
 <template>
 	<div class="universal-item report-info-item">
 		<div class="item-icon">
@@ -44,25 +61,6 @@
 	</div>
 </template>
 
-<script>
-import { mapActions } from "vuex";
-import { formatDistance } from "date-fns";
-
-import ProfilePicture from "@/components/ProfilePicture.vue";
-
-export default {
-	components: { ProfilePicture },
-	props: {
-		createdBy: { type: Object, default: () => {} },
-		createdAt: { type: String, default: "" }
-	},
-	methods: {
-		formatDistance,
-		...mapActions("modalVisibility", ["closeModal"])
-	}
-};
-</script>
-
 <style lang="less" scoped>
 .night-mode {
 	.report-info-item {

+ 122 - 146
frontend/src/components/Request.vue

@@ -1,3 +1,123 @@
+<script setup lang="ts">
+import { defineAsyncComponent, ref, computed, onMounted } from "vue";
+import Toast from "toasters";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useStationStore } from "@/stores/station";
+import { useManageStationStore } from "@/stores/manageStation";
+import { useSearchYoutube } from "@/composables/useSearchYoutube";
+import { useSearchMusare } from "@/composables/useSearchMusare";
+
+const SongItem = defineAsyncComponent(
+	() => import("@/components/SongItem.vue")
+);
+const SearchQueryItem = defineAsyncComponent(
+	() => import("@/components/SearchQueryItem.vue")
+);
+const PlaylistTabBase = defineAsyncComponent(
+	() => import("@/components/PlaylistTabBase.vue")
+);
+
+const props = defineProps({
+	modalUuid: { type: String, default: "" },
+	sector: { type: String, default: "station" },
+	disableAutoRequest: { type: Boolean, default: false }
+});
+
+const { youtubeSearch, searchForSongs, loadMoreSongs } = useSearchYoutube();
+const { musareSearch, searchForMusareSongs } = useSearchMusare();
+
+const { socket } = useWebsocketsStore();
+const stationStore = useStationStore();
+const manageStationStore = useManageStationStore(props);
+
+const tab = ref("songs");
+const sitename = ref("Musare");
+const tabs = ref({});
+
+const station = computed({
+	get() {
+		if (props.sector === "manageStation") return manageStationStore.station;
+		return stationStore.station;
+	},
+	set(value) {
+		if (props.sector === "manageStation")
+			manageStationStore.updateStation(value);
+		else stationStore.updateStation(value);
+	}
+});
+// const blacklist = computed({
+// 	get() {
+// 		if (props.sector === "manageStation") return manageStationStore.blacklist;
+// 		return stationStore.blacklist;
+// 	},
+// 	set(value) {
+// 		if (props.sector === "manageStation")
+// 			manageStationStore.setBlacklist(value);
+// 		else stationStore.setBlacklist(value);
+// 	}
+// });
+const songsList = computed({
+	get() {
+		if (props.sector === "manageStation")
+			return manageStationStore.songsList;
+		return stationStore.songsList;
+	},
+	set(value) {
+		if (props.sector === "manageStation")
+			manageStationStore.updateSongsList(value);
+		else stationStore.updateSongsList(value);
+	}
+});
+const musareResultsLeftCount = computed(
+	() => musareSearch.value.count - musareSearch.value.results.length
+);
+const nextPageMusareResultsCount = computed(() =>
+	Math.min(musareSearch.value.pageSize, musareResultsLeftCount.value)
+);
+const songsInQueue = computed(() => {
+	if (station.value.currentSong)
+		return songsList.value
+			.map(song => song.youtubeId)
+			.concat(station.value.currentSong.youtubeId);
+	return songsList.value.map(song => song.youtubeId);
+});
+// const currentUserQueueSongs = computed(
+// 	() =>
+// 		songsList.value.filter(
+// 			queueSong => queueSong.requestedBy === userId.value
+// 		).length
+// );
+
+const showTab = _tab => {
+	tabs.value[`${_tab}-tab`].scrollIntoView({ block: "nearest" });
+	tab.value = _tab;
+};
+
+const addSongToQueue = (youtubeId: string, index?: number) => {
+	socket.dispatch(
+		"stations.addToQueue",
+		station.value._id,
+		youtubeId,
+		res => {
+			if (res.status !== "success") new Toast(`Error: ${res.message}`);
+			else {
+				if (index)
+					youtubeSearch.value.songs.results[index].isAddedToQueue =
+						true;
+
+				new Toast(res.message);
+			}
+		}
+	);
+};
+
+onMounted(async () => {
+	sitename.value = await lofig.get("siteSettings.sitename");
+
+	showTab("songs");
+});
+</script>
+
 <template>
 	<div class="station-playlists">
 		<p class="top-info has-text-centered">
@@ -7,7 +127,7 @@
 			<div class="tab-selection">
 				<button
 					class="button is-default"
-					ref="songs-tab"
+					:ref="el => (tabs['songs-tab'] = el)"
 					:class="{ selected: tab === 'songs' }"
 					@click="showTab('songs')"
 				>
@@ -16,7 +136,7 @@
 				<button
 					v-if="!disableAutoRequest"
 					class="button is-default"
-					ref="autorequest-tab"
+					:ref="el => (tabs['autorequest-tab'] = el)"
 					:class="{ selected: tab === 'autorequest' }"
 					@click="showTab('autorequest')"
 				>
@@ -182,150 +302,6 @@
 		</div>
 	</div>
 </template>
-<script>
-import { mapState, mapGetters } from "vuex";
-
-import Toast from "toasters";
-
-import SongItem from "@/components/SongItem.vue";
-import SearchQueryItem from "@/components/SearchQueryItem.vue";
-import PlaylistTabBase from "@/components/PlaylistTabBase.vue";
-
-import SearchYoutube from "@/mixins/SearchYoutube.vue";
-import SearchMusare from "@/mixins/SearchMusare.vue";
-
-export default {
-	components: {
-		SongItem,
-		SearchQueryItem,
-		PlaylistTabBase
-	},
-	mixins: [SearchYoutube, SearchMusare],
-	props: {
-		modalUuid: { type: String, default: "" },
-		sector: { type: String, default: "station" },
-		disableAutoRequest: { type: Boolean, default: false }
-	},
-	data() {
-		return {
-			tab: "songs",
-			sitename: "Musare"
-		};
-	},
-	computed: {
-		station: {
-			get() {
-				if (this.sector === "manageStation")
-					return this.$store.state.modals.manageStation[
-						this.modalUuid
-					].station;
-				return this.$store.state.station.station;
-			},
-			set(station) {
-				if (this.sector === "manageStation")
-					this.$store.commit(
-						`modals/manageStation/${this.modalUuid}/updateStation`,
-						station
-					);
-				else this.$store.commit("station/updateStation", station);
-			}
-		},
-		blacklist: {
-			get() {
-				if (this.sector === "manageStation")
-					return this.$store.state.modals.manageStation[
-						this.modalUuid
-					].blacklist;
-				return this.$store.state.station.blacklist;
-			},
-			set(blacklist) {
-				if (this.sector === "manageStation")
-					this.$store.commit(
-						`modals/manageStation/${this.modalUuid}/setBlacklist`,
-						blacklist
-					);
-				else this.$store.commit("station/setBlacklist", blacklist);
-			}
-		},
-		songsList: {
-			get() {
-				if (this.sector === "manageStation")
-					return this.$store.state.modals.manageStation[
-						this.modalUuid
-					].songsList;
-				return this.$store.state.station.songsList;
-			},
-			set(songsList) {
-				if (this.sector === "manageStation")
-					this.$store.commit(
-						`modals/manageStation/${this.modalUuid}/updateSongsList`,
-						songsList
-					);
-				else this.$store.commit("station/updateSongsList", songsList);
-			}
-		},
-		musareResultsLeftCount() {
-			return this.musareSearch.count - this.musareSearch.results.length;
-		},
-		nextPageMusareResultsCount() {
-			return Math.min(
-				this.musareSearch.pageSize,
-				this.musareResultsLeftCount
-			);
-		},
-		songsInQueue() {
-			if (this.station.currentSong)
-				return this.songsList
-					.map(song => song.youtubeId)
-					.concat(this.station.currentSong.youtubeId);
-			return this.songsList.map(song => song.youtubeId);
-		},
-		currentUserQueueSongs() {
-			return this.songsList.filter(
-				queueSong => queueSong.requestedBy === this.userId
-			).length;
-		},
-		...mapState("user", {
-			loggedIn: state => state.auth.loggedIn,
-			role: state => state.auth.role,
-			userId: state => state.auth.userId
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	async mounted() {
-		this.sitename = await lofig.get("siteSettings.sitename");
-
-		this.showTab("songs");
-	},
-	methods: {
-		showTab(tab) {
-			this.$refs[`${tab}-tab`].scrollIntoView({ block: "nearest" });
-			this.tab = tab;
-		},
-		addSongToQueue(youtubeId, index) {
-			this.socket.dispatch(
-				"stations.addToQueue",
-				this.station._id,
-				youtubeId,
-				res => {
-					if (res.status !== "success")
-						new Toast(`Error: ${res.message}`);
-					else {
-						if (index)
-							this.youtubeSearch.songs.results[
-								index
-							].isAddedToQueue = true;
-
-						new Toast(res.message);
-					}
-				}
-			);
-		}
-	}
-};
-</script>
 
 <style lang="less" scoped>
 .night-mode {

+ 42 - 46
frontend/src/components/RunJobDropdown.vue

@@ -1,3 +1,45 @@
+<script setup lang="ts">
+import { defineAsyncComponent, PropType, ref } from "vue";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useLongJobsStore } from "@/stores/longJobs";
+
+const QuickConfirm = defineAsyncComponent(
+	() => import("@/components/QuickConfirm.vue")
+);
+
+defineProps({
+	jobs: { type: Array as PropType<any[]>, default: () => [] }
+});
+
+const showJobDropdown = ref(false);
+
+const { socket } = useWebsocketsStore();
+
+const { setJob } = useLongJobsStore();
+
+const runJob = job => {
+	let id;
+	let title;
+
+	socket.dispatch(job.socket, {
+		cb: () => {},
+		onProgress: res => {
+			if (res.status === "started") {
+				id = res.id;
+				title = res.title;
+			}
+
+			if (id)
+				setJob({
+					id,
+					name: title,
+					...res
+				});
+		}
+	});
+};
+</script>
+
 <template>
 	<tippy
 		class="runJobDropdown"
@@ -52,52 +94,6 @@
 	</tippy>
 </template>
 
-<script>
-import { mapGetters } from "vuex";
-
-export default {
-	props: {
-		jobs: {
-			type: Array,
-			default: () => []
-		}
-	},
-	data() {
-		return {
-			showJobDropdown: false
-		};
-	},
-	computed: {
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	methods: {
-		runJob(job) {
-			let id;
-			let title;
-
-			this.socket.dispatch(job.socket, {
-				cb: () => {},
-				onProgress: res => {
-					if (res.status === "started") {
-						id = res.id;
-						title = res.title;
-					}
-
-					if (id)
-						this.setJob({
-							id,
-							name: title,
-							...res
-						});
-				}
-			});
-		}
-	}
-};
-</script>
-
 <style lang="less" scoped>
 .nav-dropdown-items {
 	& > span:not(:last-child) .nav-item.button {

+ 66 - 66
frontend/src/components/SaveButton.vue

@@ -1,3 +1,68 @@
+<script setup lang="ts">
+import { ref, computed, defineExpose } from "vue";
+
+const props = defineProps({
+	defaultMessage: { type: String, default: "Save Changes" }
+});
+
+const emit = defineEmits(["clicked"]);
+
+const status = ref("default"); // enum: ["default", "disabled", "save-failure", "save-success"]
+
+const message = computed(() => {
+	switch (status.value) {
+		case "save-success":
+			return `<i class="material-icons icon-with-button">done</i>Saved Changes`;
+		case "save-failure":
+			return `<i class="material-icons icon-with-button">error_outline</i>Failed to save`;
+		case "disabled":
+		case "saving":
+			return "Saving...";
+		case "verifying":
+			return "Verifying...";
+		default:
+			return props.defaultMessage || "Save Changes";
+	}
+});
+const style = computed(() => {
+	switch (status.value) {
+		case "save-success":
+			return "is-success";
+		case "save-failure":
+			return `is-danger`;
+		case "saving":
+		case "verifying":
+		case "disabled":
+			return "is-default";
+		default:
+			return "is-primary";
+	}
+});
+
+const handleSuccessfulSave = () => {
+	if (status.value !== "save-success") {
+		status.value = "save-success";
+		setTimeout(() => {
+			status.value = "default";
+		}, 2000);
+	}
+};
+
+const handleFailedSave = () => {
+	if (status.value !== "save-failure") {
+		status.value = "save-failure";
+		setTimeout(() => {
+			status.value = "default";
+		}, 2000);
+	}
+};
+
+defineExpose({
+	handleSuccessfulSave,
+	handleFailedSave
+});
+</script>
+
 <template>
 	<div>
 		<transition name="save-button-transition" mode="out-in">
@@ -6,73 +71,8 @@
 				:key="status"
 				:disabled="status === 'disabled'"
 				v-html="message"
-				@click="$emit('clicked')"
+				@click="emit('clicked')"
 			/>
 		</transition>
 	</div>
 </template>
-
-<script>
-export default {
-	props: {
-		defaultMessage: { type: String, default: "Save Changes" }
-	},
-	emits: ["clicked"],
-	data() {
-		return {
-			status: "default" // enum: ["default", "disabled", "save-failure", "save-success"],
-		};
-	},
-	computed: {
-		message() {
-			switch (this.status) {
-				case "save-success":
-					return `<i class="material-icons icon-with-button">done</i>Saved Changes`;
-				case "save-failure":
-					return `<i class="material-icons icon-with-button">error_outline</i>Failed to save`;
-				case "disabled":
-				case "saving":
-					return "Saving...";
-				case "verifying":
-					return "Verifying...";
-				default:
-					return this.defaultMessage
-						? this.defaultMessage
-						: "Save Changes";
-			}
-		},
-		style() {
-			switch (this.status) {
-				case "save-success":
-					return "is-success";
-				case "save-failure":
-					return `is-danger`;
-				case "saving":
-				case "verifying":
-				case "disabled":
-					return "is-default";
-				default:
-					return "is-primary";
-			}
-		}
-	},
-	methods: {
-		handleSuccessfulSave() {
-			if (this.status !== "save-success") {
-				this.status = "save-success";
-				setTimeout(() => {
-					this.status = "default";
-				}, 2000);
-			}
-		},
-		handleFailedSave() {
-			if (this.status !== "save-failure") {
-				this.status = "save-failure";
-				setTimeout(() => {
-					this.status = "default";
-				}, 2000);
-			}
-		}
-	}
-};
-</script>

+ 6 - 11
frontend/src/components/SearchQueryItem.vue

@@ -1,3 +1,9 @@
+<script setup lang="ts">
+defineProps({
+	result: { type: Object, default: () => {} }
+});
+</script>
+
 <template>
 	<div class="universal-item search-query-item">
 		<div class="thumbnail-and-info">
@@ -34,17 +40,6 @@
 	</div>
 </template>
 
-<script>
-export default {
-	props: {
-		result: {
-			type: Object,
-			default: () => {}
-		}
-	}
-};
-</script>
-
 <style lang="less">
 .search-query-actions-enter-active,
 .musare-search-query-actions-enter-active,

+ 6 - 8
frontend/src/components/Sidebar.vue

@@ -1,3 +1,9 @@
+<script setup lang="ts">
+defineProps({
+	title: { type: String, default: "Sidebar" }
+});
+</script>
+
 <template>
 	<div class="sidebar" transition="slide">
 		<div class="inner-wrapper">
@@ -7,14 +13,6 @@
 	</div>
 </template>
 
-<script>
-export default {
-	props: {
-		title: { type: String, default: "Sidebar" }
-	}
-};
-</script>
-
 <style lang="less" scoped>
 .inner-wrapper {
 	overflow: auto;

+ 119 - 114
frontend/src/components/SongItem.vue

@@ -1,3 +1,122 @@
+<script setup lang="ts">
+import { defineAsyncComponent, ref, onMounted, onUnmounted } from "vue";
+import { formatDistance, parseISO } from "date-fns";
+import { storeToRefs } from "pinia";
+import AddToPlaylistDropdown from "./AddToPlaylistDropdown.vue";
+import { useUserAuthStore } from "@/stores/userAuth";
+import { useModalsStore } from "@/stores/modals";
+import utils from "@/utils";
+
+const SongThumbnail = defineAsyncComponent(
+	() => import("@/components/SongThumbnail.vue")
+);
+const UserLink = defineAsyncComponent(
+	() => import("@/components/UserLink.vue")
+);
+
+const props = defineProps({
+	song: {
+		type: Object,
+		default: () => {}
+	},
+	requestedBy: {
+		type: Boolean,
+		default: false
+	},
+	duration: {
+		type: Boolean,
+		default: true
+	},
+	thumbnail: {
+		type: Boolean,
+		default: true
+	},
+	disabledActions: {
+		type: Array,
+		default: () => []
+	},
+	header: {
+		type: String,
+		default: null
+	}
+});
+
+const formatedRequestedAt = ref(null);
+const formatRequestedAtInterval = ref();
+const hoveredTippy = ref(false);
+const songActions = ref(null);
+
+const userAuthStore = useUserAuthStore();
+const { loggedIn, role: userRole } = storeToRefs(userAuthStore);
+
+const { openModal } = useModalsStore();
+
+const formatRequestedAt = () => {
+	if (props.requestedBy && props.song.requestedAt)
+		formatedRequestedAt.value = formatDistance(
+			parseISO(props.song.requestedAt),
+			new Date()
+		);
+};
+
+const formatArtists = () => {
+	if (props.song.artists.length === 1) {
+		return props.song.artists[0];
+	}
+	if (props.song.artists.length === 2) {
+		return props.song.artists.join(" & ");
+	}
+	if (props.song.artists.length > 2) {
+		return `${props.song.artists
+			.slice(0, -1)
+			.join(", ")} & ${props.song.artists.slice(-1)}`;
+	}
+	return null;
+};
+
+const hideTippyElements = () => {
+	songActions.value.tippy.hide();
+
+	setTimeout(
+		() =>
+			Array.from(document.querySelectorAll(".tippy-popper")).forEach(
+				(popper: any) => popper._tippy.hide()
+			),
+		500
+	);
+};
+
+const hoverTippy = () => {
+	hoveredTippy.value = true;
+};
+
+const report = song => {
+	hideTippyElements();
+	openModal({ modal: "report", data: { song } });
+};
+
+const edit = song => {
+	hideTippyElements();
+	openModal({
+		modal: "editSong",
+		data: { song }
+	});
+};
+
+onMounted(() => {
+	if (props.requestedBy) {
+		formatRequestedAt();
+		formatRequestedAtInterval.value = setInterval(() => {
+			formatRequestedAt();
+		}, 30000);
+	}
+});
+
+onUnmounted(() => {
+	clearInterval(formatRequestedAtInterval.value);
+});
+</script>
+
 <template>
 	<div
 		class="universal-item song-item"
@@ -166,120 +285,6 @@
 	</div>
 </template>
 
-<script>
-import { mapActions, mapState } from "vuex";
-import { formatDistance, parseISO } from "date-fns";
-
-import AddToPlaylistDropdown from "./AddToPlaylistDropdown.vue";
-import utils from "../../js/utils";
-
-export default {
-	components: { AddToPlaylistDropdown },
-	props: {
-		song: {
-			type: Object,
-			default: () => {}
-		},
-		requestedBy: {
-			type: Boolean,
-			default: false
-		},
-		duration: {
-			type: Boolean,
-			default: true
-		},
-		thumbnail: {
-			type: Boolean,
-			default: true
-		},
-		disabledActions: {
-			type: Array,
-			default: () => []
-		},
-		header: {
-			type: String,
-			default: null
-		}
-	},
-	data() {
-		return {
-			utils,
-			formatedRequestedAt: null,
-			formatRequestedAtInterval: null,
-			hoveredTippy: false
-		};
-	},
-	computed: {
-		...mapState({
-			loggedIn: state => state.user.auth.loggedIn,
-			userRole: state => state.user.auth.role
-		})
-	},
-	mounted() {
-		if (this.requestedBy) {
-			this.formatRequestedAt();
-			this.formatRequestedAtInterval = setInterval(() => {
-				this.formatRequestedAt();
-			}, 30000);
-		}
-	},
-	unmounted() {
-		clearInterval(this.formatRequestedAtInterval);
-	},
-	methods: {
-		formatRequestedAt() {
-			if (this.requestedBy && this.song.requestedAt)
-				this.formatedRequestedAt = this.formatDistance(
-					parseISO(this.song.requestedAt),
-					new Date()
-				);
-		},
-		formatArtists() {
-			if (this.song.artists.length === 1) {
-				return this.song.artists[0];
-			}
-			if (this.song.artists.length === 2) {
-				return this.song.artists.join(" & ");
-			}
-			if (this.song.artists.length > 2) {
-				return `${this.song.artists
-					.slice(0, -1)
-					.join(", ")} & ${this.song.artists.slice(-1)}`;
-			}
-			return null;
-		},
-		hideTippyElements() {
-			this.$refs.songActions.tippy.hide();
-
-			setTimeout(
-				() =>
-					Array.from(
-						document.querySelectorAll(".tippy-popper")
-					).forEach(popper => popper._tippy.hide()),
-				500
-			);
-		},
-		hoverTippy() {
-			this.hoveredTippy = true;
-		},
-		report(song) {
-			this.hideTippyElements();
-			this.openModal({ modal: "report", data: { song } });
-		},
-		edit(song) {
-			this.hideTippyElements();
-			this.openModal({
-				modal: "editSong",
-				data: { song }
-			});
-		},
-		...mapActions("modalVisibility", ["openModal"]),
-		formatDistance,
-		parseISO
-	}
-};
-</script>
-
 <style lang="less" scoped>
 .night-mode {
 	.song-item {

+ 131 - 0
frontend/src/components/SongThumbnail.vue

@@ -0,0 +1,131 @@
+<script setup lang="ts">
+import { ref, computed, watch } from "vue";
+
+const props = defineProps({
+	song: { type: Object, default: () => {} },
+	fallback: { type: Boolean, default: true }
+});
+
+const emit = defineEmits(["loadError"]);
+
+const loadError = ref(0);
+
+const isYoutubeThumbnail = computed(
+	() =>
+		props.song.youtubeId &&
+		((props.song.thumbnail &&
+			(props.song.thumbnail.lastIndexOf("i.ytimg.com") !== -1 ||
+				props.song.thumbnail.lastIndexOf("img.youtube.com") !== -1)) ||
+			(props.fallback &&
+				(!props.song.thumbnail ||
+					(props.song.thumbnail &&
+						(props.song.thumbnail.lastIndexOf(
+							"notes-transparent"
+						) !== -1 ||
+							props.song.thumbnail.lastIndexOf(
+								"/assets/notes.png"
+							) !== -1 ||
+							props.song.thumbnail === "empty")) ||
+					loadError.value === 1)))
+);
+
+const onLoadError = () => {
+	// Error codes
+	// -1 - Error occured, fallback disabled
+	// 0 - No errors
+	// 1 - Error occured with thumbnail, fallback enabled
+	// 2 - Error occured with youtube thumbnail, fallback enabled
+	if (!props.fallback) loadError.value = -1;
+	else if (loadError.value === 0 && !isYoutubeThumbnail.value)
+		loadError.value = 1;
+	else loadError.value = 2;
+	emit("loadError", loadError.value);
+};
+
+watch(
+	() => props.song,
+	() => {
+		loadError.value = 0;
+		emit("loadError", loadError.value);
+	}
+);
+</script>
+
+<template>
+	<div
+		:class="{
+			thumbnail: true,
+			'youtube-thumbnail': isYoutubeThumbnail
+		}"
+	>
+		<slot name="icon" />
+		<div
+			v-if="-1 < loadError && loadError < 2 && isYoutubeThumbnail"
+			class="yt-thumbnail-bg"
+			:style="{
+				'background-image':
+					'url(' +
+					`https://img.youtube.com/vi/${song.youtubeId}/mqdefault.jpg` +
+					')'
+			}"
+		></div>
+		<img
+			v-if="-1 < loadError && loadError < 2 && isYoutubeThumbnail"
+			loading="lazy"
+			:src="`https://img.youtube.com/vi/${song.youtubeId}/mqdefault.jpg`"
+			@error="onLoadError"
+		/>
+		<img
+			v-else-if="loadError <= 0"
+			loading="lazy"
+			:src="song.thumbnail"
+			@error="onLoadError"
+		/>
+		<img v-else loading="lazy" src="/assets/notes-transparent.png" />
+	</div>
+</template>
+
+<style lang="less">
+.thumbnail {
+	min-width: 130px;
+	height: 130px;
+	position: relative;
+	margin-top: -15px;
+	margin-bottom: -15px;
+	margin-left: -10px;
+
+	.yt-thumbnail-bg {
+		display: none;
+	}
+
+	img {
+		height: 100%;
+		width: 100%;
+		margin-top: auto;
+		margin-bottom: auto;
+		z-index: 1;
+		position: absolute;
+		top: 0;
+		bottom: 0;
+		left: 0;
+		right: 0;
+	}
+
+	&.youtube-thumbnail {
+		.yt-thumbnail-bg {
+			height: 100%;
+			width: 100%;
+			display: block;
+			position: absolute;
+			top: 0;
+			filter: blur(1px);
+			background: url("/assets/notes-transparent.png") no-repeat center
+				center;
+		}
+
+		img {
+			height: auto;
+		}
+	}
+}
+</style>

+ 66 - 90
frontend/src/components/StationInfoBox.vue

@@ -1,3 +1,69 @@
+<script setup lang="ts">
+import Toast from "toasters";
+import { storeToRefs } from "pinia";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useUserAuthStore } from "@/stores/userAuth";
+import { useModalsStore } from "@/stores/modals";
+
+const userAuthStore = useUserAuthStore();
+
+const props = defineProps({
+	station: { type: Object, default: null },
+	stationPaused: { type: Boolean, default: null },
+	showManageStation: { type: Boolean, default: false },
+	showGoToStation: { type: Boolean, default: false }
+});
+
+const { socket } = useWebsocketsStore();
+const { loggedIn, userId, role } = storeToRefs(userAuthStore);
+
+const { openModal } = useModalsStore();
+
+const isOwnerOnly = () =>
+	loggedIn.value && userId.value === props.station.owner;
+
+const isAdminOnly = () => loggedIn.value && role.value === "admin";
+
+const isOwnerOrAdmin = () => isOwnerOnly() || isAdminOnly();
+
+const resumeStation = () => {
+	socket.dispatch("stations.resume", props.station._id, data => {
+		if (data.status !== "success") new Toast(`Error: ${data.message}`);
+		else new Toast("Successfully resumed the station.");
+	});
+};
+
+const pauseStation = () => {
+	socket.dispatch("stations.pause", props.station._id, data => {
+		if (data.status !== "success") new Toast(`Error: ${data.message}`);
+		else new Toast("Successfully paused the station.");
+	});
+};
+
+const skipStation = () => {
+	socket.dispatch("stations.forceSkip", props.station._id, data => {
+		if (data.status !== "success") new Toast(`Error: ${data.message}`);
+		else new Toast("Successfully skipped the station's current song.");
+	});
+};
+
+const favoriteStation = () => {
+	socket.dispatch("stations.favoriteStation", props.station._id, res => {
+		if (res.status === "success") {
+			new Toast("Successfully favorited station.");
+		} else new Toast(res.message);
+	});
+};
+
+const unfavoriteStation = () => {
+	socket.dispatch("stations.unfavoriteStation", props.station._id, res => {
+		if (res.status === "success") {
+			new Toast("Successfully unfavorited station.");
+		} else new Toast(res.message);
+	});
+};
+</script>
+
 <template>
 	<div class="about-station-container">
 		<div class="station-info">
@@ -94,96 +160,6 @@
 	</div>
 </template>
 
-<script>
-import { mapGetters, mapState, mapActions } from "vuex";
-import Toast from "toasters";
-
-export default {
-	props: {
-		station: { type: Object, default: null },
-		stationPaused: { type: Boolean, default: null },
-		showManageStation: { type: Boolean, default: false },
-		showGoToStation: { type: Boolean, default: false }
-	},
-	data() {
-		return {};
-	},
-	computed: {
-		...mapState({
-			loggedIn: state => state.user.auth.loggedIn,
-			userId: state => state.user.auth.userId,
-			role: state => state.user.auth.role
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	mounted() {},
-	methods: {
-		isOwnerOnly() {
-			return this.loggedIn && this.userId === this.station.owner;
-		},
-		isAdminOnly() {
-			return this.loggedIn && this.role === "admin";
-		},
-		isOwnerOrAdmin() {
-			return this.isOwnerOnly() || this.isAdminOnly();
-		},
-		resumeStation() {
-			this.socket.dispatch("stations.resume", this.station._id, data => {
-				if (data.status !== "success")
-					new Toast(`Error: ${data.message}`);
-				else new Toast("Successfully resumed the station.");
-			});
-		},
-		pauseStation() {
-			this.socket.dispatch("stations.pause", this.station._id, data => {
-				if (data.status !== "success")
-					new Toast(`Error: ${data.message}`);
-				else new Toast("Successfully paused the station.");
-			});
-		},
-		skipStation() {
-			this.socket.dispatch(
-				"stations.forceSkip",
-				this.station._id,
-				data => {
-					if (data.status !== "success")
-						new Toast(`Error: ${data.message}`);
-					else
-						new Toast(
-							"Successfully skipped the station's current song."
-						);
-				}
-			);
-		},
-		favoriteStation() {
-			this.socket.dispatch(
-				"stations.favoriteStation",
-				this.station._id,
-				res => {
-					if (res.status === "success") {
-						new Toast("Successfully favorited station.");
-					} else new Toast(res.message);
-				}
-			);
-		},
-		unfavoriteStation() {
-			this.socket.dispatch(
-				"stations.unfavoriteStation",
-				this.station._id,
-				res => {
-					if (res.status === "success") {
-						new Toast("Successfully unfavorited station.");
-					} else new Toast(res.message);
-				}
-			);
-		},
-		...mapActions("modalVisibility", ["openModal"])
-	}
-};
-</script>
-
 <style lang="less">
 .night-mode {
 	.about-station-container {

+ 53 - 0
frontend/src/components/UserLink.vue

@@ -0,0 +1,53 @@
+<script setup lang="ts">
+import { ref, onMounted } from "vue";
+import { useUserAuthStore } from "@/stores/userAuth";
+
+const props = defineProps({
+	userId: { type: String, default: "" },
+	link: { type: Boolean, default: true }
+});
+
+const user = ref({
+	name: "Unknown",
+	username: null
+});
+
+const { getBasicUser } = useUserAuthStore();
+
+onMounted(() => {
+	getBasicUser(props.userId).then(
+		(basicUser: { name: string; username: string } | null) => {
+			if (basicUser) {
+				const { name, username } = basicUser;
+				user.value = {
+					name,
+					username
+				};
+			}
+		}
+	);
+});
+</script>
+
+<template>
+	<router-link
+		v-if="$props.link && user.username"
+		:to="{ path: `/u/${user.username}` }"
+		:title="userId"
+	>
+		{{ user.name }}
+	</router-link>
+	<span v-else :title="userId">
+		{{ user.name }}
+	</span>
+</template>
+
+<style lang="less" scoped>
+a {
+	color: var(--primary-color);
+	&:hover,
+	&:focus {
+		filter: brightness(90%);
+	}
+}
+</style>

+ 0 - 72
frontend/src/components/global/QuickConfirm.vue

@@ -1,72 +0,0 @@
-<template>
-	<tippy
-		:interactive="true"
-		:touch="true"
-		:placement="placement"
-		theme="quickConfirm"
-		ref="quickConfirm"
-		trigger="click"
-		:append-to="body"
-		@hide="delayedHide()"
-	>
-		<div
-			@click.shift.stop="shiftClick($event)"
-			@click.exact="click($event)"
-		>
-			<slot ref="trigger" />
-		</div>
-		<template #content>
-			<a @click="confirm($event)"> Click to Confirm </a>
-		</template>
-	</tippy>
-</template>
-
-<script>
-export default {
-	props: {
-		placement: {
-			type: String,
-			default: "top"
-		}
-	},
-	emits: ["confirm"],
-	data() {
-		return {
-			clickedOnce: false,
-			body: document.body
-		};
-	},
-
-	methods: {
-		// eslint-disable-next-line no-unused-vars
-		confirm(event) {
-			if (
-				!event ||
-				event.type !== "click" ||
-				event.altKey ||
-				event.ctrlKey ||
-				event.metaKey
-			)
-				return;
-
-			this.clickedOnce = false;
-			this.$emit("confirm");
-			setTimeout(() => {
-				this.$refs.confirm.tippy.hide();
-			}, 25);
-		},
-		click(event) {
-			if (!this.clickedOnce) this.clickedOnce = true;
-			else this.confirm(event);
-		},
-		shiftClick(event) {
-			this.confirm(event);
-		},
-		delayedHide() {
-			setTimeout(() => {
-				this.clickedOnce = false;
-			}, 25);
-		}
-	}
-};
-</script>

+ 0 - 141
frontend/src/components/global/SongThumbnail.vue

@@ -1,141 +0,0 @@
-<template>
-	<div
-		:class="{
-			thumbnail: true,
-			'youtube-thumbnail': isYoutubeThumbnail
-		}"
-	>
-		<slot name="icon" />
-		<div
-			v-if="-1 < loadError < 2 && isYoutubeThumbnail"
-			class="yt-thumbnail-bg"
-			:style="{
-				'background-image':
-					'url(' +
-					`https://img.youtube.com/vi/${song.youtubeId}/mqdefault.jpg` +
-					')'
-			}"
-		></div>
-		<img
-			v-if="-1 < loadError < 2 && isYoutubeThumbnail"
-			loading="lazy"
-			:src="`https://img.youtube.com/vi/${song.youtubeId}/mqdefault.jpg`"
-			@error="onLoadError"
-		/>
-		<img
-			v-else-if="loadError <= 0"
-			loading="lazy"
-			:src="song.thumbnail"
-			@error="onLoadError"
-		/>
-		<img v-else loading="lazy" src="/assets/notes-transparent.png" />
-	</div>
-</template>
-
-<script>
-export default {
-	props: {
-		song: {
-			type: Object,
-			default: () => {}
-		},
-		fallback: {
-			type: Boolean,
-			default: true
-		}
-	},
-	emits: ["loadError"],
-	data() {
-		return {
-			loadError: 0
-		};
-	},
-	computed: {
-		isYoutubeThumbnail() {
-			return (
-				this.song.youtubeId &&
-				((this.song.thumbnail &&
-					(this.song.thumbnail.lastIndexOf("i.ytimg.com") !== -1 ||
-						this.song.thumbnail.lastIndexOf("img.youtube.com") !==
-							-1)) ||
-					(this.fallback &&
-						(!this.song.thumbnail ||
-							(this.song.thumbnail &&
-								(this.song.thumbnail.lastIndexOf(
-									"notes-transparent"
-								) !== -1 ||
-									this.song.thumbnail.lastIndexOf(
-										"/assets/notes.png"
-									) !== -1 ||
-									this.song.thumbnail === "empty")) ||
-							this.loadError === 1)))
-			);
-		}
-	},
-	watch: {
-		song() {
-			this.loadError = 0;
-			this.$emit("loadError", this.loadError);
-		}
-	},
-	methods: {
-		onLoadError() {
-			// Error codes
-			// -1 - Error occured, fallback disabled
-			// 0 - No errors
-			// 1 - Error occured with thumbnail, fallback enabled
-			// 2 - Error occured with youtube thumbnail, fallback enabled
-			if (!this.fallback) this.loadError = -1;
-			else if (this.loadError === 0 && !this.isYoutubeThumbnail)
-				this.loadError = 1;
-			else this.loadError = 2;
-			this.$emit("loadError", this.loadError);
-		}
-	}
-};
-</script>
-
-<style lang="less">
-.thumbnail {
-	min-width: 130px;
-	height: 130px;
-	position: relative;
-	margin-top: -15px;
-	margin-bottom: -15px;
-	margin-left: -10px;
-
-	.yt-thumbnail-bg {
-		display: none;
-	}
-
-	img {
-		height: 100%;
-		width: 100%;
-		margin-top: auto;
-		margin-bottom: auto;
-		z-index: 1;
-		position: absolute;
-		top: 0;
-		bottom: 0;
-		left: 0;
-		right: 0;
-	}
-
-	&.youtube-thumbnail {
-		.yt-thumbnail-bg {
-			height: 100%;
-			width: 100%;
-			display: block;
-			position: absolute;
-			top: 0;
-			filter: blur(1px);
-			background: url("/assets/notes-transparent.png") no-repeat center
-				center;
-		}
-
-		img {
-			height: auto;
-		}
-	}
-}
-</style>

+ 0 - 53
frontend/src/components/global/UserLink.vue

@@ -1,53 +0,0 @@
-<template>
-	<router-link
-		v-if="$props.link && user.username"
-		:to="{ path: `/u/${user.username}` }"
-		:title="userId"
-	>
-		{{ user.name }}
-	</router-link>
-	<span v-else :title="userId">
-		{{ user.name }}
-	</span>
-</template>
-
-<script>
-import { mapActions } from "vuex";
-
-export default {
-	props: {
-		userId: { type: String, default: "" },
-		link: { type: Boolean, default: true }
-	},
-	data() {
-		return {
-			user: {
-				name: "Unknown",
-				username: null
-			}
-		};
-	},
-	mounted() {
-		this.getBasicUser(this.$props.userId).then(user => {
-			if (user)
-				this.user = {
-					name: user.name,
-					username: user.username
-				};
-		});
-	},
-	methods: {
-		...mapActions("user/auth", ["getBasicUser"])
-	}
-};
-</script>
-
-<style lang="less" scoped>
-a {
-	color: var(--primary-color);
-	&:hover,
-	&:focus {
-		filter: brightness(90%);
-	}
-}
-</style>

+ 102 - 100
frontend/src/components/modals/BulkActions.vue

@@ -1,3 +1,105 @@
+<script setup lang="ts">
+import { ref, defineAsyncComponent, onMounted, onBeforeUnmount } from "vue";
+import Toast from "toasters";
+import { storeToRefs } from "pinia";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useLongJobsStore } from "@/stores/longJobs";
+import { useBulkActionsStore } from "@/stores/bulkActions";
+import { useModalsStore } from "@/stores/modals";
+import ws from "@/ws";
+
+const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
+const AutoSuggest = defineAsyncComponent(
+	() => import("@/components/AutoSuggest.vue")
+);
+
+const props = defineProps({
+	modalUuid: { type: String, default: "" }
+});
+
+const { closeCurrentModal } = useModalsStore();
+
+const { setJob } = useLongJobsStore();
+
+const { socket } = useWebsocketsStore();
+
+const bulkActionsStore = useBulkActionsStore(props);
+const { type } = storeToRefs(bulkActionsStore);
+
+const method = ref("add");
+const items = ref([]);
+const itemInput = ref();
+const allItems = ref([]);
+
+const init = () => {
+	if (type.value.autosuggest && type.value.autosuggestDataAction)
+		socket.dispatch(type.value.autosuggestDataAction, res => {
+			if (res.status === "success") {
+				const { items } = res.data;
+				allItems.value = items;
+			} else {
+				new Toast(res.message);
+			}
+		});
+};
+
+const addItem = () => {
+	if (!itemInput.value) return;
+	if (type.value.regex && !type.value.regex.test(itemInput.value)) {
+		new Toast(`Invalid ${type.value.name} format.`);
+	} else if (items.value.includes(itemInput.value)) {
+		new Toast(`Duplicate ${type.value.name} specified.`);
+	} else {
+		items.value.push(itemInput.value);
+		itemInput.value = null;
+	}
+};
+
+const removeItem = index => {
+	items.value.splice(index, 1);
+};
+
+const applyChanges = () => {
+	let id;
+	let title;
+
+	socket.dispatch(
+		type.value.action,
+		method.value,
+		items.value,
+		type.value.items,
+		{
+			cb: () => {},
+			onProgress: res => {
+				if (res.status === "started") {
+					id = res.id;
+					title = res.title;
+					closeCurrentModal();
+				}
+
+				if (id)
+					setJob({
+						id,
+						name: title,
+						...res
+					});
+			}
+		}
+	);
+};
+
+onBeforeUnmount(() => {
+	itemInput.value = null;
+	items.value = [];
+	// Delete the Pinia store that was created for this modal, after all other cleanup tasks are performed
+	bulkActionsStore.$dispose();
+});
+
+onMounted(() => {
+	ws.onConnect(init);
+});
+</script>
+
 <template>
 	<div>
 		<modal title="Bulk Actions" class="bulk-actions-modal" size="slim">
@@ -64,106 +166,6 @@
 	</div>
 </template>
 
-<script>
-import { mapGetters, mapActions } from "vuex";
-
-import Toast from "toasters";
-
-import AutoSuggest from "@/components/AutoSuggest.vue";
-
-import ws from "@/ws";
-import { mapModalState } from "@/vuex_helpers";
-
-export default {
-	components: { AutoSuggest },
-	props: {
-		modalUuid: { type: String, default: "" }
-	},
-	data() {
-		return {
-			method: "add",
-			items: [],
-			itemInput: null,
-			allItems: []
-		};
-	},
-	computed: {
-		...mapModalState("modals/bulkActions/MODAL_UUID", {
-			type: state => state.type
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	beforeUnmount() {
-		this.itemInput = null;
-		this.items = [];
-		// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
-		this.$store.unregisterModule(["modals", "bulkActions", this.modalUuid]);
-	},
-	mounted() {
-		ws.onConnect(this.init);
-	},
-	methods: {
-		init() {
-			if (this.type.autosuggest && this.type.autosuggestDataAction)
-				this.socket.dispatch(this.type.autosuggestDataAction, res => {
-					if (res.status === "success") {
-						const { items } = res.data;
-						this.allItems = items;
-					} else {
-						new Toast(res.message);
-					}
-				});
-		},
-		addItem() {
-			if (!this.itemInput) return;
-			if (this.type.regex && !this.type.regex.test(this.itemInput)) {
-				new Toast(`Invalid ${this.type.name} format.`);
-			} else if (this.items.includes(this.itemInput)) {
-				new Toast(`Duplicate ${this.type.name} specified.`);
-			} else {
-				this.items.push(this.itemInput);
-				this.itemInput = null;
-			}
-		},
-		removeItem(index) {
-			this.items.splice(index, 1);
-		},
-		applyChanges() {
-			let id;
-			let title;
-
-			this.socket.dispatch(
-				this.type.action,
-				this.method,
-				this.items,
-				this.type.items,
-				{
-					cb: () => {},
-					onProgress: res => {
-						if (res.status === "started") {
-							id = res.id;
-							title = res.title;
-							this.closeCurrentModal();
-						}
-
-						if (id)
-							this.setJob({
-								id,
-								name: title,
-								...res
-							});
-					}
-				}
-			);
-		},
-		...mapActions("modalVisibility", ["closeCurrentModal"]),
-		...mapActions("longJobs", ["setJob"])
-	}
-};
-</script>
-
 <style lang="less" scoped>
 .label {
 	text-transform: capitalize;

+ 37 - 30
frontend/src/components/modals/Confirm.vue

@@ -1,9 +1,45 @@
+<script setup lang="ts">
+import { defineAsyncComponent, onBeforeUnmount } from "vue";
+import { storeToRefs } from "pinia";
+import { useConfirmStore } from "@/stores/confirm";
+import { useModalsStore } from "@/stores/modals";
+
+const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
+
+const props = defineProps({
+	modalUuid: { type: String, default: "" }
+});
+
+const confirmStore = useConfirmStore(props);
+const { message } = storeToRefs(confirmStore);
+const { confirm } = confirmStore;
+
+const { closeCurrentModal } = useModalsStore();
+
+const confirmAction = () => {
+	confirm();
+	closeCurrentModal();
+};
+
+onBeforeUnmount(() => {
+	// Delete the Pinia store that was created for this modal, after all other cleanup tasks are performed
+	confirmStore.$dispose();
+});
+</script>
+
 <template>
 	<div>
 		<modal class="confirm-modal" title="Confirm Action" :size="'slim'">
 			<template #body>
 				<div class="confirm-modal-inner-container">
-					{{ message }}
+					<ul v-if="Array.isArray(message)">
+						<li v-for="messageItem in message" :key="messageItem">
+							{{ messageItem }}
+						</li>
+					</ul>
+					<template v-else>
+						{{ message }}
+					</template>
 				</div>
 			</template>
 			<template #footer>
@@ -15,32 +51,3 @@
 		</modal>
 	</div>
 </template>
-
-<script>
-import { mapActions } from "vuex";
-
-import { mapModalState, mapModalActions } from "@/vuex_helpers";
-
-export default {
-	props: {
-		modalUuid: { type: String, default: "" }
-	},
-	computed: {
-		...mapModalState("modals/confirm/MODAL_UUID", {
-			message: state => state.message
-		})
-	},
-	beforeUnmount() {
-		// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
-		this.$store.unregisterModule(["modals", "confirm", this.modalUuid]);
-	},
-	methods: {
-		confirmAction() {
-			this.confirm();
-			this.closeCurrentModal();
-		},
-		...mapModalActions("modals/confirm/MODAL_UUID", ["confirm"]),
-		...mapActions("modalVisibility", ["closeCurrentModal"])
-	}
-};
-</script>

+ 60 - 68
frontend/src/components/modals/CreatePlaylist.vue

@@ -1,3 +1,63 @@
+<script setup lang="ts">
+import { defineAsyncComponent, ref, onBeforeUnmount } from "vue";
+import Toast from "toasters";
+import validation from "@/validation";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useModalsStore } from "@/stores/modals";
+
+const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
+
+defineProps({
+	modalUuid: { type: String, default: "" }
+});
+
+const playlist = ref({
+	displayName: "",
+	privacy: "public",
+	songs: []
+});
+
+const { openModal, closeCurrentModal } = useModalsStore();
+
+const { socket } = useWebsocketsStore();
+
+const createPlaylist = () => {
+	const { displayName } = playlist.value;
+
+	if (!validation.isLength(displayName, 2, 32))
+		return new Toast("Display name must have between 2 and 32 characters.");
+	if (!validation.regex.ascii.test(displayName))
+		return new Toast(
+			"Invalid display name format. Only ASCII characters are allowed."
+		);
+
+	return socket.dispatch("playlists.create", playlist.value, res => {
+		new Toast(res.message);
+
+		if (res.status === "success") {
+			closeCurrentModal();
+
+			if (!window.addToPlaylistDropdown) {
+				openModal({
+					modal: "editPlaylist",
+					data: { playlistId: res.data.playlistId }
+				});
+			}
+		}
+	});
+};
+
+onBeforeUnmount(() => {
+	if (window.addToPlaylistDropdown)
+		window.addToPlaylistDropdown.tippy.setProps({
+			zIndex: 9999,
+			hideOnClick: true
+		});
+
+	window.addToPlaylistDropdown = null;
+});
+</script>
+
 <template>
 	<modal title="Create Playlist" :size="'slim'">
 		<template #body>
@@ -31,71 +91,3 @@
 		</template>
 	</modal>
 </template>
-
-<script>
-import { mapActions, mapGetters } from "vuex";
-
-import Toast from "toasters";
-import validation from "@/validation";
-
-export default {
-	props: {
-		modalUuid: { type: String, default: "" }
-	},
-	data() {
-		return {
-			playlist: {
-				displayName: "",
-				privacy: "public",
-				songs: []
-			}
-		};
-	},
-	computed: mapGetters({
-		socket: "websockets/getSocket"
-	}),
-	unmounted() {
-		if (window.addToPlaylistDropdown)
-			window.addToPlaylistDropdown.tippy.setProps({
-				zIndex: 9999,
-				hideOnClick: true
-			});
-
-		window.addToPlaylistDropdown = null;
-	},
-	methods: {
-		createPlaylist() {
-			const { displayName } = this.playlist;
-
-			if (!validation.isLength(displayName, 2, 32))
-				return new Toast(
-					"Display name must have between 2 and 32 characters."
-				);
-			if (!validation.regex.ascii.test(displayName))
-				return new Toast(
-					"Invalid display name format. Only ASCII characters are allowed."
-				);
-
-			return this.socket.dispatch(
-				"playlists.create",
-				this.playlist,
-				res => {
-					new Toast(res.message);
-
-					if (res.status === "success") {
-						this.closeModal("createPlaylist");
-
-						if (!window.addToPlaylistDropdown) {
-							this.openModal({
-								modal: "editPlaylist",
-								data: { playlistId: res.data.playlistId }
-							});
-						}
-					}
-				}
-			);
-		},
-		...mapActions("modalVisibility", ["closeModal", "openModal"])
-	}
-};
-</script>

+ 85 - 97
frontend/src/components/modals/CreateStation.vue

@@ -1,3 +1,88 @@
+<script setup lang="ts">
+import { defineAsyncComponent, ref, onBeforeUnmount } from "vue";
+import Toast from "toasters";
+import { storeToRefs } from "pinia";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useCreateStationStore } from "@/stores/createStation";
+import { useModalsStore } from "@/stores/modals";
+import validation from "@/validation";
+
+const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
+
+const props = defineProps({
+	modalUuid: { type: String, default: "" }
+});
+
+const { socket } = useWebsocketsStore();
+
+const createStationStore = useCreateStationStore(props);
+const { official } = storeToRefs(createStationStore);
+
+const { closeCurrentModal } = useModalsStore();
+
+const newStation = ref({
+	name: "",
+	displayName: "",
+	description: ""
+});
+
+const submitModal = () => {
+	newStation.value.name = newStation.value.name.toLowerCase();
+	const { name, displayName, description } = newStation.value;
+
+	if (!name || !displayName || !description)
+		return new Toast("Please fill in all fields");
+
+	if (!validation.isLength(name, 2, 16))
+		return new Toast("Name must have between 2 and 16 characters.");
+
+	if (!validation.regex.az09_.test(name))
+		return new Toast(
+			"Invalid name format. Allowed characters: a-z, 0-9 and _."
+		);
+
+	if (!validation.isLength(displayName, 2, 32))
+		return new Toast("Display name must have between 2 and 32 characters.");
+	if (!validation.regex.ascii.test(displayName))
+		return new Toast(
+			"Invalid display name format. Only ASCII characters are allowed."
+		);
+
+	if (!validation.isLength(description, 2, 200))
+		return new Toast("Description must have between 2 and 200 characters.");
+
+	let characters = description.split("");
+
+	characters = characters.filter(
+		character => character.charCodeAt(0) === 21328
+	);
+
+	if (characters.length !== 0)
+		return new Toast("Invalid description format.");
+
+	return socket.dispatch(
+		"stations.create",
+		{
+			name,
+			type: official.value ? "official" : "community",
+			displayName,
+			description
+		},
+		res => {
+			if (res.status === "success") {
+				new Toast(`You have added the station successfully`);
+				closeCurrentModal();
+			} else new Toast(res.message);
+		}
+	);
+};
+
+onBeforeUnmount(() => {
+	// Delete the Pinia store that was created for this modal, after all other cleanup tasks are performed
+	createStationStore.$dispose();
+});
+</script>
+
 <template>
 	<modal
 		:title="
@@ -41,103 +126,6 @@
 	</modal>
 </template>
 
-<script>
-import { mapGetters, mapActions } from "vuex";
-
-import Toast from "toasters";
-import { mapModalState } from "@/vuex_helpers";
-
-import validation from "@/validation";
-
-export default {
-	props: {
-		modalUuid: { type: String, default: "" }
-	},
-	data() {
-		return {
-			newStation: {
-				name: "",
-				displayName: "",
-				description: ""
-			}
-		};
-	},
-	computed: {
-		...mapModalState("modals/createStation/MODAL_UUID", {
-			official: state => state.official
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	beforeUnmount() {
-		// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
-		this.$store.unregisterModule([
-			"modals",
-			"createStation",
-			this.modalUuid
-		]);
-	},
-	methods: {
-		submitModal() {
-			this.newStation.name = this.newStation.name.toLowerCase();
-			const { name, displayName, description } = this.newStation;
-
-			if (!name || !displayName || !description)
-				return new Toast("Please fill in all fields");
-
-			if (!validation.isLength(name, 2, 16))
-				return new Toast("Name must have between 2 and 16 characters.");
-
-			if (!validation.regex.az09_.test(name))
-				return new Toast(
-					"Invalid name format. Allowed characters: a-z, 0-9 and _."
-				);
-
-			if (!validation.isLength(displayName, 2, 32))
-				return new Toast(
-					"Display name must have between 2 and 32 characters."
-				);
-			if (!validation.regex.ascii.test(displayName))
-				return new Toast(
-					"Invalid display name format. Only ASCII characters are allowed."
-				);
-
-			if (!validation.isLength(description, 2, 200))
-				return new Toast(
-					"Description must have between 2 and 200 characters."
-				);
-
-			let characters = description.split("");
-
-			characters = characters.filter(
-				character => character.charCodeAt(0) === 21328
-			);
-
-			if (characters.length !== 0)
-				return new Toast("Invalid description format.");
-
-			return this.socket.dispatch(
-				"stations.create",
-				{
-					name,
-					type: this.official ? "official" : "community",
-					displayName,
-					description
-				},
-				res => {
-					if (res.status === "success") {
-						new Toast(`You have added the station successfully`);
-						this.closeModal("createStation");
-					} else new Toast(res.message);
-				}
-			);
-		},
-		...mapActions("modalVisibility", ["closeModal"])
-	}
-};
-</script>
-
 <style lang="less" scoped>
 .station-id {
 	text-transform: lowercase;

+ 150 - 163
frontend/src/components/modals/EditNews.vue

@@ -1,3 +1,151 @@
+<script setup lang="ts">
+import { defineAsyncComponent, ref, onMounted, onBeforeUnmount } from "vue";
+import { marked } from "marked";
+import DOMPurify from "dompurify";
+import Toast from "toasters";
+import { formatDistance } from "date-fns";
+import { storeToRefs } from "pinia";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useEditNewsStore } from "@/stores/editNews";
+import { useModalsStore } from "@/stores/modals";
+import ws from "@/ws";
+
+const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
+const SaveButton = defineAsyncComponent(
+	() => import("@/components/SaveButton.vue")
+);
+const UserLink = defineAsyncComponent(
+	() => import("@/components/UserLink.vue")
+);
+
+const props = defineProps({
+	modalUuid: { type: String, default: "" }
+});
+
+const { socket } = useWebsocketsStore();
+
+const editNewsStore = useEditNewsStore(props);
+const { createNews, newsId } = storeToRefs(editNewsStore);
+
+const { closeCurrentModal } = useModalsStore();
+
+const markdown = ref(
+	"# Header\n## Sub-Header\n- **So**\n- _Many_\n- ~Points~\n\nOther things you want to say and [link](https://example.com).\n\n### Sub-Sub-Header\n> Oh look, a quote!\n\n`lil code`\n\n```\nbig code\n```\n"
+);
+const status = ref("published");
+const showToNewUsers = ref(false);
+const createdBy = ref();
+const createdAt = ref(0);
+
+const init = () => {
+	if (newsId && !createNews.value) {
+		socket.dispatch(`news.getNewsFromId`, newsId.value, res => {
+			if (res.status === "success") {
+				markdown.value = res.data.news.markdown;
+				status.value = res.data.news.status;
+				showToNewUsers.value = res.data.news.showToNewUsers;
+				createdBy.value = res.data.news.createdBy;
+				createdAt.value = res.data.news.createdAt;
+			} else {
+				new Toast("News with that ID not found.");
+				closeCurrentModal();
+			}
+		});
+	}
+};
+
+const getTitle = () => {
+	let title = "";
+	const preview = document.getElementById("preview");
+
+	// validate existence of h1 for the page title
+
+	if (preview.childNodes.length === 0) return "";
+
+	if (preview.childNodes[0].nodeName !== "H1") {
+		for (let node = 0; node < preview.childNodes.length; node += 1) {
+			if (preview.childNodes[node].nodeName) {
+				if (preview.childNodes[node].nodeName === "H1")
+					title = preview.childNodes[node].textContent;
+
+				break;
+			}
+		}
+	} else title = preview.childNodes[0].textContent;
+
+	return title;
+};
+
+const create = close => {
+	if (markdown.value === "") return new Toast("News item cannot be empty.");
+
+	const title = getTitle();
+	if (!title)
+		return new Toast(
+			"Please provide a title (heading level 1) at the top of the document."
+		);
+
+	return socket.dispatch(
+		"news.create",
+		{
+			title,
+			markdown: markdown.value,
+			status: status.value,
+			showToNewUsers: showToNewUsers.value
+		},
+		res => {
+			new Toast(res.message);
+			if (res.status === "success" && close) closeCurrentModal();
+		}
+	);
+};
+
+const update = close => {
+	if (markdown.value === "") return new Toast("News item cannot be empty.");
+
+	const title = getTitle();
+	if (!title)
+		return new Toast(
+			"Please provide a title (heading level 1) at the top of the document."
+		);
+
+	return socket.dispatch(
+		"news.update",
+		newsId.value,
+		{
+			title,
+			markdown: markdown.value,
+			status: status.value,
+			showToNewUsers: showToNewUsers.value
+		},
+		res => {
+			new Toast(res.message);
+			if (res.status === "success" && close) closeCurrentModal();
+		}
+	);
+};
+
+onBeforeUnmount(() => {
+	// Delete the Pinia store that was created for this modal, after all other cleanup tasks are performed
+	editNewsStore.$dispose();
+});
+
+onMounted(() => {
+	marked.use({
+		renderer: {
+			table(header, body) {
+				return `<table class="table">
+				<thead>${header}</thead>
+				<tbody>${body}</tbody>
+				</table>`;
+			}
+		}
+	});
+
+	ws.onConnect(init);
+});
+</script>
+
 <template>
 	<modal
 		class="edit-news-modal"
@@ -15,7 +163,7 @@
 				<div
 					class="news-item"
 					id="preview"
-					v-html="sanitize(marked(markdown))"
+					v-html="DOMPurify.sanitize(marked(markdown))"
 				></div>
 			</div>
 		</template>
@@ -61,7 +209,7 @@
 							:user-id="createdBy"
 							:alt="createdBy"
 						/> </span
-					>&nbsp;<span :title="new Date(createdAt)">
+					>&nbsp;<span :title="new Date(createdAt).toString()">
 						{{
 							formatDistance(createdAt, new Date(), {
 								addSuffix: true
@@ -74,167 +222,6 @@
 	</modal>
 </template>
 
-<script>
-import { mapActions, mapGetters } from "vuex";
-import { marked } from "marked";
-import { sanitize } from "dompurify";
-import Toast from "toasters";
-import { formatDistance } from "date-fns";
-
-import ws from "@/ws";
-import SaveButton from "../SaveButton.vue";
-
-import { mapModalState } from "@/vuex_helpers";
-
-export default {
-	components: { SaveButton },
-	props: {
-		modalUuid: { type: String, default: "" }
-	},
-	data() {
-		return {
-			markdown:
-				"# Header\n## Sub-Header\n- **So**\n- _Many_\n- ~Points~\n\nOther things you want to say and [link](https://example.com).\n\n### Sub-Sub-Header\n> Oh look, a quote!\n\n`lil code`\n\n```\nbig code\n```\n",
-			status: "published",
-			showToNewUsers: false,
-			createdBy: null,
-			createdAt: 0
-		};
-	},
-	computed: {
-		...mapModalState("modals/editNews/MODAL_UUID", {
-			createNews: state => state.createNews,
-			newsId: state => state.newsId,
-			sector: state => state.sector
-		}),
-		...mapGetters({ socket: "websockets/getSocket" })
-	},
-	mounted() {
-		marked.use({
-			renderer: {
-				table(header, body) {
-					return `<table class="table">
-					<thead>${header}</thead>
-					<tbody>${body}</tbody>
-					</table>`;
-				}
-			}
-		});
-
-		ws.onConnect(this.init);
-	},
-	beforeUnmount() {
-		// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
-		this.$store.unregisterModule(["modals", "editNews", this.modalUuid]);
-	},
-	methods: {
-		init() {
-			if (this.newsId && !this.createNews) {
-				this.socket.dispatch(`news.getNewsFromId`, this.newsId, res => {
-					if (res.status === "success") {
-						const {
-							markdown,
-							status,
-							showToNewUsers,
-							createdBy,
-							createdAt
-						} = res.data.news;
-						this.markdown = markdown;
-						this.status = status;
-						this.showToNewUsers = showToNewUsers;
-						this.createdBy = createdBy;
-						this.createdAt = createdAt;
-					} else {
-						new Toast("News with that ID not found.");
-						this.closeModal("editNews");
-					}
-				});
-			}
-		},
-		marked,
-		sanitize,
-		getTitle() {
-			let title = "";
-			const preview = document.getElementById("preview");
-
-			// validate existence of h1 for the page title
-
-			if (preview.childNodes.length === 0) return "";
-
-			if (preview.childNodes[0].tagName !== "H1") {
-				for (
-					let node = 0;
-					node < preview.childNodes.length;
-					node += 1
-				) {
-					if (preview.childNodes[node].tagName) {
-						if (preview.childNodes[node].tagName === "H1")
-							title = preview.childNodes[node].innerText;
-
-						break;
-					}
-				}
-			} else title = preview.childNodes[0].innerText;
-
-			return title;
-		},
-		create(close) {
-			if (this.markdown === "")
-				return new Toast("News item cannot be empty.");
-
-			const title = this.getTitle();
-			if (!title)
-				return new Toast(
-					"Please provide a title (heading level 1) at the top of the document."
-				);
-
-			return this.socket.dispatch(
-				"news.create",
-				{
-					title,
-					markdown: this.markdown,
-					status: this.status,
-					showToNewUsers: this.showToNewUsers
-				},
-				res => {
-					new Toast(res.message);
-					if (res.status === "success" && close)
-						this.closeModal("editNews");
-				}
-			);
-		},
-		update(close) {
-			if (this.markdown === "")
-				return new Toast("News item cannot be empty.");
-
-			const title = this.getTitle();
-			if (!title)
-				return new Toast(
-					"Please provide a title (heading level 1) at the top of the document."
-				);
-
-			return this.socket.dispatch(
-				"news.update",
-				this.newsId,
-				{
-					title,
-					markdown: this.markdown,
-					status: this.status,
-					showToNewUsers: this.showToNewUsers
-				},
-				res => {
-					new Toast(res.message);
-					if (res.status === "success" && close)
-						this.closeModal("editNews");
-				}
-			);
-		},
-		formatDistance,
-		...mapActions("modalVisibility", ["closeModal"])
-	}
-};
-</script>
-
 <style lang="less">
 .edit-news-modal .modal-card .modal-card-foot .right {
 	column-gap: 5px;

+ 100 - 86
frontend/src/components/modals/EditPlaylist/Tabs/AddSongs.vue

@@ -1,3 +1,97 @@
+<script setup lang="ts">
+import { defineAsyncComponent, ref, watch, onMounted } from "vue";
+import { storeToRefs } from "pinia";
+import { useSearchYoutube } from "@/composables/useSearchYoutube";
+import { useSearchMusare } from "@/composables/useSearchMusare";
+import { useEditPlaylistStore } from "@/stores/editPlaylist";
+
+const SongItem = defineAsyncComponent(
+	() => import("@/components/SongItem.vue")
+);
+const SearchQueryItem = defineAsyncComponent(
+	() => import("@/components/SearchQueryItem.vue")
+);
+
+const props = defineProps({
+	modalUuid: { type: String, default: "" }
+});
+
+const editPlaylistStore = useEditPlaylistStore(props);
+const { playlistId, playlist } = storeToRefs(editPlaylistStore);
+
+const sitename = ref("Musare");
+
+const {
+	youtubeSearch,
+	searchForSongs,
+	loadMoreSongs,
+	addYouTubeSongToPlaylist
+} = useSearchYoutube();
+
+const {
+	musareSearch,
+	resultsLeftCount,
+	nextPageResultsCount,
+	searchForMusareSongs,
+	addMusareSongToPlaylist
+} = useSearchMusare();
+
+watch(
+	() => youtubeSearch.value.songs.results,
+	songs => {
+		songs.forEach((searchItem, index) =>
+			playlist.value.songs.find(song => {
+				if (song.youtubeId === searchItem.id)
+					youtubeSearch.value.songs.results[index].isAddedToQueue =
+						true;
+				return song.youtubeId === searchItem.id;
+			})
+		);
+	}
+);
+watch(
+	() => musareSearch.value.results,
+	songs => {
+		songs.forEach((searchItem, index) =>
+			playlist.value.songs.find(song => {
+				if (song._id === searchItem._id)
+					musareSearch.value.results[index].isAddedToQueue = true;
+
+				return song._id === searchItem._id;
+			})
+		);
+	}
+);
+watch(
+	() => playlist.value.songs,
+	() => {
+		youtubeSearch.value.songs.results.forEach((searchItem, index) =>
+			playlist.value.songs.find(song => {
+				youtubeSearch.value.songs.results[index].isAddedToQueue = false;
+				if (song.youtubeId === searchItem.id)
+					youtubeSearch.value.songs.results[index].isAddedToQueue =
+						true;
+
+				return song.youtubeId === searchItem.id;
+			})
+		);
+		musareSearch.value.results.forEach((searchItem, index) =>
+			playlist.value.songs.find(song => {
+				musareSearch.value.results[index].isAddedToQueue = false;
+				if (song.youtubeId === searchItem.youtubeId)
+					musareSearch.value.results[index].isAddedToQueue = true;
+
+				return song.youtubeId === searchItem.youtubeId;
+			})
+		);
+	}
+);
+
+onMounted(async () => {
+	sitename.value = await lofig.get("siteSettings.sitename");
+});
+</script>
+
 <template>
 	<div class="youtube-tab section">
 		<div>
@@ -47,6 +141,7 @@
 								v-tippy
 								@click="
 									addMusareSongToPlaylist(
+										playlistId,
 										song.youtubeId,
 										index
 									)
@@ -120,7 +215,11 @@
 								content="Add Song to Playlist"
 								v-tippy
 								@click="
-									addYouTubeSongToPlaylist(result.id, index)
+									addYouTubeSongToPlaylist(
+										playlistId,
+										result.id,
+										index
+									)
 								"
 								>playlist_add</i
 							>
@@ -139,91 +238,6 @@
 	</div>
 </template>
 
-<script>
-import { mapGetters } from "vuex";
-
-import { mapModalState } from "@/vuex_helpers";
-
-import SearchYoutube from "@/mixins/SearchYoutube.vue";
-import SearchMusare from "@/mixins/SearchMusare.vue";
-
-import SongItem from "@/components/SongItem.vue";
-import SearchQueryItem from "@/components/SearchQueryItem.vue";
-
-export default {
-	components: { SearchQueryItem, SongItem },
-	mixins: [SearchYoutube, SearchMusare],
-	props: {
-		modalUuid: { type: String, default: "" }
-	},
-	data() {
-		return {
-			sitename: "Musare"
-		};
-	},
-	computed: {
-		...mapModalState("modals/editPlaylist/MODAL_UUID", {
-			playlist: state => state.playlist
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	watch: {
-		"youtubeSearch.songs.results": function checkIfSongInPlaylist(songs) {
-			songs.forEach((searchItem, index) =>
-				this.playlist.songs.find(song => {
-					if (song.youtubeId === searchItem.id)
-						this.youtubeSearch.songs.results[
-							index
-						].isAddedToQueue = true;
-
-					return song.youtubeId === searchItem.id;
-				})
-			);
-		},
-		"musareSearch.results": function checkIfSongInPlaylist(songs) {
-			songs.forEach((searchItem, index) =>
-				this.playlist.songs.find(song => {
-					if (song._id === searchItem._id)
-						this.musareSearch.results[index].isAddedToQueue = true;
-
-					return song._id === searchItem._id;
-				})
-			);
-		},
-		"playlist.songs": function checkIfSongInPlaylist() {
-			this.youtubeSearch.songs.results.forEach((searchItem, index) =>
-				this.playlist.songs.find(song => {
-					this.youtubeSearch.songs.results[
-						index
-					].isAddedToQueue = false;
-					if (song.youtubeId === searchItem.id)
-						this.youtubeSearch.songs.results[
-							index
-						].isAddedToQueue = true;
-
-					return song.youtubeId === searchItem.id;
-				})
-			);
-			console.log(222);
-			this.musareSearch.results.forEach((searchItem, index) =>
-				this.playlist.songs.find(song => {
-					this.musareSearch.results[index].isAddedToQueue = false;
-					if (song.youtubeId === searchItem.youtubeId)
-						this.musareSearch.results[index].isAddedToQueue = true;
-
-					return song.youtubeId === searchItem.youtubeId;
-				})
-			);
-		}
-	},
-	async mounted() {
-		this.sitename = await lofig.get("siteSettings.sitename");
-	}
-};
-</script>
-
 <style lang="less" scoped>
 .youtube-tab {
 	.song-query-results {

+ 68 - 69
frontend/src/components/modals/EditPlaylist/Tabs/ImportPlaylists.vue

@@ -1,3 +1,71 @@
+<script setup lang="ts">
+import Toast from "toasters";
+import { storeToRefs } from "pinia";
+import { useSearchYoutube } from "@/composables/useSearchYoutube";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useLongJobsStore } from "@/stores/longJobs";
+import { useEditPlaylistStore } from "@/stores/editPlaylist";
+
+const props = defineProps({
+	modalUuid: { type: String, default: "" }
+});
+
+const { socket } = useWebsocketsStore();
+
+const editPlaylistStore = useEditPlaylistStore(props);
+const { playlist } = storeToRefs(editPlaylistStore);
+
+const { setJob } = useLongJobsStore();
+
+const { youtubeSearch } = useSearchYoutube();
+
+const importPlaylist = () => {
+	let id;
+	let title;
+
+	// import query is blank
+	if (!youtubeSearch.value.playlist.query)
+		return new Toast("Please enter a YouTube playlist URL.");
+
+	const playlistRegex = /[\\?&]list=([^&#]*)/;
+	const channelRegex =
+		/\.[\w]+\/(?:(?:channel\/(UC[0-9A-Za-z_-]{21}[AQgw]))|(?:user\/?([\w-]+))|(?:c\/?([\w-]+))|(?:\/?([\w-]+)))/;
+
+	if (
+		!playlistRegex.exec(youtubeSearch.value.playlist.query) &&
+		!channelRegex.exec(youtubeSearch.value.playlist.query)
+	) {
+		return new Toast({
+			content: "Please enter a valid YouTube playlist URL.",
+			timeout: 4000
+		});
+	}
+
+	return socket.dispatch(
+		"playlists.addSetToPlaylist",
+		youtubeSearch.value.playlist.query,
+		playlist.value._id,
+		youtubeSearch.value.playlist.isImportingOnlyMusic,
+		{
+			cb: () => {},
+			onProgress: res => {
+				if (res.status === "started") {
+					id = res.id;
+					title = res.title;
+				}
+
+				if (id)
+					setJob({
+						id,
+						name: title,
+						...res
+					});
+			}
+		}
+	);
+};
+</script>
+
 <template>
 	<div class="youtube-tab section">
 		<label class="label"> Search for a playlist from YouTube </label>
@@ -31,75 +99,6 @@
 	</div>
 </template>
 
-<script>
-import { mapGetters } from "vuex";
-import Toast from "toasters";
-
-import { mapModalState } from "@/vuex_helpers";
-import SearchYoutube from "@/mixins/SearchYoutube.vue";
-
-export default {
-	mixins: [SearchYoutube],
-	props: {
-		modalUuid: { type: String, default: "" }
-	},
-	data() {
-		return {};
-	},
-	computed: {
-		...mapModalState("modals/editPlaylist/MODAL_UUID", {
-			playlist: state => state.playlist
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	methods: {
-		importPlaylist() {
-			let id;
-			let title;
-
-			// import query is blank
-			if (!this.youtubeSearch.playlist.query)
-				return new Toast("Please enter a YouTube playlist URL.");
-
-			const regex = /[\\?&]list=([^&#]*)/;
-			const splitQuery = regex.exec(this.youtubeSearch.playlist.query);
-
-			if (!splitQuery) {
-				return new Toast({
-					content: "Please enter a valid YouTube playlist URL.",
-					timeout: 4000
-				});
-			}
-
-			return this.socket.dispatch(
-				"playlists.addSetToPlaylist",
-				this.youtubeSearch.playlist.query,
-				this.playlist._id,
-				this.youtubeSearch.playlist.isImportingOnlyMusic,
-				{
-					cb: () => {},
-					onProgress: res => {
-						if (res.status === "started") {
-							id = res.id;
-							title = res.title;
-						}
-
-						if (id)
-							this.setJob({
-								id,
-								name: title,
-								...res
-							});
-					}
-				}
-			);
-		}
-	}
-};
-</script>
-
 <style lang="less" scoped>
 #playlist-import-type select {
 	border-radius: 0;

+ 64 - 78
frontend/src/components/modals/EditPlaylist/Tabs/Settings.vue

@@ -1,3 +1,67 @@
+<script setup lang="ts">
+import Toast from "toasters";
+import { storeToRefs } from "pinia";
+import validation from "@/validation";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useUserAuthStore } from "@/stores/userAuth";
+import { useEditPlaylistStore } from "@/stores/editPlaylist";
+
+const props = defineProps({
+	modalUuid: { type: String, default: "" }
+});
+
+const userAuthStore = useUserAuthStore();
+const { userId, role: userRole } = storeToRefs(userAuthStore);
+
+const { socket } = useWebsocketsStore();
+
+const editPlaylistStore = useEditPlaylistStore(props);
+const { playlist } = storeToRefs(editPlaylistStore);
+
+const isEditable = () =>
+	(playlist.value.type === "user" ||
+		playlist.value.type === "user-liked" ||
+		playlist.value.type === "user-disliked") &&
+	(userId.value === playlist.value.createdBy || userRole.value === "admin");
+
+const isAdmin = () => userRole.value === "admin";
+
+const renamePlaylist = () => {
+	const { displayName } = playlist.value;
+	if (!validation.isLength(displayName, 2, 32))
+		return new Toast("Display name must have between 2 and 32 characters.");
+	if (!validation.regex.ascii.test(displayName))
+		return new Toast(
+			"Invalid display name format. Only ASCII characters are allowed."
+		);
+
+	return socket.dispatch(
+		"playlists.updateDisplayName",
+		playlist.value._id,
+		playlist.value.displayName,
+		res => {
+			new Toast(res.message);
+		}
+	);
+};
+
+const updatePrivacy = () => {
+	const { privacy } = playlist.value;
+	if (privacy === "public" || privacy === "private") {
+		socket.dispatch(
+			playlist.value.type === "genre"
+				? "playlists.updatePrivacyAdmin"
+				: "playlists.updatePrivacy",
+			playlist.value._id,
+			privacy,
+			res => {
+				new Toast(res.message);
+			}
+		);
+	}
+};
+</script>
+
 <template>
 	<div class="settings-tab section">
 		<div
@@ -54,84 +118,6 @@
 	</div>
 </template>
 
-<script>
-import { mapState, mapGetters /* , mapActions */ } from "vuex";
-import Toast from "toasters";
-
-import { mapModalState } from "@/vuex_helpers";
-import validation from "@/validation";
-
-export default {
-	props: {
-		modalUuid: { type: String, default: "" }
-	},
-	data() {
-		return {};
-	},
-	computed: {
-		...mapModalState("modals/editPlaylist/MODAL_UUID", {
-			playlist: state => state.playlist
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		}),
-		...mapState({
-			userId: state => state.user.auth.userId,
-			userRole: state => state.user.auth.role
-		})
-	},
-	methods: {
-		isEditable() {
-			return (
-				(this.playlist.type === "user" ||
-					this.playlist.type === "user-liked" ||
-					this.playlist.type === "user-disliked") &&
-				(this.userId === this.playlist.createdBy ||
-					this.userRole === "admin")
-			);
-		},
-		isAdmin() {
-			return this.userRole === "admin";
-		},
-		renamePlaylist() {
-			const { displayName } = this.playlist;
-			if (!validation.isLength(displayName, 2, 32))
-				return new Toast(
-					"Display name must have between 2 and 32 characters."
-				);
-			if (!validation.regex.ascii.test(displayName))
-				return new Toast(
-					"Invalid display name format. Only ASCII characters are allowed."
-				);
-
-			return this.socket.dispatch(
-				"playlists.updateDisplayName",
-				this.playlist._id,
-				this.playlist.displayName,
-				res => {
-					new Toast(res.message);
-				}
-			);
-		},
-		updatePrivacy() {
-			const { privacy } = this.playlist;
-			if (privacy === "public" || privacy === "private") {
-				this.socket.dispatch(
-					this.playlist.type === "genre"
-						? "playlists.updatePrivacyAdmin"
-						: "playlists.updatePrivacy",
-					this.playlist._id,
-					privacy,
-					res => {
-						new Toast(res.message);
-					}
-				);
-			}
-		}
-	}
-};
-</script>
-
 <style lang="less" scoped>
 @media screen and (max-width: 1300px) {
 	.section {

+ 390 - 470
frontend/src/components/modals/EditPlaylist/index.vue

@@ -1,3 +1,316 @@
+<script setup lang="ts">
+import {
+	defineAsyncComponent,
+	ref,
+	computed,
+	onMounted,
+	onBeforeUnmount
+} from "vue";
+import Toast from "toasters";
+import { storeToRefs } from "pinia";
+import { DraggableList } from "vue-draggable-list";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useEditPlaylistStore } from "@/stores/editPlaylist";
+import { useStationStore } from "@/stores/station";
+import { useUserAuthStore } from "@/stores/userAuth";
+import { useModalsStore } from "@/stores/modals";
+import ws from "@/ws";
+import utils from "@/utils";
+
+const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
+const SongItem = defineAsyncComponent(
+	() => import("@/components/SongItem.vue")
+);
+const Settings = defineAsyncComponent(() => import("./Tabs/Settings.vue"));
+const AddSongs = defineAsyncComponent(() => import("./Tabs/AddSongs.vue"));
+const ImportPlaylists = defineAsyncComponent(
+	() => import("./Tabs/ImportPlaylists.vue")
+);
+const QuickConfirm = defineAsyncComponent(
+	() => import("@/components/QuickConfirm.vue")
+);
+
+const props = defineProps({
+	modalUuid: { type: String, default: "" }
+});
+
+const { socket } = useWebsocketsStore();
+const editPlaylistStore = useEditPlaylistStore(props);
+const stationStore = useStationStore();
+const userAuthStore = useUserAuthStore();
+
+const { station } = storeToRefs(stationStore);
+const { loggedIn, userId, role: userRole } = storeToRefs(userAuthStore);
+
+const drag = ref(false);
+const apiDomain = ref("");
+const gettingSongs = ref(false);
+const tabs = ref([]);
+const songItems = ref([]);
+
+const playlistSongs = computed({
+	get: () => editPlaylistStore.playlist.songs,
+	set: value => {
+		editPlaylistStore.updatePlaylistSongs(value);
+	}
+});
+
+const { playlistId, tab, playlist } = storeToRefs(editPlaylistStore);
+const { setPlaylist, clearPlaylist, addSong, removeSong, repositionedSong } =
+	editPlaylistStore;
+
+const { closeCurrentModal } = useModalsStore();
+
+const showTab = payload => {
+	tabs.value[`${payload}-tab`].scrollIntoView({ block: "nearest" });
+	editPlaylistStore.showTab(payload);
+};
+
+const isEditable = () =>
+	(playlist.value.type === "user" ||
+		playlist.value.type === "user-liked" ||
+		playlist.value.type === "user-disliked") &&
+	(userId.value === playlist.value.createdBy || userRole.value === "admin");
+
+const init = () => {
+	gettingSongs.value = true;
+	socket.dispatch("playlists.getPlaylist", playlistId.value, res => {
+		if (res.status === "success") {
+			setPlaylist(res.data.playlist);
+		} else new Toast(res.message);
+		gettingSongs.value = false;
+	});
+};
+
+const isAdmin = () => userRole.value === "admin";
+
+const isOwner = () =>
+	loggedIn.value && userId.value === playlist.value.createdBy;
+
+const repositionSong = ({ moved }) => {
+	const { oldIndex, newIndex } = moved;
+	if (oldIndex === newIndex) return; // we only need to update when song is moved
+	const song = playlistSongs.value[newIndex];
+	socket.dispatch(
+		"playlists.repositionSong",
+		playlist.value._id,
+		{
+			...song,
+			oldIndex,
+			newIndex
+		},
+		res => {
+			if (res.status !== "success")
+				repositionedSong({
+					...song,
+					newIndex: oldIndex,
+					oldIndex: newIndex
+				});
+		}
+	);
+};
+
+const moveSongToTop = index => {
+	songItems.value[`song-item-${index}`].$refs.songActions.tippy.hide();
+	playlistSongs.value.splice(0, 0, playlistSongs.value.splice(index, 1)[0]);
+	repositionSong({
+		moved: {
+			oldIndex: index,
+			newIndex: 0
+		}
+	});
+};
+
+const moveSongToBottom = index => {
+	songItems.value[`song-item-${index}`].$refs.songActions.tippy.hide();
+	playlistSongs.value.splice(
+		playlistSongs.value.length - 1,
+		0,
+		playlistSongs.value.splice(index, 1)[0]
+	);
+	repositionSong({
+		moved: {
+			oldIndex: index,
+			newIndex: playlistSongs.value.length - 1
+		}
+	});
+};
+
+const totalLength = () => {
+	let length = 0;
+	playlist.value.songs.forEach(song => {
+		length += song.duration;
+	});
+	return utils.formatTimeLong(length);
+};
+
+// const shuffle = () => {
+// 	socket.dispatch("playlists.shuffle", playlist.value._id, res => {
+// 		new Toast(res.message);
+// 		if (res.status === "success") {
+// 			updatePlaylistSongs(
+// 				res.data.playlist.songs.sort((a, b) => a.position - b.position)
+// 			);
+// 		}
+// 	});
+// };
+
+const removeSongFromPlaylist = id =>
+	socket.dispatch(
+		"playlists.removeSongFromPlaylist",
+		id,
+		playlist.value._id,
+		res => {
+			new Toast(res.message);
+		}
+	);
+
+const removePlaylist = () => {
+	if (isOwner()) {
+		socket.dispatch("playlists.remove", playlist.value._id, res => {
+			new Toast(res.message);
+			if (res.status === "success") closeCurrentModal();
+		});
+	} else if (isAdmin()) {
+		socket.dispatch("playlists.removeAdmin", playlist.value._id, res => {
+			new Toast(res.message);
+			if (res.status === "success") closeCurrentModal();
+		});
+	}
+};
+
+const downloadPlaylist = async () => {
+	if (apiDomain.value === "")
+		apiDomain.value = await lofig.get("backend.apiDomain");
+
+	fetch(`${apiDomain.value}/export/playlist/${playlist.value._id}`, {
+		credentials: "include"
+	})
+		.then(res => res.blob())
+		.then(blob => {
+			const url = window.URL.createObjectURL(blob);
+
+			const a = document.createElement("a");
+			a.style.display = "none";
+			a.href = url;
+
+			a.download = `musare-playlist-${
+				playlist.value._id
+			}-${new Date().toISOString()}.json`;
+
+			document.body.appendChild(a);
+			a.click();
+			window.URL.revokeObjectURL(url);
+
+			new Toast("Successfully downloaded playlist.");
+		})
+		.catch(() => new Toast("Failed to export and download playlist."));
+};
+
+const addSongToQueue = youtubeId => {
+	socket.dispatch(
+		"stations.addToQueue",
+		station.value._id,
+		youtubeId,
+		data => {
+			if (data.status !== "success")
+				new Toast({
+					content: `Error: ${data.message}`,
+					timeout: 8000
+				});
+			else new Toast({ content: data.message, timeout: 4000 });
+		}
+	);
+};
+
+const clearAndRefillStationPlaylist = () => {
+	socket.dispatch(
+		"playlists.clearAndRefillStationPlaylist",
+		playlist.value._id,
+		data => {
+			if (data.status !== "success")
+				new Toast({
+					content: `Error: ${data.message}`,
+					timeout: 8000
+				});
+			else new Toast({ content: data.message, timeout: 4000 });
+		}
+	);
+};
+
+const clearAndRefillGenrePlaylist = () => {
+	socket.dispatch(
+		"playlists.clearAndRefillGenrePlaylist",
+		playlist.value._id,
+		data => {
+			if (data.status !== "success")
+				new Toast({
+					content: `Error: ${data.message}`,
+					timeout: 8000
+				});
+			else new Toast({ content: data.message, timeout: 4000 });
+		}
+	);
+};
+
+onMounted(() => {
+	ws.onConnect(init);
+
+	socket.on(
+		"event:playlist.song.added",
+		res => {
+			if (playlist.value._id === res.data.playlistId)
+				addSong(res.data.song);
+		},
+		{ modalUuid: props.modalUuid }
+	);
+
+	socket.on(
+		"event:playlist.song.removed",
+		res => {
+			if (playlist.value._id === res.data.playlistId) {
+				// remove song from array of playlists
+				removeSong(res.data.youtubeId);
+			}
+		},
+		{ modalUuid: props.modalUuid }
+	);
+
+	socket.on(
+		"event:playlist.displayName.updated",
+		res => {
+			if (playlist.value._id === res.data.playlistId) {
+				setPlaylist({
+					displayName: res.data.displayName,
+					...playlist.value
+				});
+			}
+		},
+		{ modalUuid: props.modalUuid }
+	);
+
+	socket.on(
+		"event:playlist.song.repositioned",
+		res => {
+			if (playlist.value._id === res.data.playlistId) {
+				const { song, playlistId } = res.data;
+
+				if (playlist.value._id === playlistId) {
+					repositionedSong(song);
+				}
+			}
+		},
+		{ modalUuid: props.modalUuid }
+	);
+});
+
+onBeforeUnmount(() => {
+	clearPlaylist();
+	// Delete the Pinia store that was created for this modal, after all other cleanup tasks are performed
+	editPlaylistStore.$dispose();
+});
+</script>
+
 <template>
 	<modal
 		:title="
@@ -23,7 +336,7 @@
 						<button
 							class="button is-default"
 							:class="{ selected: tab === 'settings' }"
-							ref="settings-tab"
+							:ref="el => (tabs['settings-tab'] = el)"
 							@click="showTab('settings')"
 							v-if="
 								userId === playlist.createdBy ||
@@ -36,7 +349,7 @@
 						<button
 							class="button is-default"
 							:class="{ selected: tab === 'add-songs' }"
-							ref="add-songs-tab"
+							:ref="el => (tabs['add-songs-tab'] = el)"
 							@click="showTab('add-songs')"
 							v-if="isEditable()"
 						>
@@ -47,7 +360,7 @@
 							:class="{
 								selected: tab === 'import-playlists'
 							}"
-							ref="import-playlists-tab"
+							:ref="el => (tabs['import-playlists-tab'] = el)"
 							@click="showTab('import-playlists')"
 							v-if="isEditable()"
 						>
@@ -92,108 +405,91 @@
 					</div>
 
 					<aside class="menu">
-						<draggable
-							:component-data="{
-								name: !drag ? 'draggable-list-transition' : null
-							}"
+						<draggable-list
 							v-if="playlistSongs.length > 0"
-							v-model="playlistSongs"
+							v-model:list="playlistSongs"
 							item-key="_id"
-							v-bind="dragOptions"
 							@start="drag = true"
 							@end="drag = false"
-							@change="repositionSong"
+							@update="repositionSong"
+							:disabled="!isEditable()"
 						>
 							<template #item="{ element, index }">
-								<div class="menu-list scrollable-list">
-									<song-item
-										:song="element"
-										:class="{
-											'item-draggable': isEditable()
-										}"
-										:ref="`song-item-${index}`"
-									>
-										<template #tippyActions>
-											<i
-												class="material-icons add-to-queue-icon"
-												v-if="
-													station &&
-													station.requests &&
-													station.requests.enabled &&
+								<song-item
+									:song="element"
+									:ref="
+										el =>
+											(songItems[`song-item-${index}`] =
+												el)
+									"
+								>
+									<template #tippyActions>
+										<i
+											class="material-icons add-to-queue-icon"
+											v-if="
+												station &&
+												station.requests &&
+												station.requests.enabled &&
+												(station.requests.access ===
+													'user' ||
 													(station.requests.access ===
-														'user' ||
-														(station.requests
-															.access ===
-															'owner' &&
-															(userRole ===
-																'admin' ||
-																station.owner ===
-																	userId)))
-												"
-												@click="
-													addSongToQueue(
-														element.youtubeId
-													)
-												"
-												content="Add Song to Queue"
-												v-tippy
-												>queue</i
-											>
-											<quick-confirm
-												v-if="
-													userId ===
-														playlist.createdBy ||
-													isEditable()
-												"
-												placement="left"
-												@confirm="
-													removeSongFromPlaylist(
-														element.youtubeId
-													)
-												"
-											>
-												<i
-													class="material-icons delete-icon"
-													content="Remove Song from Playlist"
-													v-tippy
-													>delete_forever</i
-												>
-											</quick-confirm>
+														'owner' &&
+														(userRole === 'admin' ||
+															station.owner ===
+																userId)))
+											"
+											@click="
+												addSongToQueue(
+													element.youtubeId
+												)
+											"
+											content="Add Song to Queue"
+											v-tippy
+											>queue</i
+										>
+										<quick-confirm
+											v-if="
+												userId === playlist.createdBy ||
+												isEditable()
+											"
+											placement="left"
+											@confirm="
+												removeSongFromPlaylist(
+													element.youtubeId
+												)
+											"
+										>
 											<i
-												class="material-icons"
-												v-if="isEditable() && index > 0"
-												@click="
-													moveSongToTop(
-														element,
-														index
-													)
-												"
-												content="Move to top of Playlist"
+												class="material-icons delete-icon"
+												content="Remove Song from Playlist"
 												v-tippy
-												>vertical_align_top</i
+												>delete_forever</i
 											>
-											<i
-												v-if="
-													isEditable() &&
-													playlistSongs.length - 1 !==
-														index
-												"
-												@click="
-													moveSongToBottom(
-														element,
-														index
-													)
-												"
-												class="material-icons"
-												content="Move to bottom of Playlist"
-												v-tippy
-												>vertical_align_bottom</i
-											>
-										</template>
-									</song-item>
-								</div>
+										</quick-confirm>
+										<i
+											class="material-icons"
+											v-if="isEditable() && index > 0"
+											@click="moveSongToTop(index)"
+											content="Move to top of Playlist"
+											v-tippy
+											>vertical_align_top</i
+										>
+										<i
+											v-if="
+												isEditable() &&
+												playlistSongs.length - 1 !==
+													index
+											"
+											@click="moveSongToBottom(index)"
+											class="material-icons"
+											content="Move to bottom of Playlist"
+											v-tippy
+											>vertical_align_bottom</i
+										>
+									</template>
+								</song-item>
 							</template>
-						</draggable>
+						</draggable-list>
 						<p v-else-if="gettingSongs" class="nothing-here-text">
 							Loading songs...
 						</p>
@@ -246,361 +542,6 @@
 	</modal>
 </template>
 
-<script>
-import { mapState, mapGetters, mapActions } from "vuex";
-import draggable from "vuedraggable";
-import Toast from "toasters";
-
-import { mapModalState, mapModalActions } from "@/vuex_helpers";
-import ws from "@/ws";
-import SongItem from "../../SongItem.vue";
-
-import Settings from "./Tabs/Settings.vue";
-import AddSongs from "./Tabs/AddSongs.vue";
-import ImportPlaylists from "./Tabs/ImportPlaylists.vue";
-
-import utils from "../../../../js/utils";
-
-export default {
-	components: {
-		draggable,
-		SongItem,
-		Settings,
-		AddSongs,
-		ImportPlaylists
-	},
-	props: {
-		modalUuid: { type: String, default: "" }
-	},
-	data() {
-		return {
-			utils,
-			drag: false,
-			apiDomain: "",
-			gettingSongs: false
-		};
-	},
-	computed: {
-		...mapState("station", {
-			station: state => state.station
-		}),
-		...mapModalState("modals/editPlaylist/MODAL_UUID", {
-			playlistId: state => state.playlistId,
-			tab: state => state.tab,
-			playlist: state => state.playlist
-		}),
-		playlistSongs: {
-			get() {
-				return this.$store.state.modals.editPlaylist[this.modalUuid]
-					.playlist.songs;
-			},
-			set(value) {
-				this.$store.commit(
-					`modals/editPlaylist/${this.modalUuid}/updatePlaylistSongs`,
-					value
-				);
-			}
-		},
-		...mapState({
-			loggedIn: state => state.user.auth.loggedIn,
-			userId: state => state.user.auth.userId,
-			userRole: state => state.user.auth.role
-		}),
-		dragOptions() {
-			return {
-				animation: 200,
-				group: "songs",
-				disabled: !this.isEditable(),
-				ghostClass: "draggable-list-ghost"
-			};
-		},
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	mounted() {
-		ws.onConnect(this.init);
-
-		this.socket.on(
-			"event:playlist.song.added",
-			res => {
-				if (this.playlist._id === res.data.playlistId)
-					this.addSong(res.data.song);
-			},
-			{ modalUuid: this.modalUuid }
-		);
-
-		this.socket.on(
-			"event:playlist.song.removed",
-			res => {
-				if (this.playlist._id === res.data.playlistId) {
-					// remove song from array of playlists
-					this.removeSong(res.data.youtubeId);
-				}
-			},
-			{ modalUuid: this.modalUuid }
-		);
-
-		this.socket.on(
-			"event:playlist.displayName.updated",
-			res => {
-				if (this.playlist._id === res.data.playlistId) {
-					const playlist = {
-						displayName: res.data.displayName,
-						...this.playlist
-					};
-					this.setPlaylist(playlist);
-				}
-			},
-			{ modalUuid: this.modalUuid }
-		);
-
-		this.socket.on(
-			"event:playlist.song.repositioned",
-			res => {
-				if (this.playlist._id === res.data.playlistId) {
-					const { song, playlistId } = res.data;
-
-					if (this.playlist._id === playlistId) {
-						this.repositionedSong(song);
-					}
-				}
-			},
-			{ modalUuid: this.modalUuid }
-		);
-	},
-	beforeUnmount() {
-		this.clearPlaylist();
-		// Delete the VueX module that was created for this modal, after all other cleanup tasks are performed
-		this.$store.unregisterModule([
-			"modals",
-			"editPlaylist",
-			this.modalUuid
-		]);
-	},
-	methods: {
-		init() {
-			this.gettingSongs = true;
-			this.socket.dispatch(
-				"playlists.getPlaylist",
-				this.playlistId,
-				res => {
-					if (res.status === "success") {
-						this.setPlaylist(res.data.playlist);
-					} else new Toast(res.message);
-					this.gettingSongs = false;
-				}
-			);
-		},
-		isEditable() {
-			return (
-				(this.playlist.type === "user" ||
-					this.playlist.type === "user-liked" ||
-					this.playlist.type === "user-disliked") &&
-				(this.userId === this.playlist.createdBy ||
-					this.userRole === "admin")
-			);
-		},
-		isAdmin() {
-			return this.userRole === "admin";
-		},
-		isOwner() {
-			return this.loggedIn && this.userId === this.playlist.createdBy;
-		},
-		repositionSong({ moved }) {
-			if (!moved) return; // we only need to update when song is moved
-
-			this.socket.dispatch(
-				"playlists.repositionSong",
-				this.playlist._id,
-				{
-					...moved.element,
-					oldIndex: moved.oldIndex,
-					newIndex: moved.newIndex
-				},
-				res => {
-					if (res.status !== "success")
-						this.repositionedSong({
-							...moved.element,
-							newIndex: moved.oldIndex,
-							oldIndex: moved.newIndex
-						});
-				}
-			);
-		},
-		moveSongToTop(song, index) {
-			this.$refs[`song-item-${index}`].$refs.songActions.tippy.hide();
-
-			this.repositionSong({
-				moved: {
-					element: song,
-					oldIndex: index,
-					newIndex: 0
-				}
-			});
-		},
-		moveSongToBottom(song, index) {
-			this.$refs[`song-item-${index}`].$refs.songActions.tippy.hide();
-
-			this.repositionSong({
-				moved: {
-					element: song,
-					oldIndex: index,
-					newIndex: this.playlistSongs.length
-				}
-			});
-		},
-		totalLength() {
-			let length = 0;
-			this.playlist.songs.forEach(song => {
-				length += song.duration;
-			});
-			return this.utils.formatTimeLong(length);
-		},
-		shuffle() {
-			this.socket.dispatch(
-				"playlists.shuffle",
-				this.playlist._id,
-				res => {
-					new Toast(res.message);
-					if (res.status === "success") {
-						this.updatePlaylistSongs(
-							res.data.playlist.songs.sort(
-								(a, b) => a.position - b.position
-							)
-						);
-					}
-				}
-			);
-		},
-		removeSongFromPlaylist(id) {
-			return this.socket.dispatch(
-				"playlists.removeSongFromPlaylist",
-				id,
-				this.playlist._id,
-				res => {
-					new Toast(res.message);
-				}
-			);
-		},
-		removePlaylist() {
-			if (this.isOwner()) {
-				this.socket.dispatch(
-					"playlists.remove",
-					this.playlist._id,
-					res => {
-						new Toast(res.message);
-						if (res.status === "success")
-							this.closeModal("editPlaylist");
-					}
-				);
-			} else if (this.isAdmin()) {
-				this.socket.dispatch(
-					"playlists.removeAdmin",
-					this.playlist._id,
-					res => {
-						new Toast(res.message);
-						if (res.status === "success")
-							this.closeModal("editPlaylist");
-					}
-				);
-			}
-		},
-		async downloadPlaylist() {
-			if (this.apiDomain === "")
-				this.apiDomain = await lofig.get("backend.apiDomain");
-
-			fetch(`${this.apiDomain}/export/playlist/${this.playlist._id}`, {
-				credentials: "include"
-			})
-				.then(res => res.blob())
-				.then(blob => {
-					const url = window.URL.createObjectURL(blob);
-
-					const a = document.createElement("a");
-					a.style.display = "none";
-					a.href = url;
-
-					a.download = `musare-playlist-${
-						this.playlist._id
-					}-${new Date().toISOString()}.json`;
-
-					document.body.appendChild(a);
-					a.click();
-					window.URL.revokeObjectURL(url);
-
-					new Toast("Successfully downloaded playlist.");
-				})
-				.catch(
-					() => new Toast("Failed to export and download playlist.")
-				);
-		},
-		addSongToQueue(youtubeId) {
-			this.socket.dispatch(
-				"stations.addToQueue",
-				this.station._id,
-				youtubeId,
-				data => {
-					if (data.status !== "success")
-						new Toast({
-							content: `Error: ${data.message}`,
-							timeout: 8000
-						});
-					else new Toast({ content: data.message, timeout: 4000 });
-				}
-			);
-		},
-		clearAndRefillStationPlaylist() {
-			this.socket.dispatch(
-				"playlists.clearAndRefillStationPlaylist",
-				this.playlist._id,
-				data => {
-					if (data.status !== "success")
-						new Toast({
-							content: `Error: ${data.message}`,
-							timeout: 8000
-						});
-					else new Toast({ content: data.message, timeout: 4000 });
-				}
-			);
-		},
-		clearAndRefillGenrePlaylist() {
-			this.socket.dispatch(
-				"playlists.clearAndRefillGenrePlaylist",
-				this.playlist._id,
-				data => {
-					if (data.status !== "success")
-						new Toast({
-							content: `Error: ${data.message}`,
-							timeout: 8000
-						});
-					else new Toast({ content: data.message, timeout: 4000 });
-				}
-			);
-		},
-		...mapActions({
-			showTab(dispatch, payload) {
-				this.$refs[`${payload}-tab`].scrollIntoView({
-					block: "nearest"
-				});
-				return dispatch(
-					`modals/editPlaylist/${this.modalUuid}/showTab`,
-					payload
-				);
-			}
-		}),
-		...mapModalActions("modals/editPlaylist/MODAL_UUID", [
-			"setPlaylist",
-			"clearPlaylist",
-			"addSong",
-			"removeSong",
-			"repositionedSong"
-		]),
-		...mapActions("modalVisibility", ["openModal", "closeModal"])
-	}
-};
-</script>
-
 <style lang="less" scoped>
 .night-mode {
 	.label,
@@ -633,19 +574,6 @@ export default {
 	}
 }
 
-.menu-list li {
-	display: flex;
-	justify-content: space-between;
-
-	&:not(:last-of-type) {
-		margin-bottom: 10px;
-	}
-
-	a {
-		display: flex;
-	}
-}
-
 .controls {
 	display: flex;
 
@@ -741,13 +669,5 @@ export default {
 			}
 		}
 	}
-
-	.right-section {
-		#rearrange-songs-section {
-			.scrollable-list:not(:last-of-type) {
-				margin-bottom: 10px;
-			}
-		}
-	}
 }
 </style>

Some files were not shown because too many files changed in this diff