1
0
Эх сурвалжийг харах

Merge tag 'v3.4.0' into staging

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

+ 3 - 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,12 @@ MONGO_PORT=27017
 MONGO_ROOT_PASSWORD=PASSWORD_HERE
 MONGO_USER_USERNAME=musare
 MONGO_USER_PASSWORD=OTHER_PASSWORD_HERE
+MONGO_DATA_LOCATION=.db
 
 REDIS_HOST=127.0.0.1
 REDIS_PORT=6379
 REDIS_PASSWORD=PASSWORD
+REDIS_DATA_LOCATION=.redis
 
 BACKUP_LOCATION=
 BACKUP_NAME=

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

@@ -14,9 +14,11 @@ env:
     MONGO_ROOT_PASSWORD: PASSWORD_HERE
     MONGO_USER_USERNAME: musare
     MONGO_USER_PASSWORD: OTHER_PASSWORD_HERE
+    MONGO_DATA_LOCATION: .db
     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

+ 6 - 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,10 @@ 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. |
 | `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`. |

+ 5 - 5
.wiki/Installation.md

@@ -9,8 +9,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 +43,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 +70,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

+ 211 - 0
CHANGELOG.md

@@ -0,0 +1,211 @@
+# Changelog
+
+## [v3.4.0] - 2022-03-05
+
+### **Breaking Changes**
+This release includes a MongoDB upgrade with breaking changes. Before upgrading please backup your database and reset it, and after upgrading restore it.
+Documentation on how to do each of these steps can be found in [.wiki/Utility_Script.md](.wiki/Utility_Script.md).
+Please note that `musare.sh update` does not automatically do this for you.
+
+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
+
+### 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
+
+### 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.

+ 13 - 4
README.md

@@ -4,6 +4,8 @@
 
 Musare is an open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.
 
+A demonstration instance of Musare can be found at [demo.musare.com](https://demo.musare.com).
+
 ---
 
 ## Documentation
@@ -41,13 +43,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 +67,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 } });
+			}
+		);
 	},
 
 	/**

+ 12 - 17
backend/logic/actions/playlists.js

@@ -1208,22 +1208,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 })
@@ -1553,11 +1552,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(() => {});
 								});
@@ -849,7 +904,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(() => {});
 					});
@@ -976,6 +1031,8 @@ export default {
 
 				(song, next) => {
 					song.verified = false;
+					song.verifiedBy = null;
+					song.verifiedAt = null;
 					song.save(err => {
 						next(err, song);
 					});
@@ -983,7 +1040,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(() => {});
 					});
@@ -1864,11 +1921,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();
 					});
 				}
 			],
@@ -1957,11 +2019,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();
 					});
 				}
 			],
@@ -2050,11 +2117,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();
 					});
 				}
 			],

+ 5 - 2
backend/logic/actions/users.js

@@ -612,7 +612,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 +625,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
 					);

+ 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 => {
@@ -230,7 +229,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: 96, required: true },
+	displayName: { type: String, min: 2, max: 96, 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();
 					}
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -569,6 +569,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) {
@@ -586,13 +587,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);
 							});
 					},
@@ -1133,7 +1135,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 => {
@@ -1192,8 +1194,8 @@ class _PlaylistsModule extends CoreClass {
 					if (err && err !== true) return reject(new Error(err));
 					return resolve(playlist);
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -1204,7 +1206,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 => {
@@ -1240,8 +1242,8 @@ class _PlaylistsModule extends CoreClass {
 					if (err && err !== true) return reject(new Error(err));
 					return resolve(playlist);
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -1252,7 +1254,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 => {
@@ -1288,8 +1290,8 @@ class _PlaylistsModule extends CoreClass {
 					if (err && err !== true) return reject(new Error(err));
 					return resolve();
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -1309,7 +1311,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 => {
@@ -1379,8 +1381,8 @@ class _PlaylistsModule extends CoreClass {
 					if (err && err !== true) return reject(new Error(err));
 					return resolve(data);
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -1456,7 +1458,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);
 				}
-			)
-		);
+			);
+		});
 	}
 }
 

+ 102 - 44
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();
 									})
@@ -477,8 +534,8 @@ class _SongsModule extends CoreClass {
 
 					return resolve(song);
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -493,7 +550,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
@@ -710,16 +767,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();
 									})
@@ -754,8 +812,8 @@ class _SongsModule extends CoreClass {
 
 					return resolve();
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -764,7 +822,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 => {
@@ -798,8 +856,8 @@ class _SongsModule extends CoreClass {
 					if (err && err !== true) return reject(new Error(err));
 					return resolve();
 				}
-			)
-		);
+			);
+		});
 	}
 
 	// /**
@@ -894,7 +952,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 => {
@@ -976,8 +1034,8 @@ class _SongsModule extends CoreClass {
 					if (err && err !== true) return reject(new Error(err));
 					return resolve(data);
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -1081,7 +1139,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 => {
@@ -1106,8 +1164,8 @@ class _SongsModule extends CoreClass {
 					if (err && err !== true) return reject(new Error(err));
 					return resolve({ genres });
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -1116,7 +1174,7 @@ class _SongsModule extends CoreClass {
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	GET_ALL_ARTISTS() {
-		return new Promise((resolve, reject) =>
+		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
 					next => {
@@ -1141,8 +1199,8 @@ class _SongsModule extends CoreClass {
 					if (err && err !== true) return reject(new Error(err));
 					return resolve({ artists });
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -1153,7 +1211,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 => {
@@ -1170,8 +1228,8 @@ class _SongsModule extends CoreClass {
 					if (err && err !== true) return reject(new Error(err));
 					return resolve({ songs });
 				}
-			)
-		);
+			);
+		});
 	}
 
 	/**
@@ -1182,7 +1240,7 @@ class _SongsModule extends CoreClass {
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	GET_ALL_SONGS_WITH_ARTIST(payload) {
-		return new Promise((resolve, reject) =>
+		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
 					next => {
@@ -1199,8 +1257,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", {

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 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:5.0
+    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
 }

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 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"
   }

+ 75 - 32
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";
@@ -840,15 +836,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 {
@@ -980,7 +975,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 {
@@ -1085,7 +1080,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) {
@@ -1097,7 +1092,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;
@@ -1119,7 +1114,7 @@ img {
 			font-size: 15.5px;
 			min-height: 36px;
 			background: var(--light-grey);
-			border-radius: 5px;
+			border-radius: @border-radius;
 			cursor: pointer;
 
 			.checkbox-control {
@@ -1219,7 +1214,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;
@@ -1245,6 +1240,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);
@@ -1262,11 +1267,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;
@@ -1286,7 +1292,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;
 
@@ -1352,7 +1358,7 @@ button.delete:focus {
 	width: 100%;
 	border-collapse: collapse;
 	border-spacing: 0;
-	border-radius: 5px;
+	border-radius: @border-radius;
 
 	thead th {
 		padding: 5px 10px;
@@ -1376,7 +1382,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;
@@ -1447,7 +1453,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);
 }
@@ -1502,6 +1508,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 {
@@ -1516,20 +1550,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;
 			}
 		}
 	}
@@ -1544,14 +1583,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;
 	}
 }
 
@@ -1616,14 +1655,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 {
@@ -1803,7 +1842,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;
@@ -1875,10 +1914,10 @@ h4.section-title {
 
 .news-item {
 	font-family: Nunito, Arial, sans-serif;
-	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: Nunito, Arial, sans-serif;
@@ -1933,6 +1972,10 @@ h4.section-title {
 	code {
 		font-style: italic;
 	}
+
+	hr {
+		margin: 10px 0;
+	}
 }
 .checkbox-control {
 	display: flex;
@@ -2050,12 +2093,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 {
@@ -83,7 +83,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,

+ 59 - 75
frontend/src/components/layout/MainFooter.vue

@@ -15,34 +15,32 @@
 				</router-link>
 				<div id="footer-links">
 					<a
-						:href="siteSettings.github"
+						v-for="(url, title, index) in filteredFooterLinks"
+						:key="`footer-link-${index}`"
+						:href="url"
 						target="_blank"
-						title="GitHub Repository"
-						>GitHub</a
+						: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>
-				</div>
-				<div id="footer-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>
+					<router-link
+						v-if="getLink('news') === true"
+						title="News"
+						to="/news"
+						>News</router-link
+					>
 				</div>
 			</div>
 		</div>
@@ -50,8 +48,7 @@
 </template>
 
 <script>
-import Toast from "toasters";
-import { mapState, mapGetters, mapActions } from "vuex";
+import { mapState, mapGetters } from "vuex";
 
 export default {
 	data() {
@@ -59,57 +56,58 @@ export default {
 			siteSettings: {
 				logo: "",
 				sitename: "Musare",
-				github: ""
-			},
-			localNightmode: null
+				github: "",
+				footerLinks: {}
+			}
 		};
 	},
 	computed: {
+		filteredFooterLinks() {
+			return Object.fromEntries(
+				Object.entries(this.siteSettings.footerLinks).filter(
+					([title, url]) =>
+						!(
+							["about", "team", "news"].includes(
+								title.toLowerCase()
+							) && typeof url === "boolean"
+						)
+				)
+			);
+		},
 		...mapState({
-			loggedIn: state => state.user.auth.loggedIn,
-			nightmode: state => state.user.preferences.nightmode
+			loggedIn: state => state.user.auth.loggedIn
 		}),
 		...mapGetters({
 			socket: "websockets/getSocket"
 		})
 	},
-	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;
-		}
-	},
 	async mounted() {
-		this.localNightmode = JSON.parse(localStorage.getItem("nightmode"));
-		if (this.localNightmode === null) this.localNightmode = false;
-
 		this.frontendDomain = await lofig.get("frontendDomain");
-		this.siteSettings = await lofig.get("siteSettings");
+		lofig.get("siteSettings").then(siteSettings => {
+			this.siteSettings = {
+				...siteSettings,
+				footerLinks: {
+					about: true,
+					team: true,
+					news: true,
+					...siteSettings.footerLinks
+				}
+			};
+		});
 	},
 	methods: {
-		...mapActions("user/preferences", ["changeNightmode"])
+		getLink(title) {
+			return this.siteSettings.footerLinks[
+				Object.keys(this.siteSettings.footerLinks).find(
+					key => key.toLowerCase() === title
+				)
+			];
+		}
 	}
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	footer.footer,
 	footer.footer .container,
@@ -123,12 +121,10 @@ export default {
 	bottom: 0;
 	flex-shrink: 0;
 	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: 200px;
+	height: 160px;
 	font-size: 16px;
 
 	.container {
@@ -150,10 +146,6 @@ export default {
 		}
 	}
 
-	@media (max-width: 650px) {
-		border-radius: 0;
-	}
-
 	#footer-logo {
 		display: block;
 		margin-left: auto;
@@ -205,15 +197,11 @@ export default {
 	#footer-copyright {
 		order: 4;
 	}
-
-	#footer-nightmode-toggle {
-		order: 2;
-	}
 }
 
 @media only screen and (min-width: 990px) {
 	.footer {
-		height: 140px;
+		height: 100px;
 
 		#footer-copyright {
 			order: 3;
@@ -230,10 +218,6 @@ export default {
 			position: absolute;
 			line-height: 50px;
 		}
-
-		#footer-nightmode-toggle {
-			order: 4;
-		}
 	}
 }
 </style>

+ 106 - 44
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
@@ -25,6 +28,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'"
@@ -49,7 +72,12 @@
 			</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>
 
@@ -64,6 +92,8 @@
 import { mapState, mapGetters, mapActions } from "vuex";
 import { defineAsyncComponent } from "vue";
 
+import Toast from "toasters";
+
 export default {
 	components: {
 		ChristmasLights: defineAsyncComponent(() =>
@@ -77,12 +107,14 @@ export default {
 	},
 	data() {
 		return {
+			localNightmode: false,
 			isMobile: false,
 			frontendDomain: "",
 			siteSettings: {
 				logo_white: "",
 				sitename: "",
-				christmas: false
+				christmas: false,
+				registrationDisabled: false
 			},
 			windowWidth: 0
 		};
@@ -99,6 +131,12 @@ export default {
 			socket: "websockets/getSocket"
 		})
 	},
+	watch: {
+		nightmode(nightmode) {
+			if (this.localNightmode !== nightmode)
+				this.toggleNightmode(nightmode);
+		}
+	},
 	async mounted() {
 		this.frontendDomain = await lofig.get("frontendDomain");
 		this.siteSettings = await lofig.get("siteSettings");
@@ -109,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;
 		},
@@ -118,12 +173,14 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
-.night-mode .nav {
-	background-color: var(--dark-grey-3) !important;
+<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;
 		}
 	}
@@ -139,17 +196,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;
@@ -248,6 +300,10 @@ export default {
 				-webkit-user-drag: none;
 			}
 		}
+
+		.night-mode-label {
+			display: none;
+		}
 	}
 }
 
@@ -265,46 +321,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

@@ -134,7 +134,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

@@ -117,10 +117,7 @@
 									>
 										<template #tippyActions>
 											<i
-												class="
-													material-icons
-													add-to-queue-icon
-												"
+												class="material-icons add-to-queue-icon"
 												v-if="
 													station.partyMode &&
 													!station.locked
@@ -148,10 +145,7 @@
 												"
 											>
 												<i
-													class="
-														material-icons
-														delete-icon
-													"
+													class="material-icons delete-icon"
 													content="Remove Song from Playlist"
 													v-tippy
 													>delete_forever</i
@@ -609,7 +603,7 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	.label,
 	p,
@@ -636,7 +630,7 @@ export default {
 			}
 		}
 		.right-section .section {
-			border-radius: 5px;
+			border-radius: @border-radius;
 		}
 	}
 }
@@ -674,7 +668,7 @@ export default {
 		max-height: unset !important;
 	}
 
-	/deep/ .section {
+	:deep(.section) {
 		max-width: 100% !important;
 	}
 }
@@ -718,7 +712,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;
@@ -741,14 +735,14 @@ 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;
 				margin: 0;
 			}
 		}
 
 		#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"
@@ -306,7 +292,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;
@@ -367,7 +353,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) {
@@ -393,11 +379,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;

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 311 - 214
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/Blacklist.vue

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

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

@@ -692,7 +692,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;

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

@@ -174,7 +174,7 @@ export default {
 };
 </script>
 
-<style lang="scss">
+<style lang="less">
 .search {
 	.musare-search,
 	.universal-item:not(:last-of-type) {

+ 5 - 12
frontend/src/components/modals/ManageStation/index.vue

@@ -170,14 +170,6 @@
 			>
 				View Station Playlist
 			</a>
-			<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 class="optional-desktop-only-text"> Request Song </span>
-			</button>
 			<div v-if="isOwnerOrAdmin()" class="right">
 				<quick-confirm @confirm="clearAndRefillStationQueue()">
 					<a class="button is-danger">
@@ -599,7 +591,7 @@ export default {
 };
 </script>
 
-<style lang="scss">
+<style lang="less">
 .manage-station-modal.modal .modal-card {
 	.tab > button {
 		width: 100%;
@@ -615,7 +607,7 @@ export default {
 }
 </style>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	.manage-station-modal.modal .modal-card-body {
 		.left-section {
@@ -633,6 +625,7 @@ export default {
 		}
 		.right-section .section,
 		#queue {
+			border-radius: @border-radius;
 			background-color: transparent !important;
 		}
 	}
@@ -647,7 +640,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;
@@ -670,7 +663,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;
 			}
 		}
 	}

+ 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"
 									>
@@ -445,7 +437,7 @@ export default {
 };
 </script>
 
-<style lang="scss">
+<style lang="less">
 .report-modal .song-item .thumbnail {
 	min-width: 130px;
 	width: 130px;
@@ -453,7 +445,7 @@ export default {
 }
 </style>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .night-mode {
 	@media screen and (max-width: 900px) {
 		#right-part {
@@ -462,7 +454,7 @@ export default {
 	}
 	.columns {
 		background-color: var(--dark-grey-3) !important;
-		border-radius: 5px;
+		border-radius: @border-radius;
 	}
 }
 
@@ -484,7 +476,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;
 			}
 
 			&:only-child {

+ 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,

+ 48 - 8
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;
 
 lofig.folder = "../config/default.json";
 
@@ -86,6 +86,7 @@ const router = createRouter({
 	history: createWebHistory(),
 	routes: [
 		{
+			name: "home",
 			path: "/",
 			component: () => import("@/pages/Home.vue")
 		},
@@ -151,13 +152,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
 			}
@@ -176,6 +209,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();
@@ -210,6 +248,8 @@ router.beforeEach((to, from, next) => {
 
 app.use(router);
 
+lofig.folder = "/config/default.json";
+
 (async () => {
 	lofig.fetchConfig().then(config => {
 		const { configVersion, skipConfigVersionCheck } = 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));
 		},

+ 24 - 37
frontend/src/pages/Admin/tabs/Songs.vue → frontend/src/pages/Admin/Songs.vue

@@ -3,11 +3,14 @@
 		<page-metadata title="Admin | Songs" />
 		<div class="admin-tab">
 			<div class="button-row">
+				<button class="button is-primary" @click="create()">
+					Create song
+				</button>
 				<button
 					class="button is-primary"
-					@click="openModal('requestSong')"
+					@click="openModal('importPlaylist')"
 				>
-					Request song
+					Import playlist
 				</button>
 				<button
 					class="button is-primary"
@@ -28,12 +31,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="editOne(slotProps.item)"
 							:disabled="slotProps.item.removed"
 							content="Edit Song"
@@ -46,12 +44,7 @@
 							@confirm="unverifyOne(slotProps.item._id)"
 						>
 							<button
-								class="
-									button
-									is-danger
-									icon-with-button
-									material-icons
-								"
+								class="button is-danger icon-with-button material-icons"
 								:disabled="slotProps.item.removed"
 								content="Unverify Song"
 								v-tippy
@@ -61,12 +54,7 @@
 						</quick-confirm>
 						<button
 							v-else
-							class="
-								button
-								is-success
-								icon-with-button
-								material-icons
-							"
+							class="button is-success icon-with-button material-icons"
 							@click="verifyOne(slotProps.item._id)"
 							:disabled="slotProps.item.removed"
 							content="Verify Song"
@@ -75,12 +63,7 @@
 							check_circle
 						</button>
 						<button
-							class="
-								button
-								is-danger
-								icon-with-button
-								material-icons
-							"
+							class="button is-danger icon-with-button material-icons"
 							@click.prevent="
 								confirmAction({
 									message:
@@ -277,7 +260,7 @@
 		<edit-song v-if="modals.editSong" song-type="songs" />
 		<edit-songs v-if="modals.editSongs" />
 		<report v-if="modals.report" />
-		<request-song v-if="modals.requestSong" />
+		<import-playlist v-if="modals.importPlaylist" />
 		<bulk-actions v-if="modals.bulkActions" :type="bulkActionsType" />
 		<confirm v-if="modals.confirm" @confirmed="handleConfirmed()" />
 	</div>
@@ -308,8 +291,8 @@ export default {
 		ImportAlbum: defineAsyncComponent(() =>
 			import("@/components/modals/ImportAlbum.vue")
 		),
-		RequestSong: defineAsyncComponent(() =>
-			import("@/components/modals/RequestSong.vue")
+		ImportPlaylist: defineAsyncComponent(() =>
+			import("@/components/modals/ImportPlaylist.vue")
 		),
 		BulkActions: defineAsyncComponent(() =>
 			import("@/components/modals/BulkActions.vue")
@@ -675,6 +658,10 @@ export default {
 		}
 	},
 	methods: {
+		create() {
+			this.editSong({ newSong: true });
+			this.openModal("editSong");
+		},
 		editOne(song) {
 			this.editSong({ songId: song._id });
 			this.openModal("editSong");
@@ -722,9 +709,7 @@ export default {
 				name: "tags",
 				action: "songs.editTags",
 				items: selectedRows.map(row => row._id),
-				regex: new RegExp(
-					/^[a-zA-Z0-9_]{1,64}$|^[a-zA-Z0-9_]{1,64}\[[a-zA-Z0-9_]{1,64}\]$/
-				),
+				regex: /^[a-zA-Z0-9_]{1,64}$|^[a-zA-Z0-9_]{1,64}\[[a-zA-Z0-9_]{1,64}\]$/,
 				autosuggest: true,
 				autosuggestDataAction: "songs.getTags"
 			};
@@ -735,7 +720,7 @@ export default {
 				name: "artists",
 				action: "songs.editArtists",
 				items: selectedRows.map(row => row._id),
-				regex: new RegExp(/^(?=.{1,64}$).*$/),
+				regex: /^(?=.{1,64}$).*$/,
 				autosuggest: true,
 				autosuggestDataAction: "songs.getArtists"
 			};
@@ -746,7 +731,7 @@ export default {
 				name: "genres",
 				action: "songs.editGenres",
 				items: selectedRows.map(row => row._id),
-				regex: new RegExp(/^[\x00-\x7F]{1,32}$/),
+				regex: /^[\x00-\x7F]{1,32}$/,
 				autosuggest: true,
 				autosuggestDataAction: "songs.getGenres"
 			};
@@ -800,14 +785,16 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="less" scoped>
 .song-thumbnail {
 	display: block;
-	max-width: 50px;
+	width: 50px;
+	height: 50px;
 	margin: 0 auto;
+	object-fit: contain;
 }
 
-/deep/ .bulk-popup .bulk-actions {
+:deep(.bulk-popup .bulk-actions) {
 	.verify-songs-icon {
 		color: var(--green);
 	}

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно