Bläddra i källkod

Merge branch 'custom-draggable' into staging

Owen Diffey 2 år sedan
förälder
incheckning
83a6d1de73

+ 17 - 38
frontend/package-lock.json

@@ -20,14 +20,13 @@
         "marked": "^4.0.18",
         "normalize.css": "^8.0.1",
         "pinia": "^2.0.19",
-        "sortablejs": "^1.15.0",
-        "sortablejs-vue3": "^1.2.0",
         "toasters": "^2.3.1",
         "typescript": "^4.7.4",
         "vite": "^3.0.9",
         "vue": "^3.2.36",
         "vue-chartjs": "^4.1.1",
         "vue-content-loader": "^2.0.1",
+        "vue-draggable-list": "^0.1.0",
         "vue-json-pretty": "^2.2.0",
         "vue-router": "^4.1.3",
         "vue-tippy": "^6.0.0-alpha.63"
@@ -4118,28 +4117,6 @@
         "node": ">=8"
       }
     },
-    "node_modules/sortablejs": {
-      "version": "1.15.0",
-      "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.0.tgz",
-      "integrity": "sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w=="
-    },
-    "node_modules/sortablejs-vue3": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/sortablejs-vue3/-/sortablejs-vue3-1.2.0.tgz",
-      "integrity": "sha512-/dkDoWI2cxtre9ZwAxwNOu/74nI7DqEkzOAlnS340Y66KlBcv8zkoIC+YuUN8H4phlvHKctJK3pgEX8PgSgCFA==",
-      "dependencies": {
-        "sortablejs": "^1.15.0",
-        "vue": "^3.2.37"
-      },
-      "funding": {
-        "type": "individual",
-        "url": "https://github.com/sponsors/MaxLeiter/"
-      },
-      "peerDependencies": {
-        "sortablejs": "^1.15.0",
-        "vue": "^3.2.25"
-      }
-    },
     "node_modules/source-map": {
       "version": "0.6.1",
       "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -4512,6 +4489,14 @@
         "vue": "^3"
       }
     },
+    "node_modules/vue-draggable-list": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/vue-draggable-list/-/vue-draggable-list-0.1.0.tgz",
+      "integrity": "sha512-bSVxTaTqr16srBD7vpLLEbN+vAckDVTklWW3/iEfIr5GnBNZgSN+Z0Jnq75WnteZPdaT6BgfCdgISf6MzC5PSw==",
+      "dependencies": {
+        "vue": "^3.2.37"
+      }
+    },
     "node_modules/vue-eslint-parser": {
       "version": "9.0.3",
       "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.0.3.tgz",
@@ -7517,20 +7502,6 @@
       "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
       "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="
     },
-    "sortablejs": {
-      "version": "1.15.0",
-      "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.0.tgz",
-      "integrity": "sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w=="
-    },
-    "sortablejs-vue3": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/sortablejs-vue3/-/sortablejs-vue3-1.2.0.tgz",
-      "integrity": "sha512-/dkDoWI2cxtre9ZwAxwNOu/74nI7DqEkzOAlnS340Y66KlBcv8zkoIC+YuUN8H4phlvHKctJK3pgEX8PgSgCFA==",
-      "requires": {
-        "sortablejs": "^1.15.0",
-        "vue": "^3.2.37"
-      }
-    },
     "source-map": {
       "version": "0.6.1",
       "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -7803,6 +7774,14 @@
       "integrity": "sha512-pkof4+q2xmzNEdhqelxtJejeP/vQUJtLle4/v2ueG+HURqM9Q/GIGC8GJ2bVVWeLfTDET51jqimwQdmxJTlu0g==",
       "requires": {}
     },
+    "vue-draggable-list": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/vue-draggable-list/-/vue-draggable-list-0.1.0.tgz",
+      "integrity": "sha512-bSVxTaTqr16srBD7vpLLEbN+vAckDVTklWW3/iEfIr5GnBNZgSN+Z0Jnq75WnteZPdaT6BgfCdgISf6MzC5PSw==",
+      "requires": {
+        "vue": "^3.2.37"
+      }
+    },
     "vue-eslint-parser": {
       "version": "9.0.3",
       "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.0.3.tgz",

+ 1 - 2
frontend/package.json

@@ -43,14 +43,13 @@
     "marked": "^4.0.18",
     "normalize.css": "^8.0.1",
     "pinia": "^2.0.19",
-    "sortablejs": "^1.15.0",
-    "sortablejs-vue3": "^1.2.0",
     "toasters": "^2.3.1",
     "typescript": "^4.7.4",
     "vite": "^3.0.9",
     "vue": "^3.2.36",
     "vue-chartjs": "^4.1.1",
     "vue-content-loader": "^2.0.1",
+    "vue-draggable-list": "^0.1.0",
     "vue-json-pretty": "^2.2.0",
     "vue-router": "^4.1.3",
     "vue-tippy": "^6.0.0-alpha.63"

+ 3 - 4
frontend/src/App.vue

@@ -307,6 +307,7 @@ onMounted(async () => {
 @import "normalize.css/normalize.css";
 @import "tippy.js/dist/tippy.css";
 @import "tippy.js/animations/scale.css";
+@import "vue-draggable-list/dist/style.css";
 
 :root {
 	--primary-color: var(--blue);
@@ -577,6 +578,8 @@ body {
 	line-height: 1.4285714;
 	font-size: 1rem;
 	font-family: "Inter", Helvetica, Arial, sans-serif;
+	max-width: 100%;
+	overflow-x: hidden;
 }
 
 .app {
@@ -1636,10 +1639,6 @@ h4.section-title {
 }
 
 /** Universial items e.g. playlist items, queue items, activity items */
-.item-draggable {
-	cursor: move;
-}
-
 .universal-item {
 	display: flex;
 	flex-direction: row;

+ 187 - 217
frontend/src/components/AdvancedTable.vue

@@ -11,9 +11,9 @@ import {
 	nextTick
 } from "vue";
 import { useRoute, useRouter } from "vue-router";
-import { Sortable } from "sortablejs-vue3";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
+import { DraggableList } from "vue-draggable-list";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useModalsStore } from "@/stores/modals";
 import keyboardShortcuts from "@/keyboardShortcuts";
@@ -86,15 +86,6 @@ const count = ref(0);
 const sort = ref({});
 const orderedColumns = ref([]);
 const shownColumns = ref([]);
-const columnDragOptions = ref({
-	animation: 200,
-	group: "columns",
-	disabled: false,
-	ghostClass: "draggable-list-ghost",
-	filter: ".ignore-elements",
-	fallbackTolerance: 50,
-	draggable: ".item-draggable"
-});
 const editingFilters = ref([]);
 const appliedFilters = ref([]);
 const filterOperator = ref("or");
@@ -674,52 +665,8 @@ const applyFilterAndGetData = () => {
 	storeTableSettings();
 };
 
-const columnOrderChanged = ({ oldIndex, newIndex }) => {
-	if (columnOrderChangedDebounceTimeout.value)
-		clearTimeout(columnOrderChangedDebounceTimeout.value);
-
-	columnOrderChangedDebounceTimeout.value = setTimeout(() => {
-		if (oldIndex === newIndex) return;
-		orderedColumns.value.splice(
-			newIndex,
-			0,
-			orderedColumns.value.splice(oldIndex, 1)[0]
-		);
-		storeTableSettings();
-	}, 100);
-};
-
-const columnOrderChangedDropdown = event => {
-	const filteredOrderedColumns = orderedColumns.value.filter(
-		column =>
-			column.name !== "select" &&
-			column.name !== "placeholder" &&
-			column.name !== "updatedPlaceholder" &&
-			column.draggable
-	);
-	const oldColumn = filteredOrderedColumns[event.oldDraggableIndex];
-	const newColumn = filteredOrderedColumns[event.newDraggableIndex];
-
-	const oldIndex = orderedColumns.value.indexOf(oldColumn);
-	const newIndex = orderedColumns.value.indexOf(newColumn);
-
-	columnOrderChanged({ oldIndex, newIndex });
-};
-
-const columnOrderChangedHead = event => {
-	const filteredOrderedColumns = orderedColumns.value.filter(
-		column =>
-			shownColumns.value.indexOf(column.name) !== -1 &&
-			(column.name !== "updatedPlaceholder" || rows.value.length > 0) &&
-			column.draggable
-	);
-	const oldColumn = filteredOrderedColumns[event.oldDraggableIndex];
-	const newColumn = filteredOrderedColumns[event.newDraggableIndex];
-
-	const oldIndex = orderedColumns.value.indexOf(oldColumn);
-	const newIndex = orderedColumns.value.indexOf(newColumn);
-
-	columnOrderChanged({ oldIndex, newIndex });
+const columnOrderChanged = () => {
+	storeTableSettings();
 };
 
 const getTableSettings = () => {
@@ -1572,66 +1519,79 @@ watch(selectedRows, (newSelectedRows, oldSelectedRows) => {
 						</div>
 
 						<template #content>
-							<Sortable
-								:list="orderedColumns"
-								item-key="name"
-								:options="columnDragOptions"
-								class="nav-dropdown-items"
-								@update="columnOrderChangedDropdown"
-							>
-								<template #item="{ element: column }">
-									<button
-										v-if="
-											column.name !== 'select' &&
-											column.name !== 'placeholder' &&
-											column.name !== 'updatedPlaceholder'
-										"
-										:class="{
+							<div class="nav-dropdown-items">
+								<draggable-list
+									v-model:list="orderedColumns"
+									item-key="name"
+									@update="columnOrderChanged"
+									:attributes="{
+										class: column => ({
 											sortable: column.sortable,
-											'item-draggable': column.draggable,
 											'nav-item': true
-										}"
-										@click.prevent="
-											toggleColumnVisibility(column)
-										"
-									>
-										<p
-											class="control is-expanded checkbox-control"
+										})
+									}"
+									:disabled="column => !column.draggable"
+									tag="button"
+								>
+									<template #item="{ element: column }">
+										<template
+											v-if="
+												column.name !== 'select' &&
+												column.name !== 'placeholder' &&
+												column.name !==
+													'updatedPlaceholder'
+											"
 										>
-											<label class="switch">
-												<input
-													type="checkbox"
-													:id="`column-dropdown-checkbox-${column.name}`"
-													:checked="
-														shownColumns.indexOf(
-															column.name
-														) !== -1
-													"
-													@click="
-														toggleColumnVisibility(
-															column
-														)
-													"
-												/>
-												<span
-													:class="{
-														slider: true,
-														round: true,
-														disabled:
-															!column.hidable
-													}"
-												></span>
-											</label>
-											<label
-												:for="`column-dropdown-checkbox-${column.name}`"
+											<div
+												@click.prevent="
+													toggleColumnVisibility(
+														column
+													)
+												"
 											>
-												<span></span>
-												<p>{{ column.displayName }}</p>
-											</label>
-										</p>
-									</button>
-								</template>
-							</Sortable>
+												<p
+													class="control is-expanded checkbox-control"
+												>
+													<label class="switch">
+														<input
+															type="checkbox"
+															:id="`column-dropdown-checkbox-${column.name}`"
+															:checked="
+																shownColumns.indexOf(
+																	column.name
+																) !== -1
+															"
+															@click="
+																toggleColumnVisibility(
+																	column
+																)
+															"
+														/>
+														<span
+															:class="{
+																slider: true,
+																round: true,
+																disabled:
+																	!column.hidable
+															}"
+														></span>
+													</label>
+													<label
+														:for="`column-dropdown-checkbox-${column.name}`"
+													>
+														<span></span>
+														<p>
+															{{
+																column.displayName
+															}}
+														</p>
+													</label>
+												</p>
+											</div>
+										</template>
+									</template>
+								</draggable-list>
+							</div>
 						</template>
 					</tippy>
 				</div>
@@ -1644,29 +1604,14 @@ watch(selectedRows, (newSelectedRows, oldSelectedRows) => {
 					}"
 				>
 					<thead>
-						<Sortable
-							:list="orderedColumns"
-							item-key="name"
-							tag="tr"
-							:options="{
-								...columnDragOptions,
-								handle: '.handle'
-							}"
-							@update="columnOrderChangedHead"
-						>
-							<template #item="{ element: column }">
-								<th
-									v-if="
-										shownColumns.indexOf(column.name) !==
-											-1 &&
-										(column.name !== 'updatedPlaceholder' ||
-											rows.length > 0)
-									"
-									:class="{
-										sortable: column.sortable,
-										'item-draggable': column.draggable
-									}"
-									:style="{
+						<tr>
+							<draggable-list
+								v-model:list="orderedColumns"
+								item-key="name"
+								@update="columnOrderChanged"
+								tag="th"
+								:attributes="{
+									style: column => ({
 										minWidth: Number.isNaN(column.minWidth)
 											? column.minWidth
 											: `${column.minWidth}px`,
@@ -1676,86 +1621,111 @@ watch(selectedRows, (newSelectedRows, oldSelectedRows) => {
 										maxWidth: Number.isNaN(column.maxWidth)
 											? column.maxWidth
 											: `${column.maxWidth}px`
-									}"
-								>
-									<div v-if="column.name === 'select'">
-										<p class="checkbox">
-											<input
-												v-if="rows.length === 0"
-												type="checkbox"
-												disabled
-											/>
-											<input
-												v-else
-												type="checkbox"
-												:checked="
-													rows.filter(
-														row => !row.removed
-													).length ===
-													selectedRows.length
-												"
-												@click="toggleAllRows()"
-											/>
-										</p>
-									</div>
-									<div v-else class="handle">
-										<span>
-											{{ column.displayName }}
-										</span>
-										<span
-											v-if="column.sortable"
-											:content="`Sort by ${column.displayName}`"
-											v-tippy
-										>
-											<span
-												v-if="
-													!sort[column.sortProperty]
-												"
-												class="material-icons"
-												@click="changeSort(column)"
-											>
-												unfold_more
-											</span>
-											<span
-												v-if="
-													sort[
-														column.sortProperty
-													] === 'ascending'
-												"
-												class="material-icons active"
-												@click="changeSort(column)"
-											>
-												expand_more
+									}),
+									class: column => ({
+										sortable: column.sortable
+									})
+								}"
+								:disabled="column => !column.draggable"
+							>
+								<template #item="{ element: column }">
+									<template
+										v-if="
+											shownColumns.indexOf(
+												column.name
+											) !== -1 &&
+											(column.name !==
+												'updatedPlaceholder' ||
+												rows.length > 0)
+										"
+									>
+										<div v-if="column.name === 'select'">
+											<p class="checkbox">
+												<input
+													v-if="rows.length === 0"
+													type="checkbox"
+													disabled
+												/>
+												<input
+													v-else
+													type="checkbox"
+													:checked="
+														rows.filter(
+															row => !row.removed
+														).length ===
+														selectedRows.length
+													"
+													@click="toggleAllRows()"
+												/>
+											</p>
+										</div>
+										<div v-else class="handle">
+											<span>
+												{{ column.displayName }}
 											</span>
 											<span
-												v-if="
-													sort[
-														column.sortProperty
-													] === 'descending'
-												"
-												class="material-icons active"
-												@click="changeSort(column)"
+												v-if="column.sortable"
+												:content="`Sort by ${column.displayName}`"
+												v-tippy
 											>
-												expand_less
+												<span
+													v-if="
+														!sort[
+															column.sortProperty
+														]
+													"
+													class="material-icons"
+													@click="changeSort(column)"
+												>
+													unfold_more
+												</span>
+												<span
+													v-if="
+														sort[
+															column.sortProperty
+														] === 'ascending'
+													"
+													class="material-icons active"
+													@click="changeSort(column)"
+												>
+													expand_more
+												</span>
+												<span
+													v-if="
+														sort[
+															column.sortProperty
+														] === 'descending'
+													"
+													class="material-icons active"
+													@click="changeSort(column)"
+												>
+													expand_less
+												</span>
 											</span>
-										</span>
-									</div>
-									<div
-										class="resizer"
-										v-if="column.resizable"
-										@mousedown.prevent.stop="
-											columnResizingStart(column, $event)
-										"
-										@touchstart.prevent.stop="
-											columnResizingStart(column, $event)
-										"
-										@mouseup="columnResizingStop()"
-										@touchend="columnResizingStop()"
-										@dblclick="columnResetWidth(column)"
-									></div>
-								</th>
-							</template>
-						</Sortable>
+										</div>
+										<div
+											class="resizer"
+											v-if="column.resizable"
+											@mousedown.prevent.stop="
+												columnResizingStart(
+													column,
+													$event
+												)
+											"
+											@touchstart.prevent.stop="
+												columnResizingStart(
+													column,
+													$event
+												)
+											"
+											@mouseup="columnResizingStop()"
+											@touchend="columnResizingStop()"
+											@dblclick="columnResetWidth(column)"
+										></div>
+									</template>
+								</template>
+							</draggable-list>
+						</tr>
 					</thead>
 					<tbody>
 						<tr
@@ -1980,13 +1950,13 @@ watch(selectedRows, (newSelectedRows, oldSelectedRows) => {
 	.table-outer-container {
 		.table-container .table {
 			&,
-			thead th {
+			:deep(thead th) {
 				background-color: var(--dark-grey-3) !important;
 				color: var(--light-grey-2);
 			}
 
 			tr {
-				th,
+				:deep(th),
 				td {
 					border-color: var(--dark-grey) !important;
 					background-color: var(--dark-grey-3) !important;
@@ -1999,7 +1969,7 @@ watch(selectedRows, (newSelectedRows, oldSelectedRows) => {
 				&:hover,
 				&:focus,
 				&.highlighted {
-					th,
+					:deep(th),
 					td {
 						background-color: var(--dark-grey-4) !important;
 					}
@@ -2075,7 +2045,7 @@ watch(selectedRows, (newSelectedRows, oldSelectedRows) => {
 			border-collapse: separate;
 			table-layout: fixed;
 
-			thead {
+			:deep(thead) {
 				tr {
 					th {
 						height: 40px;
@@ -2176,7 +2146,7 @@ watch(selectedRows, (newSelectedRows, oldSelectedRows) => {
 		}
 
 		table {
-			thead tr,
+			:deep(thead tr),
 			tbody tr {
 				th,
 				td {
@@ -2223,7 +2193,7 @@ watch(selectedRows, (newSelectedRows, oldSelectedRows) => {
 			}
 
 			&.has-checkboxes {
-				thead,
+				:deep(thead),
 				tbody {
 					tr {
 						th,

+ 6 - 15
frontend/src/components/PlaylistTabBase.vue

@@ -52,10 +52,9 @@ const featuredPlaylists = ref([]);
 const tabs = ref({});
 
 const {
-	Sortable,
+	DraggableList,
 	drag,
 	playlists,
-	dragOptions,
 	savePlaylistOrder,
 	orderOfPlaylists,
 	myUserId,
@@ -812,22 +811,15 @@ onMounted(() => {
 					class="menu-list scrollable-list"
 					v-if="playlists.length > 0"
 				>
-					<sortable
-						:component-data="{
-							name: !drag ? 'draggable-list-transition' : null
-						}"
+					<draggable-list
+						v-model:list="playlists"
 						item-key="_id"
-						:list="playlists"
-						:options="dragOptions"
 						@start="drag = true"
 						@end="drag = false"
 						@update="savePlaylistOrder"
 					>
 						<template #item="{ element }">
-							<playlist-item
-								class="item-draggable"
-								:playlist="element"
-							>
+							<playlist-item :playlist="element">
 								<template #item-icon>
 									<i
 										class="material-icons blacklisted-icon"
@@ -975,7 +967,7 @@ onMounted(() => {
 								</template>
 							</playlist-item>
 						</template>
-					</sortable>
+					</draggable-list>
 				</div>
 
 				<p v-else class="has-text-centered scrollable-list">
@@ -1032,8 +1024,7 @@ onMounted(() => {
 		.tab {
 			padding: 15px 0;
 			border-radius: 0;
-			.playlist-item:not(:last-of-type),
-			.item.item-draggable:not(:last-of-type) {
+			.playlist-item:not(:last-of-type) {
 				margin-bottom: 10px;
 			}
 			.load-more-button {

+ 26 - 40
frontend/src/components/Queue.vue

@@ -1,8 +1,8 @@
 <script setup lang="ts">
 import { defineAsyncComponent, ref, computed, onUpdated } from "vue";
-import { Sortable } from "sortablejs-vue3";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
+import { DraggableList } from "vue-draggable-list";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useStationStore } from "@/stores/station";
 import { useUserAuthStore } from "@/stores/userAuth";
@@ -27,12 +27,6 @@ const manageStationStore = useManageStationStore(props);
 
 const { loggedIn, userId, role: userRole } = storeToRefs(userAuthStore);
 
-const repositionSongInList = payload => {
-	if (props.sector === "manageStation")
-		return manageStationStore.repositionSongInList(payload);
-	return stationStore.repositionSongInList(payload);
-};
-
 const actionableButtonVisible = ref(false);
 const drag = ref(false);
 const songItems = ref([]);
@@ -67,13 +61,6 @@ const isOwnerOnly = () =>
 
 const isAdminOnly = () => loggedIn.value && userRole.value === "admin";
 
-const dragOptions = computed(() => ({
-	animation: 200,
-	group: "queue",
-	disabled: !(isAdminOnly() || isOwnerOnly()),
-	ghostClass: "draggable-list-ghost"
-}));
-
 const removeFromQueue = youtubeId => {
 	socket.dispatch(
 		"stations.removeFromQueue",
@@ -87,9 +74,10 @@ const removeFromQueue = youtubeId => {
 	);
 };
 
-const repositionSongInQueue = ({ oldIndex, newIndex }) => {
+const repositionSongInQueue = ({ moved }) => {
+	const { oldIndex, newIndex } = moved;
 	if (oldIndex === newIndex) return; // we only need to update when song is moved
-	const song = queue.value[oldIndex];
+	const song = queue.value[newIndex];
 	socket.dispatch(
 		"stations.repositionSongInQueue",
 		station.value._id,
@@ -101,30 +89,38 @@ const repositionSongInQueue = ({ oldIndex, newIndex }) => {
 		res => {
 			new Toast({ content: res.message, timeout: 4000 });
 			if (res.status !== "success")
-				repositionSongInList({
-					...song,
+				queue.value.splice(
 					oldIndex,
-					newIndex
-				});
+					0,
+					queue.value.splice(newIndex, 1)[0]
+				);
 		}
 	);
 };
 
 const moveSongToTop = index => {
 	songItems.value[`song-item-${index}`].$refs.songActions.tippy.hide();
-
+	queue.value.splice(0, 0, queue.value.splice(index, 1)[0]);
 	repositionSongInQueue({
-		oldIndex: index,
-		newIndex: 0
+		moved: {
+			oldIndex: index,
+			newIndex: 0
+		}
 	});
 };
 
 const moveSongToBottom = index => {
 	songItems.value[`song-item-${index}`].$refs.songActions.tippy.hide();
-
+	queue.value.splice(
+		queue.value.length - 1,
+		0,
+		queue.value.splice(index, 1)[0]
+	);
 	repositionSongInQueue({
-		oldIndex: index,
-		newIndex: queue.value.length
+		moved: {
+			oldIndex: index,
+			newIndex: queue.value.length - 1
+		}
 	});
 };
 
@@ -146,24 +142,18 @@ onUpdated(() => {
 				'scrollable-list': true
 			}"
 		>
-			<Sortable
-				:component-data="{
-					name: !drag ? 'draggable-list-transition' : null
-				}"
-				:list="queue"
+			<draggable-list
+				v-model:list="queue"
 				item-key="_id"
-				:options="dragOptions"
 				@start="drag = true"
 				@end="drag = false"
 				@update="repositionSongInQueue"
+				:disabled="!(isAdminOnly() || isOwnerOnly())"
 			>
 				<template #item="{ element, index }">
 					<song-item
 						:song="element"
 						:requested-by="true"
-						:class="{
-							'item-draggable': isAdminOnly() || isOwnerOnly()
-						}"
 						:disabled-actions="[]"
 						:ref="el => (songItems[`song-item-${index}`] = el)"
 					>
@@ -202,7 +192,7 @@ onUpdated(() => {
 						</template>
 					</song-item>
 				</template>
-			</Sortable>
+			</draggable-list>
 		</div>
 		<p class="nothing-here-text has-text-centered" v-else>
 			There are no songs currently queued
@@ -227,10 +217,6 @@ onUpdated(() => {
 		max-height: 100%;
 	}
 
-	.song-item:not(:last-of-type) {
-		margin-bottom: 10px;
-	}
-
 	#queue-locked {
 		display: flex;
 		justify-content: center;

+ 93 - 135
frontend/src/components/modals/EditPlaylist/index.vue

@@ -6,9 +6,9 @@ import {
 	onMounted,
 	onBeforeUnmount
 } from "vue";
-import { Sortable } from "sortablejs-vue3";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
+import { DraggableList } from "vue-draggable-list";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useEditPlaylistStore } from "@/stores/editPlaylist";
 import { useStationStore } from "@/stores/station";
@@ -72,13 +72,6 @@ const isEditable = () =>
 		playlist.value.type === "user-disliked") &&
 	(userId.value === playlist.value.createdBy || userRole.value === "admin");
 
-const dragOptions = computed(() => ({
-	animation: 200,
-	group: "songs",
-	disabled: !isEditable(),
-	ghostClass: "draggable-list-ghost"
-}));
-
 const init = () => {
 	gettingSongs.value = true;
 	socket.dispatch("playlists.getPlaylist", playlistId.value, res => {
@@ -94,10 +87,10 @@ const isAdmin = () => userRole.value === "admin";
 const isOwner = () =>
 	loggedIn.value && userId.value === playlist.value.createdBy;
 
-const repositionSong = ({ oldIndex, newIndex }) => {
+const repositionSong = ({ moved }) => {
+	const { oldIndex, newIndex } = moved;
 	if (oldIndex === newIndex) return; // we only need to update when song is moved
-	const song = playlistSongs.value[oldIndex];
-
+	const song = playlistSongs.value[newIndex];
 	socket.dispatch(
 		"playlists.repositionSong",
 		playlist.value._id,
@@ -117,21 +110,29 @@ const repositionSong = ({ oldIndex, newIndex }) => {
 	);
 };
 
-const moveSongToTop = (song, index) => {
+const moveSongToTop = index => {
 	songItems.value[`song-item-${index}`].$refs.songActions.tippy.hide();
-
+	playlistSongs.value.splice(0, 0, playlistSongs.value.splice(index, 1)[0]);
 	repositionSong({
-		oldIndex: index,
-		newIndex: 0
+		moved: {
+			oldIndex: index,
+			newIndex: 0
+		}
 	});
 };
 
-const moveSongToBottom = (song, index) => {
+const moveSongToBottom = index => {
 	songItems.value[`song-item-${index}`].$refs.songActions.tippy.hide();
-
+	playlistSongs.value.splice(
+		playlistSongs.value.length - 1,
+		0,
+		playlistSongs.value.splice(index, 1)[0]
+	);
 	repositionSong({
-		oldIndex: index,
-		newIndex: playlistSongs.value.length
+		moved: {
+			oldIndex: index,
+			newIndex: playlistSongs.value.length - 1
+		}
 	});
 };
 
@@ -404,113 +405,91 @@ onBeforeUnmount(() => {
 					</div>
 
 					<aside class="menu">
-						<sortable
-							:component-data="{
-								name: !drag ? 'draggable-list-transition' : null
-							}"
+						<draggable-list
 							v-if="playlistSongs.length > 0"
-							:list="playlistSongs"
+							v-model:list="playlistSongs"
 							item-key="_id"
-							:options="dragOptions"
 							@start="drag = true"
 							@end="drag = false"
 							@update="repositionSong"
+							:disabled="!isEditable()"
 						>
 							<template #item="{ element, index }">
-								<div class="menu-list scrollable-list">
-									<song-item
-										:song="element"
-										:class="{
-											'item-draggable': isEditable()
-										}"
-										:ref="
-											el =>
-												(songItems[
-													`song-item-${index}`
-												] = el)
-										"
-									>
-										<template #tippyActions>
-											<i
-												class="material-icons add-to-queue-icon"
-												v-if="
-													station &&
-													station.requests &&
-													station.requests.enabled &&
+								<song-item
+									:song="element"
+									:ref="
+										el =>
+											(songItems[`song-item-${index}`] =
+												el)
+									"
+								>
+									<template #tippyActions>
+										<i
+											class="material-icons add-to-queue-icon"
+											v-if="
+												station &&
+												station.requests &&
+												station.requests.enabled &&
+												(station.requests.access ===
+													'user' ||
 													(station.requests.access ===
-														'user' ||
-														(station.requests
-															.access ===
-															'owner' &&
-															(userRole ===
-																'admin' ||
-																station.owner ===
-																	userId)))
-												"
-												@click="
-													addSongToQueue(
-														element.youtubeId
-													)
-												"
-												content="Add Song to Queue"
-												v-tippy
-												>queue</i
-											>
-											<quick-confirm
-												v-if="
-													userId ===
-														playlist.createdBy ||
-													isEditable()
-												"
-												placement="left"
-												@confirm="
-													removeSongFromPlaylist(
-														element.youtubeId
-													)
-												"
-											>
-												<i
-													class="material-icons delete-icon"
-													content="Remove Song from Playlist"
-													v-tippy
-													>delete_forever</i
-												>
-											</quick-confirm>
-											<i
-												class="material-icons"
-												v-if="isEditable() && index > 0"
-												@click="
-													moveSongToTop(
-														element,
-														index
-													)
-												"
-												content="Move to top of Playlist"
-												v-tippy
-												>vertical_align_top</i
-											>
+														'owner' &&
+														(userRole === 'admin' ||
+															station.owner ===
+																userId)))
+											"
+											@click="
+												addSongToQueue(
+													element.youtubeId
+												)
+											"
+											content="Add Song to Queue"
+											v-tippy
+											>queue</i
+										>
+										<quick-confirm
+											v-if="
+												userId === playlist.createdBy ||
+												isEditable()
+											"
+											placement="left"
+											@confirm="
+												removeSongFromPlaylist(
+													element.youtubeId
+												)
+											"
+										>
 											<i
-												v-if="
-													isEditable() &&
-													playlistSongs.length - 1 !==
-														index
-												"
-												@click="
-													moveSongToBottom(
-														element,
-														index
-													)
-												"
-												class="material-icons"
-												content="Move to bottom of Playlist"
+												class="material-icons delete-icon"
+												content="Remove Song from Playlist"
 												v-tippy
-												>vertical_align_bottom</i
+												>delete_forever</i
 											>
-										</template>
-									</song-item>
-								</div>
+										</quick-confirm>
+										<i
+											class="material-icons"
+											v-if="isEditable() && index > 0"
+											@click="moveSongToTop(index)"
+											content="Move to top of Playlist"
+											v-tippy
+											>vertical_align_top</i
+										>
+										<i
+											v-if="
+												isEditable() &&
+												playlistSongs.length - 1 !==
+													index
+											"
+											@click="moveSongToBottom(index)"
+											class="material-icons"
+											content="Move to bottom of Playlist"
+											v-tippy
+											>vertical_align_bottom</i
+										>
+									</template>
+								</song-item>
 							</template>
-						</sortable>
+						</draggable-list>
 						<p v-else-if="gettingSongs" class="nothing-here-text">
 							Loading songs...
 						</p>
@@ -595,19 +574,6 @@ onBeforeUnmount(() => {
 	}
 }
 
-.menu-list li {
-	display: flex;
-	justify-content: space-between;
-
-	&:not(:last-of-type) {
-		margin-bottom: 10px;
-	}
-
-	a {
-		display: flex;
-	}
-}
-
 .controls {
 	display: flex;
 
@@ -703,13 +669,5 @@ onBeforeUnmount(() => {
 			}
 		}
 	}
-
-	.right-section {
-		#rearrange-songs-section {
-			.scrollable-list:not(:last-of-type) {
-				margin-bottom: 10px;
-			}
-		}
-	}
 }
 </style>

+ 23 - 85
frontend/src/components/modals/ImportAlbum.vue

@@ -1,5 +1,4 @@
 <script setup lang="ts">
-// TODO - Fix sortable
 import {
 	defineAsyncComponent,
 	ref,
@@ -8,8 +7,8 @@ import {
 	onBeforeUnmount
 } from "vue";
 import Toast from "toasters";
-import { Sortable } from "sortablejs-vue3";
 import { storeToRefs } from "pinia";
+import { DraggableList } from "vue-draggable-list";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useModalsStore } from "@/stores/modals";
 import { useImportAlbumStore } from "@/stores/importAlbum";
@@ -56,7 +55,6 @@ const discogs = ref({
 	disableLoadMore: false
 });
 const discogsTabs = ref([]);
-const sortableUpdateNumber = ref(0);
 
 // TODO might not not be needed anymore, might be able to directly edit prefillDiscogs
 const localPrefillDiscogs = computed({
@@ -333,61 +331,6 @@ const updateTrackSong = updatedSong => {
 	});
 };
 
-const updatePlaylistSongPosition = ({ oldIndex, newIndex }) => {
-	if (oldIndex === newIndex) return;
-	const oldSongs = playlistSongs.value;
-	oldSongs.splice(newIndex, 0, oldSongs.splice(oldIndex, 1)[0]);
-	playlistSongs.value = oldSongs;
-};
-
-const updateTrackSongPosition = (trackIndex, { oldIndex, newIndex }) => {
-	if (oldIndex === newIndex) return;
-	const oldSongs = trackSongs.value[trackIndex];
-	oldSongs.splice(newIndex, 0, oldSongs.splice(oldIndex, 1)[0]);
-	trackSongs.value[trackIndex] = oldSongs;
-};
-
-const playlistSongAdded = event => {
-	const fromTrack = event.from;
-	const fromTrackIndex = Number(fromTrack.dataset.trackIndex);
-	const song = trackSongs.value[fromTrackIndex][event.oldIndex];
-	const newPlaylistSongs = JSON.parse(JSON.stringify(playlistSongs.value));
-	newPlaylistSongs.splice(event.newIndex, 0, song);
-	playlistSongs.value = newPlaylistSongs;
-
-	sortableUpdateNumber.value += 1;
-};
-
-const playlistSongRemoved = event => {
-	playlistSongs.value.splice(event.oldIndex, 1);
-
-	sortableUpdateNumber.value += 1;
-};
-
-const trackSongAdded = (trackIndex, event) => {
-	const fromElement = event.from;
-	let song = null;
-	if (fromElement.dataset.trackIndex) {
-		const fromTrackIndex = Number(fromElement.dataset.trackIndex);
-		song = trackSongs.value[fromTrackIndex][event.oldIndex];
-	} else {
-		song = playlistSongs.value[event.oldIndex];
-	}
-	const newTrackSongs = JSON.parse(
-		JSON.stringify(trackSongs.value[trackIndex])
-	);
-	newTrackSongs.splice(event.newIndex, 0, song);
-	trackSongs.value[trackIndex] = newTrackSongs;
-
-	sortableUpdateNumber.value += 1;
-};
-
-const trackSongRemoved = (trackIndex, event) => {
-	trackSongs.value[trackIndex].splice(event.oldIndex, 1);
-
-	sortableUpdateNumber.value += 1;
-};
-
 onMounted(() => {
 	ws.onConnect(init);
 
@@ -657,15 +600,11 @@ onBeforeUnmount(() => {
 					>
 						Reset
 					</button>
-					<sortable
-						:key="`${sortableUpdateNumber}-playlistSongs`"
+					<draggable-list
 						v-if="playlistSongs.length > 0"
-						:list="playlistSongs"
+						v-model:list="playlistSongs"
 						item-key="_id"
-						:options="{ group: 'songs' }"
-						@update="updatePlaylistSongPosition"
-						@add="playlistSongAdded"
-						@remove="playlistSongRemoved"
+						:group="`import-album-${modalUuid}-songs`"
 					>
 						<template #item="{ element }">
 							<song-item
@@ -674,7 +613,7 @@ onBeforeUnmount(() => {
 							>
 							</song-item>
 						</template>
-					</sortable>
+					</draggable-list>
 				</div>
 				<div
 					class="track-boxes"
@@ -689,25 +628,22 @@ onBeforeUnmount(() => {
 							<span>{{ track.position }}.</span>
 							<p>{{ track.title }}</p>
 						</div>
-						<sortable
-							:key="`${sortableUpdateNumber}-${index}-playlistSongs`"
-							class="track-box-songs-drag-area"
-							:list="trackSongs[index]"
-							:data-track-index="index"
-							item-key="_id"
-							:options="{ group: 'songs' }"
-							@update="updateTrackSongPosition(index, $event)"
-							@add="trackSongAdded(index, $event)"
-							@remove="trackSongRemoved(index, $event)"
-						>
-							<template #item="{ element }">
-								<song-item
-									:key="`track-song-${element._id}`"
-									:song="element"
-								>
-								</song-item>
-							</template>
-						</sortable>
+						<!-- :data-track-index="index" -->
+						<div class="track-box-songs-drag-area">
+							<draggable-list
+								v-model:list="trackSongs[index]"
+								item-key="_id"
+								:group="`import-album-${modalUuid}-songs`"
+							>
+								<template #item="{ element }">
+									<song-item
+										:key="`track-song-${element._id}`"
+										:song="element"
+									>
+									</song-item>
+								</template>
+							</draggable-list>
+						</div>
 					</div>
 				</div>
 			</template>
@@ -1168,6 +1104,8 @@ onBeforeUnmount(() => {
 		.track-box-songs-drag-area {
 			flex: 1;
 			min-height: 100px;
+			display: flex;
+			flex-direction: column;
 		}
 	}
 }

+ 8 - 17
frontend/src/composables/useSortablePlaylists.ts

@@ -1,7 +1,7 @@
 import { ref, computed, onMounted, onBeforeUnmount, nextTick } from "vue";
-import { Sortable } from "sortablejs-vue3";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
+import { DraggableList } from "vue-draggable-list";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useUserAuthStore } from "@/stores/userAuth";
 import { useUserPlaylistsStore } from "@/stores/userPlaylists";
@@ -24,12 +24,6 @@ export const useSortablePlaylists = () => {
 		}
 	});
 	const isCurrentUser = computed(() => userId.value === myUserId.value);
-	const dragOptions = computed(() => ({
-		animation: 200,
-		group: "playlists",
-		disabled: !isCurrentUser.value,
-		ghostClass: "draggable-list-ghost"
-	}));
 
 	const { socket } = useWebsocketsStore();
 
@@ -42,15 +36,13 @@ export const useSortablePlaylists = () => {
 		return calculatedOrder;
 	};
 
-	const savePlaylistOrder = ({ oldIndex, newIndex }) => {
-		if (oldIndex === newIndex) return;
-		const oldPlaylists = playlists.value;
-
-		oldPlaylists.splice(newIndex, 0, oldPlaylists.splice(oldIndex, 1)[0]);
-
-		setPlaylists(oldPlaylists);
-
+	const savePlaylistOrder = () => {
 		const recalculatedOrder = calculatePlaylistOrder();
+		if (
+			JSON.stringify(orderOfPlaylists.value) ===
+			JSON.stringify(recalculatedOrder)
+		)
+			return; // nothing has changed
 
 		socket.dispatch(
 			"users.updateOrderOfPlaylists",
@@ -190,12 +182,11 @@ export const useSortablePlaylists = () => {
 	});
 
 	return {
-		Sortable,
+		DraggableList,
 		drag,
 		userId,
 		isCurrentUser,
 		playlists,
-		dragOptions,
 		orderOfPlaylists,
 		myUserId,
 		savePlaylistOrder,

+ 11 - 38
frontend/src/pages/Home.vue

@@ -7,9 +7,9 @@ import {
 	onMounted,
 	onBeforeUnmount
 } from "vue";
-import { Sortable } from "sortablejs-vue3";
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
+import { DraggableList } from "vue-draggable-list";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useUserAuthStore } from "@/stores/userAuth";
 import { useModalsStore } from "@/stores/modals";
@@ -46,7 +46,6 @@ const siteSettings = ref({
 });
 const orderOfFavoriteStations = ref([]);
 const handledLoginRegisterRedirect = ref(false);
-const changeFavoriteOrderDebounceTimeout = ref();
 
 const isOwner = station => loggedIn.value && station.owner === userId.value;
 
@@ -76,15 +75,6 @@ const filteredStations = computed(() => {
 		);
 });
 
-const dragOptions = computed(() => ({
-	animation: 200,
-	group: "favoriteStations",
-	disabled: false,
-	ghostClass: "draggable-list-ghost",
-	filter: ".ignore-elements",
-	fallbackTolerance: 50
-}));
-
 const favoriteStations = computed(() =>
 	filteredStations.value
 		.filter(station => station.isFavorited === true)
@@ -154,29 +144,13 @@ const unfavoriteStation = stationId => {
 	});
 };
 
-const changeFavoriteOrder = ({ oldIndex, newIndex }) => {
-	if (changeFavoriteOrderDebounceTimeout.value)
-		clearTimeout(changeFavoriteOrderDebounceTimeout.value);
-
-	changeFavoriteOrderDebounceTimeout.value = setTimeout(() => {
-		if (oldIndex === newIndex) return;
-		favoriteStations.value.splice(
-			newIndex,
-			0,
-			favoriteStations.value.splice(oldIndex, 1)[0]
-		);
-
-		const recalculatedOrder = [];
-		favoriteStations.value.forEach(station =>
-			recalculatedOrder.push(station._id)
-		);
-
-		socket.dispatch(
-			"users.updateOrderOfFavoriteStations",
-			recalculatedOrder,
-			res => new Toast(res.message)
-		);
-	}, 100);
+const changeFavoriteOrder = ({ moved }) => {
+	const { updatedList } = moved;
+	socket.dispatch(
+		"users.updateOrderOfFavoriteStations",
+		updatedList.map(station => station._id),
+		res => new Toast(res.message)
+	);
 };
 
 onMounted(async () => {
@@ -410,10 +384,10 @@ onBeforeUnmount(() => {
 					</div>
 				</div>
 
-				<sortable
+				<draggable-list
 					item-key="_id"
+					tag="span"
 					:list="favoriteStations"
-					:options="dragOptions"
 					@update="changeFavoriteOrder"
 				>
 					<template #item="{ element }">
@@ -424,7 +398,6 @@ onBeforeUnmount(() => {
 							}"
 							:class="{
 								'station-card': true,
-								'item-draggable': true,
 								isPrivate: element.privacy === 'private',
 								isMine: isOwner(element)
 							}"
@@ -629,7 +602,7 @@ onBeforeUnmount(() => {
 							</div>
 						</router-link>
 					</template>
-				</sortable>
+				</draggable-list>
 			</div>
 			<div class="group bottom">
 				<div class="group-title">

+ 4 - 13
frontend/src/pages/Profile/Tabs/Playlists.vue

@@ -13,12 +13,11 @@ const props = defineProps({
 });
 
 const {
-	Sortable,
+	DraggableList,
 	drag,
 	userId,
 	isCurrentUser,
 	playlists,
-	dragOptions,
 	savePlaylistOrder
 } = useSortablePlaylists();
 
@@ -47,14 +46,10 @@ onMounted(() => {
 
 			<hr class="section-horizontal-rule" />
 
-			<sortable
-				:component-data="{
-					name: !drag ? 'draggable-list-transition' : null
-				}"
+			<draggable-list
 				v-if="playlists.length > 0"
-				:list="playlists"
+				v-model:list="playlists"
 				item-key="_id"
-				:options="dragOptions"
 				@start="drag = true"
 				@end="drag = false"
 				@update="savePlaylistOrder"
@@ -67,10 +62,6 @@ onMounted(() => {
 								element.createdBy === userId)
 						"
 						:playlist="element"
-						:class="{
-							item: true,
-							'item-draggable': isCurrentUser
-						}"
 					>
 						<template #actions>
 							<i
@@ -102,7 +93,7 @@ onMounted(() => {
 						</template>
 					</playlist-item>
 				</template>
-			</sortable>
+			</draggable-list>
 
 			<button
 				v-if="isCurrentUser"

+ 2 - 1
frontend/src/pages/Profile/index.vue

@@ -394,7 +394,8 @@ onMounted(() => {
 			font-weight: 400;
 		}
 
-		.item {
+		.item,
+		.draggable-item {
 			overflow: hidden;
 
 			&:not(:last-of-type) {

+ 10 - 0
frontend/src/types/global.d.ts

@@ -10,6 +10,16 @@ declare global {
 	var addToPlaylistDropdown: any;
 	var scrollDebounceId: any;
 	var focusedElementBefore: any;
+	var draggingItem:
+		| undefined
+		| {
+				itemIndex: number;
+				itemListUuid: string;
+				itemGroup: string;
+				itemOnMove?: (index: number) => any;
+				initialItemIndex: number;
+				initialItemListUuid: string;
+		  };
 }
 
 export {};