Browse Source

Merge branch 'staging' of https://github.com/Musare/Musare into staging

Kristian Vos 3 years ago
parent
commit
ff13a16903

+ 198 - 3
backend/logic/actions/songs.js

@@ -1744,7 +1744,7 @@ export default {
 	 * @param session
 	 * @param cb
 	 */
-	getGenres: isAdminRequired(function getModule(session, cb) {
+	getGenres: isAdminRequired(function getGenres(session, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -1774,13 +1774,78 @@ export default {
 		);
 	}),
 
+	/**
+	 * Bulk update genres for selected songs
+	 *
+	 * @param session
+	 * @param method Whether to add, remove or replace genres
+	 * @param genres Array of genres to apply
+	 * @param songIds Array of songIds to apply genres to
+	 * @param cb
+	 */
+	editGenres: isAdminRequired(async function editGenres(session, method, genres, songIds, cb) {
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+		async.waterfall(
+			[
+				next => {
+					songModel.find({ _id: { $in: songIds } }, next);
+				},
+
+				(songs, next) => {
+					const songsFound = songs.map(song => song._id);
+					if (songsFound.length > 0) next(null, songsFound);
+					else next("None of the specified songs were found.");
+				},
+
+				(songsFound, next) => {
+					const query = {};
+					if (method === "add") {
+						query.$push = { genres: { $each: genres } };
+					} else if (method === "remove") {
+						query.$pullAll = { genres };
+					} else if (method === "replace") {
+						query.$set = { genres };
+					} else {
+						next("Invalid method.");
+					}
+
+					songModel.updateMany({ _id: { $in: songsFound } }, query, { runValidators: true }, err => {
+						if (err) next(err);
+						async.eachLimit(
+							songsFound,
+							1,
+							(songId, next) => {
+								SongsModule.runJob("UPDATE_SONG", { songId });
+								next();
+							},
+							next
+						);
+					});
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "EDIT_GENRES", `User ${session.userId} failed to edit genres. '${err}'`);
+					cb({ status: "error", message: err });
+				} else {
+					this.log("SUCCESS", "EDIT_GENRES", `User ${session.userId} has successfully edited genres.`);
+					cb({
+						status: "success",
+						message: "Successfully edited genres."
+					});
+				}
+			}
+		);
+	}),
+
 	/**
 	 * Gets a list of all artists
 	 *
 	 * @param session
 	 * @param cb
 	 */
-	getArtists: isAdminRequired(function getModule(session, cb) {
+	getArtists: isAdminRequired(function getArtists(session, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -1810,13 +1875,78 @@ export default {
 		);
 	}),
 
+	/**
+	 * Bulk update artists for selected songs
+	 *
+	 * @param session
+	 * @param method Whether to add, remove or replace artists
+	 * @param artists Array of artists to apply
+	 * @param songIds Array of songIds to apply artists to
+	 * @param cb
+	 */
+	editArtists: isAdminRequired(async function editArtists(session, method, artists, songIds, cb) {
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+		async.waterfall(
+			[
+				next => {
+					songModel.find({ _id: { $in: songIds } }, next);
+				},
+
+				(songs, next) => {
+					const songsFound = songs.map(song => song._id);
+					if (songsFound.length > 0) next(null, songsFound);
+					else next("None of the specified songs were found.");
+				},
+
+				(songsFound, next) => {
+					const query = {};
+					if (method === "add") {
+						query.$push = { artists: { $each: artists } };
+					} else if (method === "remove") {
+						query.$pullAll = { artists };
+					} else if (method === "replace") {
+						query.$set = { artists };
+					} else {
+						next("Invalid method.");
+					}
+
+					songModel.updateMany({ _id: { $in: songsFound } }, query, { runValidators: true }, err => {
+						if (err) next(err);
+						async.eachLimit(
+							songsFound,
+							1,
+							(songId, next) => {
+								SongsModule.runJob("UPDATE_SONG", { songId });
+								next();
+							},
+							next
+						);
+					});
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "EDIT_ARTISTS", `User ${session.userId} failed to edit artists. '${err}'`);
+					cb({ status: "error", message: err });
+				} else {
+					this.log("SUCCESS", "EDIT_ARTISTS", `User ${session.userId} has successfully edited artists.`);
+					cb({
+						status: "success",
+						message: "Successfully edited artists."
+					});
+				}
+			}
+		);
+	}),
+
 	/**
 	 * Gets a list of all tags
 	 *
 	 * @param session
 	 * @param cb
 	 */
-	getTags: isAdminRequired(function getModule(session, cb) {
+	getTags: isAdminRequired(function getTags(session, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -1844,5 +1974,70 @@ export default {
 				}
 			}
 		);
+	}),
+
+	/**
+	 * Bulk update tags for selected songs
+	 *
+	 * @param session
+	 * @param method Whether to add, remove or replace tags
+	 * @param tags Array of tags to apply
+	 * @param songIds Array of songIds to apply tags to
+	 * @param cb
+	 */
+	editTags: isAdminRequired(async function editTags(session, method, tags, songIds, cb) {
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+		async.waterfall(
+			[
+				next => {
+					songModel.find({ _id: { $in: songIds } }, next);
+				},
+
+				(songs, next) => {
+					const songsFound = songs.map(song => song._id);
+					if (songsFound.length > 0) next(null, songsFound);
+					else next("None of the specified songs were found.");
+				},
+
+				(songsFound, next) => {
+					const query = {};
+					if (method === "add") {
+						query.$push = { tags: { $each: tags } };
+					} else if (method === "remove") {
+						query.$pullAll = { tags };
+					} else if (method === "replace") {
+						query.$set = { tags };
+					} else {
+						next("Invalid method.");
+					}
+
+					songModel.updateMany({ _id: { $in: songsFound } }, query, { runValidators: true }, err => {
+						if (err) next(err);
+						async.eachLimit(
+							songsFound,
+							1,
+							(songId, next) => {
+								SongsModule.runJob("UPDATE_SONG", { songId });
+								next();
+							},
+							next
+						);
+					});
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "EDIT_TAGS", `User ${session.userId} failed to edit tags. '${err}'`);
+					cb({ status: "error", message: err });
+				} else {
+					this.log("SUCCESS", "EDIT_TAGS", `User ${session.userId} has successfully edited tags.`);
+					cb({
+						status: "success",
+						message: "Successfully edited tags."
+					});
+				}
+			}
+		);
 	})
 };

+ 3 - 3
frontend/package-lock.json

@@ -5058,9 +5058,9 @@
       "dev": true
     },
     "marked": {
-      "version": "3.0.7",
-      "resolved": "https://registry.npmjs.org/marked/-/marked-3.0.7.tgz",
-      "integrity": "sha512-ctKqbnLuNbsHbI26cfMyOlKgXGfl1orOv1AvWWDX7AkgfMOwCWvmuYc+mVLeWhQ9W6hdWVBynOs96VkcscKo0Q=="
+      "version": "4.0.10",
+      "resolved": "https://registry.npmjs.org/marked/-/marked-4.0.10.tgz",
+      "integrity": "sha512-+QvuFj0nGgO970fySghXGmuw+Fd0gD2x3+MqCWLIPf5oxdv1Ka6b2q+z9RP01P/IaKPMEramy+7cNy/Lw8c3hw=="
     },
     "media-typer": {
       "version": "0.3.0",

+ 1 - 1
frontend/package.json

@@ -49,7 +49,7 @@
     "eslint-config-airbnb-base": "^14.2.1",
     "html-webpack-plugin": "^5.3.2",
     "lofig": "^1.3.4",
-    "marked": "^3.0.7",
+    "marked": "^4.0.10",
     "normalize.css": "^8.0.1",
     "toasters": "^2.3.1",
     "vue": "^3.2.20",

+ 27 - 0
frontend/src/App.vue

@@ -396,6 +396,11 @@ export default {
 			background-color: var(--white);
 		}
 	}
+
+	.pill {
+		background-color: var(--dark-grey);
+		color: var(--primary-color);
+	}
 }
 
 .christmas-mode {
@@ -2013,4 +2018,26 @@ html {
 .disabled {
 	cursor: not-allowed;
 }
+
+.pill {
+	background-color: var(--light-grey);
+	color: var(--primary-color);
+	padding: 5px 10px;
+	border-radius: 5px;
+	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);
+	transition: all 0.2s ease-in-out;
+
+	&:hover,
+	&:focus {
+		filter: brightness(95%);
+	}
+
+	&:not(:last-of-type) {
+		margin-right: 5px;
+	}
+}
 </style>

+ 39 - 181
frontend/src/components/AdvancedTable.vue

@@ -177,79 +177,18 @@
 										:disabled="!filter.filterType"
 										@keydown.enter="applyFilterAndGetData()"
 									/>
-									<input
+									<auto-suggest
 										v-else
 										v-model="filter.data"
-										class="input"
-										type="text"
 										placeholder="Search value"
 										:disabled="!filter.filterType"
-										@keydown.enter="applyFilterAndGetData()"
-										@blur="blurFilterInput(filter, $event)"
-										@focus="focusFilterInput(filter)"
-										@keydown="keydownFilterInput(filter)"
-									/>
-									<div
-										class="autosuggest-container"
-										v-if="
-											filter.filter.autosuggest &&
-											(autosuggest.inputFocussed[
-												filter.filter.name
-											] ||
-												autosuggest.containerFocussed[
-													filter.filter.name
-												] ||
-												autosuggest.itemFocussed[
-													filter.filter.name
-												]) &&
-											autosuggest.items[
+										:all-items="
+											autosuggest.allItems[
 												filter.filter.name
-											]?.length > 0
-										"
-										@mouseover="
-											focusFilterAutosuggestContainer(
-												filter
-											)
+											]
 										"
-										@mouseleave="
-											blurFilterAutosuggestContainer(
-												filter
-											)
-										"
-									>
-										<span
-											class="autosuggest-item"
-											tabindex="0"
-											@click="
-												selectAutosuggestItem(
-													index,
-													filter,
-													item
-												)
-											"
-											@keyup.enter="
-												selectAutosuggestItem(
-													index,
-													filter,
-													item
-												)
-											"
-											@focus="
-												focusAutosuggestItem(filter)
-											"
-											@blur="
-												blurAutosuggestItem(
-													filter,
-													$event
-												)
-											"
-											v-for="item in autosuggest.items[
-												filter.filter.name
-											]"
-											:key="item"
-											>{{ item }}
-										</span>
-									</div>
+										@submitted="applyFilterAndGetData()"
+									/>
 								</div>
 								<div class="control">
 									<button
@@ -816,13 +755,15 @@ import { mapGetters } from "vuex";
 import draggable from "vuedraggable";
 
 import Toast from "toasters";
+import AutoSuggest from "@/components/AutoSuggest.vue";
 
 import keyboardShortcuts from "@/keyboardShortcuts";
 import ws from "@/ws";
 
 export default {
 	components: {
-		draggable
+		draggable,
+		AutoSuggest
 	},
 	props: {
 		/*
@@ -949,14 +890,7 @@ export default {
 			lastColumnResizerTappedDate: 0,
 			lastBulkActionsTappedDate: 0,
 			autosuggest: {
-				inputFocussed: {},
-				containerFocussed: {},
-				itemFocussed: {},
-				keydownInputTimeout: {},
-				items: {},
-				allItems: {
-					genres: ["edm", "pop", "test"]
-				}
+				allItems: {}
 			}
 		};
 	},
@@ -1962,50 +1896,6 @@ export default {
 				selected: false,
 				removed: true
 			};
-		},
-		blurFilterInput(filter, event) {
-			if (
-				event.relatedTarget &&
-				event.relatedTarget.classList.contains("autosuggest-item")
-			)
-				this.autosuggest.itemFocussed[filter.filter.name] = true;
-			this.autosuggest.inputFocussed[filter.filter.name] = false;
-		},
-		focusFilterInput(filter) {
-			this.autosuggest.inputFocussed[filter.filter.name] = true;
-		},
-		keydownFilterInput(filter) {
-			const { name } = filter.filter;
-			clearTimeout(this.autosuggest.keydownInputTimeout[name]);
-			this.autosuggest.keydownInputTimeout[name] = setTimeout(() => {
-				if (filter.data.length > 1) {
-					this.autosuggest.items[name] = this.autosuggest.allItems[
-						name
-					]?.filter(item =>
-						item.toLowerCase().startsWith(filter.data.toLowerCase())
-					);
-				} else this.autosuggest.items[name] = [];
-			}, 1000);
-		},
-		focusFilterAutosuggestContainer(filter) {
-			this.autosuggest.containerFocussed[filter.filter.name] = true;
-		},
-		blurFilterAutosuggestContainer(filter) {
-			this.autosuggest.containerFocussed[filter.filter.name] = false;
-		},
-		selectAutosuggestItem(index, filter, item) {
-			this.editingFilters[index].data = item;
-			this.autosuggest.items[filter.filter.name] = [];
-		},
-		focusAutosuggestItem(filter) {
-			this.autosuggest.itemFocussed[filter.filter.name] = true;
-		},
-		blurAutosuggestItem(filter, event) {
-			if (
-				!event.relatedTarget ||
-				!event.relatedTarget.classList.contains("autosuggest-item")
-			)
-				this.autosuggest.itemFocussed[filter.filter.name] = false;
 		}
 	}
 };
@@ -2096,20 +1986,6 @@ export default {
 			color: var(--white);
 		}
 	}
-	.autosuggest-container {
-		background-color: var(--dark-grey) !important;
-
-		.autosuggest-item {
-			background-color: var(--dark-grey) !important;
-			color: var(--white) !important;
-			border-color: var(--dark-grey) !important;
-		}
-
-		.autosuggest-item:hover,
-		.autosuggest-item:focus {
-			background-color: var(--dark-grey-2) !important;
-		}
-	}
 }
 
 .table-outer-container {
@@ -2304,19 +2180,29 @@ export default {
 		background-color: var(--white);
 	}
 
-	.table-header > div {
-		display: flex;
-		flex-direction: row;
+	.table-header {
+		& > div {
+			display: flex;
+			flex-direction: row;
 
-		> span > .control {
-			margin: 5px;
+			> span > .control {
+				margin: 5px;
+			}
+
+			.filters-indicator {
+				line-height: 46px;
+				display: flex;
+				align-items: center;
+				column-gap: 4px;
+			}
 		}
 
-		.filters-indicator {
-			line-height: 46px;
-			display: flex;
-			align-items: center;
-			column-gap: 4px;
+		@media screen and (max-width: 400px) {
+			flex-direction: column;
+
+			& > div {
+				justify-content: center;
+			}
 		}
 	}
 
@@ -2343,6 +2229,15 @@ export default {
 				top: 18px;
 			}
 		}
+
+		@media screen and (max-width: 600px) {
+			flex-direction: column;
+
+			.page-controls,
+			.page-size > .control {
+				justify-content: center;
+			}
+		}
 	}
 
 	.table-no-results {
@@ -2430,43 +2325,6 @@ export default {
 	}
 }
 
-.autosuggest-container {
-	position: absolute;
-	background: var(--white);
-	width: calc(100% + 1px);
-	top: 35px;
-	z-index: 200;
-	overflow: auto;
-	max-height: 98px;
-	clear: both;
-
-	.autosuggest-item {
-		padding: 8px;
-		display: block;
-		border: 1px solid var(--light-grey-2);
-		margin-top: -1px;
-		line-height: 16px;
-		cursor: pointer;
-		-webkit-user-select: none;
-		-ms-user-select: none;
-		-moz-user-select: none;
-		user-select: none;
-	}
-
-	.autosuggest-item:hover,
-	.autosuggest-item:focus {
-		background-color: var(--light-grey);
-	}
-
-	.autosuggest-item:first-child {
-		border-top: none;
-	}
-
-	.autosuggest-item:last-child {
-		border-radius: 0 0 3px 3px;
-	}
-}
-
 .advanced-filter {
 	.control {
 		position: relative;

+ 177 - 0
frontend/src/components/AutoSuggest.vue

@@ -0,0 +1,177 @@
+<template>
+	<div>
+		<input
+			v-model="value"
+			class="input"
+			type="text"
+			:placeholder="placeholder"
+			:disabled="disabled"
+			@blur="blurInput($event)"
+			@focus="focusInput()"
+			@keydown.enter="$emit('submitted')"
+			@keydown="keydownInput()"
+		/>
+		<div
+			class="autosuggest-container"
+			v-if="
+				(inputFocussed || containerFocussed || itemFocussed) &&
+				items.length > 0
+			"
+			@mouseover="focusAutosuggestContainer()"
+			@mouseleave="blurAutosuggestContainer()"
+		>
+			<span
+				class="autosuggest-item"
+				tabindex="0"
+				@click="selectAutosuggestItem(item)"
+				@keyup.enter="selectAutosuggestItem(item)"
+				@focus="focusAutosuggestItem()"
+				@blur="blurAutosuggestItem($event)"
+				v-for="item in items"
+				:key="item"
+			>
+				{{ item }}
+			</span>
+		</div>
+	</div>
+</template>
+
+<script>
+export default {
+	props: {
+		modelValue: {
+			type: String,
+			default: ""
+		},
+		placeholder: {
+			type: String,
+			default: "Search value"
+		},
+		disabled: {
+			type: Boolean,
+			default: false
+		},
+		allItems: {
+			type: Array,
+			default: () => []
+		}
+	},
+	emits: ["update:modelValue"],
+	data() {
+		return {
+			inputFocussed: false,
+			containerFocussed: false,
+			itemFocussed: false,
+			keydownInputTimeout: null,
+			items: []
+		};
+	},
+	computed: {
+		value: {
+			get() {
+				return this.modelValue;
+			},
+			set(value) {
+				this.$emit("update:modelValue", value);
+			}
+		}
+	},
+	methods: {
+		blurInput(event) {
+			if (
+				event.relatedTarget &&
+				event.relatedTarget.classList.contains("autosuggest-item")
+			)
+				this.itemFocussed = true;
+			this.inputFocussed = false;
+		},
+		focusInput() {
+			this.inputFocussed = true;
+		},
+		keydownInput() {
+			clearTimeout(this.keydownInputTimeout);
+			this.keydownInputTimeout = setTimeout(() => {
+				if (this.value && this.value.length > 1) {
+					this.items = this.allItems.filter(item =>
+						item.toLowerCase().startsWith(this.value.toLowerCase())
+					);
+				} else this.items = [];
+			}, 1000);
+		},
+		focusAutosuggestContainer() {
+			this.containerFocussed = true;
+		},
+		blurAutosuggestContainer() {
+			this.containerFocussed = false;
+		},
+		selectAutosuggestItem(item) {
+			this.value = item;
+			this.items = [];
+		},
+		focusAutosuggestItem() {
+			this.itemFocussed = true;
+		},
+		blurAutosuggestItem(event) {
+			if (
+				!event.relatedTarget ||
+				!event.relatedTarget.classList.contains("autosuggest-item")
+			)
+				this.itemFocussed = false;
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.night-mode .autosuggest-container {
+	background-color: var(--dark-grey) !important;
+
+	.autosuggest-item {
+		background-color: var(--dark-grey) !important;
+		color: var(--white) !important;
+		border-color: var(--dark-grey) !important;
+	}
+
+	.autosuggest-item:hover,
+	.autosuggest-item:focus {
+		background-color: var(--dark-grey-2) !important;
+	}
+}
+
+.autosuggest-container {
+	position: absolute;
+	background: var(--white);
+	width: calc(100% + 1px);
+	top: 35px;
+	z-index: 200;
+	overflow: auto;
+	max-height: 98px;
+	clear: both;
+
+	.autosuggest-item {
+		padding: 8px;
+		display: block;
+		border: 1px solid var(--light-grey-2);
+		margin-top: -1px;
+		line-height: 16px;
+		cursor: pointer;
+		-webkit-user-select: none;
+		-ms-user-select: none;
+		-moz-user-select: none;
+		user-select: none;
+	}
+
+	.autosuggest-item:hover,
+	.autosuggest-item:focus {
+		background-color: var(--light-grey);
+	}
+
+	.autosuggest-item:first-child {
+		border-top: none;
+	}
+
+	.autosuggest-item:last-child {
+		border-radius: 0 0 3px 3px;
+	}
+}
+</style>

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

@@ -220,7 +220,7 @@ export default {
 		.modal-card-foot {
 			border-top: 1px solid var(--light-grey-2);
 			border-radius: 0 0 5px 5px;
-			overflow: initial;
+			overflow-x: auto;
 			column-gap: 16px;
 
 			& > div {

+ 175 - 0
frontend/src/components/modals/BulkActions.vue

@@ -0,0 +1,175 @@
+<template>
+	<div>
+		<modal title="Bulk Actions" class="bulk-actions-modal">
+			<template #body>
+				<label class="label">Method</label>
+				<div class="control is-expanded select">
+					<select v-model="method">
+						<option value="add">Add</option>
+						<option value="remove">Remove</option>
+						<option value="replace">Replace</option>
+					</select>
+				</div>
+
+				<label class="label">{{ type.name.slice(0, -1) }}</label>
+				<div class="control is-grouped input-with-button">
+					<auto-suggest
+						v-model="itemInput"
+						:placeholder="`Enter ${type.name} to ${method}`"
+						:all-items="allItems"
+						@submitted="addItem()"
+					/>
+					<p class="control">
+						<button
+							class="button is-primary material-icons"
+							@click="addItem()"
+						>
+							add
+						</button>
+					</p>
+				</div>
+
+				<label class="label"
+					>{{ type.name }} to be
+					{{ method === "add" ? `added` : `${method}d` }}</label
+				>
+				<div v-if="items.length > 0">
+					<div
+						v-for="(item, index) in items"
+						:key="`item-${item}`"
+						class="pill"
+					>
+						{{ item }}
+						<span
+							class="material-icons remove-item"
+							@click="removeItem(index)"
+							content="Remove item"
+							v-tippy
+							>highlight_off</span
+						>
+					</div>
+				</div>
+				<p v-else>No {{ type.name }} specified</p>
+			</template>
+			<template #footer>
+				<button
+					class="button is-primary"
+					:disabled="items.length === 0"
+					@click="applyChanges()"
+				>
+					Apply Changes
+				</button>
+			</template>
+		</modal>
+	</div>
+</template>
+
+<script>
+import { mapGetters, mapActions } from "vuex";
+
+import Toast from "toasters";
+
+import Modal from "../Modal.vue";
+import AutoSuggest from "@/components/AutoSuggest.vue";
+
+import ws from "@/ws";
+
+export default {
+	components: { Modal, AutoSuggest },
+	props: {
+		type: {
+			type: Object,
+			default: () => {}
+		}
+	},
+	data() {
+		return {
+			method: "add",
+			items: [],
+			itemInput: null,
+			allItems: []
+		};
+	},
+	computed: {
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	beforeUnmount() {
+		this.itemInput = null;
+		this.items = [];
+	},
+	mounted() {
+		ws.onConnect(this.init);
+	},
+	methods: {
+		init() {
+			if (this.type.autosuggest && this.type.autosuggestDataAction)
+				this.socket.dispatch(this.type.autosuggestAction, res => {
+					if (res.status === "success") {
+						const { items } = res.data;
+						this.allItems = items;
+					} else {
+						new Toast(res.message);
+					}
+				});
+		},
+		addItem() {
+			if (!this.itemInput) return;
+			if (this.type.regex && !this.type.regex.test(this.itemInput)) {
+				new Toast(`Invalid ${this.type.name} format.`);
+			} else if (this.items.includes(this.itemInput)) {
+				new Toast(`Duplicate ${this.type.name} specified.`);
+			} else {
+				this.items.push(this.itemInput);
+				this.itemInput = null;
+			}
+		},
+		removeItem(index) {
+			this.items.splice(index, 1);
+		},
+		applyChanges() {
+			this.socket.dispatch(
+				this.type.action,
+				this.method,
+				this.items,
+				this.type.items,
+				res => {
+					new Toast(res.message);
+					this.closeModal("bulkActions");
+				}
+			);
+		},
+		...mapActions("modalVisibility", ["closeModal"])
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.label {
+	text-transform: capitalize;
+}
+
+.select.is-expanded select {
+	width: 100%;
+}
+
+.control.input-with-button > div {
+	flex: 1;
+}
+
+.pill {
+	display: inline-flex;
+
+	.remove-item {
+		font-size: 16px;
+		margin: auto 2px auto 5px;
+		cursor: pointer;
+	}
+}
+
+/deep/ .autosuggest-container {
+	width: calc(100% - 40px);
+	top: unset;
+}
+</style>

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

@@ -62,7 +62,7 @@
 
 <script>
 import { mapActions, mapGetters, mapState } from "vuex";
-import marked from "marked";
+import { marked } from "marked";
 import { sanitize } from "dompurify";
 import Toast from "toasters";
 import { formatDistance } from "date-fns";

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

@@ -29,7 +29,7 @@
 
 <script>
 import { formatDistance } from "date-fns";
-import marked from "marked";
+import { marked } from "marked";
 import { sanitize } from "dompurify";
 import { mapGetters, mapActions } from "vuex";
 import ws from "@/ws";

+ 40 - 9
frontend/src/pages/Admin/tabs/Songs.vue

@@ -228,8 +228,8 @@
 						</quick-confirm>
 						<i
 							class="material-icons tag-songs-icon"
-							@click.prevent="tagMany(slotProps.item)"
-							content="Tag Songs"
+							@click.prevent="setTags(slotProps.item)"
+							content="Set Tags"
 							v-tippy
 							tabindex="0"
 						>
@@ -278,6 +278,7 @@
 		<edit-songs />
 		<report v-if="modals.report" />
 		<request-song v-if="modals.requestSong" />
+		<bulk-actions v-if="modals.bulkActions" :type="bulkActionsType" />
 		<confirm v-if="modals.confirm" @confirmed="handleConfirmed()" />
 	</div>
 </template>
@@ -310,6 +311,9 @@ export default {
 		RequestSong: defineAsyncComponent(() =>
 			import("@/components/modals/RequestSong.vue")
 		),
+		BulkActions: defineAsyncComponent(() =>
+			import("@/components/modals/BulkActions.vue")
+		),
 		Confirm: defineAsyncComponent(() =>
 			import("@/components/modals/Confirm.vue")
 		),
@@ -642,7 +646,8 @@ export default {
 				message: "",
 				action: "",
 				params: null
-			}
+			},
+			bulkActionsType: null
 		};
 	},
 	computed: {
@@ -710,14 +715,40 @@ export default {
 				}
 			);
 		},
-		tagMany() {
-			new Toast("Bulk tagging not yet implemented.");
+		setTags(selectedRows) {
+			this.bulkActionsType = {
+				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}\]$/
+				),
+				autosuggest: true,
+				autosuggestAction: "songs.getTags"
+			};
+			this.openModal("bulkActions");
 		},
-		setArtists() {
-			new Toast("Bulk setting artists not yet implemented.");
+		setArtists(selectedRows) {
+			this.bulkActionsType = {
+				name: "artists",
+				action: "songs.editArtists",
+				items: selectedRows.map(row => row._id),
+				regex: new RegExp(/^(?=.{1,64}$).*$/),
+				autosuggest: true,
+				autosuggestAction: "songs.getArtists"
+			};
+			this.openModal("bulkActions");
 		},
-		setGenres() {
-			new Toast("Bulk setting genres not yet implemented.");
+		setGenres(selectedRows) {
+			this.bulkActionsType = {
+				name: "genres",
+				action: "songs.editGenres",
+				items: selectedRows.map(row => row._id),
+				regex: new RegExp(/^[\x00-\x7F]{1,32}$/),
+				autosuggest: true,
+				autosuggestAction: "songs.getGenres"
+			};
+			this.openModal("bulkActions");
 		},
 		deleteOne(songId) {
 			this.socket.dispatch("songs.remove", songId, res => {

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

@@ -39,7 +39,7 @@
 <script>
 import { formatDistance } from "date-fns";
 import { mapGetters } from "vuex";
-import marked from "marked";
+import { marked } from "marked";
 import { sanitize } from "dompurify";
 
 import ws from "@/ws";

+ 2 - 16
frontend/src/pages/Team.vue

@@ -43,6 +43,7 @@
 								<a
 									v-for="project in member.projects"
 									:key="project"
+									class="pill"
 									:href="
 										'https://github.com/Musare/' +
 										project +
@@ -94,6 +95,7 @@
 								<a
 									v-for="project in member.projects"
 									:key="project"
+									class="pill"
 									:href="
 										'https://github.com/Musare/' +
 										project +
@@ -264,10 +266,6 @@ export default {
 			color: var(--light-grey-2);
 		}
 	}
-	.group .card .card-content .projects a,
-	.other-contributors div a {
-		background-color: var(--dark-grey);
-	}
 }
 
 .container {
@@ -384,18 +382,6 @@ h2 {
 				display: flex;
 				flex-wrap: wrap;
 				margin-top: auto;
-
-				a {
-					background: var(--light-grey);
-					height: 30px;
-					padding: 5px;
-					border-radius: 5px;
-					white-space: nowrap;
-					margin-top: 5px;
-					&:not(:last-of-type) {
-						margin-right: 5px;
-					}
-				}
 			}
 		}
 	}

+ 2 - 1
frontend/src/store/modules/modalVisibility.js

@@ -20,7 +20,8 @@ const state = {
 		viewReport: false,
 		viewPunishment: false,
 		confirm: false,
-		editSongConfirm: false
+		editSongConfirm: false,
+		bulkActions: false
 	},
 	currentlyActive: []
 };