Parcourir la source

Merge branch 'staging'

Owen Diffey il y a 3 ans
Parent
commit
f671796cc5
100 fichiers modifiés avec 3122 ajouts et 2324 suppressions
  1. 4 0
      .env.example
  2. 3 0
      .github/workflows/build-eslint.yml
  3. 29 0
      .github/workflows/codeql-analysis.yml
  4. 7 1
      .wiki/Configuration.md
  5. 7 5
      .wiki/Installation.md
  6. 71 0
      .wiki/Upgrading.md
  7. 317 0
      CHANGELOG.md
  8. 14 4
      README.md
  9. 1 1
      backend/Dockerfile
  10. 16 10
      backend/logic/actions/news.js
  11. 55 52
      backend/logic/actions/playlists.js
  12. 100 8
      backend/logic/actions/reports.js
  13. 78 6
      backend/logic/actions/songs.js
  14. 10 5
      backend/logic/actions/stations.js
  15. 136 8
      backend/logic/actions/users.js
  16. 1 1
      backend/logic/app.js
  17. 54 20
      backend/logic/cache/index.js
  18. 13 5
      backend/logic/db/index.js
  19. 2 1
      backend/logic/db/schemas/news.js
  20. 7 6
      backend/logic/db/schemas/playlist.js
  21. 1 1
      backend/logic/db/schemas/report.js
  22. 5 5
      backend/logic/db/schemas/song.js
  23. 4 3
      backend/logic/db/schemas/station.js
  24. 8 4
      backend/logic/mail/index.js
  25. 47 0
      backend/logic/migration/migrations/migration19.js
  26. 5 3
      backend/logic/notifications.js
  27. 25 23
      backend/logic/playlists.js
  28. 9 9
      backend/logic/punishments.js
  29. 96 38
      backend/logic/songs.js
  30. 9 6
      backend/logic/stations.js
  31. 20 10
      backend/logic/utils.js
  32. 38 30
      backend/logic/ws.js
  33. 10 8
      backend/logic/youtube.js
  34. 228 388
      backend/package-lock.json
  35. 15 14
      backend/package.json
  36. 9 6
      docker-compose.yml
  37. 2 1
      frontend/.eslintrc
  38. 2 2
      frontend/Dockerfile
  39. 7 3
      frontend/dist/config/template.json
  40. 348 462
      frontend/package-lock.json
  41. 20 19
      frontend/package.json
  42. 76 33
      frontend/src/App.vue
  43. 15 5
      frontend/src/api/admin/reports.js
  44. 3 3
      frontend/src/components/ActivityItem.vue
  45. 1 1
      frontend/src/components/AddToPlaylistDropdown.vue
  46. 63 78
      frontend/src/components/AdvancedTable.vue
  47. 2 2
      frontend/src/components/AutoSuggest.vue
  48. 1 1
      frontend/src/components/ChristmasLights.vue
  49. 1 1
      frontend/src/components/FallingSnow.vue
  50. 2 2
      frontend/src/components/FloatingBox.vue
  51. 1 1
      frontend/src/components/InputHelpBox.vue
  52. 3 3
      frontend/src/components/Modal.vue
  53. 1 1
      frontend/src/components/PlaylistItem.vue
  54. 1 1
      frontend/src/components/ProfilePicture.vue
  55. 2 2
      frontend/src/components/PunishmentItem.vue
  56. 2 12
      frontend/src/components/Queue.vue
  57. 2 2
      frontend/src/components/ReportInfoItem.vue
  58. 2 2
      frontend/src/components/RunJobDropdown.vue
  59. 2 2
      frontend/src/components/SearchQueryItem.vue
  60. 1 1
      frontend/src/components/Sidebar.vue
  61. 17 12
      frontend/src/components/SongItem.vue
  62. 28 13
      frontend/src/components/SongThumbnail.vue
  63. 1 1
      frontend/src/components/UserIdToUsername.vue
  64. 56 15
      frontend/src/components/layout/MainFooter.vue
  65. 94 86
      frontend/src/components/layout/MainHeader.vue
  66. 4 7
      frontend/src/components/modals/BulkActions.vue
  67. 2 43
      frontend/src/components/modals/CreatePlaylist.vue
  68. 1 1
      frontend/src/components/modals/CreateStation.vue
  69. 32 8
      frontend/src/components/modals/EditNews.vue
  70. 1 1
      frontend/src/components/modals/EditPlaylist/Tabs/AddSongs.vue
  71. 2 2
      frontend/src/components/modals/EditPlaylist/Tabs/ImportPlaylists.vue
  72. 1 1
      frontend/src/components/modals/EditPlaylist/Tabs/Settings.vue
  73. 8 14
      frontend/src/components/modals/EditPlaylist/index.vue
  74. 6 7
      frontend/src/components/modals/EditSong/Tabs/Discogs.vue
  75. 8 22
      frontend/src/components/modals/EditSong/Tabs/Reports.vue
  76. 1 1
      frontend/src/components/modals/EditSong/Tabs/Songs.vue
  77. 17 4
      frontend/src/components/modals/EditSong/Tabs/Youtube.vue
  78. 320 207
      frontend/src/components/modals/EditSong/index.vue
  79. 23 43
      frontend/src/components/modals/EditSongs.vue
  80. 48 36
      frontend/src/components/modals/EditUser.vue
  81. 16 20
      frontend/src/components/modals/ImportAlbum.vue
  82. 156 0
      frontend/src/components/modals/ImportPlaylist.vue
  83. 12 5
      frontend/src/components/modals/Login.vue
  84. 1 1
      frontend/src/components/modals/ManageStation/Tabs/Playlists.vue
  85. 4 4
      frontend/src/components/modals/ManageStation/Tabs/Settings.vue
  86. 4 10
      frontend/src/components/modals/ManageStation/Tabs/Songs.vue
  87. 13 14
      frontend/src/components/modals/ManageStation/index.vue
  88. 14 3
      frontend/src/components/modals/Register.vue
  89. 2 2
      frontend/src/components/modals/RemoveAccount.vue
  90. 6 14
      frontend/src/components/modals/Report.vue
  91. 0 289
      frontend/src/components/modals/RequestSong.vue
  92. 65 35
      frontend/src/components/modals/ViewReport.vue
  93. 11 6
      frontend/src/components/modals/WhatIsNew.vue
  94. 47 9
      frontend/src/main.js
  95. 1 1
      frontend/src/pages/404.vue
  96. 3 3
      frontend/src/pages/About.vue
  97. 21 12
      frontend/src/pages/Admin/News.vue
  98. 2 7
      frontend/src/pages/Admin/Playlists.vue
  99. 17 24
      frontend/src/pages/Admin/Punishments.vue
  100. 43 20
      frontend/src/pages/Admin/Reports.vue

+ 4 - 0
.env.example

@@ -1,4 +1,5 @@
 COMPOSE_PROJECT_NAME=musare
+RESTART_POLICY=unless-stopped
 
 BACKEND_HOST=127.0.0.1
 BACKEND_PORT=8080
@@ -12,10 +13,13 @@ MONGO_PORT=27017
 MONGO_ROOT_PASSWORD=PASSWORD_HERE
 MONGO_USER_USERNAME=musare
 MONGO_USER_PASSWORD=OTHER_PASSWORD_HERE
+MONGO_DATA_LOCATION=.db
+MONGO_VERSION=5.0
 
 REDIS_HOST=127.0.0.1
 REDIS_PORT=6379
 REDIS_PASSWORD=PASSWORD
+REDIS_DATA_LOCATION=.redis
 
 BACKUP_LOCATION=
 BACKUP_NAME=

+ 3 - 0
.github/workflows/build-eslint.yml

@@ -14,9 +14,12 @@ env:
     MONGO_ROOT_PASSWORD: PASSWORD_HERE
     MONGO_USER_USERNAME: musare
     MONGO_USER_PASSWORD: OTHER_PASSWORD_HERE
+    MONGO_DATA_LOCATION: .db
+    MONGO_VERSION: 5.0
     REDIS_HOST: 127.0.0.1
     REDIS_PORT: 6379
     REDIS_PASSWORD: PASSWORD
+    REDIS_DATA_LOCATION: .redis
 
 jobs:
     build-eslint:

+ 29 - 0
.github/workflows/codeql-analysis.yml

@@ -0,0 +1,29 @@
+name: "CodeQL"
+
+on: [ push, pull_request, workflow_dispatch ]
+
+jobs:
+  analyze:
+    name: Analyze
+    runs-on: ubuntu-latest
+    permissions:
+      actions: read
+      contents: read
+      security-events: write
+
+    strategy:
+      fail-fast: false
+      matrix:
+        language: [ 'javascript' ]
+
+    steps:
+    - name: Checkout repository
+      uses: actions/checkout@v2
+
+    - name: Initialize CodeQL
+      uses: github/codeql-action/init@v1
+      with:
+        languages: ${{ matrix.language }}
+
+    - name: Perform CodeQL Analysis
+      uses: github/codeql-action/analyze@v1

+ 7 - 1
.wiki/Configuration.md

@@ -70,10 +70,12 @@ Location: `frontend/dist/config/default.json`
 | `cookie.SIDname` | Name of the cookie stored for sessions. |
 | `siteSettings.logo_white` | Path to the white logo image, by default it is `/assets/white_wordmark.png`. |
 | `siteSettings.logo_blue` | Path to the blue logo image, by default it is `/assets/blue_wordmark.png`. |
+| `siteSettings.logo_small` | Path to the small white logo image, by default it is `/assets/favicon/mstile-144x144.png`. |
 | `siteSettings.sitename` | Should be the name of the site. |
-| `siteSettings.github` | URL of GitHub repository, defaults to `https://github.com/Musare/MusareNode`. |
+| `siteSettings.footerLinks` | Add custom links to footer by specifying `"title": "url"`, e.g. `"GitHub": "https://github.com/Musare/Musare"`. You can disable about, team and news links (but not the pages themselves) by setting them to false, e.g. `"about": false`. |
 | `siteSettings.mediasession` | Whether to enable mediasession functionality. |
 | `siteSettings.christmas` | Whether to enable christmas theming. |
+| `siteSettings.registrationDisabled` | If set to true, users can't register accounts. |
 | `messages.accountRemoval` | Message to return to users on account removal. |
 | `shortcutOverrides` | Overwrite keyboard shortcuts, for example `"editSong.useAllDiscogs": { "keyCode": 68, "ctrl": true, "alt": true, "shift": false, "preventDefault": true }`. |
 | `debug.git.remote` | Allow the website/users to view the current Git repository's remote. [^1] |
@@ -97,6 +99,7 @@ The container port refers to the external docker container port, used to access
 | Property | Description |
 | --- | --- |
 | `COMPOSE_PROJECT_NAME` | Should be a unique name for this installation, especially if you have multiple instances of Musare on the same machine. |
+| `RESTART_POLICY` | Restart policy for docker containers, values can be found [here](https://docs.docker.com/config/containers/start-containers-automatically/). |
 | `BACKEND_HOST` | Backend container host. |
 | `BACKEND_PORT` | Backend container port. |
 | `FRONTEND_HOST` | Frontend container host. |
@@ -107,8 +110,11 @@ The container port refers to the external docker container port, used to access
 | `MONGO_ROOT_PASSWORD` | Password of the root/admin user for MongoDB. |
 | `MONGO_USER_USERNAME` | Application username for MongoDB. |
 | `MONGO_USER_PASSWORD` | Application password for MongoDB. |
+| `MONGO_DATA_LOCATION` | The location where MongoDB stores its data. Usually the `.db` folder inside the `Musare` folder. |
+| `MONGO_VERSION` | The MongoDB version to use for scripts and docker-compose. Must be numerical. Currently supported MongoDB versions are 4.0, 4.2, 4.4 and 5.0. |
 | `REDIS_HOST` | Redis container host. |
 | `REDIS_PORT` | Redis container port. |
 | `REDIS_PASSWORD` | Redis password. |
+| `REDIS_DATA_LOCATION` | The location where Redis stores its data. Usually the `.redis` folder inside the `Musare` folder. |
 | `BACKUP_LOCATION` | Directory to store musare.sh backups. Defaults to `/backups` in script location. |
 | `BACKUP_NAME` | Name of musare.sh backup files. Defaults to `musare-$(date +"%Y-%m-%d-%s").dump`. |

+ 7 - 5
.wiki/Installation.md

@@ -1,6 +1,8 @@
 # Installation
 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
@@ -9,8 +11,8 @@ Musare can be installed with Docker (recommended) or without, guides for both in
 - [docker-compose](https://docs.docker.com/compose/install/)
 
 ### Instructions
-1. `git clone https://github.com/Musare/MusareNode.git`
-2. `cd MusareNode`
+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).
@@ -43,8 +45,8 @@ Run this command in your shell. You will have to do this command for every shell
     - [webpack](https://webpack.js.org/guides/installation/#global-installation)
 
 ### Instructions
-1. `git clone https://github.com/Musare/MusareNode.git`
-2. `cd MusareNode`
+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)
@@ -70,7 +72,7 @@ Run this command in your shell. You will have to do this command for every shell
     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\MusareNode\.database"`
+        `"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

+ 71 - 0
.wiki/Upgrading.md

@@ -0,0 +1,71 @@
+# Upgrading
+Musare upgrade process.
+
+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`.
+        - 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`.
+        - `./musare.sh stop backend`
+        - Set `migration` to `false` in  `backend/config/default.json`
+        - `./musare.sh start backend`.
+
+---
+
+## 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.
+    - 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.
+8. Run database migration;
+    - Set `migration` to `true` in  `backend/config/default.json`
+    - Start backend service.
+    - Follow backend logs and await migration completion notice.
+    - Stop backend service.
+    - Set `migration` to `false` in  `backend/config/default.json`
+9. Start backend and frontend services.
+
+# 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.
+
+## Docker
+
+### 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`)
+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
+
+### Instructions
+1. Stop your backend
+2. Make a backup of MongoDB
+3. Stop and reset MongoDB
+4. Upgrade/downgrade MongoDB
+5. Start MongoDB
+6. Restore your MongoDB backup
+7. Start your backend

+ 317 - 0
CHANGELOG.md

@@ -0,0 +1,317 @@
+# Changelog
+
+## [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.
+
+### 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: Global LESS variables
+- refactor: Configurable Main Footer links
+- feat: Configurable Docker container restart policy
+- feat: Backend job to create a song
+- feat: Create song from scratch with Edit Song
+- chore: Added CodeQL analysis GitHub action
+- feat: Ability to select track position in Edit Song player
+- feat: Ability to select playback rate in Edit Song player
+- feat: Login with username or email
+- chore: Added CHANGELOG.md
+- feat: Added view profile button to admin/users table
+- feat: Ability to delete reports
+- feat: Added resolved attribute to reports Advanced Table
+- feat: Option to edit songs after import in Import Playlist
+- feat: Configurable MongoDB and Redis Docker container data directories
+- feat: Ability to toggle report resolution status
+- feat: Ability to show news items to new users on first visit
+- feat: Added warning label to thumbnails in Edit Song if not square
+- chore: Added Upgrading wiki page
+- 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: Consistent border-radius
+- refactor: Consistent box-shadow
+- refactor: Replace deprecated /deep/ selector with :deep()
+- chore: Update frontend and backend packages, and docker images
+- refactor: Move Edit Song verify toggle button to in-form toggle switch
+- refactor: Volume slider styling improvements
+- refactor: Replaced admin secondary nav with sidebar
+- refactor: Moved Request Song import youtube playlist to Import Playlist modal
+- refactor: Select input styling consistency
+- refactor: Show notice that song has been deleted in Edit Song
+- refactor: Reduce dropdown toggle button width
+- refactor: Set title and thumbnail on YouTube video selection in Create Song
+- refactor: Show YouTube tab by default in Create Song
+- refactor: Move admin tab routing to vue router
+- refactor: Pull images in musare.sh build command
+- refactor: Delete user sessions when account is deleted
+
+### Fixed
+- 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: 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: News item divider has no top/bottom margin
+- fix: Edit Song failing to fetch song reports
+- fix: Station refill can include current song
+- fix: Lofig can not be loaded from deep path
+- fix: CTRL/SHIFT+select Advanced Table rows no longer working
+- fix: Entering station with volume previously set to 0 is handled as muted
+- fix: Genre playlists are created even if the song is unverified
+- fix: Importing YouTube playlist throws URL invalid
+- fix: Song validation should not require genres or artists for unverified songs
+- fix: Station player not unloaded if queue runs empty
+- fix: Edit Song player state not reset on close or next song
+- 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: Station creation validation always failing
+- fix: Station info display name and description overflow horizontally
+- fix: Volume slider incorrect sensitivity
+- fix: Song thumbnail loading causes jumpiness on admin/songs
+- fix: Manage Station go to station throws an error
+- fix: Edit Song seekTo does not apply if video is stopped
+- 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
+
+### 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
+
+## [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.
+
+### 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: Global LESS variables
+- refactor: Configurable Main Footer links
+- feat: Configurable Docker container restart policy
+- feat: Backend job to create a song
+- feat: Create song from scratch with Edit Song
+- chore: Added CodeQL analysis GitHub action
+- feat: Ability to select track position in Edit Song player
+- feat: Ability to select playback rate in Edit Song player
+- feat: Login with username or email
+- chore: Added CHANGELOG.md
+- feat: Added view profile button to admin/users table
+- feat: Ability to delete reports
+- feat: Added resolved attribute to reports Advanced Table
+- feat: Option to edit songs after import in Import Playlist
+- feat: Configurable MongoDB and Redis Docker container data directories
+- feat: Ability to toggle report resolution status
+- feat: Ability to show news items to new users on first visit
+- feat: Added warning label to thumbnails in Edit Song if not square
+- chore: Added Upgrading wiki page
+- 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: Consistent border-radius
+- refactor: Consistent box-shadow
+- refactor: Replace deprecated /deep/ selector with :deep()
+- chore: Update frontend and backend packages, and docker images
+- refactor: Move Edit Song verify toggle button to in-form toggle switch
+- refactor: Volume slider styling improvements
+- refactor: Replaced admin secondary nav with sidebar
+- refactor: Moved Request Song import youtube playlist to Import Playlist modal
+- refactor: Select input styling consistency
+- refactor: Show notice that song has been deleted in Edit Song
+- refactor: Reduce dropdown toggle button width
+- refactor: Set title and thumbnail on YouTube video selection in Create Song
+- refactor: Show YouTube tab by default in Create Song
+- refactor: Move admin tab routing to vue router
+- refactor: Pull images in musare.sh build command
+
+### Fixed
+- 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: 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: News item divider has no top/bottom margin
+- fix: Edit Song failing to fetch song reports
+- fix: Station refill can include current song
+- fix: Lofig can not be loaded from deep path
+- fix: CTRL/SHIFT+select Advanced Table rows no longer working
+- fix: Entering station with volume previously set to 0 is handled as muted
+- fix: Genre playlists are created even if the song is unverified
+- fix: Importing YouTube playlist throws URL invalid
+- fix: Song validation should not require genres or artists for unverified songs
+- fix: Station player not unloaded if queue runs empty
+- fix: Edit Song player state not reset on close or next song
+- 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: Station creation validation always failing
+- fix: Station info display name and description overflow horizontally
+- fix: Volume slider incorrect sensitivity
+- fix: Song thumbnail loading causes jumpiness on admin/songs
+- fix: Manage Station go to station throws an error
+- 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: 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
+- feat: Create missing genre playlists button and job
+- feat: Delete songs
+- feat: Edit Songs modal
+- 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
+- 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: 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: 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: 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: 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: 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
+- fix: Opening edit song modal whilst loading prevents closing modal
+- fix: Queue does not have user-select set to none
+- fix: Removed legacy editSong right container styling
+- fix: Select dropdown arrow outside of container in create playlist
+- fix: Spam closing EditSong modals from ImportAlbum causes weird issues
+- fix: Tippy tooltips get cropped by modal overflow
+
+## [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 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
+- feat: Added red station theme
+
+### Changed
+- 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
+
+### 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.

+ 14 - 4
README.md

@@ -4,10 +4,13 @@
 
 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)
 - [Utility Script](./.wiki/Utility_Script.md)
 - [Backend Commands](./.wiki/Backend_Commands.md)
@@ -41,13 +44,14 @@ Musare is an open-source collaborative music listening and catalogue curation ap
     - Add songs to queue from verified catalogue or YouTube (party mode only)
 - **Song Management**
     - Verify songs to allow them to be searched for and played in official stations
-    - Hide songs to remove from unverified catalogue
-    - Import Album (WIP) to import songs in bulk
+    - Import Album to import songs in bulk
     - 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
-    - Request songs from YouTube in official stations or admin area
+    - Import YouTube playlists from admin area
     - Any song added to playlists or stations will be automatically requested
+    - Bulk admin management of songs
+    - Create songs from scratch
 - **Users**
     - Activity logs
     - Profile page showing public playlists and activity logs
@@ -64,7 +68,13 @@ Musare is an open-source collaborative music listening and catalogue curation ap
     - Admins can add/edit/remove news items
     - Markdown editor
 - **Dark 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
 ---
 
 ## Contact

+ 1 - 1
backend/Dockerfile

@@ -1,4 +1,4 @@
-FROM node:15
+FROM node:16
 
 RUN npm install -g nodemon
 

+ 16 - 10
backend/logic/actions/news.js

@@ -253,20 +253,26 @@ export default {
 	 * Gets the latest news item
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {boolean} newUser - whether the user requesting the newest news is a new user
 	 * @param {Function} cb - gets called with the result
 	 */
-	async newest(session, cb) {
+	async newest(session, newUser, cb) {
 		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
-		async.waterfall([next => newsModel.findOne({}).sort({ createdAt: "desc" }).exec(next)], async (err, news) => {
-			if (err) {
-				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-				this.log("ERROR", "NEWS_NEWEST", `Getting the latest news failed. "${err}"`);
-				return cb({ status: "error", message: err });
-			}
+		const query = { status: "published" };
+		if (newUser) query.showToNewUsers = true;
+		async.waterfall(
+			[next => newsModel.findOne(query).sort({ createdAt: "desc" }).exec(next)],
+			async (err, news) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "NEWS_NEWEST", `Getting the latest news failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
 
-			this.log("SUCCESS", "NEWS_NEWEST", `Successfully got the latest news.`, false);
-			return cb({ status: "success", data: { news } });
-		});
+				this.log("SUCCESS", "NEWS_NEWEST", `Successfully got the latest news.`, false);
+				return cb({ status: "success", data: { news } });
+			}
+		);
 	},
 
 	/**

+ 55 - 52
backend/logic/actions/playlists.js

@@ -525,25 +525,29 @@ export default {
 					userModel.findById(userId).select({ "preferences.orderOfPlaylists": -1 }).exec(next);
 				},
 
-				({ preferences }, next) => {
-					const { orderOfPlaylists } = preferences;
-
-					const match = {
-						createdBy: userId,
-						type: { $in: ["user", "user-liked", "user-disliked"] }
-					};
-
-					// if a playlist order exists
-					if (orderOfPlaylists > 0) match._id = { $in: orderOfPlaylists };
-
-					playlistModel
-						.aggregate()
-						.match(match)
-						.addFields({
-							weight: { $indexOfArray: [orderOfPlaylists, "$_id"] }
-						})
-						.sort({ weight: 1 })
-						.exec(next);
+				(user, next) => {
+					if (!user) next("User not found");
+					else {
+						const { preferences } = user;
+						const { orderOfPlaylists } = preferences;
+
+						const match = {
+							createdBy: userId,
+							type: { $in: ["user", "user-liked", "user-disliked"] }
+						};
+
+						// if a playlist order exists
+						if (orderOfPlaylists > 0) match._id = { $in: orderOfPlaylists };
+
+						playlistModel
+							.aggregate()
+							.match(match)
+							.addFields({
+								weight: { $indexOfArray: [orderOfPlaylists, "$_id"] }
+							})
+							.sort({ weight: 1 })
+							.exec(next);
+					}
 				},
 
 				(playlists, next) => {
@@ -598,25 +602,29 @@ export default {
 					userModel.findById(session.userId).select({ "preferences.orderOfPlaylists": -1 }).exec(next);
 				},
 
-				({ preferences }, next) => {
-					const { orderOfPlaylists } = preferences;
+				(user, next) => {
+					if (!user) next("User not found");
+					else {
+						const { preferences } = user;
+						const { orderOfPlaylists } = preferences;
 
-					const match = {
-						createdBy: session.userId,
-						type: { $in: ["user", "user-liked", "user-disliked"] }
-					};
+						const match = {
+							createdBy: session.userId,
+							type: { $in: ["user", "user-liked", "user-disliked"] }
+						};
 
-					// if a playlist order exists
-					if (orderOfPlaylists > 0) match._id = { $in: orderOfPlaylists };
+						// if a playlist order exists
+						if (orderOfPlaylists > 0) match._id = { $in: orderOfPlaylists };
 
-					playlistModel
-						.aggregate()
-						.match(match)
-						.addFields({
-							weight: { $indexOfArray: [orderOfPlaylists, "$_id"] }
-						})
-						.sort({ weight: 1 })
-						.exec(next);
+						playlistModel
+							.aggregate()
+							.match(match)
+							.addFields({
+								weight: { $indexOfArray: [orderOfPlaylists, "$_id"] }
+							})
+							.sort({ weight: 1 })
+							.exec(next);
+					}
 				}
 			],
 			async (err, playlists) => {
@@ -1207,22 +1215,21 @@ export default {
 					`Successfully added song "${youtubeId}" to private playlist "${playlistId}" for user "${session.userId}".`
 				);
 
-				if (!isSet && playlist.type !== "user-liked" && playlist.type !== "user-disliked") {
+				if (!isSet && playlist.type === "user" && playlist.privacy === "public") {
 					const songName = newSong.artists
 						? `${newSong.title} by ${newSong.artists.join(", ")}`
 						: newSong.title;
 
-					if (playlist.privacy === "public")
-						ActivitiesModule.runJob("ADD_ACTIVITY", {
-							userId: session.userId,
-							type: "playlist__add_song",
-							payload: {
-								message: `Added <youtubeId>${songName}</youtubeId> to playlist <playlistId>${playlist.displayName}</playlistId>`,
-								thumbnail: newSong.thumbnail,
-								playlistId,
-								youtubeId
-							}
-						});
+					ActivitiesModule.runJob("ADD_ACTIVITY", {
+						userId: session.userId,
+						type: "playlist__add_song",
+						payload: {
+							message: `Added <youtubeId>${songName}</youtubeId> to playlist <playlistId>${playlist.displayName}</playlistId>`,
+							thumbnail: newSong.thumbnail,
+							playlistId,
+							youtubeId
+						}
+					});
 				}
 
 				StationsModule.runJob("GET_STATIONS_THAT_INCLUDE_OR_EXCLUDE_PLAYLIST", { playlistId })
@@ -1552,11 +1559,7 @@ export default {
 					const { _id, title, artists, thumbnail } = newSong;
 					const songName = artists ? `${title} by ${artists.join(", ")}` : title;
 
-					if (
-						playlist.type !== "user-liked" &&
-						playlist.type !== "user-disliked" &&
-						playlist.privacy === "public"
-					) {
+					if (playlist.type === "user" && playlist.privacy === "public") {
 						ActivitiesModule.runJob("ADD_ACTIVITY", {
 							userId: session.userId,
 							type: "playlist__remove_song",

+ 100 - 8
backend/logic/actions/reports.js

@@ -25,10 +25,10 @@ CacheModule.runJob("SUB", {
 
 CacheModule.runJob("SUB", {
 	channel: "report.resolve",
-	cb: ({ reportId, songId }) =>
+	cb: ({ reportId, songId, resolved }) =>
 		WSModule.runJob("EMIT_TO_ROOMS", {
 			rooms: ["admin.reports", `edit-song.${songId}`, `view-report.${reportId}`],
-			args: ["event:admin.report.resolved", { data: { reportId } }]
+			args: ["event:admin.report.resolved", { data: { reportId, resolved } }]
 		})
 });
 
@@ -58,6 +58,33 @@ CacheModule.runJob("SUB", {
 	}
 });
 
+CacheModule.runJob("SUB", {
+	channel: "report.remove",
+	cb: reportId =>
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: ["admin.reports", `view-report.${reportId}`],
+			args: ["event:admin.report.removed", { data: { reportId } }]
+		})
+});
+
+CacheModule.runJob("SUB", {
+	channel: "report.update",
+	cb: async data => {
+		const { reportId } = data;
+
+		const reportModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "report"
+		});
+
+		reportModel.findOne({ _id: reportId }, (err, report) => {
+			WSModule.runJob("EMIT_TO_ROOMS", {
+				rooms: ["admin.reports", `view-report.${reportId}`],
+				args: ["event:admin.report.updated", { data: { report } }]
+			});
+		});
+	}
+});
+
 export default {
 	/**
 	 * Gets reports, used in the admin reports page by the AdvancedTable component
@@ -349,9 +376,10 @@ export default {
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {string} reportId - the id of the report that is getting resolved
+	 * @param {boolean} resolved - whether to set to resolved to true or false
 	 * @param {Function} cb - gets called with the result
 	 */
-	resolve: isAdminRequired(async function resolve(session, reportId, cb) {
+	resolve: isAdminRequired(async function resolve(session, reportId, resolved, cb) {
 		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
 
 		async.waterfall(
@@ -363,7 +391,7 @@ export default {
 				(report, next) => {
 					if (!report) return next("Report not found.");
 
-					report.resolved = true;
+					report.resolved = resolved;
 
 					return report.save(err => {
 						if (err) return next(err.message);
@@ -377,21 +405,32 @@ export default {
 					this.log(
 						"ERROR",
 						"REPORTS_RESOLVE",
-						`Resolving report "${reportId}" failed by user "${session.userId}". "${err}"`
+						`${resolved ? "R" : "Unr"}esolving report "${reportId}" failed by user "${
+							session.userId
+						}". "${err}"`
 					);
 					return cb({ status: "error", message: err });
 				}
 
 				CacheModule.runJob("PUB", {
 					channel: "report.resolve",
-					value: { reportId, songId }
+					value: { reportId, songId, resolved }
 				});
 
-				this.log("SUCCESS", "REPORTS_RESOLVE", `User "${session.userId}" resolved report "${reportId}".`);
+				CacheModule.runJob("PUB", {
+					channel: "report.update",
+					value: { reportId }
+				});
+
+				this.log(
+					"SUCCESS",
+					"REPORTS_RESOLVE",
+					`User "${session.userId}" ${resolved ? "" : "un"}resolved report "${reportId}".`
+				);
 
 				return cb({
 					status: "success",
-					message: "Successfully resolved Report"
+					message: `Successfully ${resolved ? "" : "un"}resolved Report`
 				});
 			}
 		);
@@ -442,6 +481,11 @@ export default {
 					value: { reportId, issueId, songId, resolved }
 				});
 
+				CacheModule.runJob("PUB", {
+					channel: "report.update",
+					value: { reportId }
+				});
+
 				this.log(
 					"SUCCESS",
 					"REPORTS_TOGGLE_ISSUE",
@@ -540,5 +584,53 @@ export default {
 				});
 			}
 		);
+	}),
+
+	/**
+	 * Removes a report
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {object} reportId - the id of the report item we want to remove
+	 * @param {Function} cb - gets called with the result
+	 */
+	remove: isAdminRequired(async function remove(session, reportId, cb) {
+		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (!reportId) return next("Please provide a report item id to remove.");
+					return next();
+				},
+
+				next => {
+					reportModel.deleteOne({ _id: reportId }, err => next(err));
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"REPORT_REMOVE",
+						`Removing report "${reportId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", { channel: "report.remove", value: reportId });
+
+				this.log(
+					"SUCCESS",
+					"REPORT_REMOVE",
+					`Removing report "${reportId}" successful by user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Successfully removed report"
+				});
+			}
+		);
 	})
 };

+ 78 - 6
backend/logic/actions/songs.js

@@ -441,6 +441,42 @@ export default {
 		);
 	}),
 
+	/**
+	 * Creates a song
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {object} newSong - the song object
+	 * @param {Function} cb
+	 */
+	create: isAdminRequired(async function create(session, newSong, cb) {
+		async.waterfall(
+			[
+				next => {
+					SongsModule.runJob("CREATE_SONG", { song: newSong, userId: session.userId }, this)
+						.then(song => next(null, song))
+						.catch(next);
+				}
+			],
+			async (err, song) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log("ERROR", "SONGS_CREATE", `Failed to create song "${JSON.stringify(newSong)}". "${err}"`);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "SONGS_CREATE", `Successfully created song "${song._id}".`);
+
+				return cb({
+					status: "success",
+					message: "Song has been successfully created",
+					data: { song }
+				});
+			}
+		);
+	}),
+
 	/**
 	 * Updates a song
 	 *
@@ -460,6 +496,22 @@ export default {
 
 				(_existingSong, next) => {
 					existingSong = _existingSong;
+
+					// Verify the song
+					if (existingSong.verified === false && song.verified === true) {
+						song.verifiedBy = session.userId;
+						song.verifiedAt = Date.now();
+					}
+					// Unverify the song
+					else if (existingSong.verified === true && song.verified === false) {
+						song.verifiedBy = null;
+						song.verifiedAt = null;
+					}
+
+					next();
+				},
+
+				next => {
 					songModel.updateOne({ _id: songId }, song, { runValidators: true }, next);
 				},
 
@@ -470,7 +522,10 @@ export default {
 								.concat(song.genres)
 								.filter((value, index, self) => self.indexOf(value) === index)
 								.forEach(genre => {
-									PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre })
+									PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", {
+										genre,
+										createPlaylist: song.verified
+									})
 										.then(() => {})
 										.catch(() => {});
 								});
@@ -840,7 +895,7 @@ export default {
 
 				(song, oldStatus, next) => {
 					song.genres.forEach(genre => {
-						PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre })
+						PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre, createPlaylist: true })
 							.then(() => {})
 							.catch(() => {});
 					});
@@ -961,6 +1016,8 @@ export default {
 
 				(song, next) => {
 					song.verified = false;
+					song.verifiedBy = null;
+					song.verifiedAt = null;
 					song.save(err => {
 						next(err, song);
 					});
@@ -968,7 +1025,7 @@ export default {
 
 				(song, next) => {
 					song.genres.forEach(genre => {
-						PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre })
+						PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre, createPlaylist: false })
 							.then(() => {})
 							.catch(() => {});
 					});
@@ -1843,11 +1900,16 @@ export default {
 						query.$set = { genres };
 					} else {
 						next("Invalid method.");
+						return;
 					}
 
 					songModel.updateMany({ _id: { $in: songsFound } }, query, { runValidators: true }, err => {
-						if (err) next(err);
+						if (err) {
+							next(err);
+							return;
+						}
 						SongsModule.runJob("UPDATE_SONGS", { songIds: songsFound });
+						next();
 					});
 				}
 			],
@@ -1936,11 +1998,16 @@ export default {
 						query.$set = { artists };
 					} else {
 						next("Invalid method.");
+						return;
 					}
 
 					songModel.updateMany({ _id: { $in: songsFound } }, query, { runValidators: true }, err => {
-						if (err) next(err);
+						if (err) {
+							next(err);
+							return;
+						}
 						SongsModule.runJob("UPDATE_SONGS", { songIds: songsFound });
+						next();
 					});
 				}
 			],
@@ -2029,11 +2096,16 @@ export default {
 						query.$set = { tags };
 					} else {
 						next("Invalid method.");
+						return;
 					}
 
 					songModel.updateMany({ _id: { $in: songsFound } }, query, { runValidators: true }, err => {
-						if (err) next(err);
+						if (err) {
+							next(err);
+							return;
+						}
 						SongsModule.runJob("UPDATE_SONGS", { songIds: songsFound });
+						next();
 					});
 				}
 			],

+ 10 - 5
backend/logic/actions/stations.js

@@ -66,11 +66,15 @@ CacheModule.runJob("SUB", {
 									this
 								).then(userModel =>
 									userModel.findOne({ _id: session.userId }, (err, user) => {
-										if (user.role === "admin")
+										if (user && user.role === "admin")
 											socket.dispatch("event:station.userCount.updated", {
 												data: { stationId, count }
 											});
-										else if (station.type === "community" && station.owner === session.userId)
+										else if (
+											user &&
+											station.type === "community" &&
+											station.owner === session.userId
+										)
 											socket.dispatch("event:station.userCount.updated", {
 												data: { stationId, count }
 											});
@@ -518,9 +522,9 @@ CacheModule.runJob("SUB", {
 						}).then(session => {
 							if (session) {
 								userModel.findOne({ _id: session.userId }, (err, user) => {
-									if (user.role === "admin")
+									if (user && user.role === "admin")
 										socket.dispatch("event:station.created", { data: { station } });
-									else if (station.type === "community" && station.owner === session.userId)
+									else if (user && station.type === "community" && station.owner === session.userId)
 										socket.dispatch("event:station.created", { data: { station } });
 								});
 							}
@@ -571,7 +575,8 @@ export default {
 					return next(null, { favoriteStations: [] });
 				},
 
-				({ favoriteStations }, next) => {
+				(user, next) => {
+					const favoriteStations = user ? user.favoriteStations : [];
 					CacheModule.runJob("HGETALL", { table: "stations" }, this).then(stations =>
 						next(null, stations, favoriteStations)
 					);

+ 136 - 8
backend/logic/actions/users.js

@@ -387,8 +387,68 @@ export default {
 					userModel.deleteMany({ _id: session.userId }, next);
 				},
 
-				// request data removal for user
+				// session
 				(res, next) => {
+					CacheModule.runJob("PUB", {
+						channel: "user.removeSessions",
+						value: session.userId
+					});
+
+					async.waterfall(
+						[
+							next => {
+								CacheModule.runJob("HGETALL", { table: "sessions" }, this)
+									.then(sessions => {
+										next(null, sessions);
+									})
+									.catch(next);
+							},
+
+							(sessions, next) => {
+								if (!sessions) return next(null, [], {});
+
+								const keys = Object.keys(sessions);
+
+								return next(null, keys, sessions);
+							},
+
+							(keys, sessions, next) => {
+								// temp fix, need to wait properly for the SUB/PUB refactor (on wekan)
+								const { userId } = session;
+								setTimeout(
+									() =>
+										async.each(
+											keys,
+											(sessionId, callback) => {
+												const session = sessions[sessionId];
+
+												if (session && session.userId === userId) {
+													CacheModule.runJob(
+														"HDEL",
+														{
+															table: "sessions",
+															key: sessionId
+														},
+														this
+													)
+														.then(() => callback(null))
+														.catch(callback);
+												} else callback();
+											},
+											err => {
+												next(err);
+											}
+										),
+									50
+								);
+							}
+						],
+						next
+					);
+				},
+
+				// request data removal for user
+				next => {
 					dataRequestModel.create({ userId: session.userId, type: "remove" }, next);
 				},
 
@@ -555,8 +615,68 @@ export default {
 					userModel.deleteMany({ _id: userId }, next);
 				},
 
-				// request data removal for user
+				// session
 				(res, next) => {
+					CacheModule.runJob("PUB", {
+						channel: "user.removeSessions",
+						value: session.userId
+					});
+
+					async.waterfall(
+						[
+							next => {
+								CacheModule.runJob("HGETALL", { table: "sessions" }, this)
+									.then(sessions => {
+										next(null, sessions);
+									})
+									.catch(next);
+							},
+
+							(sessions, next) => {
+								if (!sessions) return next(null, [], {});
+
+								const keys = Object.keys(sessions);
+
+								return next(null, keys, sessions);
+							},
+
+							(keys, sessions, next) => {
+								// temp fix, need to wait properly for the SUB/PUB refactor (on wekan)
+								const { userId } = session;
+								setTimeout(
+									() =>
+										async.each(
+											keys,
+											(sessionId, callback) => {
+												const session = sessions[sessionId];
+
+												if (session && session.userId === userId) {
+													CacheModule.runJob(
+														"HDEL",
+														{
+															table: "sessions",
+															key: sessionId
+														},
+														this
+													)
+														.then(() => callback(null))
+														.catch(callback);
+												} else callback();
+											},
+											err => {
+												next(err);
+											}
+										),
+									50
+								);
+							}
+						],
+						next
+					);
+				},
+
+				// request data removal for user
+				next => {
 					dataRequestModel.create({ userId, type: "remove" }, next);
 				},
 
@@ -612,7 +732,7 @@ export default {
 	 * Logs user in
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {string} identifier - the email of the user
+	 * @param {string} identifier - the username or email of the user
 	 * @param {string} password - the plaintext of the user
 	 * @param {Function} cb - gets called with the result
 	 */
@@ -625,9 +745,12 @@ export default {
 			[
 				// check if a user with the requested identifier exists
 				next => {
+					const query = {};
+					if (identifier.indexOf("@") !== -1) query["email.address"] = identifier;
+					else query.username = identifier;
 					userModel.findOne(
 						{
-							$or: [{ "email.address": identifier }]
+							$or: [query]
 						},
 						next
 					);
@@ -1129,7 +1252,7 @@ export default {
 								(sessionId, callback) => {
 									const session = sessions[sessionId];
 
-									if (session.userId === userId) {
+									if (session && session.userId === userId) {
 										// TODO Also maybe add this to this runJob
 										CacheModule.runJob("HDEL", {
 											table: "sessions",
@@ -1137,7 +1260,7 @@ export default {
 										})
 											.then(() => callback(null))
 											.catch(callback);
-									}
+									} else callback();
 								},
 								err => {
 									next(err);
@@ -1400,9 +1523,14 @@ export default {
 			[
 				next => {
 					userModel.findById(session.userId).select({ preferences: -1 }).exec(next);
+				},
+
+				(user, next) => {
+					if (!user) next("User not found");
+					else next(null, user);
 				}
 			],
-			async (err, { preferences }) => {
+			async (err, user) => {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 
@@ -1424,7 +1552,7 @@ export default {
 				return cb({
 					status: "success",
 					message: "Preferences successfully retrieved",
-					data: { preferences }
+					data: { preferences: user.preferences }
 				});
 			}
 		);

+ 1 - 1
backend/logic/app.js

@@ -487,7 +487,7 @@ class _AppModule extends CoreClass {
 				);
 			});
 
-			return resolve();
+			resolve();
 		});
 	}
 

+ 54 - 20
backend/logic/cache/index.js

@@ -134,19 +134,29 @@ class _CacheModule extends CoreClass {
 		return new Promise((resolve, reject) => {
 			let { key } = payload;
 
-			if (!key) return reject(new Error("Invalid key!"));
-			if (!payload.table) return reject(new Error("Invalid table!"));
+			if (!key) {
+				reject(new Error("Invalid key!"));
+				return;
+			}
+			if (!payload.table) {
+				reject(new Error("Invalid table!"));
+				return;
+			}
 			if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
 
-			return CacheModule.client.hget(payload.table, key, (err, value) => {
-				if (err) return reject(new Error(err));
+			CacheModule.client.hget(payload.table, key, (err, value) => {
+				if (err) {
+					reject(new Error(err));
+					return;
+				}
 				try {
 					value = JSON.parse(value);
 				} catch (e) {
-					return reject(err);
+					reject(err);
+					return;
 				}
 
-				return resolve(value);
+				resolve(value);
 			});
 		});
 	}
@@ -163,14 +173,23 @@ class _CacheModule extends CoreClass {
 		return new Promise((resolve, reject) => {
 			let { key } = payload;
 
-			if (!payload.table) return reject(new Error("Invalid table!"));
-			if (!key) return reject(new Error("Invalid key!"));
+			if (!payload.table) {
+				reject(new Error("Invalid table!"));
+				return;
+			}
+			if (!key) {
+				reject(new Error("Invalid key!"));
+				return;
+			}
 
 			if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
 
-			return CacheModule.client.hdel(payload.table, key, err => {
-				if (err) return reject(new Error(err));
-				return resolve();
+			CacheModule.client.hdel(payload.table, key, err => {
+				if (err) {
+					reject(new Error(err));
+					return;
+				}
+				resolve();
 			});
 		});
 	}
@@ -185,17 +204,23 @@ class _CacheModule extends CoreClass {
 	 */
 	HGETALL(payload) {
 		return new Promise((resolve, reject) => {
-			if (!payload.table) return reject(new Error("Invalid table!"));
+			if (!payload.table) {
+				reject(new Error("Invalid table!"));
+				return;
+			}
 
-			return CacheModule.client.hgetall(payload.table, (err, obj) => {
-				if (err) return reject(new Error(err));
+			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 = [];
 
-				return resolve(obj);
+				resolve(obj);
 			});
 		});
 	}
@@ -213,12 +238,18 @@ class _CacheModule extends CoreClass {
 		return new Promise((resolve, reject) => {
 			let { value } = payload;
 
-			if (!payload.channel) return reject(new Error("Invalid channel!"));
-			if (!value) return reject(new Error("Invalid value!"));
+			if (!payload.channel) {
+				reject(new Error("Invalid channel!"));
+				return;
+			}
+			if (!value) {
+				reject(new Error("Invalid value!"));
+				return;
+			}
 
 			if (["object", "array"].includes(typeof value)) value = JSON.stringify(value);
 
-			return CacheModule.client.publish(payload.channel, value, err => {
+			CacheModule.client.publish(payload.channel, value, err => {
 				if (err) reject(err);
 				else resolve();
 			});
@@ -235,7 +266,10 @@ class _CacheModule extends CoreClass {
 	 */
 	SUB(payload) {
 		return new Promise((resolve, reject) => {
-			if (!payload.channel) return reject(new Error("Invalid channel!"));
+			if (!payload.channel) {
+				reject(new Error("Invalid channel!"));
+				return;
+			}
 
 			if (subs[payload.channel] === undefined) {
 				subs[payload.channel] = {
@@ -264,7 +298,7 @@ class _CacheModule extends CoreClass {
 
 			subs[payload.channel].cbs.push(payload.cb);
 
-			return resolve();
+			resolve();
 		});
 	}
 

+ 13 - 5
backend/logic/db/index.js

@@ -7,7 +7,7 @@ import CoreClass from "../../core";
 
 const REQUIRED_DOCUMENT_VERSIONS = {
 	activity: 2,
-	news: 2,
+	news: 3,
 	playlist: 6,
 	punishment: 1,
 	queueSong: 1,
@@ -200,9 +200,8 @@ class _DBModule extends CoreClass {
 					this.schemas.song.path("genres").validate(songGenres, "Invalid genres.");
 
 					const songTags = tags =>
-						tags.filter(tag =>
-							new RegExp(/^[a-zA-Z0-9_]{1,64}$|^[a-zA-Z0-9_]{1,64}\[[a-zA-Z0-9_]{1,64}\]$/).test(tag)
-						).length === tags.length;
+						tags.filter(tag => /^[a-zA-Z0-9_]{1,64}$|^[a-zA-Z0-9_]{1,64}\[[a-zA-Z0-9_]{1,64}\]$/.test(tag))
+							.length === tags.length;
 					this.schemas.song.path("tags").validate(songTags, "Invalid tags.");
 
 					const songThumbnail = thumbnail => {
@@ -233,7 +232,16 @@ class _DBModule extends CoreClass {
 					// 	return songs[0].duration <= 10800;
 					// }, "Max 3 hours per song.");
 
-					this.schemas.playlist.index({ createdFor: 1, type: 1 }, { unique: true });
+					this.models.activity.syncIndexes();
+					this.models.dataRequest.syncIndexes();
+					this.models.news.syncIndexes();
+					this.models.playlist.syncIndexes();
+					this.models.punishment.syncIndexes();
+					this.models.queueSong.syncIndexes();
+					this.models.report.syncIndexes();
+					this.models.song.syncIndexes();
+					this.models.station.syncIndexes();
+					this.models.user.syncIndexes();
 
 					if (config.get("skipDbDocumentsVersionCheck")) resolve();
 					else {

+ 2 - 1
backend/logic/db/schemas/news.js

@@ -2,7 +2,8 @@ export default {
 	title: { type: String, required: true },
 	markdown: { type: String, required: true },
 	status: { type: String, enum: ["draft", "published", "archived"], required: true, default: "published" },
+	showToNewUsers: { type: Boolean, required: true, default: false },
 	createdBy: { type: String, required: true },
 	createdAt: { type: Number, default: Date.now, required: true },
-	documentVersion: { type: Number, default: 2, required: true }
+	documentVersion: { type: Number, default: 3, required: true }
 };

+ 7 - 6
backend/logic/db/schemas/playlist.js

@@ -1,16 +1,17 @@
 import mongoose from "mongoose";
 
 export default {
-	displayName: { type: String, min: 2, max: 32, required: true },
+	displayName: { type: String, min: 2, max: 32, trim: true, required: true },
 	songs: [
 		{
-			_id: { type: mongoose.Schema.Types.ObjectId, required: false },
-			youtubeId: { type: String },
+			_id: { type: mongoose.Schema.Types.ObjectId, required: true },
+			youtubeId: { type: String, required: true },
 			title: { type: String },
+			artists: [{ type: String }],
 			duration: { type: Number },
-			thumbnail: { type: String, required: false },
-			artists: { type: Array, required: false },
-			status: { type: String }
+			skipDuration: { type: Number },
+			thumbnail: { type: String },
+			verified: { type: Boolean }
 		}
 	],
 	createdBy: { type: String, required: true },

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

@@ -12,7 +12,7 @@ export default {
 				required: true
 			},
 			title: { type: String, required: true },
-			description: { type: String, required: false },
+			description: { type: String, trim: true, required: false },
 			resolved: { type: Boolean, default: false, required: true }
 		}
 	],

+ 5 - 5
backend/logic/db/schemas/song.js

@@ -1,12 +1,12 @@
 export default {
 	youtubeId: { type: String, min: 11, max: 11, required: true, index: true, unique: true },
-	title: { type: String, required: true },
-	artists: [{ type: String, default: [] }],
-	genres: [{ type: String, default: [] }],
-	tags: [{ type: String, default: [] }],
+	title: { type: String, trim: true, required: true },
+	artists: [{ type: String, trim: true, default: [] }],
+	genres: [{ type: String, trim: true, default: [] }],
+	tags: [{ type: String, trim: true, default: [] }],
 	duration: { type: Number, min: 1, required: true },
 	skipDuration: { type: Number, required: true, default: 0 },
-	thumbnail: { type: String },
+	thumbnail: { type: String, trim: true },
 	likes: { type: Number, default: 0, required: true },
 	dislikes: { type: Number, default: 0, required: true },
 	explicit: { type: Boolean },

+ 4 - 3
backend/logic/db/schemas/station.js

@@ -3,11 +3,11 @@ import mongoose from "mongoose";
 export default {
 	name: { type: String, lowercase: true, maxlength: 16, minlength: 2, index: true, unique: true, required: true },
 	type: { type: String, enum: ["official", "community"], required: true },
-	displayName: { type: String, minlength: 2, maxlength: 32, required: true, unique: true },
-	description: { type: String, minlength: 2, maxlength: 128, required: true },
+	displayName: { type: String, minlength: 2, maxlength: 32, trim: true, required: true, unique: true },
+	description: { type: String, minlength: 2, maxlength: 128, trim: true, required: true },
 	paused: { type: Boolean, default: false, required: true },
 	currentSong: {
-		_id: { type: String },
+		_id: { type: mongoose.Schema.Types.ObjectId },
 		youtubeId: { type: String },
 		title: { type: String },
 		artists: [{ type: String }],
@@ -28,6 +28,7 @@ export default {
 	locked: { type: Boolean, default: false },
 	queue: [
 		{
+			_id: { type: mongoose.Schema.Types.ObjectId, required: true },
 			youtubeId: { type: String, required: true },
 			title: { type: String },
 			artists: [{ type: String }],

+ 8 - 4
backend/logic/mail/index.js

@@ -45,7 +45,9 @@ class _MailModule extends CoreClass {
 				}
 			});
 
-		return new Promise(resolve => resolve());
+		return new Promise(resolve => {
+			resolve();
+		});
 	}
 
 	/**
@@ -57,8 +59,8 @@ class _MailModule extends CoreClass {
 	 */
 	SEND_MAIL(payload) {
 		return new Promise((resolve, reject) => {
-			if (MailModule.enabled)
-				return MailModule.transporter
+			if (MailModule.enabled) {
+				MailModule.transporter
 					.sendMail(payload.data)
 					.then(info => {
 						MailModule.log("SUCCESS", "MAIL_SEND", `Successfully sent email ${info.messageId}`);
@@ -68,8 +70,10 @@ class _MailModule extends CoreClass {
 						MailModule.log("ERROR", "MAIL_SEND", `Failed to send email. ${err}`);
 						return reject();
 					});
+				return;
+			}
 
-			return resolve();
+			resolve();
 		});
 	}
 

+ 47 - 0
backend/logic/migration/migrations/migration19.js

@@ -0,0 +1,47 @@
+import async from "async";
+
+/**
+ * Migration 19
+ *
+ * Migration for news showToNewUsers property.
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const newsModel = await MigrationModule.runJob("GET_MODEL", { modelName: "news" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 19. Finding news with version 2.`);
+					newsModel.updateMany(
+						{ documentVersion: 2 },
+						{
+							$set: { documentVersion: 3, showToNewUsers: false }
+						},
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 19. Matched: ${res.matchedCount}, modified: ${res.modifiedCount}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				}
+			],
+			err => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve();
+				}
+			}
+		);
+	});
+}

+ 5 - 3
backend/logic/notifications.js

@@ -212,12 +212,14 @@ class _NotificationsModule extends CoreClass {
 			if (
 				payload.unique &&
 				!!NotificationsModule.subscriptions.find(subscription => subscription.originalName === payload.name)
-			)
-				return resolve({
+			) {
+				resolve({
 					subscription: NotificationsModule.subscriptions.find(
 						subscription => subscription.originalName === payload.name
 					)
 				});
+				return;
+			}
 
 			const subscription = {
 				originalName: payload.name,
@@ -227,7 +229,7 @@ class _NotificationsModule extends CoreClass {
 
 			NotificationsModule.subscriptions.push(subscription);
 
-			return resolve({ subscription });
+			resolve({ subscription });
 		});
 	}
 

+ 25 - 23
backend/logic/playlists.js

@@ -36,7 +36,7 @@ class _PlaylistsModule extends CoreClass {
 
 		this.setStage(2);
 
-		return new Promise((resolve, reject) =>
+		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
 					next => {
@@ -106,8 +106,8 @@ class _PlaylistsModule extends CoreClass {
 						resolve();
 					}
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -417,6 +417,7 @@ class _PlaylistsModule extends CoreClass {
 	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.genre - the genre
+	 * @param {string} payload.createPlaylist - create playlist if it doesn't exist, default false
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	AUTOFILL_GENRE_PLAYLIST(payload) {
@@ -434,13 +435,14 @@ class _PlaylistsModule extends CoreClass {
 							})
 							.catch(err => {
 								if (err.message === "Playlist not found") {
-									PlaylistsModule.runJob("CREATE_GENRE_PLAYLIST", { genre: payload.genre }, this)
-										.then(playlistId => {
-											next(null, playlistId);
-										})
-										.catch(err => {
-											next(err);
-										});
+									if (payload.createPlaylist)
+										PlaylistsModule.runJob("CREATE_GENRE_PLAYLIST", { genre: payload.genre }, this)
+											.then(playlistId => {
+												next(null, playlistId);
+											})
+											.catch(err => {
+												next(err);
+											});
 								} else next(err);
 							});
 					},
@@ -800,7 +802,7 @@ class _PlaylistsModule extends CoreClass {
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	GET_PLAYLIST(payload) {
-		return new Promise((resolve, reject) =>
+		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
 					next => {
@@ -859,8 +861,8 @@ class _PlaylistsModule extends CoreClass {
 					if (err && err !== true) return reject(new Error(err));
 					return resolve(playlist);
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -871,7 +873,7 @@ class _PlaylistsModule extends CoreClass {
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	UPDATE_PLAYLIST(payload) {
-		return new Promise((resolve, reject) =>
+		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
 					next => {
@@ -907,8 +909,8 @@ class _PlaylistsModule extends CoreClass {
 					if (err && err !== true) return reject(new Error(err));
 					return resolve(playlist);
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -919,7 +921,7 @@ class _PlaylistsModule extends CoreClass {
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	DELETE_PLAYLIST(payload) {
-		return new Promise((resolve, reject) =>
+		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
 					next => {
@@ -955,8 +957,8 @@ class _PlaylistsModule extends CoreClass {
 					if (err && err !== true) return reject(new Error(err));
 					return resolve();
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -975,7 +977,7 @@ class _PlaylistsModule extends CoreClass {
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	SEARCH(payload) {
-		return new Promise((resolve, reject) =>
+		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
 					next => {
@@ -1044,8 +1046,8 @@ class _PlaylistsModule extends CoreClass {
 					if (err && err !== true) return reject(new Error(err));
 					return resolve(data);
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -1121,7 +1123,7 @@ class _PlaylistsModule extends CoreClass {
 					},
 
 					(genre, next) => {
-						PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre }, this)
+						PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre, createPlaylist: true }, this)
 							.then(() => {
 								next();
 							})

+ 9 - 9
backend/logic/punishments.js

@@ -30,7 +30,7 @@ class _PunishmentsModule extends CoreClass {
 		this.punishmentModel = this.PunishmentModel = await DBModule.runJob("GET_MODEL", { modelName: "punishment" });
 		this.punishmentSchemaCache = await CacheModule.runJob("GET_SCHEMA", { schemaName: "punishment" });
 
-		return new Promise((resolve, reject) =>
+		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
 					next => {
@@ -98,8 +98,8 @@ class _PunishmentsModule extends CoreClass {
 						reject(new Error(formattedErr));
 					} else resolve();
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -168,7 +168,7 @@ class _PunishmentsModule extends CoreClass {
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	GET_PUNISHMENT(payload) {
-		return new Promise((resolve, reject) =>
+		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
 					next => {
@@ -212,8 +212,8 @@ class _PunishmentsModule extends CoreClass {
 					if (err && err !== true) return reject(new Error(err));
 					return resolve(punishment);
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -261,7 +261,7 @@ class _PunishmentsModule extends CoreClass {
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	ADD_PUNISHMENT(payload) {
-		return new Promise((resolve, reject) =>
+		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
 					next => {
@@ -298,8 +298,8 @@ class _PunishmentsModule extends CoreClass {
 					if (err) return reject(new Error(err));
 					return resolve(punishment);
 				}
-			)
-		);
+			);
+		});
 	}
 }
 

+ 96 - 38
backend/logic/songs.js

@@ -49,7 +49,7 @@ class _SongsModule extends CoreClass {
 
 		this.setStage(2);
 
-		return new Promise((resolve, reject) =>
+		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
 					next => {
@@ -115,8 +115,8 @@ class _SongsModule extends CoreClass {
 						reject(new Error(err));
 					} else resolve();
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -127,7 +127,7 @@ class _SongsModule extends CoreClass {
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	GET_SONG(payload) {
-		return new Promise((resolve, reject) =>
+		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
 					next => {
@@ -161,8 +161,8 @@ class _SongsModule extends CoreClass {
 					if (err && err !== true) return reject(new Error(err));
 					return resolve({ song });
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -174,7 +174,7 @@ class _SongsModule extends CoreClass {
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	GET_SONGS(payload) {
-		return new Promise((resolve, reject) =>
+		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
 					next => {
@@ -201,8 +201,8 @@ class _SongsModule extends CoreClass {
 					if (err && err !== true) return reject(new Error(err));
 					return resolve({ songs });
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -215,7 +215,7 @@ class _SongsModule extends CoreClass {
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	ENSURE_SONG_EXISTS_BY_YOUTUBE_ID(payload) {
-		return new Promise((resolve, reject) =>
+		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
 					next => {
@@ -257,8 +257,8 @@ class _SongsModule extends CoreClass {
 					if (err && err !== true) return reject(new Error(err));
 					return resolve({ song });
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -269,7 +269,7 @@ class _SongsModule extends CoreClass {
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	GET_SONG_FROM_YOUTUBE_ID(payload) {
-		return new Promise((resolve, reject) =>
+		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
 					next => {
@@ -280,8 +280,61 @@ class _SongsModule extends CoreClass {
 					if (err && err !== true) return reject(new Error(err));
 					return resolve({ song });
 				}
-			)
-		);
+			);
+		});
+	}
+
+	/**
+	 * Create song
+	 *
+	 * @param {object} payload - an object containing the payload
+	 * @param {string} payload.song - the song object
+	 * @param {string} payload.userId - the user id of the person requesting the song
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	CREATE_SONG(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						DBModule.runJob("GET_MODEL", { modelName: "user" }, this)
+							.then(UserModel => {
+								UserModel.findOne(
+									{ _id: payload.userId },
+									{ "preferences.anonymousSongRequests": 1 },
+									next
+								);
+							})
+							.catch(next);
+					},
+
+					(user, next) => {
+						const song = new SongsModule.SongModel({
+							...payload.song,
+							requestedBy: user.preferences.anonymousSongRequests ? null : payload.userId,
+							requestedAt: Date.now()
+						});
+						if (song.verified) {
+							song.verifiedBy = payload.userId;
+							song.verifiedAt = Date.now();
+						}
+						song.save({ validateBeforeSave: true }, err => {
+							if (err) return next(err, song);
+							return next(null, song);
+						});
+					},
+
+					(song, next) => {
+						SongsModule.runJob("UPDATE_SONG", { songId: song._id });
+						return next(null, song);
+					}
+				],
+				(err, song) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({ song });
+				}
+			);
+		});
 	}
 
 	/**
@@ -293,7 +346,7 @@ class _SongsModule extends CoreClass {
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	UPDATE_SONG(payload) {
-		return new Promise((resolve, reject) =>
+		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
 					next => {
@@ -439,7 +492,11 @@ class _SongsModule extends CoreClass {
 							song.genres,
 							1,
 							(genre, next) => {
-								PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre }, this)
+								PlaylistsModule.runJob(
+									"AUTOFILL_GENRE_PLAYLIST",
+									{ genre, createPlaylist: song.verified },
+									this
+								)
 									.then(() => {
 										next();
 									})
@@ -463,8 +520,8 @@ class _SongsModule extends CoreClass {
 
 					return resolve(song);
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -479,7 +536,7 @@ class _SongsModule extends CoreClass {
 		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
 		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
 
-		return new Promise((resolve, reject) =>
+		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
 					// Get songs from Mongo
@@ -696,16 +753,17 @@ class _SongsModule extends CoreClass {
 						const genresToAutofill = new Set();
 
 						songs.forEach(song => {
-							song.genres.forEach(genre => {
-								genresToAutofill.add(genre);
-							});
+							if (song.verified)
+								song.genres.forEach(genre => {
+									genresToAutofill.add(genre);
+								});
 						});
 
 						async.eachLimit(
 							genresToAutofill,
 							1,
 							(genre, next) => {
-								PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre }, this)
+								PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre, createPlaylist: true }, this)
 									.then(() => {
 										next();
 									})
@@ -740,8 +798,8 @@ class _SongsModule extends CoreClass {
 
 					return resolve();
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -750,7 +808,7 @@ class _SongsModule extends CoreClass {
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	UPDATE_ALL_SONGS() {
-		return new Promise((resolve, reject) =>
+		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
 					next => {
@@ -784,8 +842,8 @@ class _SongsModule extends CoreClass {
 					if (err && err !== true) return reject(new Error(err));
 					return resolve();
 				}
-			)
-		);
+			);
+		});
 	}
 
 	// /**
@@ -880,7 +938,7 @@ class _SongsModule extends CoreClass {
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	SEARCH(payload) {
-		return new Promise((resolve, reject) =>
+		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
 					next => {
@@ -962,8 +1020,8 @@ class _SongsModule extends CoreClass {
 					if (err && err !== true) return reject(new Error(err));
 					return resolve(data);
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -1067,7 +1125,7 @@ class _SongsModule extends CoreClass {
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	GET_ALL_GENRES() {
-		return new Promise((resolve, reject) =>
+		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
 					next => {
@@ -1092,8 +1150,8 @@ class _SongsModule extends CoreClass {
 					if (err && err !== true) return reject(new Error(err));
 					return resolve({ genres });
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -1104,7 +1162,7 @@ class _SongsModule extends CoreClass {
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	GET_ALL_SONGS_WITH_GENRE(payload) {
-		return new Promise((resolve, reject) =>
+		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
 					next => {
@@ -1121,8 +1179,8 @@ class _SongsModule extends CoreClass {
 					if (err && err !== true) return reject(new Error(err));
 					return resolve({ songs });
 				}
-			)
-		);
+			);
+		});
 	}
 
 	// runjob songs GET_ORPHANED_PLAYLIST_SONGS {}

+ 9 - 6
backend/logic/stations.js

@@ -87,7 +87,7 @@ class _StationsModule extends CoreClass {
 		const stationModel = (this.stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }));
 		const stationSchema = (this.stationSchema = await CacheModule.runJob("GET_SCHEMA", { schemaName: "station" }));
 
-		return new Promise((resolve, reject) =>
+		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
 					next => {
@@ -183,8 +183,8 @@ class _StationsModule extends CoreClass {
 						resolve();
 					}
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -364,7 +364,7 @@ class _StationsModule extends CoreClass {
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	async GET_STATION_BY_NAME(payload) {
-		return new Promise((resolve, reject) =>
+		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
 					next => {
@@ -387,8 +387,8 @@ class _StationsModule extends CoreClass {
 					if (err && err !== true) return reject(new Error(err));
 					return resolve(station);
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -502,6 +502,9 @@ class _StationsModule extends CoreClass {
 						const songsToAdd = [];
 						let lastSongAdded = null;
 
+						if (station.currentSong && station.currentSong.youtubeId)
+							currentYoutubeIds.push(station.currentSong.youtubeId);
+
 						playlistSongs.every(song => {
 							if (
 								songsToAdd.length < songsStillNeeded &&

+ 20 - 10
backend/logic/utils.js

@@ -17,7 +17,9 @@ class _UtilsModule extends CoreClass {
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	initialize() {
-		return new Promise(resolve => resolve());
+		return new Promise(resolve => {
+			resolve();
+		});
 	}
 
 	/**
@@ -31,7 +33,10 @@ class _UtilsModule extends CoreClass {
 		return new Promise((resolve, reject) => {
 			const cookies = {};
 
-			if (typeof payload.cookieString !== "string") return reject(new Error("Cookie string is not a string"));
+			if (typeof payload.cookieString !== "string") {
+				reject(new Error("Cookie string is not a string"));
+				return;
+			}
 
 			// eslint-disable-next-line array-callback-return
 			payload.cookieString.split("; ").map(cookie => {
@@ -41,7 +46,7 @@ class _UtilsModule extends CoreClass {
 				);
 			});
 
-			return resolve(cookies);
+			resolve(cookies);
 		});
 	}
 
@@ -76,12 +81,13 @@ class _UtilsModule extends CoreClass {
 					this
 				);
 			} catch (err) {
-				return reject(err);
+				reject(err);
+				return;
 			}
 
 			delete cookies[payload.cookieName];
 
-			return resolve();
+			resolve();
 		});
 	}
 
@@ -135,7 +141,9 @@ class _UtilsModule extends CoreClass {
 			randomChars.push(chars[randomNums[i]]);
 		}
 
-		return new Promise(resolve => resolve(randomChars.join("")));
+		return new Promise(resolve => {
+			resolve(randomChars.join(""));
+		});
 	}
 
 	/**
@@ -148,9 +156,9 @@ class _UtilsModule extends CoreClass {
 	 */
 	GET_RANDOM_NUMBER(payload) {
 		// min, max
-		return new Promise(resolve =>
-			resolve(Math.floor(Math.random() * (payload.max - payload.min + 1)) + payload.min)
-		);
+		return new Promise(resolve => {
+			resolve(Math.floor(Math.random() * (payload.max - payload.min + 1)) + payload.min);
+		});
 	}
 
 	/**
@@ -342,7 +350,9 @@ class _UtilsModule extends CoreClass {
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	DEBUG() {
-		return new Promise(resolve => resolve());
+		return new Promise(resolve => {
+			resolve();
+		});
 	}
 }
 

+ 38 - 30
backend/logic/ws.js

@@ -86,7 +86,7 @@ class _WSModule extends CoreClass {
 
 			this.setStage(4);
 
-			return resolve();
+			resolve();
 		});
 	}
 
@@ -96,7 +96,9 @@ class _WSModule extends CoreClass {
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	WS() {
-		return new Promise(resolve => resolve(WSModule._io));
+		return new Promise(resolve => {
+			resolve(WSModule._io);
+		});
 	}
 
 	/**
@@ -117,7 +119,7 @@ class _WSModule extends CoreClass {
 				});
 
 			// socket doesn't exist
-			return resolve();
+			resolve();
 		});
 	}
 
@@ -134,7 +136,7 @@ class _WSModule extends CoreClass {
 			const sockets = [];
 
 			if (clients) {
-				return async.each(
+				async.each(
 					Object.keys(clients),
 					(id, next) => {
 						const { session } = clients[id];
@@ -143,9 +145,10 @@ class _WSModule extends CoreClass {
 					},
 					() => resolve(sockets)
 				);
+				return;
 			}
 
-			return resolve();
+			resolve();
 		});
 	}
 
@@ -160,7 +163,7 @@ class _WSModule extends CoreClass {
 		return new Promise((resolve, reject) => {
 			const sockets = [];
 
-			return async.eachLimit(
+			async.eachLimit(
 				WSModule._io.clients,
 				1,
 				(socket, next) => {
@@ -198,7 +201,7 @@ class _WSModule extends CoreClass {
 
 			const sockets = [];
 
-			return async.each(
+			async.each(
 				Object.keys(clients),
 				(id, next) => {
 					const { session } = clients[id];
@@ -228,7 +231,7 @@ class _WSModule extends CoreClass {
 			const sockets = [];
 
 			if (clients) {
-				return async.each(
+				async.each(
 					Object.keys(clients),
 					(id, next) => {
 						const { session } = clients[id];
@@ -237,9 +240,10 @@ class _WSModule extends CoreClass {
 					},
 					() => resolve(sockets)
 				);
+				return;
 			}
 
-			return resolve();
+			resolve();
 		});
 	}
 
@@ -257,7 +261,7 @@ class _WSModule extends CoreClass {
 				WSModule.rooms[room] = WSModule.rooms[room].filter(participant => participant !== payload.socketId);
 			});
 
-			return resolve();
+			resolve();
 		});
 	}
 
@@ -277,7 +281,7 @@ class _WSModule extends CoreClass {
 					participant => participant !== payload.socketId
 				);
 
-			return resolve();
+			resolve();
 		});
 	}
 
@@ -297,7 +301,7 @@ class _WSModule extends CoreClass {
 				if (WSModule.rooms[room].indexOf(socketId) === -1) WSModule.rooms[room].push(socketId);
 			} else WSModule.rooms[room] = [socketId];
 
-			return resolve();
+			resolve();
 		});
 	}
 
@@ -312,15 +316,17 @@ class _WSModule extends CoreClass {
 	async EMIT_TO_ROOM(payload) {
 		return new Promise(resolve => {
 			// if the room exists
-			if (WSModule.rooms[payload.room] && WSModule.rooms[payload.room].length > 0)
-				return WSModule.rooms[payload.room].forEach(async socketId => {
+			if (WSModule.rooms[payload.room] && WSModule.rooms[payload.room].length > 0) {
+				WSModule.rooms[payload.room].forEach(async socketId => {
 					// get every socketId (and thus every socket) in the room, and dispatch to each
 					const socket = await WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId }, this);
 					if (socket) socket.dispatch(...payload.args);
-					return resolve();
+					resolve();
 				});
+				return;
+			}
 
-			return resolve();
+			resolve();
 		});
 	}
 
@@ -333,16 +339,16 @@ class _WSModule extends CoreClass {
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	async EMIT_TO_ROOMS(payload) {
-		return new Promise(resolve =>
+		return new Promise(resolve => {
 			async.each(
 				payload.rooms,
 				(room, next) => {
 					WSModule.runJob("EMIT_TO_ROOM", { room, args: payload.args });
-					return next();
+					next();
 				},
 				() => resolve()
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -364,7 +370,7 @@ class _WSModule extends CoreClass {
 			if (WSModule.rooms[room]) WSModule.rooms[room].push(socketId);
 			else WSModule.rooms[room] = [socketId];
 
-			return resolve();
+			resolve();
 		});
 	}
 
@@ -394,7 +400,7 @@ class _WSModule extends CoreClass {
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	SOCKETS_LEAVE_SONG_ROOMS(payload) {
-		return new Promise(resolve =>
+		return new Promise(resolve => {
 			Promise.allSettled(
 				payload.sockets.map(async socketId => {
 					const rooms = await WSModule.runJob("GET_ROOMS_FOR_SOCKET", { socketId }, this);
@@ -404,8 +410,8 @@ class _WSModule extends CoreClass {
 							WSModule.rooms[room] = WSModule.rooms[room].filter(participant => participant !== socketId);
 					});
 				})
-			).then(() => resolve())
-		);
+			).then(() => resolve());
+		});
 	}
 
 	/**
@@ -417,8 +423,8 @@ class _WSModule extends CoreClass {
 	 */
 	async GET_SOCKETS_FOR_ROOM(payload) {
 		return new Promise(resolve => {
-			if (WSModule.rooms[payload.room]) return resolve(WSModule.rooms[payload.room]);
-			return resolve([]);
+			if (WSModule.rooms[payload.room]) resolve(WSModule.rooms[payload.room]);
+			else resolve([]);
 		});
 	}
 
@@ -437,7 +443,7 @@ class _WSModule extends CoreClass {
 				if (WSModule.rooms[room].includes(payload.socketId)) rooms.push(room);
 			});
 
-			return resolve(rooms);
+			resolve(rooms);
 		});
 	}
 
@@ -454,7 +460,7 @@ class _WSModule extends CoreClass {
 
 			socket.ip = req.headers["x-forwarded-for"] || "0..0.0";
 
-			return async.waterfall(
+			async.waterfall(
 				[
 					next => {
 						if (!req.headers.cookie) return next("No cookie exists yet.");
@@ -557,7 +563,9 @@ class _WSModule extends CoreClass {
 
 				socket.dispatch("keep.event:banned", { data: { ban: socket.banishment.ban } });
 
-				return socket.close(); // close socket connection
+				socket.close(); // close socket connection
+
+				return;
 			}
 
 			WSModule.log("INFO", "IO_CONNECTION", `User connected. IP: ${socket.ip}.${sessionInfo}`);
@@ -652,7 +660,7 @@ class _WSModule extends CoreClass {
 				});
 			});
 
-			return resolve();
+			resolve();
 		});
 	}
 

+ 10 - 8
backend/logic/youtube.js

@@ -93,7 +93,7 @@ class _YouTubeModule extends CoreClass {
 
 		if (payload.pageToken) params.pageToken = payload.pageToken;
 
-		return new Promise((resolve, reject) =>
+		return new Promise((resolve, reject) => {
 			YouTubeModule.rateLimiter.continue().then(() => {
 				YouTubeModule.rateLimiter.restart();
 				YouTubeModule.axios
@@ -122,8 +122,8 @@ class _YouTubeModule extends CoreClass {
 						YouTubeModule.log("ERROR", "SEARCH", `${err.message}`);
 						return reject(new Error("An error has occured. Please try again later."));
 					});
-			})
-		);
+			});
+		});
 	}
 
 	/**
@@ -224,16 +224,17 @@ class _YouTubeModule extends CoreClass {
 	 */
 	GET_PLAYLIST(payload) {
 		return new Promise((resolve, reject) => {
-			const regex = new RegExp(`[\\?&]list=([^&#]*)`);
+			const regex = /[\\?&]list=([^&#]*)/;
 			const splitQuery = regex.exec(payload.url);
 
 			if (!splitQuery) {
 				YouTubeModule.log("ERROR", "GET_PLAYLIST", "Invalid YouTube playlist URL query.");
-				return reject(new Error("Invalid playlist URL."));
+				reject(new Error("Invalid playlist URL."));
+				return;
 			}
 			const playlistId = splitQuery[1];
 
-			return async.waterfall(
+			async.waterfall(
 				[
 					next => {
 						let songs = [];
@@ -367,7 +368,8 @@ class _YouTubeModule extends CoreClass {
 			const localVideoIds = payload.videoIds.splice(page * 50, videosPerPage);
 
 			if (localVideoIds.length === 0) {
-				return resolve({ videoIds: [] });
+				resolve({ videoIds: [] });
+				return;
 			}
 
 			const params = {
@@ -377,7 +379,7 @@ class _YouTubeModule extends CoreClass {
 				maxResults: videosPerPage
 			};
 
-			return YouTubeModule.rateLimiter.continue().then(() => {
+			YouTubeModule.rateLimiter.continue().then(() => {
 				YouTubeModule.rateLimiter.restart();
 				YouTubeModule.axios
 					.get("https://www.googleapis.com/youtube/v3/videos", {

Fichier diff supprimé car celui-ci est trop grand
+ 228 - 388
backend/package-lock.json


+ 15 - 14
backend/package.json

@@ -1,7 +1,7 @@
 {
   "name": "musare-backend",
   "private": true,
-  "version": "3.3.1",
+  "version": "3.4.0",
   "type": "module",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "index.js",
@@ -15,33 +15,34 @@
     "lint": "npx eslint logic"
   },
   "dependencies": {
-    "async": "^3.2.1",
-    "axios": "^0.22.0",
+    "async": "^3.2.3",
+    "axios": "^0.25.0",
     "bcrypt": "^5.0.1",
     "bluebird": "^3.7.2",
-    "body-parser": "^1.19.0",
-    "config": "^3.3.6",
-    "cookie-parser": "^1.4.5",
+    "body-parser": "^1.19.1",
+    "config": "^3.3.7",
+    "cookie-parser": "^1.4.6",
     "cors": "^2.8.5",
-    "express": "^4.17.1",
+    "express": "^4.17.2",
     "moment": "^2.29.1",
-    "mongoose": "^6.0.10",
-    "nodemailer": "^6.7.0",
+    "mongoose": "^6.2.3",
+    "nodemailer": "^6.7.2",
     "oauth": "^0.9.15",
     "redis": "^3.1.2",
     "retry-axios": "^2.6.0",
     "sha256": "^0.2.0",
+    "socks": "^2.6.2",
     "underscore": "^1.13.1",
     "ws": "^8.2.3"
   },
   "devDependencies": {
-    "eslint": "^7.32.0",
-    "eslint-config-airbnb-base": "^14.2.1",
+    "eslint": "^8.8.0",
+    "eslint-config-airbnb-base": "^15.0.0",
     "eslint-config-prettier": "^8.3.0",
-    "eslint-plugin-import": "^2.24.2",
-    "eslint-plugin-jsdoc": "^36.1.1",
+    "eslint-plugin-import": "^2.25.4",
+    "eslint-plugin-jsdoc": "^37.8.2",
     "eslint-plugin-prettier": "^4.0.0",
-    "prettier": "2.4.1",
+    "prettier": "2.5.1",
     "trace-unhandled": "^2.0.1"
   }
 }

+ 9 - 6
docker-compose.yml

@@ -3,6 +3,7 @@ services:
 
   backend:
     build: ./backend
+    restart: ${RESTART_POLICY}
     ports:
       - "${BACKEND_HOST}:${BACKEND_PORT}:8080"
     volumes:
@@ -17,6 +18,7 @@ services:
 
   frontend:
     build: ./frontend
+    restart: ${RESTART_POLICY}
     ports:
       - "${FRONTEND_HOST}:${FRONTEND_PORT}:80"
     volumes:
@@ -29,7 +31,8 @@ services:
       - backend
 
   mongo:
-    image: mongo:4.0
+    image: mongo:${MONGO_VERSION}
+    restart: ${RESTART_POLICY}
     ports:
       - "${MONGO_HOST}:${MONGO_PORT}:${MONGO_PORT}"
     environment:
@@ -42,13 +45,13 @@ services:
       - MONGO_USER_PASSWORD=${MONGO_USER_PASSWORD}
     volumes:
       - ./tools/docker/setup-mongo.sh:/docker-entrypoint-initdb.d/setup-mongo.sh
-      - ./.db:/data/db
+      - ${MONGO_DATA_LOCATION}:/data/db
 
   redis:
-    image: redis
+    image: redis:6.2
+    restart: ${RESTART_POLICY}
     ports:
       - "${REDIS_HOST}:${REDIS_PORT}:6379"
-    command: "--notify-keyspace-events Ex --requirepass ${REDIS_PASSWORD}
-      --appendonly yes"
+    command: "--notify-keyspace-events Ex --requirepass ${REDIS_PASSWORD} --appendonly yes"
     volumes:
-      - .redis:/data
+      - ${REDIS_DATA_LOCATION}:/data

+ 2 - 1
frontend/.eslintrc

@@ -41,6 +41,7 @@
 			"error"
 		],
 		"vue/order-in-components": 2,
-		"vue/no-v-for-template-key": 0
+		"vue/no-v-for-template-key": 0,
+		"vue/multi-word-component-names": 0
 	}
 }

+ 2 - 2
frontend/Dockerfile

@@ -1,4 +1,4 @@
-FROM node:14
+FROM node:16
 
 RUN apt-get update
 RUN apt-get install nginx -y
@@ -8,7 +8,7 @@ WORKDIR /opt
 ADD package.json /opt/package.json
 ADD package-lock.json /opt/package-lock.json
 
-RUN npm install -g webpack@5.58.1 webpack-cli@4.9.0
+RUN npm install -g webpack@5.68.0 webpack-cli@4.9.2
 
 RUN npm install
 

+ 7 - 3
frontend/dist/config/template.json

@@ -21,10 +21,14 @@
 	"siteSettings": {
 		"logo_white": "/assets/white_wordmark.png",
 		"logo_blue": "/assets/blue_wordmark.png",
+		"logo_small": "/assets/favicon/mstile-144x144.png",
 		"sitename": "Musare",
-		"github": "https://github.com/Musare/Musare",
+		"footerLinks": {
+			"GitHub": "https://github.com/Musare/Musare"
+		},
 		"mediasession": false,
-		"christmas": false
+		"christmas": false,
+		"registrationDisabled": false
 	},
 	"messages": {
 		"accountRemoval": "Your account will be deactivated instantly and your data will shortly be deleted by an admin."
@@ -49,5 +53,5 @@
 		"version": true
 	},
 	"skipConfigVersionCheck": false,
-	"configVersion": 9
+	"configVersion": 11
 }

Fichier diff supprimé car celui-ci est trop grand
+ 348 - 462
frontend/package-lock.json


+ 20 - 19
frontend/package.json

@@ -5,7 +5,7 @@
     "*.vue"
   ],
   "private": true,
-  "version": "3.3.1",
+  "version": "3.4.0",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "main.js",
   "author": "Musare Team",
@@ -17,49 +17,50 @@
     "prod": "npx webpack --config webpack.prod.js"
   },
   "devDependencies": {
-    "@babel/core": "^7.15.8",
-    "@babel/eslint-parser": "^7.15.8",
+    "@babel/core": "^7.17.0",
+    "@babel/eslint-parser": "^7.17.0",
     "@babel/plugin-proposal-object-rest-spread": "^7.15.6",
     "@babel/plugin-syntax-dynamic-import": "^7.8.3",
-    "@babel/plugin-transform-runtime": "^7.15.8",
-    "@babel/preset-env": "^7.15.8",
+    "@babel/plugin-transform-runtime": "^7.17.0",
+    "@babel/preset-env": "^7.16.8",
     "@vue/compiler-sfc": "^3.2.20",
     "babel-loader": "^8.2.2",
-    "css-loader": "^6.4.0",
-    "eslint": "^7.32.0",
+    "css-loader": "^6.6.0",
+    "eslint": "^8.8.0",
     "eslint-config-prettier": "^8.3.0",
     "eslint-plugin-import": "^2.24.2",
     "eslint-plugin-prettier": "^4.0.0",
-    "eslint-plugin-vue": "^7.19.1",
+    "eslint-plugin-vue": "^8.4.0",
     "eslint-webpack-plugin": "^3.0.1",
     "fetch": "^1.1.0",
-    "node-sass": "^6.0.1",
+    "less": "^4.1.2",
+    "less-loader": "^10.2.0",
     "prettier": "^2.4.1",
-    "sass-loader": "^12.1.0",
+    "style-resources-loader": "^1.5.0",
     "vue-style-loader": "^4.1.3",
-    "webpack-cli": "^4.9.0",
-    "webpack-dev-server": "^4.3.1"
+    "webpack-cli": "^4.9.2",
+    "webpack-dev-server": "^4.7.4"
   },
   "dependencies": {
-    "@babel/runtime": "^7.15.4",
+    "@babel/runtime": "^7.17.0",
     "can-autoplay": "^3.0.2",
     "config": "^3.3.6",
     "date-fns": "^2.25.0",
     "dompurify": "^2.3.3",
-    "eslint-config-airbnb-base": "^14.2.1",
+    "eslint-config-airbnb-base": "^15.0.0",
     "html-webpack-plugin": "^5.3.2",
     "lofig": "^1.3.4",
     "marked": "^4.0.10",
     "normalize.css": "^8.0.1",
     "toasters": "^2.3.1",
-    "vue": "^3.2.20",
+    "vue": "^3.2.29",
     "vue-content-loader": "^2.0.0",
-    "vue-loader": "^16.8.1",
-    "vue-router": "^4.0.11",
-    "vue-tippy": "^6.0.0-alpha.34",
+    "vue-loader": "^16.8.3",
+    "vue-router": "^4.0.12",
+    "vue-tippy": "^6.0.0-alpha.45",
     "vuedraggable": "^4.1.0",
     "vuex": "^4.0.2",
-    "webpack": "5.58.1",
+    "webpack": "5.68.0",
     "webpack-bundle-analyzer": "^4.4.2",
     "webpack-merge": "^5.8.0"
   }

+ 76 - 33
frontend/src/App.vue

@@ -210,18 +210,14 @@ export default {
 		this.$router.isReady().then(() => {
 			if (this.$route.query.err) {
 				let { err } = this.$route.query;
-				err = err
-					.replace(new RegExp("<", "g"), "&lt;")
-					.replace(new RegExp(">", "g"), "&gt;");
+				err = err.replace(/</g, "&lt;").replace(/>/g, "&gt;");
 				this.$router.push({ query: {} });
 				new Toast({ content: err, timeout: 20000 });
 			}
 
 			if (this.$route.query.msg) {
 				let { msg } = this.$route.query;
-				msg = msg
-					.replace(new RegExp("<", "g"), "&lt;")
-					.replace(new RegExp(">", "g"), "&gt;");
+				msg = msg.replace(/</g, "&lt;").replace(/>/g, "&gt;");
 				this.$router.push({ query: {} });
 				new Toast({ content: msg, timeout: 20000 });
 			}
@@ -287,7 +283,7 @@ export default {
 };
 </script>
 
-<style lang="scss">
+<style lang="less">
 @import "normalize.css/normalize.css";
 @import "tippy.js/dist/tippy.css";
 @import "tippy.js/animations/scale.css";
@@ -817,15 +813,14 @@ img {
 	top: 50px;
 	right: 50px;
 	font-size: 2em;
-	border-radius: 5px;
+	border-radius: @border-radius;
 	z-index: 10000000;
 }
 
 .night-mode {
 	.tippy-box {
 		border: 1px solid var(--light-grey-3);
-		box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25),
-			0 10px 10px rgba(0, 0, 0, 0.22);
+		box-shadow: @box-shadow-dropdown;
 		background-color: var(--white);
 
 		.tippy-content {
@@ -957,7 +952,7 @@ img {
 	font-size: 15px;
 	padding: 5px 10px;
 	border: 1px solid var(--light-grey-3);
-	box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22);
+	box-shadow: @box-shadow-dropdown;
 	background-color: var(--white);
 
 	.button {
@@ -1062,7 +1057,7 @@ img {
 
 .tippy-box[data-theme~="stationSettings"] {
 	border: 1px solid var(--light-grey-3);
-	box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22);
+	box-shadow: @box-shadow-dropdown;
 	background-color: var(--white);
 
 	button:not(:last-of-type) {
@@ -1074,7 +1069,7 @@ img {
 	font-size: 15px;
 	padding: 0;
 	border: 1px solid var(--light-grey-3);
-	box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22);
+	box-shadow: @box-shadow-dropdown;
 	background-color: var(--white);
 	color: var(--dark-grey);
 	width: 350px;
@@ -1096,7 +1091,7 @@ img {
 			font-size: 15.5px;
 			min-height: 36px;
 			background: var(--light-grey);
-			border-radius: 5px;
+			border-radius: @border-radius;
 			cursor: pointer;
 
 			.checkbox-control {
@@ -1188,7 +1183,7 @@ img {
 
 	#create-playlist {
 		margin: 10px 10px 10px 10px;
-		width: unset;
+		width: calc(100% - 20px);
 	}
 }
 
@@ -1196,7 +1191,7 @@ img {
 	font-size: 15px;
 	padding: 0;
 	border: 1px solid var(--light-grey-3);
-	box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22);
+	box-shadow: @box-shadow-dropdown;
 	background-color: var(--white);
 	color: var(--dark-grey);
 	width: 100% !important;
@@ -1222,6 +1217,16 @@ img {
 .select {
 	position: relative;
 
+	&.is-expanded {
+		width: 100%;
+	}
+
+	&.disabled,
+	.disabled {
+		filter: grayscale(1);
+		cursor: not-allowed;
+	}
+
 	&:after {
 		content: " ";
 		border: 1.5px solid var(--primary-color);
@@ -1239,11 +1244,12 @@ img {
 
 	select {
 		height: 36px;
+		width: 100%;
 		background-color: var(--white);
 		border: 1px solid var(--light-grey-2);
 		color: var(--dark-grey-2);
 		appearance: none;
-		border-radius: 3px;
+		border-radius: @border-radius;
 		font-size: 14px;
 		line-height: 24px;
 		padding-left: 8px;
@@ -1263,7 +1269,7 @@ img {
 		background-color: var(--white);
 		border: 1px solid var(--light-grey-2);
 		appearance: none;
-		border-radius: 3px;
+		border-radius: @border-radius;
 		cursor: pointer;
 		position: relative;
 
@@ -1329,7 +1335,7 @@ button.delete:focus {
 	width: 100%;
 	border-collapse: collapse;
 	border-spacing: 0;
-	border-radius: 5px;
+	border-radius: @border-radius;
 
 	thead th {
 		padding: 5px 10px;
@@ -1353,7 +1359,7 @@ button.delete:focus {
 	border: 1px solid var(--light-grey-2);
 	background-color: var(--white);
 	color: var(--dark-grey-2);
-	border-radius: 3px;
+	border-radius: @border-radius;
 	line-height: 24px;
 	align-items: center;
 	display: inline-flex;
@@ -1424,7 +1430,7 @@ button.delete:focus {
 	padding-right: 8px;
 	line-height: 24px;
 	font-size: 14px;
-	border-radius: 3px;
+	border-radius: @border-radius;
 	box-shadow: inset 0 1px 2px rgba(10, 10, 10, 0.1);
 	border: 1px solid var(--light-grey-2);
 }
@@ -1479,6 +1485,34 @@ button.delete:focus {
 
 	&.is-grouped {
 		display: flex;
+
+		& > .control {
+			&.select.is-expanded > select {
+				width: 100%;
+			}
+			& > input,
+			& > select,
+			& > .button,
+			&.label {
+				border-radius: 0;
+			}
+			&:first-child {
+				& > input,
+				& > select,
+				& > .button,
+				&.label {
+					border-radius: @border-radius 0 0 @border-radius;
+				}
+			}
+			&:last-child {
+				& > input,
+				& > select,
+				& > .button,
+				&.label {
+					border-radius: 0 @border-radius @border-radius 0;
+				}
+			}
+		}
 	}
 
 	&.is-expanded {
@@ -1493,20 +1527,25 @@ button.delete:focus {
 			margin-right: -1px;
 
 			&:first-child {
-				border-radius: 3px 0 0 3px;
+				border-radius: @border-radius 0 0 @border-radius;
 			}
 
 			&:last-child {
-				border-radius: 0 3px 3px 0;
+				border-radius: 0 @border-radius @border-radius 0;
 				padding-left: 10px;
 			}
+
+			&.dropdown-toggle {
+				padding-left: 5px;
+				padding-right: 5px;
+			}
 		}
 
 		.input {
 			margin-right: -1px;
 
 			&:first-child {
-				border-radius: 3px 0 0 3px;
+				border-radius: @border-radius 0 0 @border-radius;
 			}
 		}
 	}
@@ -1521,14 +1560,14 @@ button.delete:focus {
 	select {
 		width: 100%;
 		height: 36px;
-		border-radius: 3px 0 0 3px;
+		border-radius: @border-radius 0 0 @border-radius;
 		border-right: 0;
 		border-color: var(--light-grey-3);
 	}
 
 	.button {
 		height: 36px;
-		border-radius: 0 3px 3px 0;
+		border-radius: 0 @border-radius @border-radius 0;
 	}
 }
 
@@ -1593,14 +1632,14 @@ h4.section-title {
 	justify-content: space-between;
 	padding: 7.5px;
 	border: 1px solid var(--light-grey-3);
-	border-radius: 3px;
+	border-radius: @border-radius;
 	overflow: hidden;
 
 	.item-thumbnail {
 		width: 65px;
 		height: 65px;
 		margin: -7.5px;
-		border-radius: 3px 0 0 3px;
+		border-radius: @border-radius 0 0 @border-radius;
 	}
 
 	.item-title {
@@ -1780,7 +1819,7 @@ h4.section-title {
 }
 
 .content-box {
-	border-radius: 3px;
+	border-radius: @border-radius;
 	background-color: var(--white);
 	border: 1px solid var(--dark-grey);
 	max-width: 580px;
@@ -1852,10 +1891,10 @@ h4.section-title {
 
 .news-item {
 	font-family: "Karla";
-	border-radius: 5px;
+	border-radius: @border-radius;
 	padding: 20px;
 	border: unset !important;
-	box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1);
+	box-shadow: @box-shadow;
 
 	* {
 		font-family: Karla, Arial, sans-serif;
@@ -1910,6 +1949,10 @@ h4.section-title {
 	code {
 		font-style: italic;
 	}
+
+	hr {
+		margin: 10px 0;
+	}
 }
 .checkbox-control {
 	display: flex;
@@ -2027,12 +2070,12 @@ html {
 	background-color: var(--light-grey);
 	color: var(--primary-color);
 	padding: 5px 10px;
-	border-radius: 5px;
+	border-radius: @border-radius;
 	font-size: 14px;
 	font-weight: 600;
 	white-space: nowrap;
 	margin-top: 5px;
-	box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1);
+	box-shadow: @box-shadow;
 	transition: all 0.2s ease-in-out;
 
 	&:hover,

+ 15 - 5
frontend/src/api/admin/reports.js

@@ -4,14 +4,24 @@ import Toast from "toasters";
 import ws from "@/ws";
 
 export default {
-	resolve(reportId) {
-		return new Promise((resolve, reject) =>
-			ws.socket.dispatch("reports.resolve", reportId, res => {
+	resolve({ reportId, value }) {
+		return new Promise((resolve, reject) => {
+			ws.socket.dispatch("reports.resolve", reportId, value, res => {
 				new Toast(res.message);
 				if (res.status === "success")
 					return resolve({ status: "success" });
 				return reject(new Error(res.message));
-			})
-		);
+			});
+		});
+	},
+	remove(reportId) {
+		return new Promise((resolve, reject) => {
+			ws.socket.dispatch("reports.remove", reportId, res => {
+				new Toast(res.message);
+				if (res.status === "success")
+					return resolve({ status: "success" });
+				return reject(new Error(res.message));
+			});
+		});
 	}
 };

+ 3 - 3
frontend/src/components/ActivityItem.vue

@@ -191,7 +191,7 @@ export default {
 };
 </script>
 
-<style lang="scss">
+<style lang="less">
 .activity-item-link {
 	color: var(--primary-color) !important;
 
@@ -201,11 +201,11 @@ export default {
 }
 </style>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .activity-item {
 	height: 72px;
 	border: 0.5px var(--light-grey-3) solid;
-	border-radius: 3px;
+	border-radius: @border-radius;
 	padding: 0;
 
 	.thumbnail {

+ 1 - 1
frontend/src/components/AddToPlaylistDropdown.vue

@@ -171,7 +171,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .no-playlists {
 	text-align: center;
 	margin-top: 10px;

+ 63 - 78
frontend/src/components/AdvancedTable.vue

@@ -33,12 +33,12 @@
 								>
 								Filters
 							</button>
-							<button class="button">
+							<button class="button dropdown-toggle">
 								<i class="material-icons">
 									{{
 										showFiltersDropdown
-											? "expand_more"
-											: "expand_less"
+											? "expand_less"
+											: "expand_more"
 									}}
 								</i>
 							</button>
@@ -70,11 +70,7 @@
 							<div
 								v-for="(filter, index) in editingFilters"
 								:key="`filter-${index}`"
-								class="
-									advanced-filter
-									control
-									is-grouped is-expanded
-								"
+								class="advanced-filter control is-grouped is-expanded"
 							>
 								<div class="control select">
 									<select
@@ -228,10 +224,7 @@
 										@click="applyFilterAndGetData()"
 									>
 										<i
-											class="
-												material-icons
-												icon-with-button
-											"
+											class="material-icons icon-with-button"
 											>filter_list</i
 										>
 										Apply filters
@@ -248,10 +241,7 @@
 										@click="applyFilterAndGetData()"
 									>
 										<i
-											class="
-												material-icons
-												icon-with-button
-											"
+											class="material-icons icon-with-button"
 											>filter_list</i
 										>
 										Apply filters
@@ -334,12 +324,12 @@
 								>
 								Columns
 							</button>
-							<button class="button">
+							<button class="button dropdown-toggle">
 								<i class="material-icons">
 									{{
 										showColumnsDropdown
-											? "expand_more"
-											: "expand_less"
+											? "expand_less"
+											: "expand_more"
 									}}
 								</i>
 							</button>
@@ -372,11 +362,7 @@
 										"
 									>
 										<p
-											class="
-												control
-												is-expanded
-												checkbox-control
-											"
+											class="control is-expanded checkbox-control"
 										>
 											<label class="switch">
 												<input
@@ -881,7 +867,12 @@ export default {
 				pos1: 0,
 				pos2: 0,
 				pos3: 0,
-				pos4: 0
+				pos4: 0,
+				initial: {
+					top: null,
+					left: null
+				},
+				debounceTimeout: null
 			},
 			addFilterValue: null,
 			showFiltersDropdown: false,
@@ -1445,7 +1436,7 @@ export default {
 		toggleSelectedRow(itemIndex, event) {
 			const { shiftKey, ctrlKey } = event;
 			// Shift was pressed, so attempt to select all items between the clicked item and last clicked item
-			if (shiftKey) {
+			if (shiftKey && !ctrlKey) {
 				// If the clicked item is already selected, prevent default, otherwise the checkbox will be unchecked
 				if (this.rows[itemIndex].selected) event.preventDefault();
 				// If there is a last clicked item
@@ -1475,13 +1466,13 @@ export default {
 				}
 			}
 			// Ctrl was pressed, so attempt to unselect all items between the clicked item and last clicked item
-			else if (ctrlKey) {
+			else if (!shiftKey && ctrlKey) {
 				// If the clicked item is already unselected, prevent default, otherwise the checkbox will be checked
 				if (!this.rows[itemIndex].selected) event.preventDefault();
 				// If there is a last clicked item
 				if (this.lastSelectedItemIndex >= 0) {
 					// Clicked item is lower than last item, so unselect upwards until it reaches the last selected item
-					if (itemIndex > this.lastSelectedItemIndex) {
+					if (itemIndex >= this.lastSelectedItemIndex) {
 						for (
 							let itemIndexUp = itemIndex;
 							itemIndexUp >= this.lastSelectedItemIndex;
@@ -1546,13 +1537,19 @@ export default {
 			// Set the last clicked item to no longer be highlighted, if it exists
 			if (this.lastSelectedItemIndex >= 0)
 				this.rows[this.lastSelectedItemIndex].highlighted = false;
-			if (rowElement) rowElement.focus();
+			if (rowElement)
+				this.$nextTick(() => {
+					rowElement.focus();
+				});
 			// Set the item to be highlighted
 			this.rows[itemIndex].highlighted = true;
 		},
 		unhighlightRow(itemIndex) {
 			const rowElement = this.$refs[`row-${itemIndex}`];
-			if (rowElement) rowElement.blur();
+			if (rowElement)
+				this.$nextTick(() => {
+					rowElement.blur();
+				});
 			// Set the item to no longer be highlighted
 			this.rows[itemIndex].highlighted = false;
 		},
@@ -1764,6 +1761,8 @@ export default {
 		resetBulkActionsPosition() {
 			this.bulkPopup.top = document.body.clientHeight - 56;
 			this.bulkPopup.left = document.body.clientWidth / 2 - 200;
+			this.bulkPopup.initial.top = this.bulkPopup.top;
+			this.bulkPopup.initial.left = this.bulkPopup.left;
 		},
 		applyFilterAndGetData() {
 			this.appliedFilters = JSON.parse(
@@ -1921,14 +1920,26 @@ export default {
 			);
 		},
 		onWindowResize() {
-			// Only change the position if the popup is actually visible
-			if (this.selectedRows.length === 0) return;
-			if (this.bulkPopup.top < 0) this.bulkPopup.top = 0;
-			if (this.bulkPopup.top > document.body.clientHeight - 50)
-				this.bulkPopup.top = document.body.clientHeight - 50;
-			if (this.bulkPopup.left < 0) this.bulkPopup.left = 0;
-			if (this.bulkPopup.left > document.body.clientWidth - 400)
-				this.bulkPopup.left = document.body.clientWidth - 400;
+			if (this.bulkPopup.debounceTimeout)
+				clearTimeout(this.bulkPopup.debounceTimeout);
+
+			this.bulkPopup.debounceTimeout = setTimeout(() => {
+				// Only change the position if the popup is actually visible
+				if (this.selectedRows.length === 0) return;
+				if (
+					this.bulkPopup.top === this.bulkPopup.initial.top &&
+					this.bulkPopup.left === this.bulkPopup.initial.left
+				)
+					this.resetBulkActionsPosition();
+				else {
+					if (this.bulkPopup.top < 0) this.bulkPopup.top = 0;
+					if (this.bulkPopup.top > document.body.clientHeight - 50)
+						this.bulkPopup.top = document.body.clientHeight - 50;
+					if (this.bulkPopup.left < 0) this.bulkPopup.left = 0;
+					if (this.bulkPopup.left > document.body.clientWidth - 400)
+						this.bulkPopup.left = document.body.clientWidth - 400;
+				}
+			}, 50);
 		},
 		updateData(index, data) {
 			this.rows[index] = { ...this.rows[index], ...data, updated: true };
@@ -1944,7 +1955,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	.table-outer-container {
 		.table-container .table {
@@ -2032,8 +2043,8 @@ export default {
 }
 
 .table-outer-container {
-	border-radius: 5px;
-	box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1);
+	border-radius: @border-radius;
+	box-shadow: @box-shadow;
 	margin: 10px 0;
 	overflow: hidden;
 
@@ -2115,7 +2126,7 @@ export default {
 							border-width: 0 0 1px;
 						}
 
-						/deep/ .row-options {
+						:deep(.row-options) {
 							display: flex;
 							justify-content: space-evenly;
 
@@ -2304,38 +2315,15 @@ export default {
 			border: 1px solid var(--light-grey-2);
 			color: var(--dark-grey-2);
 			appearance: none;
-			border-radius: 3px;
+			border-radius: @border-radius;
 			font-size: 14px;
 			line-height: 34px;
 			padding-left: 8px;
 			padding-right: 8px;
 		}
-		&.select.is-expanded > select {
-			width: 100%;
-		}
-		& > input,
-		/deep/ & > div > input,
-		& > select,
-		& > .button,
-		&.label {
+		:deep(& > div > input) {
 			border-radius: 0;
 		}
-		&:first-child {
-			& > input,
-			& > select,
-			& > .button,
-			&.label {
-				border-radius: 5px 0 0 5px;
-			}
-		}
-		&:last-child {
-			& > input,
-			& > select,
-			& > .button,
-			&.label {
-				border-radius: 0 5px 5px 0;
-			}
-		}
 		& > .button {
 			font-size: 22px;
 		}
@@ -2346,27 +2334,24 @@ export default {
 			flex-wrap: wrap;
 			.control.select {
 				width: 50%;
-				select {
-					width: 100%;
-				}
 			}
 			.control {
 				margin-bottom: 0 !important;
 				&:nth-child(1) > select {
-					border-radius: 5px 0 0 0;
+					border-radius: @border-radius 0 0 0;
 				}
 				&:nth-child(2) > select {
-					border-radius: 0 5px 0 0;
+					border-radius: 0 @border-radius 0 0;
 				}
-				/deep/ &:nth-child(3) {
+				:deep(&:nth-child(3)) {
 					& > input,
 					& > div > input,
 					& > select {
-						border-radius: 0 0 0 5px;
+						border-radius: 0 0 0 @border-radius;
 					}
 				}
 				&:nth-child(4) > button {
-					border-radius: 0 0 5px 0;
+					border-radius: 0 0 @border-radius 0;
 				}
 			}
 		}
@@ -2392,7 +2377,7 @@ export default {
 	}
 }
 
-/deep/ .bulk-popup {
+:deep(.bulk-popup) {
 	display: flex;
 	position: fixed;
 	flex-direction: row;
@@ -2401,8 +2386,8 @@ export default {
 	line-height: 36px;
 	z-index: 5;
 	border: 1px solid var(--light-grey-3);
-	border-radius: 5px;
-	box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22);
+	border-radius: @border-radius;
+	box-shadow: @box-shadow-dropdown;
 	background-color: var(--white);
 	color: var(--dark-grey);
 	padding: 5px;

+ 2 - 2
frontend/src/components/AutoSuggest.vue

@@ -122,7 +122,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode .autosuggest-container {
 	background-color: var(--dark-grey) !important;
 
@@ -171,7 +171,7 @@ export default {
 	}
 
 	.autosuggest-item:last-child {
-		border-radius: 0 0 3px 3px;
+		border-radius: 0 0 @border-radius @border-radius;
 	}
 }
 </style>

+ 1 - 1
frontend/src/components/ChristmasLights.vue

@@ -34,7 +34,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .christmas-mode {
 	.christmas-lights {
 		position: absolute;

+ 1 - 1
frontend/src/components/FallingSnow.vue

@@ -9,7 +9,7 @@
 	</div>
 </template>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode .winter-is-coming {
 	background: var(--black);
 }

+ 2 - 2
frontend/src/components/FloatingBox.vue

@@ -135,7 +135,7 @@ export default {
 };
 </script>
 
-<style lang="scss">
+<style lang="less">
 .night-mode .floating-box {
 	background-color: var(--dark-grey-2) !important;
 	border: 0 !important;
@@ -154,7 +154,7 @@ export default {
 	resize: both;
 	overflow: auto;
 	border: 1px solid var(--light-grey-2);
-	border-radius: 5px;
+	border-radius: @border-radius;
 	min-height: 50px !important;
 	min-width: 50px !important;
 	padding: 0;

+ 1 - 1
frontend/src/components/InputHelpBox.vue

@@ -33,7 +33,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .help {
 	margin-top: 0 !important;
 	margin-bottom: 10px !important;

+ 3 - 3
frontend/src/components/Modal.vue

@@ -85,7 +85,7 @@ export default {
 };
 </script>
 
-<style lang="scss">
+<style lang="less">
 .night-mode .modal .modal-card {
 	.modal-card-head,
 	.modal-card-foot {
@@ -204,7 +204,7 @@ export default {
 
 		.modal-card-head {
 			border-bottom: 1px solid var(--light-grey-2);
-			border-radius: 5px 5px 0 0;
+			border-radius: @border-radius @border-radius 0 0;
 
 			.modal-card-title {
 				display: flex;
@@ -228,7 +228,7 @@ export default {
 
 		.modal-card-foot {
 			border-top: 1px solid var(--light-grey-2);
-			border-radius: 0 0 5px 5px;
+			border-radius: 0 0 @border-radius @border-radius;
 			overflow-x: auto;
 			column-gap: 16px;
 

+ 1 - 1
frontend/src/components/PlaylistItem.vue

@@ -72,7 +72,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	.playlist-item {
 		background-color: var(--dark-grey-2) !important;

+ 1 - 1
frontend/src/components/ProfilePicture.vue

@@ -48,7 +48,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .profile-picture {
 	width: 100px;
 	height: 100px;

+ 2 - 2
frontend/src/components/PunishmentItem.vue

@@ -103,7 +103,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	.punishment-item {
 		background-color: var(--dark-grey-2) !important;
@@ -126,7 +126,7 @@ export default {
 		align-items: center;
 		justify-content: space-evenly;
 		border: 1px solid var(--light-grey-3);
-		border-radius: 5px;
+		border-radius: @border-radius;
 
 		.checkbox-control .slider {
 			cursor: default;

+ 2 - 12
frontend/src/components/Queue.vue

@@ -88,16 +88,6 @@
 			<i class="material-icons icon-with-button">queue</i>
 			<span> Add Song To Queue </span>
 		</button>
-		<button
-			class="button is-primary tab-actionable-button"
-			v-if="
-				sector === 'station' && loggedIn && station.type === 'official'
-			"
-			@click="openModal('requestSong')"
-		>
-			<i class="material-icons icon-with-button">queue</i>
-			<span> Request Song </span>
-		</button>
 		<button
 			class="button is-primary tab-actionable-button disabled"
 			v-if="
@@ -294,7 +284,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	#queue {
 		background-color: var(--dark-grey-3) !important;
@@ -304,7 +294,7 @@ export default {
 
 #queue {
 	background-color: var(--white);
-	border-radius: 0 0 5px 5px;
+	border-radius: 0 0 @border-radius @border-radius;
 	user-select: none;
 
 	.actionable-button-hidden {

+ 2 - 2
frontend/src/components/ReportInfoItem.vue

@@ -63,7 +63,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	.report-info-item {
 		background-color: var(--dark-grey) !important;
@@ -78,7 +78,7 @@ export default {
 		height: 45px;
 		margin-right: 10px;
 
-		/deep/ .profile-picture.using-initials span {
+		:deep(.profile-picture.using-initials span) {
 			font-size: calc(
 				45px / 5 * 2
 			); // 2/5th of .profile-picture height/width

+ 2 - 2
frontend/src/components/RunJobDropdown.vue

@@ -21,7 +21,7 @@
 	>
 		<div class="control has-addons" ref="trigger">
 			<button class="button is-primary">Run Job</button>
-			<button class="button">
+			<button class="button dropdown-toggle">
 				<i class="material-icons">
 					{{ showJobDropdown ? "expand_more" : "expand_less" }}
 				</i>
@@ -94,7 +94,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .nav-dropdown-items {
 	& > span:not(:last-child) .nav-item.button {
 		margin-bottom: 10px !important;

+ 2 - 2
frontend/src/components/SearchQueryItem.vue

@@ -45,7 +45,7 @@ export default {
 };
 </script>
 
-<style lang="scss">
+<style lang="less">
 .search-query-actions-enter-active,
 .musare-search-query-actions-enter-active,
 .youtube-search-query-actions-enter-active {
@@ -73,7 +73,7 @@ export default {
 }
 </style>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	.search-query-item {
 		background-color: var(--dark-grey-2) !important;

+ 1 - 1
frontend/src/components/Sidebar.vue

@@ -15,7 +15,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .inner-wrapper {
 	overflow: auto;
 	height: 100%;

+ 17 - 12
frontend/src/components/SongItem.vue

@@ -11,12 +11,12 @@
 				<h6 v-if="header">{{ header }}</h6>
 				<div class="song-title">
 					<h4
-						class="item-title"
-						:style="
-							song.artists && song.artists.length < 1
-								? { fontSize: '16px' }
-								: null
-						"
+						:class="{
+							'item-title': true,
+							'no-artists':
+								!song.artists ||
+								(song.artists && song.artists.length < 1)
+						}"
 						:title="song.title"
 					>
 						{{ song.title }}
@@ -109,10 +109,7 @@
 							>
 								<template #button>
 									<i
-										class="
-											material-icons
-											add-to-playlist-icon
-										"
+										class="material-icons add-to-playlist-icon"
 										content="Add Song to Playlist"
 										v-tippy
 										>playlist_add</i
@@ -282,7 +279,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	.song-item {
 		background-color: var(--dark-grey-2) !important;
@@ -290,7 +287,7 @@ export default {
 	}
 }
 
-/deep/ #nav-dropdown {
+:deep(#nav-dropdown) {
 	margin-top: 36px;
 	width: 0;
 	height: 0;
@@ -362,6 +359,14 @@ export default {
 			.verified-song {
 				margin-left: 5px;
 			}
+
+			.item-title.no-artists {
+				display: -webkit-inline-box;
+				font-size: 16px;
+				white-space: normal;
+				-webkit-box-orient: vertical;
+				-webkit-line-clamp: 2;
+			}
 		}
 
 		.song-request-time {

+ 28 - 13
frontend/src/components/SongThumbnail.vue

@@ -1,8 +1,8 @@
 <template>
-	<div class="thumbnail">
-		<slot name="icon" />
-		<div
-			v-if="
+	<div
+		:class="{
+			thumbnail: true,
+			'youtube-thumbnail':
 				song.youtubeId &&
 				(!song.thumbnail ||
 					(song.thumbnail &&
@@ -14,7 +14,10 @@
 								-1)) ||
 					song.thumbnail === 'empty' ||
 					song.thumbnail == null)
-			"
+		}"
+	>
+		<slot name="icon" />
+		<div
 			class="yt-thumbnail-bg"
 			:style="{
 				'background-image':
@@ -61,7 +64,7 @@ export default {
 };
 </script>
 
-<style lang="scss">
+<style lang="less">
 .thumbnail {
 	min-width: 130px;
 	height: 130px;
@@ -71,16 +74,11 @@ export default {
 	margin-left: -10px;
 
 	.yt-thumbnail-bg {
-		height: 100%;
-		width: 100%;
-		position: absolute;
-		top: 0;
-		filter: blur(1px);
-		background: url("/assets/notes-transparent.png") no-repeat center center;
+		display: none;
 	}
 
 	img {
-		height: auto;
+		height: 100%;
 		width: 100%;
 		margin-top: auto;
 		margin-bottom: auto;
@@ -91,5 +89,22 @@ export default {
 		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>

+ 1 - 1
frontend/src/components/UserIdToUsername.vue

@@ -35,7 +35,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 a {
 	color: var(--primary-color);
 	&:hover,

+ 56 - 15
frontend/src/components/layout/MainFooter.vue

@@ -9,16 +9,33 @@
 					><img src="/assets/blue_wordmark.png" alt="Musare"
 				/></router-link>
 				<div id="footer-links">
-					<a :href="github" target="_blank" title="GitHub Repository"
-						>GitHub</a
+					<a
+						v-for="(url, title, index) in filteredFooterLinks"
+						:key="`footer-link-${index}`"
+						:href="url"
+						target="_blank"
+						:title="title"
 					>
-					<router-link title="About Musare" to="/about"
+						{{ title }}
+					</a>
+					<router-link
+						v-if="getLink('about') === true"
+						title="About Musare"
+						to="/about"
 						>About</router-link
 					>
-					<router-link title="Musare Team" to="/team"
+					<router-link
+						v-if="getLink('team') === true"
+						title="Musare Team"
+						to="/team"
 						>Team</router-link
 					>
-					<router-link title="News" to="/news">News</router-link>
+					<router-link
+						v-if="getLink('news') === true"
+						title="News"
+						to="/news"
+						>News</router-link
+					>
 				</div>
 			</div>
 		</div>
@@ -29,16 +46,46 @@
 export default {
 	data() {
 		return {
-			github: ""
+			footerLinks: {}
 		};
 	},
+	computed: {
+		filteredFooterLinks() {
+			return Object.fromEntries(
+				Object.entries(this.footerLinks).filter(
+					([title, url]) =>
+						!(
+							["about", "team", "news"].includes(
+								title.toLowerCase()
+							) && typeof url === "boolean"
+						)
+				)
+			);
+		}
+	},
 	async mounted() {
-		this.github = await lofig.get("siteSettings.github");
+		lofig.get("siteSettings.footerLinks").then(footerLinks => {
+			this.footerLinks = {
+				about: true,
+				team: true,
+				news: true,
+				...footerLinks
+			};
+		});
+	},
+	methods: {
+		getLink(title) {
+			return this.footerLinks[
+				Object.keys(this.footerLinks).find(
+					key => key.toLowerCase() === title
+				)
+			];
+		}
 	}
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	footer.footer,
 	footer.footer .container,
@@ -53,9 +100,7 @@ export default {
 	flex-shrink: 0;
 	height: auto;
 	padding: 20px;
-	border-radius: 33% 33% 0% 0% / 7% 7% 0% 0%;
-	box-shadow: 0 4px 8px 0 rgba(3, 169, 244, 0.4),
-		0 6px 20px 0 rgba(3, 169, 244, 0.2);
+	box-shadow: @box-shadow;
 	background-color: var(--white);
 	width: 100%;
 	height: 160px;
@@ -80,10 +125,6 @@ export default {
 		}
 	}
 
-	@media (max-width: 650px) {
-		border-radius: 0;
-	}
-
 	#footer-logo {
 		display: block;
 		margin-left: auto;

+ 94 - 86
frontend/src/components/layout/MainHeader.vue

@@ -1,5 +1,8 @@
 <template>
-	<nav class="nav is-info" :class="{ transparent }">
+	<nav
+		class="nav is-info"
+		:class="{ transparent, 'hide-logged-out': !loggedIn && hideLoggedOut }"
+	>
 		<div class="nav-left">
 			<router-link v-if="!hideLogo" class="nav-item is-brand" to="/">
 				<img
@@ -23,6 +26,26 @@
 		</span>
 
 		<div class="nav-right nav-menu" :class="{ 'is-active': isMobile }">
+			<div
+				class="nav-item"
+				id="nightmode-toggle"
+				@click="toggleNightmode()"
+			>
+				<span
+					:class="{
+						'material-icons': true,
+						'night-mode-toggle': true,
+						'night-mode-on': localNightmode
+					}"
+					:content="`${
+						localNightmode ? 'Disable' : 'Enable'
+					} Nightmode`"
+					v-tippy
+				>
+					{{ localNightmode ? "dark_mode" : "light_mode" }}
+				</span>
+				<span class="night-mode-label">Toggle Nightmode</span>
+			</div>
 			<span v-if="loggedIn" class="grouped">
 				<router-link
 					v-if="role === 'admin'"
@@ -47,24 +70,13 @@
 			</span>
 			<span v-if="!loggedIn && !hideLoggedOut" class="grouped">
 				<a class="nav-item" @click="openModal('login')">Login</a>
-				<a class="nav-item" @click="openModal('register')">Register</a>
+				<a
+					v-if="!siteSettings.registrationDisabled"
+					class="nav-item"
+					@click="openModal('register')"
+					>Register</a
+				>
 			</span>
-			<div class="nav-item" id="nightmode-toggle">
-				<p class="is-expanded checkbox-control">
-					<label class="switch">
-						<input
-							type="checkbox"
-							id="instant-nightmode"
-							v-model="localNightmode"
-						/>
-						<span class="slider round"></span>
-					</label>
-
-					<label for="instant-nightmode">
-						<p>Nightmode</p>
-					</label>
-				</p>
-			</div>
 		</div>
 
 		<christmas-lights
@@ -92,13 +104,14 @@ export default {
 	},
 	data() {
 		return {
-			localNightmode: null,
+			localNightmode: false,
 			isMobile: false,
 			frontendDomain: "",
 			siteSettings: {
 				logo: "",
 				sitename: "",
-				christmas: false
+				christmas: false,
+				registrationDisabled: false
 			},
 			windowWidth: 0
 		};
@@ -116,26 +129,9 @@ export default {
 		})
 	},
 	watch: {
-		localNightmode(newValue, oldValue) {
-			if (oldValue === null) return;
-
-			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);
-		},
 		nightmode(nightmode) {
 			if (this.localNightmode !== nightmode)
-				this.localNightmode = nightmode;
+				this.toggleNightmode(nightmode);
 		}
 	},
 	async mounted() {
@@ -151,6 +147,23 @@ export default {
 		});
 	},
 	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;
 		},
@@ -161,14 +174,14 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	.nav {
 		background-color: var(--dark-grey-3) !important;
 	}
 
 	@media screen and (max-width: 768px) {
-		.nav-menu {
+		.nav:not(.hide-logged-out) .nav-menu {
 			background-color: var(--dark-grey-3) !important;
 		}
 	}
@@ -184,17 +197,12 @@ export default {
 	position: relative;
 	background-color: var(--primary-color);
 	height: 64px;
-	border-radius: 0% 0% 33% 33% / 0% 0% 7% 7%;
-	z-index: 2;
+	z-index: 3;
 
 	&.transparent {
 		background-color: transparent !important;
 	}
 
-	@media (max-width: 650px) {
-		border-radius: 0;
-	}
-
 	.nav-left,
 	.nav-right {
 		flex: 1;
@@ -293,16 +301,10 @@ export default {
 				-webkit-user-drag: none;
 			}
 		}
-	}
 
-	.nav-menu {
-		// box-shadow: 0 4px 7px rgb(10 10 10 / 10%);
-		// left: 0;
-		// display: block;
-		// right: 0;
-		// top: 100%;
-		// position: absolute;
-		// background: var(--white);
+		.night-mode-label {
+			display: none;
+		}
 	}
 }
 
@@ -320,46 +322,52 @@ export default {
 }
 
 @media screen and (max-width: 768px) {
-	.nav-toggle {
-		display: block !important;
-	}
-
-	.nav-menu {
-		display: none !important;
-		box-shadow: 0 4px 7px rgba(10, 10, 10, 0.1);
-		left: 0;
-		right: 0;
-		top: 100%;
-		position: absolute;
-		background: var(--white);
-	}
+	.nav:not(.hide-logged-out) {
+		.nav-toggle {
+			display: block !important;
+		}
 
-	.nav-menu.is-active {
-		display: block !important;
+		.nav-menu {
+			display: none !important;
+			box-shadow: @box-shadow-dropdown;
+			left: 0;
+			right: 0;
+			top: 100%;
+			position: absolute;
+			background: var(--white);
+		}
 
-		.nav-item {
-			color: var(--dark-grey-2);
+		.nav-menu.is-active {
+			display: flex !important;
+			flex-direction: column-reverse;
 
-			&:hover {
+			.nav-item {
 				color: var(--dark-grey-2);
+
+				&:hover {
+					color: var(--dark-grey-2);
+				}
+
+				.night-mode-label {
+					display: inline;
+					margin-left: 5px;
+				}
 			}
 		}
-	}
 
-	.nav .nav-menu .grouped {
-		flex-direction: column;
-		.nav-item {
-			padding: 10px 20px;
-			&:hover,
-			&:focus {
-				border-top: 0;
-				height: unset;
+		.nav-menu {
+			.grouped {
+				flex-direction: column;
+			}
+			.nav-item {
+				padding: 10px 20px;
+				&:hover,
+				&:focus {
+					border-top: 0;
+					height: unset;
+				}
 			}
 		}
 	}
 }
-
-.christmas-mode .nav {
-	border-radius: 0 !important;
-}
 </style>

+ 4 - 7
frontend/src/components/modals/BulkActions.vue

@@ -136,7 +136,8 @@ export default {
 				this.type.items,
 				res => {
 					new Toast(res.message);
-					this.closeModal("bulkActions");
+					if (res.status === "success")
+						this.closeModal("bulkActions");
 				}
 			);
 		},
@@ -145,15 +146,11 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .label {
 	text-transform: capitalize;
 }
 
-.select.is-expanded select {
-	width: 100%;
-}
-
 .control.input-with-button > div {
 	flex: 1;
 }
@@ -168,7 +165,7 @@ export default {
 	}
 }
 
-/deep/ .autosuggest-container {
+:deep(.autosuggest-container) {
 	width: calc(100% - 40px);
 	top: unset;
 }

+ 2 - 43
frontend/src/components/modals/CreatePlaylist.vue

@@ -1,5 +1,5 @@
 <template>
-	<modal title="Create Playlist">
+	<modal title="Create Playlist" :size="'slim'">
 		<template #body>
 			<p class="control is-expanded">
 				<label class="label">Display Name</label>
@@ -15,7 +15,7 @@
 
 			<div class="control" id="privacy-selection">
 				<label class="label">Privacy</label>
-				<p class="control select">
+				<p class="control is-expanded select">
 					<select v-model="playlist.privacy">
 						<option value="private">Private</option>
 						<option value="public" selected>Public</option>
@@ -97,44 +97,3 @@ export default {
 	}
 };
 </script>
-
-<style lang="scss" scoped>
-.menu {
-	padding: 0 20px;
-}
-
-.menu-list li {
-	display: flex;
-	justify-content: space-between;
-}
-
-.menu-list a:hover {
-	color: var(--black) !important;
-}
-
-li a {
-	display: flex;
-	align-items: center;
-}
-
-#privacy-selection {
-	margin-top: 15px;
-}
-
-.controls {
-	display: flex;
-
-	a {
-		display: flex;
-		align-items: center;
-	}
-}
-
-.control.select {
-	width: min-content;
-}
-
-.label {
-	font-size: 1rem;
-}
-</style>

+ 1 - 1
frontend/src/components/modals/CreateStation.vue

@@ -125,7 +125,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .station-id {
 	text-transform: lowercase;
 

+ 32 - 8
frontend/src/components/modals/EditNews.vue

@@ -28,6 +28,21 @@
 					</select>
 				</p>
 
+				<p class="is-expanded checkbox-control">
+					<label class="switch">
+						<input
+							type="checkbox"
+							id="show-to-new-users"
+							v-model="showToNewUsers"
+						/>
+						<span class="slider round"></span>
+					</label>
+
+					<label for="show-to-new-users">
+						<p>Show to new users</p>
+					</label>
+				</p>
+
 				<save-button
 					ref="saveButton"
 					v-if="newsId"
@@ -83,6 +98,7 @@ export default {
 			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
 		};
@@ -110,10 +126,16 @@ export default {
 			if (this.newsId) {
 				this.socket.dispatch(`news.getNewsFromId`, this.newsId, res => {
 					if (res.status === "success") {
-						const { markdown, status, createdBy, createdAt } =
-							res.data.news;
+						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 {
@@ -165,7 +187,8 @@ export default {
 				{
 					title,
 					markdown: this.markdown,
-					status: this.status
+					status: this.status,
+					showToNewUsers: this.showToNewUsers
 				},
 				res => {
 					new Toast(res.message);
@@ -190,7 +213,8 @@ export default {
 				{
 					title,
 					markdown: this.markdown,
-					status: this.status
+					status: this.status,
+					showToNewUsers: this.showToNewUsers
 				},
 				res => {
 					new Toast(res.message);
@@ -210,13 +234,13 @@ export default {
 };
 </script>
 
-<style lang="scss">
+<style lang="less">
 .edit-news-modal .modal-card .modal-card-foot .right {
 	column-gap: 5px;
 }
 </style>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	.edit-news-modal .modal-card .modal-card-body textarea,
 	.edit-news-modal .modal-card .modal-card-body #preview {
@@ -244,14 +268,14 @@ export default {
 	#preview {
 		word-break: break-all;
 		overflow: auto;
-		box-shadow: none;
+		box-shadow: 0;
 	}
 
 	textarea,
 	#preview {
 		padding: 5px;
 		border: 1px solid var(--light-grey-3) !important;
-		border-radius: 5px;
+		border-radius: @border-radius;
 		height: calc(100vh - 280px);
 		width: 100%;
 	}

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

@@ -211,7 +211,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .youtube-tab {
 	.song-query-results {
 		margin-top: 10px;

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

@@ -58,7 +58,7 @@ export default {
 			if (!this.youtubeSearch.playlist.query)
 				return new Toast("Please enter a YouTube playlist URL.");
 
-			const regex = new RegExp(`[\\?&]list=([^&#]*)`);
+			const regex = /[\\?&]list=([^&#]*)/;
 			const splitQuery = regex.exec(this.youtubeSearch.playlist.query);
 
 			if (!splitQuery) {
@@ -123,7 +123,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 #playlist-import-type select {
 	border-radius: 0;
 }

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

@@ -128,7 +128,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 @media screen and (max-width: 1300px) {
 	.section {
 		max-width: 100% !important;

+ 8 - 14
frontend/src/components/modals/EditPlaylist/index.vue

@@ -113,10 +113,7 @@
 									>
 										<template #tippyActions>
 											<i
-												class="
-													material-icons
-													add-to-queue-icon
-												"
+												class="material-icons add-to-queue-icon"
 												v-if="
 													station.partyMode &&
 													!station.locked
@@ -144,10 +141,7 @@
 												"
 											>
 												<i
-													class="
-														material-icons
-														delete-icon
-													"
+													class="material-icons delete-icon"
 													content="Remove Song from Playlist"
 													v-tippy
 													>delete_forever</i
@@ -583,7 +577,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	.label,
 	p,
@@ -610,7 +604,7 @@ export default {
 			}
 		}
 		.right-section .section {
-			border-radius: 5px;
+			border-radius: @border-radius;
 		}
 	}
 }
@@ -644,7 +638,7 @@ export default {
 		max-width: 100%;
 
 		.button {
-			border-radius: 5px 5px 0 0;
+			border-radius: @border-radius @border-radius 0 0;
 			border: 0;
 			text-transform: uppercase;
 			font-size: 14px;
@@ -666,7 +660,7 @@ export default {
 	}
 	.tab {
 		border: 1px solid var(--light-grey-3);
-		border-radius: 0 0 5px 5px;
+		border-radius: 0 0 @border-radius @border-radius;
 	}
 }
 
@@ -682,7 +676,7 @@ export default {
 			max-height: unset !important;
 		}
 
-		/deep/ .section {
+		:deep(.section) {
 			max-width: 100% !important;
 		}
 	}
@@ -705,7 +699,7 @@ export default {
 	.left-section {
 		#playlist-info-section {
 			border: 1px solid var(--light-grey-3);
-			border-radius: 3px;
+			border-radius: @border-radius;
 			padding: 15px !important;
 
 			h3 {

+ 6 - 7
frontend/src/components/modals/EditSong/Tabs/Discogs.vue

@@ -202,7 +202,7 @@ export default {
 					.then(data => {
 						apiResult.album.artists = [];
 						apiResult.album.artistIds = [];
-						const artistRegex = new RegExp(" \\([0-9]+\\)$");
+						const artistRegex = /\\([0-9]+\\)$/;
 
 						apiResult.dataQuality = data.data_quality;
 						data.artists.forEach(artist => {
@@ -293,7 +293,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	.api-section,
 	.api-result {
@@ -304,7 +304,6 @@ export default {
 	.api-result .tracks .track:focus,
 	.selected-discogs-info {
 		background-color: var(--dark-grey-2) !important;
-		border: 0 !important;
 	}
 
 	.label,
@@ -391,7 +390,7 @@ export default {
 	.selected-discogs-info {
 		background-color: var(--white);
 		border: 1px solid var(--light-grey-3);
-		border-radius: 3px;
+		border-radius: @border-radius;
 		margin-bottom: 16px;
 
 		.selected-discogs-info-none {
@@ -408,7 +407,7 @@ export default {
 		.api-result {
 			background-color: var(--white);
 			border: 0.5px solid var(--light-grey-3);
-			border-radius: 3px;
+			border-radius: @border-radius;
 			margin-bottom: 16px;
 		}
 	}
@@ -427,11 +426,11 @@ export default {
 
 		.track:first-child {
 			margin-top: 0;
-			border-radius: 3px 3px 0 0;
+			border-radius: @border-radius @border-radius 0 0;
 		}
 
 		.track:last-child {
-			border-radius: 0 0 3px 3px;
+			border-radius: 0 0 @border-radius @border-radius;
 		}
 
 		.track {

+ 8 - 22
frontend/src/components/modals/EditSong/Tabs/Reports.vue

@@ -50,11 +50,7 @@
 							:key="issueIndex"
 						>
 							<i
-								class="
-									material-icons
-									duration-icon
-									report-sub-item-left-icon
-								"
+								class="material-icons duration-icon report-sub-item-left-icon"
 								:content="issue.category"
 								v-tippy
 							>
@@ -74,10 +70,7 @@
 							</p>
 
 							<div
-								class="
-									report-sub-item-actions
-									universal-item-actions
-								"
+								class="report-sub-item-actions universal-item-actions"
 							>
 								<i
 									class="material-icons resolve-icon"
@@ -145,11 +138,7 @@
 							:key="issueIndex"
 						>
 							<i
-								class="
-									material-icons
-									duration-icon
-									report-sub-item-left-icon
-								"
+								class="material-icons duration-icon report-sub-item-left-icon"
 								:content="issue.category"
 								v-tippy
 							>
@@ -168,10 +157,7 @@
 							</p>
 
 							<div
-								class="
-									report-sub-item-actions
-									universal-item-actions
-								"
+								class="report-sub-item-actions universal-item-actions"
 							>
 								<i
 									class="material-icons resolve-icon"
@@ -307,7 +293,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	.report-items .report-item {
 		background-color: var(--dark-grey-3) !important;
@@ -368,7 +354,7 @@ export default {
 	.report-item {
 		background-color: var(--white);
 		border: 0.5px solid var(--primary-color);
-		border-radius: 5px;
+		border-radius: @border-radius;
 		padding: 8px;
 
 		&:not(:first-of-type) {
@@ -394,11 +380,11 @@ export default {
 				display: flex;
 
 				&:first-child {
-					border-radius: 3px 3px 0 0;
+					border-radius: @border-radius @border-radius 0 0;
 				}
 
 				&:last-child {
-					border-radius: 0 0 3px 3px;
+					border-radius: 0 0 @border-radius @border-radius;
 				}
 
 				&.report-sub-item-resolved {

+ 1 - 1
frontend/src/components/modals/EditSong/Tabs/Songs.vue

@@ -66,7 +66,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .musare-songs-tab #song-query-results {
 	height: calc(100% - 74px);
 	overflow: auto;

+ 17 - 4
frontend/src/components/modals/EditSong/Tabs/Youtube.vue

@@ -41,7 +41,7 @@
 					<i
 						class="material-icons icon-not-selected"
 						v-else
-						@click.prevent="updateYoutubeId(result.id)"
+						@click.prevent="selectSong(result)"
 						key="not-selected"
 						>radio_button_unchecked
 					</i>
@@ -73,19 +73,32 @@ export default {
 	},
 	computed: {
 		...mapState("modals/editSong", {
-			song: state => state.song
+			song: state => state.song,
+			newSong: state => state.newSong
 		}),
 		...mapGetters({
 			socket: "websockets/getSocket"
 		})
 	},
 	methods: {
-		...mapActions("modals/editSong", ["updateYoutubeId"])
+		selectSong(result) {
+			this.updateYoutubeId(result.id);
+
+			if (this.newSong) {
+				this.updateTitle(result.title);
+				this.updateThumbnail(result.thumbnail);
+			}
+		},
+		...mapActions("modals/editSong", [
+			"updateYoutubeId",
+			"updateTitle",
+			"updateThumbnail"
+		])
 	}
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .youtube-tab #song-query-results {
 	height: calc(100% - 74px);
 	overflow: auto;

Fichier diff supprimé car celui-ci est trop grand
+ 320 - 207
frontend/src/components/modals/EditSong/index.vue


+ 23 - 43
frontend/src/components/modals/EditSongs.vue

@@ -43,6 +43,7 @@
 								{ status, flagged, song }, index
 							) in filteredItems"
 							:key="song._id"
+							:ref="`edit-songs-item-${song._id}`"
 						>
 							<song-item
 								:song="song"
@@ -59,11 +60,7 @@
 								<template #leftIcon>
 									<i
 										v-if="currentSong._id === song._id"
-										class="
-											material-icons
-											item-icon
-											editing-icon
-										"
+										class="material-icons item-icon editing-icon"
 										content="Currently editing song"
 										v-tippy="{ theme: 'info' }"
 										@click="toggleDone(index)"
@@ -71,22 +68,14 @@
 									>
 									<i
 										v-else-if="song.removed"
-										class="
-											material-icons
-											item-icon
-											removed-icon
-										"
+										class="material-icons item-icon removed-icon"
 										content="Song removed"
 										v-tippy="{ theme: 'info' }"
 										>delete_forever</i
 									>
 									<i
 										v-else-if="status === 'error'"
-										class="
-											material-icons
-											item-icon
-											error-icon
-										"
+										class="material-icons item-icon error-icon"
 										content="Error saving song"
 										v-tippy="{ theme: 'info' }"
 										@click="toggleDone(index)"
@@ -94,22 +83,14 @@
 									>
 									<i
 										v-else-if="status === 'saving'"
-										class="
-											material-icons
-											item-icon
-											saving-icon
-										"
+										class="material-icons item-icon saving-icon"
 										content="Currently saving song"
 										v-tippy="{ theme: 'info' }"
 										>pending</i
 									>
 									<i
 										v-else-if="flagged"
-										class="
-											material-icons
-											item-icon
-											flag-icon
-										"
+										class="material-icons item-icon flag-icon"
 										content="Song flagged"
 										v-tippy="{ theme: 'info' }"
 										@click="toggleDone(index)"
@@ -117,11 +98,7 @@
 									>
 									<i
 										v-else-if="status === 'done'"
-										class="
-											material-icons
-											item-icon
-											done-icon
-										"
+										class="material-icons item-icon done-icon"
 										content="Song marked complete"
 										v-tippy="{ theme: 'info' }"
 										@click="toggleDone(index)"
@@ -129,11 +106,7 @@
 									>
 									<i
 										v-else-if="status === 'todo'"
-										class="
-											material-icons
-											item-icon
-											todo-icon
-										"
+										class="material-icons item-icon todo-icon"
 										content="Song marked todo"
 										v-tippy="{ theme: 'info' }"
 										@click="toggleDone(index)"
@@ -302,7 +275,7 @@ export default {
 		this.socket.on(`event:admin.song.removed`, res => {
 			const index = this.items
 				.map(item => item.song._id)
-				.indexOf(res.songId);
+				.indexOf(res.data.songId);
 			this.items[index].song.removed = true;
 		});
 	},
@@ -317,6 +290,11 @@ export default {
 				prefill: this.songPrefillData[song._id]
 			});
 			this.currentSong = song;
+			if (
+				this.$refs[`edit-songs-item-${song._id}`] &&
+				this.$refs[`edit-songs-item-${song._id}`][0]
+			)
+				this.$refs[`edit-songs-item-${song._id}`][0].scrollIntoView();
 		},
 		editNextSong() {
 			const currentlyEditingSongIndex = this.filteredEditingItemIndex;
@@ -332,8 +310,10 @@ export default {
 				}
 			}
 
-			if (newEditingSongIndex > -1)
-				this.pickSong(this.filteredItems[newEditingSongIndex].song);
+			if (newEditingSongIndex > -1) {
+				const nextSong = this.filteredItems[newEditingSongIndex].song;
+				this.pickSong(nextSong);
+			}
 		},
 		toggleFlag(songIndex = null) {
 			if (songIndex && songIndex > -1) {
@@ -446,7 +426,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode .sidebar {
 	.sidebar-head,
 	.sidebar-foot {
@@ -495,7 +475,7 @@ export default {
 	max-height: calc(100vh - 40px);
 	overflow: auto;
 	margin-right: 8px;
-	border-radius: 5px;
+	border-radius: @border-radius;
 
 	.sidebar-head,
 	.sidebar-foot {
@@ -510,7 +490,7 @@ export default {
 
 	.sidebar-head {
 		border-bottom: 1px solid var(--light-grey-2);
-		border-radius: 5px 5px 0 0;
+		border-radius: @border-radius @border-radius 0 0;
 
 		.sidebar-title {
 			display: flex;
@@ -536,7 +516,7 @@ export default {
 			align-items: center;
 			column-gap: 8px;
 
-			/deep/ .song-item {
+			:deep(.song-item) {
 				.item-icon {
 					margin-right: 10px;
 					cursor: pointer;
@@ -581,7 +561,7 @@ export default {
 
 	.sidebar-foot {
 		border-top: 1px solid var(--light-grey-2);
-		border-radius: 0 0 5px 5px;
+		border-radius: 0 0 @border-radius @border-radius;
 
 		.button {
 			flex: 1;

+ 48 - 36
frontend/src/components/modals/EditUser.vue

@@ -4,35 +4,43 @@
 			<template #body v-if="user && user._id">
 				<div class="section">
 					<label class="label"> Change username </label>
-					<p class="control has-addons">
-						<input
-							v-model="user.username"
-							class="input is-expanded"
-							type="text"
-							placeholder="Username"
-							autofocus
-						/>
-						<a class="button is-info" @click="updateUsername()"
-							>Update Username</a
-						>
+					<p class="control is-grouped">
+						<span class="control is-expanded">
+							<input
+								v-model="user.username"
+								class="input"
+								type="text"
+								placeholder="Username"
+								autofocus
+							/>
+						</span>
+						<span class="control">
+							<a class="button is-info" @click="updateUsername()"
+								>Update Username</a
+							>
+						</span>
 					</p>
 
 					<label class="label"> Change email address </label>
-					<p class="control has-addons">
-						<input
-							v-model="user.email.address"
-							class="input is-expanded"
-							type="text"
-							placeholder="Email Address"
-							autofocus
-						/>
-						<a class="button is-info" @click="updateEmail()"
-							>Update Email Address</a
-						>
+					<p class="control is-grouped">
+						<span class="control is-expanded">
+							<input
+								v-model="user.email.address"
+								class="input"
+								type="text"
+								placeholder="Email Address"
+								autofocus
+							/>
+						</span>
+						<span class="control">
+							<a class="button is-info" @click="updateEmail()"
+								>Update Email Address</a
+							>
+						</span>
 					</p>
 
 					<label class="label"> Change user role </label>
-					<div class="control is-grouped input-with-button">
+					<div class="control is-grouped">
 						<div class="control is-expanded select">
 							<select v-model="user.role">
 								<option>default</option>
@@ -49,8 +57,8 @@
 
 				<div class="section">
 					<label class="label"> Punish/Ban User </label>
-					<p class="control has-addons">
-						<span class="select">
+					<p class="control is-grouped">
+						<span class="control select">
 							<select v-model="ban.expiresAt">
 								<option value="1h">1 Hour</option>
 								<option value="12h">12 Hours</option>
@@ -62,16 +70,20 @@
 								<option value="1y">1 Year</option>
 							</select>
 						</span>
-						<input
-							v-model="ban.reason"
-							class="input is-expanded"
-							type="text"
-							placeholder="Ban reason"
-							autofocus
-						/>
-						<a class="button is-danger" @click="banUser()">
-							Ban user
-						</a>
+						<span class="control is-expanded">
+							<input
+								v-model="ban.reason"
+								class="input"
+								type="text"
+								placeholder="Ban reason"
+								autofocus
+							/>
+						</span>
+						<span class="control">
+							<a class="button is-danger" @click="banUser()">
+								Ban user
+							</a>
+						</span>
 					</p>
 				</div>
 			</template>
@@ -272,7 +284,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode .section {
 	background-color: transparent !important;
 }

+ 16 - 20
frontend/src/components/modals/ImportAlbum.vue

@@ -149,11 +149,7 @@
 								!discogs.disableLoadMore &&
 								discogs.page < discogs.pages
 							"
-							class="
-								button
-								is-fullwidth is-info
-								discogs-load-more
-							"
+							class="button is-fullwidth is-info discogs-load-more"
 							@click="loadNextDiscogsPage()"
 						>
 							Load more...
@@ -470,7 +466,7 @@ export default {
 			if (!this.search.playlist.query)
 				return new Toast("Please enter a YouTube playlist URL.");
 
-			const regex = new RegExp(`[\\?&]list=([^&#]*)`);
+			const regex = /[\\?&]list=([^&#]*)/;
 			const splitQuery = regex.exec(this.search.playlist.query);
 
 			if (!splitQuery) {
@@ -561,7 +557,7 @@ export default {
 					.then(data => {
 						apiResult.album.artists = [];
 						apiResult.album.artistIds = [];
-						const artistRegex = new RegExp(" \\([0-9]+\\)$");
+						const artistRegex = /\\([0-9]+\\)$/;
 
 						apiResult.dataQuality = data.data_quality;
 						data.artists.forEach(artist => {
@@ -676,7 +672,7 @@ export default {
 };
 </script>
 
-<style lang="scss">
+<style lang="less">
 .night-mode {
 	.search-discogs-album,
 	.discogs-album,
@@ -757,7 +753,7 @@ export default {
 }
 </style>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .break {
 	flex-basis: 100%;
 	height: 0;
@@ -778,7 +774,7 @@ export default {
 		overflow-x: auto;
 
 		.button {
-			border-radius: 5px 5px 0 0;
+			border-radius: @border-radius @border-radius 0 0;
 			border: 0;
 			text-transform: uppercase;
 			font-size: 14px;
@@ -800,7 +796,7 @@ export default {
 	}
 	.tab {
 		border: 1px solid var(--light-grey-3);
-		border-radius: 0 0 3px 3px;
+		border-radius: 0 0 @border-radius @border-radius;
 		padding: 15px;
 		height: calc(100% - 32px);
 		overflow: auto;
@@ -885,7 +881,7 @@ export default {
 		.api-result {
 			background-color: var(--white);
 			border: 0.5px solid var(--primary-color);
-			border-radius: 5px;
+			border-radius: @border-radius;
 			margin-bottom: 16px;
 		}
 
@@ -903,11 +899,11 @@ export default {
 
 			.track:first-child {
 				margin-top: 0;
-				border-radius: 3px 3px 0 0;
+				border-radius: @border-radius @border-radius 0 0;
 			}
 
 			.track:last-child {
-				border-radius: 0 0 3px 3px;
+				border-radius: 0 0 @border-radius @border-radius;
 			}
 
 			.track {
@@ -1010,11 +1006,11 @@ export default {
 
 				.track:first-child {
 					margin-top: 0;
-					border-radius: 3px 3px 0 0;
+					border-radius: @border-radius @border-radius 0 0;
 				}
 
 				.track:last-child {
-					border-radius: 0 0 3px 3px;
+					border-radius: 0 0 @border-radius @border-radius;
 				}
 
 				.track {
@@ -1051,7 +1047,7 @@ export default {
 	width: 376px;
 	background-color: var(--light-grey);
 	border: 1px rgba(163, 224, 255, 0.75) solid;
-	border-radius: 5px;
+	border-radius: @border-radius;
 	padding: 16px;
 	overflow: auto;
 	height: 100%;
@@ -1065,18 +1061,18 @@ export default {
 	width: 376px;
 	background-color: var(--light-grey);
 	border: 1px rgba(163, 224, 255, 0.75) solid;
-	border-radius: 5px;
+	border-radius: @border-radius;
 	padding: 16px;
 	overflow: auto;
 	height: 100%;
 
 	.track-box:first-child {
 		margin-top: 0;
-		border-radius: 3px 3px 0 0;
+		border-radius: @border-radius @border-radius 0 0;
 	}
 
 	.track-box:last-child {
-		border-radius: 0 0 3px 3px;
+		border-radius: 0 0 @border-radius @border-radius;
 	}
 
 	.track-box {

+ 156 - 0
frontend/src/components/modals/ImportPlaylist.vue

@@ -0,0 +1,156 @@
+<template>
+	<modal title="Import Playlist">
+		<template #body>
+			<div class="vertical-padding">
+				<p class="section-description">
+					Import a playlist by using a link from YouTube
+				</p>
+
+				<div class="control is-grouped">
+					<p class="control is-expanded">
+						<input
+							class="input"
+							type="text"
+							placeholder="YouTube Playlist URL"
+							v-model="youtubeSearch.playlist.query"
+							@keyup.enter="importPlaylist()"
+						/>
+					</p>
+					<p id="playlist-import-type" class="control select">
+						<select
+							v-model="
+								youtubeSearch.playlist.isImportingOnlyMusic
+							"
+						>
+							<option :value="false">Import all</option>
+							<option :value="true">Import only music</option>
+						</select>
+					</p>
+					<p class="control">
+						<button
+							class="button is-info"
+							@click.prevent="importPlaylist()"
+						>
+							<i class="material-icons icon-with-button"
+								>publish</i
+							>Import
+						</button>
+					</p>
+				</div>
+			</div>
+		</template>
+		<template #footer>
+			<p class="is-expanded checkbox-control">
+				<label class="switch">
+					<input
+						type="checkbox"
+						id="edit-imported-songs"
+						v-model="localEditSongs"
+					/>
+					<span class="slider round"></span>
+				</label>
+
+				<label for="edit-imported-songs">
+					<p>Edit Songs</p>
+				</label>
+			</p>
+		</template>
+	</modal>
+</template>
+
+<script>
+import { mapActions, mapState, mapGetters } from "vuex";
+
+import Toast from "toasters";
+
+import SearchYoutube from "@/mixins/SearchYoutube.vue";
+
+import Modal from "../Modal.vue";
+
+export default {
+	components: { Modal },
+	mixins: [SearchYoutube],
+	computed: {
+		localEditSongs: {
+			get() {
+				return this.$store.state.modals.importPlaylist
+					.editImportedSongs;
+			},
+			set(editImportedSongs) {
+				this.$store.commit(
+					"modals/importPlaylist/updateEditImportedSongs",
+					editImportedSongs
+				);
+			}
+		},
+		...mapState({
+			loggedIn: state => state.user.auth.loggedIn,
+			station: state => state.station.station
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	methods: {
+		importPlaylist() {
+			let isImportingPlaylist = true;
+
+			// 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
+				});
+			}
+
+			// don't give starting import message instantly in case of instant error
+			setTimeout(() => {
+				if (isImportingPlaylist) {
+					new Toast(
+						"Starting to import your playlist. This can take some time to do."
+					);
+				}
+			}, 750);
+
+			return this.socket.dispatch(
+				"songs.requestSet",
+				this.youtubeSearch.playlist.query,
+				this.youtubeSearch.playlist.isImportingOnlyMusic,
+				true,
+				res => {
+					isImportingPlaylist = false;
+
+					if (
+						this.localEditSongs &&
+						res.status === "success" &&
+						res.songs &&
+						res.songs.length > 0
+					) {
+						this.editSongs(
+							res.songs.map(song => ({
+								...song,
+								songId: song._id
+							}))
+						);
+						this.openModal("editSongs");
+					}
+
+					this.closeModal("importPlaylist");
+					return new Toast({
+						content: res.message,
+						timeout: 20000
+					});
+				}
+			);
+		},
+		...mapActions("modals/editSongs", ["editSongs"]),
+		...mapActions("modalVisibility", ["openModal", "closeModal"])
+	}
+};
+</script>

+ 12 - 5
frontend/src/components/modals/Login.vue

@@ -10,12 +10,12 @@
 				<form>
 					<!-- email address -->
 					<p class="control">
-						<label class="label">Email</label>
+						<label class="label">Username/Email</label>
 						<input
 							v-model="email"
 							class="input"
 							type="email"
-							placeholder="Email..."
+							placeholder="Username/Email..."
 							@keypress="submitOnEnter(submitModal, $event)"
 						/>
 					</p>
@@ -89,7 +89,10 @@
 					</a>
 				</div>
 
-				<p class="content-box-optional-helper">
+				<p
+					v-if="!registrationDisabled"
+					class="content-box-optional-helper"
+				>
 					<a @click="changeToRegisterModal()">
 						Don't have an account?
 					</a>
@@ -116,11 +119,15 @@ export default {
 				value: "",
 				visible: false
 			},
-			apiDomain: ""
+			apiDomain: "",
+			registrationDisabled: false
 		};
 	},
 	async mounted() {
 		this.apiDomain = await lofig.get("backend.apiDomain");
+		this.registrationDisabled = await lofig.get(
+			"siteSettings.registrationDisabled"
+		);
 	},
 	methods: {
 		checkForAutofill(event) {
@@ -171,7 +178,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	.modal-card,
 	.modal-card-head,

+ 1 - 1
frontend/src/components/modals/ManageStation/Tabs/Playlists.vue

@@ -1142,7 +1142,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	.tabs-container .tab-selection .button {
 		background: var(--dark-grey) !important;

+ 4 - 4
frontend/src/components/modals/ManageStation/Tabs/Settings.vue

@@ -544,7 +544,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .station-settings {
 	.settings-buttons {
 		display: flex;
@@ -560,7 +560,7 @@ export default {
 		display: flex;
 		flex-direction: column;
 
-		* >>> .tippy-box[data-theme~="dropdown"] .tippy-content > span {
+		:deep(* .tippy-box[data-theme~="dropdown"] .tippy-content > span) {
 			max-width: 150px !important;
 		}
 
@@ -572,10 +572,10 @@ export default {
 			width: 100%;
 			height: 36px;
 			border: 0;
-			border-radius: 3px;
+			border-radius: @border-radius;
 			font-size: 18px;
 			color: var(--white);
-			box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.25);
+			box-shadow: @box-shadow;
 			display: flex;
 			text-align: center;
 			justify-content: center;

+ 4 - 10
frontend/src/components/modals/ManageStation/Tabs/Songs.vue

@@ -75,10 +75,7 @@
 												song.youtubeId
 											) !== -1
 										"
-										class="
-											material-icons
-											added-to-playlist-icon
-										"
+										class="material-icons added-to-playlist-icon"
 										content="Song is already in queue"
 										v-tippy
 										>done</i
@@ -148,10 +145,7 @@
 											songsInQueue.indexOf(result.id) !==
 											-1
 										"
-										class="
-											material-icons
-											added-to-playlist-icon
-										"
+										class="material-icons added-to-playlist-icon"
 										content="Song is already in queue"
 										v-tippy
 										>done</i
@@ -378,7 +372,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	.tabs-container .tab-selection .button {
 		background: var(--dark-grey) !important;
@@ -437,7 +431,7 @@ export default {
 
 	#playlist-info-section {
 		border: 1px solid var(--light-grey-3);
-		border-radius: 3px;
+		border-radius: @border-radius;
 		padding: 15px !important;
 		margin-bottom: 16px;
 

+ 13 - 14
frontend/src/components/modals/ManageStation/index.vue

@@ -162,14 +162,6 @@
 			</div>
 		</template>
 		<template #footer>
-			<button
-				class="button is-primary tab-actionable-button"
-				v-if="loggedIn && station.type === 'official'"
-				@click="openModal('requestSong')"
-			>
-				<i class="material-icons icon-with-button">queue</i>
-				<span> Request Song </span>
-			</button>
 			<div v-if="isOwnerOrAdmin()" class="right">
 				<quick-confirm @confirm="clearAndRefillStationQueue()">
 					<a class="button is-danger">
@@ -654,7 +646,7 @@ export default {
 };
 </script>
 
-<style lang="scss">
+<style lang="less">
 .manage-station-modal.modal .modal-card {
 	.tab > button {
 		width: 100%;
@@ -670,7 +662,7 @@ export default {
 }
 </style>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	.manage-station-modal.modal .modal-card-body {
 		.left-section {
@@ -692,7 +684,7 @@ export default {
 		}
 		.right-section .section,
 		#queue {
-			border-radius: 5px;
+			border-radius: @border-radius;
 			background-color: transparent !important;
 		}
 	}
@@ -709,7 +701,7 @@ export default {
 			display: flex;
 			flex-direction: column;
 			flex-grow: unset;
-			border-radius: 5px;
+			border-radius: @border-radius;
 			margin: 0 0 20px 0;
 			background-color: var(--white);
 			border: 1px solid var(--light-grey-3);
@@ -725,6 +717,8 @@ export default {
 						margin: 0;
 						font-size: 36px;
 						line-height: 0.8;
+						text-overflow: ellipsis;
+						overflow: hidden;
 					}
 
 					i {
@@ -744,8 +738,13 @@ export default {
 				}
 
 				p {
+					display: -webkit-box;
 					max-width: 700px;
 					margin-bottom: 10px;
+					overflow: hidden;
+					text-overflow: ellipsis;
+					-webkit-box-orient: vertical;
+					-webkit-line-clamp: 3;
 				}
 			}
 
@@ -763,7 +762,7 @@ export default {
 			overflow-x: auto;
 
 			.button {
-				border-radius: 5px 5px 0 0;
+				border-radius: @border-radius @border-radius 0 0;
 				border: 0;
 				text-transform: uppercase;
 				font-size: 14px;
@@ -786,7 +785,7 @@ export default {
 		.tab {
 			border: 1px solid var(--light-grey-3);
 			padding: 15px;
-			border-radius: 0 0 5px 5px;
+			border-radius: 0 0 @border-radius @border-radius;
 		}
 	}
 	.right-section {

+ 14 - 3
frontend/src/components/modals/Register.vue

@@ -171,7 +171,8 @@ export default {
 				token: "",
 				enabled: false
 			},
-			apiDomain: ""
+			apiDomain: "",
+			registrationDisabled: false
 		};
 	},
 	watch: {
@@ -230,6 +231,16 @@ export default {
 	async mounted() {
 		this.apiDomain = await lofig.get("backend.apiDomain");
 
+		lofig
+			.get("siteSettings.registrationDisabled")
+			.then(registrationDisabled => {
+				this.registrationDisabled = registrationDisabled;
+				if (registrationDisabled) {
+					new Toast("Registration is disabled.");
+					this.closeModal("register");
+				}
+			});
+
 		lofig.get("recaptcha").then(obj => {
 			this.recaptcha.enabled = obj.enabled;
 			if (obj.enabled === true) {
@@ -305,7 +316,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	.modal-card,
 	.modal-card-head,
@@ -356,7 +367,7 @@ a {
 }
 </style>
 
-<style lang="scss">
+<style lang="less">
 .grecaptcha-badge {
 	z-index: 2000;
 }

+ 2 - 2
frontend/src/components/modals/RemoveAccount.vue

@@ -281,7 +281,7 @@ export default {
 };
 </script>
 
-<style lang="scss">
+<style lang="less">
 .confirm-account-removal-modal {
 	.modal-card {
 		width: 650px;
@@ -289,7 +289,7 @@ export default {
 }
 </style>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 h2 {
 	margin: 0;
 }

+ 6 - 14
frontend/src/components/modals/Report.vue

@@ -89,10 +89,7 @@
 											@click="customIssues.push('')"
 										>
 											<i
-												class="
-													material-icons
-													icon-with-button
-												"
+												class="material-icons icon-with-button"
 												>add</i
 											>
 											<span> Add Custom Issue </span>
@@ -100,12 +97,7 @@
 									</div>
 
 									<div
-										class="
-											custom-issue
-											control
-											is-grouped
-											input-with-button
-										"
+										class="custom-issue control is-grouped input-with-button"
 										v-for="(issue, index) in customIssues"
 										:key="index"
 									>
@@ -434,7 +426,7 @@ export default {
 };
 </script>
 
-<style lang="scss">
+<style lang="less">
 .report-modal .song-item .thumbnail {
 	min-width: 130px;
 	width: 130px;
@@ -442,7 +434,7 @@ export default {
 }
 </style>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	@media screen and (max-width: 900px) {
 		#right-part {
@@ -451,7 +443,7 @@ export default {
 	}
 	.columns {
 		background-color: var(--dark-grey-3) !important;
-		border-radius: 5px;
+		border-radius: @border-radius;
 	}
 }
 
@@ -473,7 +465,7 @@ export default {
 			margin-bottom: 20px;
 			padding: 20px;
 			background-color: var(--light-grey);
-			border-radius: 5px;
+			border-radius: @border-radius;
 		}
 	}
 

+ 0 - 289
frontend/src/components/modals/RequestSong.vue

@@ -1,289 +0,0 @@
-<template>
-	<modal title="Request Song">
-		<template #body>
-			<div class="vertical-padding">
-				<!-- Choosing a song from youtube -->
-
-				<h4 class="section-title">Choose a song</h4>
-				<p class="section-description">
-					Choose a song by searching or using a link from YouTube
-				</p>
-
-				<br />
-
-				<div class="control is-grouped input-with-button">
-					<p class="control is-expanded">
-						<input
-							class="input"
-							type="text"
-							placeholder="Enter your YouTube query here..."
-							v-model="youtubeSearch.songs.query"
-							autofocus
-							@keyup.enter="searchForSongs()"
-						/>
-					</p>
-					<p class="control">
-						<button
-							class="button is-info"
-							@click.prevent="searchForSongs()"
-						>
-							<i class="material-icons icon-with-button">search</i
-							>Search
-						</button>
-					</p>
-				</div>
-
-				<!-- Choosing a song from youtube - query results -->
-
-				<div
-					id="song-query-results"
-					v-if="youtubeSearch.songs.results.length > 0"
-				>
-					<search-query-item
-						v-for="(result, index) in youtubeSearch.songs.results"
-						:key="result.id"
-						:result="result"
-					>
-						<template #actions>
-							<transition
-								name="search-query-actions"
-								mode="out-in"
-							>
-								<i
-									v-if="result.isRequested"
-									key="added-to-playlist"
-									class="material-icons icon-requested"
-									content="Requested song"
-									v-tippy
-									>done</i
-								>
-								<i
-									v-else
-									@click.prevent="
-										addSongToQueue(result.id, index)
-									"
-									key="add-to-queue"
-									class="material-icons icon-request"
-									content="Request song"
-									v-tippy
-									>add</i
-								>
-							</transition>
-						</template>
-					</search-query-item>
-
-					<button
-						class="button is-default load-more-button"
-						@click.prevent="loadMoreSongs()"
-					>
-						Load more...
-					</button>
-				</div>
-
-				<!-- Import a playlist from youtube -->
-
-				<div v-if="station.type === 'official'">
-					<hr class="section-horizontal-rule" />
-
-					<h4 class="section-title">Import a playlist</h4>
-					<p class="section-description">
-						Import a playlist by using a link from YouTube
-					</p>
-
-					<br />
-
-					<div class="control is-grouped input-with-button">
-						<p class="control is-expanded">
-							<input
-								class="input"
-								type="text"
-								placeholder="YouTube Playlist URL"
-								v-model="youtubeSearch.playlist.query"
-								@keyup.enter="importPlaylist()"
-							/>
-						</p>
-						<p class="control has-addons">
-							<span class="select" id="playlist-import-type">
-								<select
-									v-model="
-										youtubeSearch.playlist
-											.isImportingOnlyMusic
-									"
-								>
-									<option :value="false">Import all</option>
-									<option :value="true">
-										Import only music
-									</option>
-								</select>
-							</span>
-							<button
-								class="button is-info"
-								@click.prevent="importPlaylist()"
-							>
-								<i class="material-icons icon-with-button"
-									>publish</i
-								>Import
-							</button>
-						</p>
-					</div>
-				</div>
-			</div>
-		</template>
-	</modal>
-</template>
-
-<script>
-import { mapState, mapGetters } from "vuex";
-
-import Toast from "toasters";
-
-import SearchYoutube from "@/mixins/SearchYoutube.vue";
-
-import SearchQueryItem from "../SearchQueryItem.vue";
-import Modal from "../Modal.vue";
-
-export default {
-	components: { Modal, SearchQueryItem },
-	mixins: [SearchYoutube],
-	computed: {
-		...mapState({
-			loggedIn: state => state.user.auth.loggedIn,
-			station: state => state.station.station
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	methods: {
-		addSongToQueue(youtubeId, index) {
-			if (this.station.type === "community") {
-				this.socket.dispatch(
-					"stations.addToQueue",
-					this.station._id,
-					youtubeId,
-					res => {
-						if (res.status !== "success")
-							new Toast(`Error: ${res.message}`);
-						else {
-							this.youtubeSearch.songs.results[
-								index
-							].isRequested = true;
-
-							new Toast(res.message);
-						}
-					}
-				);
-			} else {
-				this.socket.dispatch("songs.request", youtubeId, false, res => {
-					if (res.status !== "success")
-						new Toast(`Error: ${res.message}`);
-					else {
-						this.youtubeSearch.songs.results[
-							index
-						].isRequested = true;
-
-						new Toast(res.message);
-					}
-				});
-			}
-		},
-		importPlaylist() {
-			let isImportingPlaylist = true;
-
-			// import query is blank
-			if (!this.youtubeSearch.playlist.query)
-				return new Toast("Please enter a YouTube playlist URL.");
-
-			const regex = new RegExp(`[\\?&]list=([^&#]*)`);
-			const splitQuery = regex.exec(this.youtubeSearch.playlist.query);
-
-			if (!splitQuery) {
-				return new Toast({
-					content: "Please enter a valid YouTube playlist URL.",
-					timeout: 4000
-				});
-			}
-
-			// don't give starting import message instantly in case of instant error
-			setTimeout(() => {
-				if (isImportingPlaylist) {
-					new Toast(
-						"Starting to import your playlist. This can take some time to do."
-					);
-				}
-			}, 750);
-
-			return this.socket.dispatch(
-				"songs.requestSet",
-				this.youtubeSearch.playlist.query,
-				this.youtubeSearch.playlist.isImportingOnlyMusic,
-				false,
-				res => {
-					isImportingPlaylist = false;
-					return new Toast({ content: res.message, timeout: 20000 });
-				}
-			);
-		}
-	}
-};
-</script>
-
-<style lang="scss" scoped>
-.night-mode {
-	div {
-		color: var(--dark-grey);
-	}
-
-	.icon-request {
-		color: var(--white);
-	}
-}
-
-.song-actions {
-	.button {
-		height: 36px;
-		width: 140px;
-	}
-}
-
-.song-thumbnail div {
-	width: 96px;
-	height: 54px;
-	background-position: center;
-	background-repeat: no-repeat;
-}
-
-.table {
-	margin-bottom: 0;
-	margin-top: 20px;
-}
-
-.vertical-padding {
-	padding: 20px;
-}
-
-.icon-requested {
-	color: var(--green);
-}
-
-.icon-request {
-	color: var(--dark-gray-3);
-}
-
-#song-query-results {
-	padding: 10px;
-	max-height: 500px;
-	overflow: auto;
-	border: 1px solid var(--light-grey-3);
-	border-radius: 3px;
-
-	.search-query-item:not(:last-of-type) {
-		margin-bottom: 10px;
-	}
-
-	.load-more-button {
-		width: 100%;
-		margin-top: 10px;
-	}
-}
-</style>

+ 65 - 35
frontend/src/components/modals/ViewReport.vue

@@ -28,11 +28,7 @@
 						:key="issueIndex"
 					>
 						<i
-							class="
-								material-icons
-								duration-icon
-								report-sub-item-left-icon
-							"
+							class="material-icons duration-icon report-sub-item-left-icon"
 							:content="issue.category"
 							v-tippy
 						>
@@ -51,10 +47,7 @@
 						</p>
 
 						<div
-							class="
-								report-sub-item-actions
-								universal-item-actions
-							"
+							class="report-sub-item-actions universal-item-actions"
 						>
 							<i
 								class="material-icons resolve-icon"
@@ -80,26 +73,43 @@
 			</div>
 		</template>
 		<template #footer v-if="report && report._id">
-			<a class="button is-primary" @click="openSong()">
-				<i
-					class="material-icons icon-with-button"
-					content="Edit Song"
-					v-tippy
-				>
-					edit
-				</i>
-				Edit Song
+			<a
+				class="button is-primary material-icons icon-with-button"
+				@click="openSong()"
+				content="Edit Song"
+				v-tippy
+			>
+				edit
 			</a>
-			<button class="button is-success" @click="resolve()">
-				<i
-					class="material-icons icon-with-button"
-					content="Resolve"
-					v-tippy
-				>
-					done_all
-				</i>
-				Resolve
+			<button
+				v-if="report.resolved"
+				class="button is-danger material-icons icon-with-button"
+				@click="resolve(false)"
+				content="Unresolve"
+				v-tippy
+			>
+				remove_done
 			</button>
+			<button
+				v-else
+				class="button is-success material-icons icon-with-button"
+				@click="resolve(true)"
+				content="Resolve"
+				v-tippy
+			>
+				done_all
+			</button>
+			<div class="right">
+				<quick-confirm @confirm="remove()">
+					<button
+						class="button is-danger material-icons icon-with-button"
+						content="Delete Report"
+						v-tippy
+					>
+						delete_forever
+					</button>
+				</quick-confirm>
+			</div>
 		</template>
 	</modal>
 </template>
@@ -112,9 +122,10 @@ import ws from "@/ws";
 import Modal from "@/components/Modal.vue";
 import SongItem from "@/components/SongItem.vue";
 import ReportInfoItem from "@/components/ReportInfoItem.vue";
+import QuickConfirm from "@/components/QuickConfirm.vue";
 
 export default {
-	components: { Modal, SongItem, ReportInfoItem },
+	components: { Modal, SongItem, ReportInfoItem, QuickConfirm },
 	props: {
 		sector: { type: String, default: "admin" }
 	},
@@ -145,6 +156,14 @@ export default {
 
 		this.socket.on(
 			"event:admin.report.resolved",
+			res => {
+				this.report.resolved = res.data.resolved;
+			},
+			{ modal: "viewReport" }
+		);
+
+		this.socket.on(
+			"event:admin.report.removed",
 			() => this.closeModal("viewReport"),
 			{ modal: "viewReport" }
 		);
@@ -199,8 +218,15 @@ export default {
 				}
 			});
 		},
-		resolve() {
-			return this.resolveReport(this.reportId)
+		resolve(value) {
+			return this.resolveReport({ reportId: this.reportId, value })
+				.then(res => {
+					if (res.status !== "success") new Toast(res.message);
+				})
+				.catch(err => new Toast(err.message));
+		},
+		remove() {
+			return this.removeReport(this.reportId)
 				.then(res => {
 					if (res.status === "success") this.closeModal("viewReport");
 				})
@@ -220,14 +246,18 @@ export default {
 			this.editSong({ songId: this.report.song._id });
 			this.openModal("editSong");
 		},
-		...mapActions("admin/reports", ["indexReports", "resolveReport"]),
+		...mapActions("admin/reports", [
+			"indexReports",
+			"resolveReport",
+			"removeReport"
+		]),
 		...mapActions("modals/editSong", ["editSong"]),
 		...mapActions("modalVisibility", ["closeModal", "openModal"])
 	}
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	.report-sub-items {
 		background-color: var(--dark-grey-2) !important;
@@ -256,7 +286,7 @@ export default {
 		}
 	}
 
-	/deep/ .report-info-item {
+	:deep(.report-info-item) {
 		justify-content: flex-start;
 
 		.item-title-description {
@@ -283,11 +313,11 @@ export default {
 			display: flex;
 
 			&:first-child {
-				border-radius: 3px 3px 0 0;
+				border-radius: @border-radius @border-radius 0 0;
 			}
 
 			&:last-child {
-				border-radius: 0 0 3px 3px;
+				border-radius: 0 0 @border-radius @border-radius;
 			}
 
 			&.report-sub-item-resolved {

+ 11 - 6
frontend/src/components/modals/WhatIsNew.vue

@@ -66,14 +66,17 @@ export default {
 	},
 	methods: {
 		init() {
-			this.socket.dispatch("news.newest", res => {
+			const newUser = !localStorage.getItem("firstVisited");
+			this.socket.dispatch("news.newest", newUser, res => {
 				if (res.status !== "success") return;
 
 				const { news } = res.data;
 
 				this.news = news;
-				if (this.news && localStorage.getItem("firstVisited")) {
-					if (localStorage.getItem("whatIsNew")) {
+				if (this.news) {
+					if (newUser) {
+						this.openModal("whatIsNew");
+					} else if (localStorage.getItem("whatIsNew")) {
 						if (
 							parseInt(localStorage.getItem("whatIsNew")) <
 							news.createdAt
@@ -89,7 +92,9 @@ export default {
 							this.openModal("whatIsNew");
 						localStorage.setItem("whatIsNew", news.createdAt);
 					}
-				} else if (!localStorage.getItem("firstVisited"))
+				}
+
+				if (!localStorage.getItem("firstVisited"))
 					localStorage.setItem("firstVisited", Date.now());
 			});
 		},
@@ -101,13 +106,13 @@ export default {
 };
 </script>
 
-<style lang="scss">
+<style lang="less">
 .what-is-news-modal .modal-card .modal-card-foot {
 	column-gap: 0;
 }
 </style>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	.modal-card,
 	.modal-card-head,

+ 47 - 9
frontend/src/main.js

@@ -11,7 +11,7 @@ import store from "./store";
 
 import AppComponent from "./App.vue";
 
-const REQUIRED_CONFIG_VERSION = 9;
+const REQUIRED_CONFIG_VERSION = 11;
 
 const handleMetadata = attrs => {
 	document.title = `Musare | ${attrs.title}`;
@@ -78,6 +78,7 @@ const router = createRouter({
 	history: createWebHistory(),
 	routes: [
 		{
+			name: "home",
 			path: "/",
 			component: () => import("@/pages/Home.vue")
 		},
@@ -143,13 +144,45 @@ const router = createRouter({
 		{
 			path: "/admin",
 			component: () => import("@/pages/Admin/index.vue"),
-			meta: {
-				adminRequired: true
-			}
-		},
-		{
-			path: "/admin/:page",
-			component: () => import("@/pages//Admin/index.vue"),
+			children: [
+				{
+					path: "songs",
+					component: () => import("@/pages/Admin/Songs.vue")
+				},
+				{
+					path: "reports",
+					component: () => import("@/pages/Admin/Reports.vue")
+				},
+				{
+					path: "stations",
+					component: () => import("@/pages/Admin/Stations.vue")
+				},
+				{
+					path: "playlists",
+					component: () => import("@/pages/Admin/Playlists.vue")
+				},
+				{
+					path: "users",
+					component: () => import("@/pages/Admin/Users/index.vue")
+				},
+				{
+					path: "users/data-requests",
+					component: () =>
+						import("@/pages/Admin/Users/DataRequests.vue")
+				},
+				{
+					path: "punishments",
+					component: () => import("@/pages/Admin/Punishments.vue")
+				},
+				{
+					path: "news",
+					component: () => import("@/pages/Admin/News.vue")
+				},
+				{
+					path: "statistics",
+					component: () => import("@/pages/Admin/Statistics.vue")
+				}
+			],
 			meta: {
 				adminRequired: true
 			}
@@ -168,6 +201,11 @@ router.beforeEach((to, from, next) => {
 		window.stationInterval = 0;
 	}
 
+	if (from.name === "home" && to.name === "station") {
+		if (store.state.modalVisibility.modals.manageStation)
+			store.dispatch("modalVisibility/closeModal", "manageStation");
+	}
+
 	if (ws.socket && to.fullPath !== from.fullPath) {
 		ws.clearCallbacks();
 		ws.destroyListeners();
@@ -202,7 +240,7 @@ router.beforeEach((to, from, next) => {
 
 app.use(router);
 
-lofig.folder = "../config/default.json";
+lofig.folder = "/config/default.json";
 
 (async () => {
 	lofig.fetchConfig().then(config => {

+ 1 - 1
frontend/src/pages/404.vue

@@ -23,7 +23,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .wrapper {
 	min-height: calc(100vh - 100px);
 	display: flex;

+ 3 - 3
frontend/src/pages/About.vue

@@ -68,7 +68,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	.card {
 		background-color: var(--dark-grey-3);
@@ -85,11 +85,11 @@ export default {
 	flex-direction: column;
 	padding: 20px;
 	margin: 10px 10px 50px 10px;
-	border-radius: 5px;
+	border-radius: @border-radius;
 	overflow: hidden;
 	background-color: var(--white);
 	color: var(--dark-grey);
-	box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1);
+	box-shadow: @box-shadow;
 
 	.card-header {
 		font-weight: 700;

+ 21 - 12
frontend/src/pages/Admin/tabs/News.vue → frontend/src/pages/Admin/News.vue

@@ -19,12 +19,7 @@
 				<template #column-options="slotProps">
 					<div class="row-options">
 						<button
-							class="
-								button
-								is-primary
-								icon-with-button
-								material-icons
-							"
+							class="button is-primary icon-with-button material-icons"
 							@click="edit(slotProps.item._id)"
 							content="Edit News"
 							v-tippy
@@ -36,12 +31,7 @@
 							:disabled="slotProps.item.removed"
 						>
 							<button
-								class="
-									button
-									is-danger
-									icon-with-button
-									material-icons
-								"
+								class="button is-danger icon-with-button material-icons"
 								content="Remove News"
 								v-tippy
 							>
@@ -55,6 +45,11 @@
 						slotProps.item.status
 					}}</span>
 				</template>
+				<template #column-showToNewUsers="slotProps">
+					<span :title="slotProps.item.showToNewUsers">{{
+						slotProps.item.showToNewUsers
+					}}</span>
+				</template>
 				<template #column-title="slotProps">
 					<span :title="slotProps.item.title">{{
 						slotProps.item.title
@@ -131,6 +126,13 @@ export default {
 					sortProperty: "status",
 					defaultWidth: 150
 				},
+				{
+					name: "showToNewUsers",
+					displayName: "Show to new users",
+					properties: ["showToNewUsers"],
+					sortProperty: "showToNewUsers",
+					defaultWidth: 180
+				},
 				{
 					name: "title",
 					displayName: "Title",
@@ -159,6 +161,13 @@ export default {
 					filterTypes: ["contains", "exact", "regex"],
 					defaultFilterType: "contains"
 				},
+				{
+					name: "showToNewUsers",
+					displayName: "Show to new users",
+					property: "showToNewUsers",
+					filterTypes: ["boolean"],
+					defaultFilterType: "boolean"
+				},
 				{
 					name: "title",
 					displayName: "Title",

+ 2 - 7
frontend/src/pages/Admin/tabs/Playlists.vue → frontend/src/pages/Admin/Playlists.vue

@@ -16,12 +16,7 @@
 				<template #column-options="slotProps">
 					<div class="row-options">
 						<button
-							class="
-								button
-								is-primary
-								icon-with-button
-								material-icons
-							"
+							class="button is-primary icon-with-button material-icons"
 							@click="edit(slotProps.item._id)"
 							:disabled="slotProps.item.removed"
 							content="Edit Playlist"
@@ -98,7 +93,7 @@ import AdvancedTable from "@/components/AdvancedTable.vue";
 import RunJobDropdown from "@/components/RunJobDropdown.vue";
 import UserIdToUsername from "@/components/UserIdToUsername.vue";
 
-import utils from "../../../../js/utils";
+import utils from "../../../js/utils";
 
 export default {
 	components: {

+ 17 - 24
frontend/src/pages/Admin/tabs/Punishments.vue → frontend/src/pages/Admin/Punishments.vue

@@ -1,6 +1,6 @@
 <template>
 	<div>
-		<page-metadata title="Admin | Punishments" />
+		<page-metadata title="Admin | Users | Punishments" />
 		<div class="container">
 			<advanced-table
 				:column-default="columnDefault"
@@ -13,12 +13,7 @@
 				<template #column-options="slotProps">
 					<div class="row-options">
 						<button
-							class="
-								button
-								is-primary
-								icon-with-button
-								material-icons
-							"
+							class="button is-primary icon-with-button material-icons"
 							@click="view(slotProps.item._id)"
 							:disabled="slotProps.item.removed"
 							content="View Punishment"
@@ -84,16 +79,18 @@
 				</header>
 				<div class="card-content">
 					<label class="label">Expires In</label>
-					<select v-model="ipBan.expiresAt">
-						<option value="1h">1 Hour</option>
-						<option value="12h">12 Hours</option>
-						<option value="1d">1 Day</option>
-						<option value="1w">1 Week</option>
-						<option value="1m">1 Month</option>
-						<option value="3m">3 Months</option>
-						<option value="6m">6 Months</option>
-						<option value="1y">1 Year</option>
-					</select>
+					<p class="control is-expanded select">
+						<select v-model="ipBan.expiresAt">
+							<option value="1h">1 Hour</option>
+							<option value="12h">12 Hours</option>
+							<option value="1d">1 Day</option>
+							<option value="1w">1 Week</option>
+							<option value="1m">1 Month</option>
+							<option value="3m">3 Months</option>
+							<option value="6m">6 Months</option>
+							<option value="1y">1 Year</option>
+						</select>
+					</p>
 					<label class="label">IP</label>
 					<p class="control is-expanded">
 						<input
@@ -319,7 +316,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	.card {
 		background: var(--dark-grey-3);
@@ -337,10 +334,10 @@ export default {
 	flex-direction: column;
 	padding: 20px;
 	margin: 10px 0;
-	border-radius: 5px;
+	border-radius: @border-radius;
 	background-color: var(--white);
 	color: var(--dark-grey);
-	box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1);
+	box-shadow: @box-shadow;
 
 	.card-header {
 		font-weight: 700;
@@ -350,9 +347,5 @@ export default {
 	.button.is-primary {
 		width: 100%;
 	}
-
-	select {
-		margin-bottom: 10px;
-	}
 }
 </style>

+ 43 - 20
frontend/src/pages/Admin/tabs/Reports.vue → frontend/src/pages/Admin/Reports.vue

@@ -1,6 +1,6 @@
 <template>
 	<div>
-		<page-metadata title="Admin | Reports" />
+		<page-metadata title="Admin | Songs | Reports" />
 		<div class="container">
 			<advanced-table
 				:column-default="columnDefault"
@@ -14,12 +14,7 @@
 				<template #column-options="slotProps">
 					<div class="row-options">
 						<button
-							class="
-								button
-								is-primary
-								icon-with-button
-								material-icons
-							"
+							class="button is-primary icon-with-button material-icons"
 							@click="view(slotProps.item._id)"
 							:disabled="slotProps.item.removed"
 							content="View Report"
@@ -28,13 +23,19 @@
 							open_in_full
 						</button>
 						<button
-							class="
-								button
-								is-success
-								icon-with-button
-								material-icons
-							"
-							@click="resolve(slotProps.item._id)"
+							v-if="slotProps.item.resolved"
+							class="button is-danger material-icons icon-with-button"
+							@click="resolve(slotProps.item._id, false)"
+							:disabled="slotProps.item.removed"
+							content="Unresolve Report"
+							v-tippy
+						>
+							remove_done
+						</button>
+						<button
+							v-else
+							class="button is-success material-icons icon-with-button"
+							@click="resolve(slotProps.item._id, true)"
 							:disabled="slotProps.item.removed"
 							content="Resolve Report"
 							v-tippy
@@ -64,6 +65,11 @@
 						{{ slotProps.item.song.youtubeId }}
 					</a>
 				</template>
+				<template #column-resolved="slotProps">
+					<span :title="slotProps.item.resolved">{{
+						slotProps.item.resolved
+					}}</span>
+				</template>
 				<template #column-categories="slotProps">
 					<span
 						:title="
@@ -140,7 +146,7 @@ export default {
 				{
 					name: "options",
 					displayName: "Options",
-					properties: ["_id"],
+					properties: ["_id", "resolved"],
 					sortable: false,
 					hidable: false,
 					resizable: false,
@@ -171,6 +177,12 @@ export default {
 					minWidth: 165,
 					defaultWidth: 165
 				},
+				{
+					name: "resolved",
+					displayName: "Resolved",
+					properties: ["resolved"],
+					sortProperty: "resolved"
+				},
 				{
 					name: "categories",
 					displayName: "Categories",
@@ -214,6 +226,13 @@ export default {
 					filterTypes: ["contains", "exact", "regex"],
 					defaultFilterType: "contains"
 				},
+				{
+					name: "resolved",
+					displayName: "Resolved",
+					property: "resolved",
+					filterTypes: ["boolean"],
+					defaultFilterType: "boolean"
+				},
 				{
 					name: "categories",
 					displayName: "Categories",
@@ -238,8 +257,13 @@ export default {
 			],
 			events: {
 				adminRoom: "reports",
+				updated: {
+					event: "admin.report.updated",
+					id: "report._id",
+					item: "report"
+				},
 				removed: {
-					event: "admin.report.resolved",
+					event: "admin.report.removed",
 					id: "reportId"
 				}
 			}
@@ -255,11 +279,10 @@ export default {
 			this.viewReport(reportId);
 			this.openModal("viewReport");
 		},
-		resolve(reportId) {
-			return this.resolveReport(reportId)
+		resolve(reportId, value) {
+			return this.resolveReport({ reportId, value })
 				.then(res => {
-					if (res.status === "success" && this.modals.viewReport)
-						this.closeModal("viewReport");
+					if (res.status !== "success") new Toast(res.message);
 				})
 				.catch(err => new Toast(err.message));
 		},

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff