1
0

4 Commits c05f64fcbb ... 2a4045234b

Autor SHA1 Mensagem Data
  Kristian Vos 2a4045234b feature: show error if open modal doesn't exist as a type há 1 mês atrás
  Kristian Vos 3d71b50993 refactor: add some useful helper util functions for rounding numbers or getting emoji flag há 1 mês atrás
  Kristian Vos d0c9bb409d refactor: temporarily use DraggableList locally with some WIP changes há 1 mês atrás
  Kristian Vos 9d515ce087 refactor: WIP changes to EditArtist há 1 mês atrás

+ 7 - 0
frontend/package-lock.json

@@ -17,6 +17,7 @@
         "dompurify": "^3.2.3",
         "eslint-config-airbnb-base": "^15.0.0",
         "marked": "^15.0.6",
+        "mobile-drag-drop": "^3.0.0-rc.0",
         "normalize.css": "^8.0.1",
         "pinia": "^2.3.0",
         "toasters": "^2.3.1",
@@ -5020,6 +5021,12 @@
       "integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==",
       "license": "MIT"
     },
+    "node_modules/mobile-drag-drop": {
+      "version": "3.0.0-rc.0",
+      "resolved": "https://registry.npmjs.org/mobile-drag-drop/-/mobile-drag-drop-3.0.0-rc.0.tgz",
+      "integrity": "sha512-f8wIDTbBYLBW/+5sei1cqUE+StyDpf/LP+FRZELlVX6tmOOmELk84r3wh1z3woxCB9G5octhF06K5COvFjGgqg==",
+      "license": "MIT"
+    },
     "node_modules/ms": {
       "version": "2.1.3",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

+ 1 - 0
frontend/package.json

@@ -48,6 +48,7 @@
     "dompurify": "^3.2.3",
     "eslint-config-airbnb-base": "^15.0.0",
     "marked": "^15.0.6",
+    "mobile-drag-drop": "^3.0.0-rc.0",
     "normalize.css": "^8.0.1",
     "pinia": "^2.3.0",
     "toasters": "^2.3.1",

+ 27 - 0
frontend/src/components/ModalManager.vue

@@ -44,6 +44,33 @@ const modalComponents = shallowRef(
 				:modal-uuid="activeModalUuid"
 				v-bind="modals[activeModalUuid].props"
 			/>
+			<div
+				v-if="!modalComponents[modals[activeModalUuid].modal]"
+				class="modal-error"
+			>
+				<p>
+					Modal "{{ modals[activeModalUuid].modal }}" does not exist
+				</p>
+			</div>
 		</div>
 	</div>
 </template>
+
+<style lang="less" scoped>
+.modal-error {
+	position: absolute;
+	bottom: 0;
+	left: 0;
+	width: 100%;
+	background-color: var(--dark-red);
+	color: var(--white);
+	z-index: 10000;
+	padding: 32px;
+
+	p {
+		padding: 0;
+		margin: 0;
+		font-size: 3em;
+	}
+}
+</style>

+ 441 - 0
frontend/src/components/TempDraggableList.vue

@@ -0,0 +1,441 @@
+<script setup lang="ts">
+/* eslint-disable prettier/prettier */
+import { PropType, Slot as SlotType, watch, onMounted, ref } from "vue";
+</script>
+
+<script lang="ts">
+import { polyfill as mobileDragDropPolyfill } from "mobile-drag-drop";
+import { scrollBehaviourDragImageTranslateOverride as mobileDragDropScrollBehaviourDragImageTranslateOverride } from "mobile-drag-drop/scroll-behaviour";
+
+const props = defineProps({
+    itemKey: { type: String, default: "" },
+    list: { type: Array as PropType<any[]>, default: () => [] },
+    attributes: { type: Object, default: () => ({}) },
+    tag: { type: String, default: "div" },
+    group: { type: String, default: "" },
+    disabled: { type: [Boolean, Function], default: false },
+    touchTimeout: { type: Number, default: 250 },
+    handleClass: { type: String, required: false, default: null },
+    // New
+    readOnly: { type: Boolean, default: false },
+    unique: { type: Boolean, default: false },
+    debugName: { type: String, default: "" }
+});
+
+const listUuid = ref(
+    "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, symbol => {
+        let array;
+
+        if (symbol === "y") {
+            array = ["8", "9", "a", "b"];
+            return array[Math.floor(Math.random() * array.length)];
+        }
+
+        array = new Uint8Array(1);
+        window.crypto.getRandomValues(array);
+        return (array[0] % 16).toString(16);
+    })
+);
+const mounted = ref(false);
+const data = ref([] as any[]);
+const isDraggable = ref(!props.handleClass);
+
+const touching = ref(false);
+const touchDragging = ref(false);
+const touchingTimeout = ref<null | number>(null);
+
+watch(
+    () => props.list,
+    list => {
+        data.value = list;
+    }
+);
+
+onMounted(() => {
+    data.value = props.list;
+    mounted.value = true;
+});
+
+const emit = defineEmits(["update:list", "start", "end", "update"]);
+
+const hasHandleAtPosition = (event: MouseEvent | DragEvent) => {
+    const { clientX: x, clientY: y } = event;
+    const elementsAtPosition = document.elementsFromPoint(x, y);
+    return elementsAtPosition.reduce<boolean | null>(
+        (clickedHandle, elementAtPosition) => {
+            // If we already have a boolean result, return that
+            if (typeof clickedHandle === "boolean") return clickedHandle;
+            // If the clicked element (or one of its parents) has the handle class, we clicked the handle
+            if (elementAtPosition.classList.contains(props.handleClass!))
+                return true;
+            // If we've reached the draggable element itself, we found no handle, so return false to avoid
+            // accidentally using handles with the same class outside the draggable element
+            if (elementAtPosition.classList.contains("draggable-item"))
+                return false;
+            return null;
+        },
+        null
+    );
+};
+
+const onMouseDown = (itemIndex: number, event: MouseEvent) => {
+    if (!props.handleClass) return;
+
+    isDraggable.value = !!hasHandleAtPosition(event);
+};
+
+const onMouseUp = (itemIndex: number, event: MouseEvent) => {
+    if (props.handleClass) isDraggable.value = false;
+};
+
+const itemOnMove = (index: number, remove: boolean = false) => {
+    console.log("TDL itemOnMove", index, remove, props.debugName);
+    // debugger;
+    // Deletes the remove function for the dragging element
+    if (window.draggingItem && window.draggingItem.itemOnMove)
+        delete window.draggingItem.itemOnMove;
+
+    if (remove) {
+        // Remove the item from the current list and return it
+        const listItem = data.value.splice(index, 1)[0];
+        emit("update:list", data.value);
+        return listItem;
+    }
+
+    const listItem = data.value[index];
+    return listItem;
+};
+
+// When an element starts being dragged
+const onDragStart = (itemIndex: number, event: DragEvent) => {
+    const { draggable } = event.target as HTMLElement;
+
+    if (
+        props.disabled === true ||
+        (typeof props.disabled === "function" &&
+            props.disabled(data.value[itemIndex])) ||
+        !draggable ||
+        !event.dataTransfer ||
+        (touching.value && !touchDragging.value)
+    ) {
+        event.preventDefault();
+        return;
+    }
+
+    // If we only want to start dragging if the user clicked on a handle element
+    if (props.handleClass) {
+        // If no handle was clicked, we don't want to start dragging the element
+        if (!hasHandleAtPosition(event)) return;
+    }
+
+    // Set the effect of moving an element, which by default is clone. Not being used right now
+    event.dataTransfer.dropEffect = "move";
+
+    console.log("Set dragging item", props.debugName, props.readOnly);
+    // Sets the dragging element index, list uuid and adds a remove function for when this item is moved to a different list
+    window.draggingItem = {
+        itemIndex,
+        itemListUuid: listUuid.value,
+        itemGroup: props.group,
+        itemOnMove,
+        initialItemIndex: itemIndex,
+        initialItemListUuid: listUuid.value,
+        itemReadOnly: props.readOnly
+    };
+
+    // Emits the start event to the parent component, indicating that dragging has started
+    emit("start");
+};
+
+// When a dragging element hovers over another draggable element, this gets triggered, usually many times in a second
+// Push is for if the current list is empty
+const onDragOver = (itemIndex: number, event: DragEvent, push = false) => {
+    const getDraggableElement = (
+        element: HTMLElement
+    ): HTMLElement | undefined => {
+        if (
+            element.classList.contains("draggable-item") ||
+            element.classList.contains("empty-list-placeholder")
+        )
+            return element;
+        if (element.parentElement)
+            return getDraggableElement(element.parentElement);
+        return undefined;
+    };
+    const draggableElement = getDraggableElement(event.target as HTMLElement);
+
+    console.log("TDL onDragOver start ------------------------------------- ", props.debugName);
+
+    // console.log("TDL onDragOver 111", itemIndex, push, props, JSON.stringify(draggableElement.dataset), props.debugName);
+
+    // console.log("TDL onDragOver 222", props.disabled, props.readOnly, !draggableElement, (!draggableElement.draggable && !push), !window.draggingItem, window.draggingItem);
+
+    if (
+        props.disabled === true ||
+        !draggableElement ||
+        (!draggableElement.draggable && !push) ||
+        !window.draggingItem
+    )
+        return;
+
+    // console.log("TDL Update dragging item", props.debugName, props.readOnly);
+    // The index and list uuid of the item that is being dragged, stored in window since it can come from another list as well
+    const fromIndex = window.draggingItem.itemIndex;
+    const fromList = window.draggingItem.itemListUuid;
+    // The new index and list uuid of the item that is being dragged
+    const toIndex = itemIndex;
+    const toList = listUuid.value;
+
+    // console.log("TDL onDragOver 333");
+
+    // If the item hasn't changed position in the same list, don't continue
+    if (fromIndex === toIndex && fromList === toList) return;
+
+    // If the dragging item isn't from the same group, don't continue
+    if (
+        fromList !== toList &&
+        (props.group === "" || window.draggingItem.itemGroup !== props.group)
+    )
+        return;
+
+    // If dragging within the same list, but the list is readonly, don't continue
+    if (toList === fromList && props.readOnly)
+        return;
+
+    const oldListReadOnly = window.draggingItem.itemReadOnly; // TODO set readOnly
+    const newListReadOnly = props.readOnly;
+
+    if (!newListReadOnly) {
+        // Update the index and list uuid of the dragged item
+        window.draggingItem.itemIndex = toIndex;
+        window.draggingItem.itemListUuid = listUuid.value;
+        window.draggingItem.itemReadOnly = newListReadOnly;
+    }
+
+    console.log(`TDL onDragOver fromIndex ${fromIndex}, fromList ${fromList}, toIndex ${toIndex}, toList ${toList}, draggingItem.itemOnMove ${window.draggingItem.itemOnMove}`);
+
+    // If the item comes from another list
+    if (toList !== fromList && window.draggingItem.itemOnMove) {
+        // Call the remove function from the dragging element, which removes the item from the previous list and returns it
+        console.log("TDL onDragOver in difference. Remove from previous list:", !oldListReadOnly, "Add to new list:", !newListReadOnly);
+        const item = window.draggingItem.itemOnMove(fromIndex, !oldListReadOnly);
+        console.log(`TDL onDragOver in difference. Item: ${item}`);
+
+        if (newListReadOnly) {
+            // console.log("444");
+            console.log("TDL onDragOver new list read only return 111");
+            window.draggingItem.itemOnMove = () => {
+                console.log("TDL onDragOver fake itemOnMove 111");
+                return item;
+            }
+            return;
+        }
+
+        if (props.unique && data.value.includes(item)) {
+            console.log("TDL onDragOver unique return 222");
+            window.draggingItem.itemOnMove = () => {
+                console.log("TDL onDragOver fake itemOnMove 222");
+                return item;
+            }
+            return;
+        }
+
+        // Define a new remove function for the dragging element
+        window.draggingItem.itemOnMove = itemOnMove;
+        window.draggingItem.itemGroup = props.group;
+        // Add the item to the list at the new index
+        if (push) data.value.push(item);
+        else data.value.splice(toIndex, 0, item);
+        emit("update:list", data.value);
+    }
+    // If the item is being reordered in the same list
+    else {
+        console.log("TDL onDragOver in same list to a different position", itemIndex);
+        // if (props.readOnly) {
+        //     console.log("555");
+        //     return;
+        // }
+        // Remove the item from the old position, and add the item to the new position
+
+        data.value.splice(toIndex, 0, data.value.splice(fromIndex, 1)[0]);
+        emit("update:list", data.value);
+    }
+};
+// Gets called when the element that is being dragged is released
+const onDragEnd = () => {
+    // Emits the end event to parent component, indicating that dragging has ended
+    emit("end");
+
+    if (props.handleClass) isDraggable.value = false;
+
+    // Emits the update event to parent component, indicating that the order is now done and ordering/moving is done
+    if (!window.draggingItem) return;
+    const { itemIndex, itemListUuid, initialItemIndex, initialItemListUuid } =
+        window.draggingItem;
+    if (itemListUuid === initialItemListUuid) {
+        if (initialItemIndex === itemIndex) return;
+
+        emit("update", {
+            moved: {
+                oldIndex: initialItemIndex,
+                newIndex: itemIndex,
+                updatedList: data.value
+            }
+        });
+    } else emit("update", {});
+    delete window.draggingItem;
+};
+
+// Gets called when an element is dropped on another element, currently not used
+const onDrop = () => {};
+
+// Function that gets called for each item and returns attributes
+const convertAttributes = (item: any) =>
+    Object.fromEntries(
+        Object.entries(props.attributes).map(([key, value]) => [
+            key,
+            typeof value === "function" ? value(item) : value
+        ])
+    );
+
+const hasSlotContent = (slot: SlotType | undefined, slotProps = {}) => {
+    if (!slot) return false;
+
+    return slot(slotProps).some(vnode => {
+        if (
+            vnode.type === Comment ||
+            vnode.type.toString() === "Symbol(Comment)"
+        )
+            return false;
+
+        if (Array.isArray(vnode.children) && !vnode.children.length)
+            return false;
+
+        if (vnode.children === "") return false;
+
+        return (
+            vnode.type !== Text ||
+            vnode.type.toString() !== "Symbol(Text)" ||
+            (typeof vnode.children === "string" && vnode.children.trim() !== "")
+        );
+    });
+};
+
+const onTouchStart = (event: TouchEvent) => {
+    touching.value = true;
+    touchDragging.value = false;
+
+    // When we use handles, we need to find the element that is the handle, up until the draggable-item element itself
+    if (props.handleClass) {
+        let handleElement = event.target as HTMLElement | null;
+        while (
+            handleElement &&
+            !handleElement.classList.contains(props.handleClass)
+        ) {
+            if (handleElement.classList.contains("draggable-item")) {
+                handleElement = null;
+                break;
+            }
+            handleElement = handleElement.parentElement;
+        }
+        // If the user is touching the handle, set isDraggable to true so dragging is allowed to start in onDragStart
+        if (handleElement) isDraggable.value = true;
+    }
+
+    if (touchingTimeout.value) clearTimeout(touchingTimeout.value);
+
+    touchingTimeout.value = setTimeout(() => {
+        touchDragging.value = true;
+    }, props.touchTimeout);
+};
+
+const onTouchEnd = () => {
+    touching.value = false;
+    touchDragging.value = false;
+    // When we use handles, isDragging should default to false, so the user has to start dragging the handle for isDragging to be changed to true
+    if (props.handleClass) isDraggable.value = false;
+
+    if (touchingTimeout.value) clearTimeout(touchingTimeout.value);
+    touchingTimeout.value = null;
+};
+
+mobileDragDropPolyfill({
+    dragImageTranslateOverride:
+        mobileDragDropScrollBehaviourDragImageTranslateOverride,
+    tryFindDraggableTarget: event => {
+        const getDraggableElement = (
+            element: HTMLElement
+        ): HTMLElement | undefined => {
+            if (element.classList.contains("draggable-item")) return element;
+            if (element.parentElement)
+                return getDraggableElement(element.parentElement);
+            return undefined;
+        };
+
+        return getDraggableElement(event.target as HTMLElement);
+    }
+});
+
+window.addEventListener("touchmove", () => {});
+</script>
+
+<template>
+    <template v-for="(item, itemIndex) in data" :key="itemKey ? item[itemKey] : item">
+        <component
+            v-if="hasSlotContent($slots.item, { element: item })"
+            :is="tag"
+            :draggable="
+                (typeof disabled === 'function'
+                    ? !disabled(item)
+                    : !disabled) && isDraggable
+            "
+            @mousedown="onMouseDown(itemIndex, $event)"
+            @mouseup="onMouseUp(itemIndex, $event)"
+            @touchstart.passive="onTouchStart($event)"
+            @touchend="onTouchEnd()"
+            @dragstart="onDragStart(itemIndex, $event)"
+            @dragenter.prevent
+            @dragover.prevent="onDragOver(itemIndex, $event)"
+            @dragend="onDragEnd()"
+            @drop="onDrop()"
+            :data-index="itemIndex"
+            :data-list="listUuid"
+            class="draggable-item"
+            v-bind="convertAttributes(item)"
+        >
+            <slot name="item" :element="item" :index="itemIndex"></slot>
+        </component>
+    </template>
+    <div
+        v-if="data.length === 0"
+        class="empty-list-placeholder"
+        @dragover.prevent="onDragOver(0, $event, true)"
+        @drop.prevent
+    >
+        <slot name="empty-list-placeholder"></slot>
+    </div>
+    <!-- <div
+        v-else-if="data.length === 0"
+        class="empty-list-placeholder"
+        @dragover.prevent="onDragOver(0, $event, true)"
+        @drop.prevent
+    ></div> -->
+</template>
+
+<style lang="css">
+@import "mobile-drag-drop/default.css";
+
+.draggable-item[draggable="true"] {
+    cursor: move;
+}
+.draggable-item:not(:last-of-type) {
+    margin-bottom: 10px;
+}
+.draggable-item .draggable-handle {
+    cursor: move;
+    user-select: none;
+}
+.empty-list-placeholder {
+    flex: 1;
+}
+</style>

+ 448 - 229
frontend/src/components/modals/EditArtist.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import Toast from "toasters";
-import { defineAsyncComponent, ref, onMounted } from "vue";
+import { defineAsyncComponent, ref, computed, onMounted } from "vue";
 import { GenericResponse } from "@musare_types/actions/GenericActions";
 import VueJsonPretty from "vue-json-pretty";
 import { useForm } from "@/composables/useForm";
@@ -23,13 +23,28 @@ const props = defineProps({
 
 const { socket } = useWebsocketsStore();
 
-const { closeCurrentModal } = useModalsStore();
+const { closeCurrentModal, openModal } = useModalsStore();
 const { setJob } = useLongJobsStore();
 
 const createdBy = ref();
 const createdAt = ref(0);
 const hideMusicbrainzData = ref(true);
 
+const importMode = ref(false);
+
+const modalTitle = computed(() => {
+	// createArtist ? 'Create Artist' : 'Edit Artist'
+	if (props.createArtist) return "Create Artist";
+	if (importMode.value) return "Edit Artist - Import Mode";
+	return "Edit Artist";
+});
+
+// const releaseGroupsResponse = ref({});
+// const releaseGroupResponse = ref({});
+// const releaseResponse = ref({});
+
+// const buttonStatus = ref({});
+
 // const relatedSongs = ref([]);
 
 const refreshRelatedSongs = () => {};
@@ -147,27 +162,27 @@ const removeYoutubeChannel = index => {
 	inputs.value.youtubeChannels.value.splice(index, 1);
 };
 
-const addSpotifyArtist = () => {
-	inputs.value.spotifyArtists.value.push({
-		spotifyArtistId: "",
-		comment: ""
-	});
-};
+// const addSpotifyArtist = () => {
+// 	inputs.value.spotifyArtists.value.push({
+// 		spotifyArtistId: "",
+// 		comment: ""
+// 	});
+// };
 
-const removeSpotifyArtist = index => {
-	inputs.value.spotifyArtists.value.splice(index, 1);
-};
+// const removeSpotifyArtist = index => {
+// 	inputs.value.spotifyArtists.value.splice(index, 1);
+// };
 
-const addSoundcloudArtist = () => {
-	inputs.value.soundcloudArtists.value.push({
-		soundcloudArtistId: "",
-		comment: ""
-	});
-};
+// const addSoundcloudArtist = () => {
+// 	inputs.value.soundcloudArtists.value.push({
+// 		soundcloudArtistId: "",
+// 		comment: ""
+// 	});
+// };
 
-const removeSoundcloudArtist = index => {
-	inputs.value.soundcloudArtists.value.splice(index, 1);
-};
+// const removeSoundcloudArtist = index => {
+// 	inputs.value.soundcloudArtists.value.splice(index, 1);
+// };
 
 const importYoutubeChannel = youtubeChannelId => {
 	let id;
@@ -262,264 +277,412 @@ const fillMissingUrls = musicbrainzIdentifier => {
 <template>
 	<modal
 		class="edit-artist-modal"
-		:title="createArtist ? 'Create Artist' : 'Edit Artist'"
+		:title="modalTitle"
 		:size="'wide'"
 		:split="true"
 	>
 		<template #body>
-			<div class="flex flex-row w-full">
-				<div class="flex flex-column gap-4 w-2/3">
-					<div>
-						<div class="control is-grouped">
-							<div class="name-container">
-								<label class="label">Name</label>
-								<p class="control has-addons">
+			<div class="flex flex-column w-full">
+				<div class="flex flex-row w-full">
+					<div class="flex flex-column gap-4 w-2/3">
+						<div>
+							<div class="control is-grouped">
+								<div class="name-container">
+									<label class="label">Name</label>
+									<p class="control has-addons">
+										<input
+											class="input"
+											type="text"
+											:ref="
+												el => (inputs['name'].ref = el)
+											"
+											v-model="inputs['name'].value"
+											placeholder="Enter artist name..."
+											:disabled="importMode"
+										/>
+									</p>
+								</div>
+							</div>
+						</div>
+						<div>
+							<div class="control is-grouped gap-4">
+								<div class="musicbrainz-identifier-container">
+									<label class="label"
+										>MusicBrainz identifier</label
+									>
 									<input
 										class="input"
 										type="text"
-										:ref="el => (inputs['name'].ref = el)"
-										v-model="inputs['name'].value"
-										placeholder="Enter artist name..."
+										:ref="
+											el =>
+												(inputs[
+													'musicbrainzIdentifier'
+												].ref = el)
+										"
+										v-model="
+											inputs['musicbrainzIdentifier']
+												.value
+										"
+										placeholder="Enter MusicBrainz identifier..."
+										:disabled="importMode"
 									/>
-								</p>
-							</div>
-						</div>
-					</div>
-					<div>
-						<div class="control is-grouped gap-4">
-							<div class="musicbrainz-identifier-container">
-								<label class="label"
-									>MusicBrainz identifier</label
-								>
-								<input
-									class="input"
-									type="text"
-									:ref="
-										el =>
-											(inputs[
-												'musicbrainzIdentifier'
-											].ref = el)
-									"
-									v-model="
-										inputs['musicbrainzIdentifier'].value
+								</div>
+								<button
+									class="button is-primary button-bottom"
+									@click="
+										getMusicbrainzArtistData(
+											inputs['musicbrainzIdentifier']
+												.value
+										)
 									"
-									placeholder="Enter MusicBrainz identifier..."
-								/>
-							</div>
-							<button
-								class="button is-primary button-bottom"
-								@click="
-									getMusicbrainzArtistData(
-										inputs['musicbrainzIdentifier'].value
-									)
-								"
-							>
-								Get MusicBrainz artist data
-							</button>
-							<button
-								class="button is-primary button-bottom"
-								@click="
-									fillMissingUrls(
-										inputs['musicbrainzIdentifier'].value
-									)
-								"
-							>
-								Fill artists/channels from MusicBrainz
-							</button>
-						</div>
-						<div>
-							<div class="flex flex-row gap-4">
-								<p class="text-vcenter">MusicBrainz data</p>
+									v-if="!importMode"
+								>
+									Get MusicBrainz artist data
+								</button>
 								<button
-									class="button is-primary"
+									class="button is-primary button-bottom"
 									@click="
-										hideMusicbrainzData =
-											!hideMusicbrainzData
+										fillMissingUrls(
+											inputs['musicbrainzIdentifier']
+												.value
+										)
 									"
+									v-if="!importMode"
 								>
-									<span v-show="hideMusicbrainzData"
-										>Show MusicBrainz data</span
-									>
-									<span v-show="!hideMusicbrainzData"
-										>Hide MusicBrainz data</span
-									>
+									Fill artists/channels from MusicBrainz
 								</button>
 							</div>
-							<vue-json-pretty
-								:data="inputs['musicbrainzData'].value"
-								:show-length="true"
-								v-if="!hideMusicbrainzData"
-							></vue-json-pretty>
-						</div>
-					</div>
-					<div>
-						<p>YouTube channels</p>
-						<div class="flex flex-column gap-4">
-							<template
-								v-for="(youtubeChannel, index) in inputs[
-									'youtubeChannels'
-								].value"
-								:key="`${index}`"
-							>
-								<div class="control is-grouped gap-4">
-									<div class="name-container">
-										<label class="label"
-											>YouTube channel ID</label
-										>
-										<input
-											class="input"
-											type="text"
-											v-model="
-												youtubeChannel.youtubeChannelId
-											"
-											placeholder="Enter YouTube channel ID..."
-										/>
-									</div>
-									<div class="name-container">
-										<label class="label">Comment</label>
-										<input
-											class="input"
-											type="text"
-											v-model="youtubeChannel.comment"
-											placeholder="Enter comment..."
-										/>
-									</div>
-									<button
-										class="button is-primary button-bottom"
-										@click="removeYoutubeChannel(index)"
-									>
-										Remove
-									</button>
+							<div>
+								<div class="flex flex-row gap-4">
+									<p class="text-vcenter">MusicBrainz data</p>
 									<button
-										class="button is-primary button-bottom"
+										class="button is-primary"
 										@click="
-											importYoutubeChannel(
-												youtubeChannel.youtubeChannelId
-											)
+											hideMusicbrainzData =
+												!hideMusicbrainzData
 										"
 									>
-										Import
+										<span v-show="hideMusicbrainzData"
+											>Show MusicBrainz data</span
+										>
+										<span v-show="!hideMusicbrainzData"
+											>Hide MusicBrainz data</span
+										>
 									</button>
 								</div>
-							</template>
+								<vue-json-pretty
+									:data="inputs['musicbrainzData'].value"
+									:show-length="true"
+									v-if="!hideMusicbrainzData"
+								></vue-json-pretty>
+							</div>
 						</div>
-						<button
-							class="button is-primary"
-							@click="addYoutubeChannel()"
-						>
-							Add YouTube channel
-						</button>
-					</div>
-					<div>
-						<p>Spotify artists</p>
-						<div class="flex flex-column gap-4">
-							<template
-								v-for="(spotifyArtist, index) in inputs[
-									'spotifyArtists'
-								].value"
-								:key="`${index}`"
-							>
-								<div class="control is-grouped gap-4">
-									<div class="name-container">
-										<label class="label"
-											>Spotify artist ID</label
+						<div>
+							<p>YouTube channels</p>
+							<div class="flex flex-column gap-4">
+								<template
+									v-for="(youtubeChannel, index) in inputs[
+										'youtubeChannels'
+									].value"
+									:key="`${index}`"
+								>
+									<div class="control is-grouped gap-4">
+										<div class="name-container">
+											<label class="label"
+												>YouTube channel ID (or
+												playlist?)</label
+											>
+											<input
+												class="input"
+												type="text"
+												v-model="
+													youtubeChannel.youtubeChannelId
+												"
+												placeholder="Enter YouTube channel ID..."
+												:disabled="importMode"
+											/>
+										</div>
+										<div class="name-container">
+											<label class="label">Comment</label>
+											<input
+												class="input"
+												type="text"
+												v-model="youtubeChannel.comment"
+												placeholder="Enter comment..."
+												:disabled="importMode"
+											/>
+										</div>
+										<button
+											class="button is-primary button-bottom"
+											@click="removeYoutubeChannel(index)"
+											v-if="!importMode"
 										>
-										<input
-											class="input"
-											type="text"
-											v-model="
-												spotifyArtist.spotifyArtistId
+											Remove
+										</button>
+										<button
+											class="button is-primary button-bottom"
+											@click="
+												importYoutubeChannel(
+													youtubeChannel.youtubeChannelId
+												)
 											"
-											placeholder="Enter Spotify artist ID..."
-										/>
+										>
+											Import
+										</button>
+										<p
+											style="
+												align-self: center;
+												padding-top: 27px;
+											"
+										>
+											Last import: ???
+										</p>
 									</div>
-									<div class="name-container">
-										<label class="label">Comment</label>
-										<input
-											class="input"
-											type="text"
-											v-model="spotifyArtist.comment"
-											placeholder="Enter comment..."
-										/>
+								</template>
+							</div>
+							<button
+								class="button is-primary"
+								@click="addYoutubeChannel()"
+								v-if="!importMode"
+							>
+								Add YouTube channel
+							</button>
+						</div>
+						<!-- <div>
+							<p>Spotify artists</p>
+							<div class="flex flex-column gap-4">
+								<template
+									v-for="(spotifyArtist, index) in inputs[
+										'spotifyArtists'
+									].value"
+									:key="`${index}`"
+								>
+									<div class="control is-grouped gap-4">
+										<div class="name-container">
+											<label class="label"
+												>Spotify artist ID</label
+											>
+											<input
+												class="input"
+												type="text"
+												v-model="
+													spotifyArtist.spotifyArtistId
+												"
+												placeholder="Enter Spotify artist ID..."
+											/>
+										</div>
+										<div class="name-container">
+											<label class="label">Comment</label>
+											<input
+												class="input"
+												type="text"
+												v-model="spotifyArtist.comment"
+												placeholder="Enter comment..."
+											/>
+										</div>
+										<button
+											class="button is-primary button-bottom"
+											@click="removeSpotifyArtist(index)"
+										>
+											Remove
+										</button>
 									</div>
-									<button
-										class="button is-primary button-bottom"
-										@click="removeSpotifyArtist(index)"
-									>
-										Remove
-									</button>
-								</div>
-							</template>
+								</template>
+							</div>
+							<button
+								class="button is-primary"
+								@click="addSpotifyArtist()"
+							>
+								Add Spotify artist
+							</button>
 						</div>
+						<div>
+							<p>SoundCloud artists</p>
+							<div class="flex flex-column gap-4">
+								<template
+									v-for="(soundcloudArtist, index) in inputs[
+										'soundcloudArtists'
+									].value"
+									:key="`${index}`"
+								>
+									<div class="control is-grouped gap-4">
+										<div class="name-container">
+											<label class="label"
+												>SoundCloud artist ID</label
+											>
+											<input
+												class="input"
+												type="text"
+												v-model="
+													soundcloudArtist.soundcloudArtistId
+												"
+												placeholder="Enter Soundcloud artist ID..."
+											/>
+										</div>
+										<div class="name-container">
+											<label class="label">Comment</label>
+											<input
+												class="input"
+												type="text"
+												v-model="soundcloudArtist.comment"
+												placeholder="Enter comment..."
+											/>
+										</div>
+										<button
+											class="button is-primary button-bottom"
+											@click="removeSoundcloudArtist(index)"
+										>
+											Remove
+										</button>
+									</div>
+								</template>
+							</div>
+							<button
+								class="button is-primary"
+								@click="addSoundcloudArtist()"
+							>
+								Add Soundcloud artist
+							</button>
+						</div> -->
+					</div>
+				</div>
+				<!-- <hr /> -->
+				<!-- <div class="flex flex-column w-full">
+					<p>List of channels/playlists with data?</p>
+					<p>List of release groups</p>
+					<div class="flex flex-column w-full">
+						<p>Release groups</p>
 						<button
 							class="button is-primary"
-							@click="addSpotifyArtist()"
+							@click="
+								loadReleaseGroups(
+									inputs['musicbrainzIdentifier'].value
+								)
+							"
 						>
-							Add Spotify artist
+							Load release groups
 						</button>
-					</div>
-					<div>
-						<p>SoundCloud artists</p>
-						<div class="flex flex-column gap-4">
-							<template
-								v-for="(soundcloudArtist, index) in inputs[
-									'soundcloudArtists'
-								].value"
-								:key="`${index}`"
+						<div
+							class="flex flex-column w-full release-groups"
+							v-if="
+								releaseGroupsResponse[
+									inputs['musicbrainzIdentifier'].value
+								]
+							"
+						>
+							<div
+								v-for="releaseGroup in releaseGroupsResponse[
+									inputs['musicbrainzIdentifier'].value
+								]['release-groups']"
+								:key="releaseGroup.id"
+								class="flex flex-column"
 							>
-								<div class="control is-grouped gap-4">
-									<div class="name-container">
-										<label class="label"
-											>SoundCloud artist ID</label
-										>
-										<input
-											class="input"
-											type="text"
-											v-model="
-												soundcloudArtist.soundcloudArtistId
-											"
-											placeholder="Enter Soundcloud artist ID..."
-										/>
-									</div>
-									<div class="name-container">
-										<label class="label">Comment</label>
-										<input
-											class="input"
-											type="text"
-											v-model="soundcloudArtist.comment"
-											placeholder="Enter comment..."
-										/>
+								<div class="flex flex-row mrow">
+									<div class="four-buttons">
+										<button class="button u" @click="toggleButtonStatus(releaseGroup.id, 'U')" :class="!buttonStatus[releaseGroup.id] ? 'active' : ''">U</button>
+										<button class="button y" @click="toggleButtonStatus(releaseGroup.id, 'Y')" :class="buttonStatus[releaseGroup.id] === 'Y' ? 'active' : ''">Y</button>
+										<button class="button m" @click="toggleButtonStatus(releaseGroup.id, 'M')" :class="buttonStatus[releaseGroup.id] === 'M' ? 'active' : ''">M</button>
+										<button class="button n" @click="toggleButtonStatus(releaseGroup.id, 'N')" :class="buttonStatus[releaseGroup.id] === 'N' ? 'active' : ''">N</button>
 									</div>
-									<button
-										class="button is-primary button-bottom"
-										@click="removeSoundcloudArtist(index)"
+									<button class="button is-primary" @click="loadReleaseGroup(releaseGroup.id)">Show</button>
+									<p>
+										{{ releaseGroup.title }} -
+										{{ releaseGroup["first-release-date"] }} -
+										{{ releaseGroup["primary-type"] }}
+									</p>
+								</div>
+								<div
+									class="flex flex-column w-full release-group"
+									v-if="
+										releaseGroupResponse[
+											releaseGroup.id
+										]
+									"
+								>
+									<div
+										v-for="release in releaseGroupResponse[
+											releaseGroup.id
+										]['releases']"
+										:key="release.id"
+										class="flex flex-column"
 									>
-										Remove
-									</button>
+										<div class="flex flex-row mrow">
+											<div class="four-buttons">
+												<button class="button u" @click="toggleButtonStatus(release.id, 'U')" :class="!buttonStatus[release.id] ? 'active' : ''">U</button>
+												<button class="button y" @click="toggleButtonStatus(release.id, 'Y')" :class="buttonStatus[release.id] === 'Y' ? 'active' : ''">Y</button>
+												<button class="button m" @click="toggleButtonStatus(release.id, 'M')" :class="buttonStatus[release.id] === 'M' ? 'active' : ''">M</button>
+												<button class="button n" @click="toggleButtonStatus(release.id, 'N')" :class="buttonStatus[release.id] === 'N' ? 'active' : ''">N</button>
+											</div>
+											<button class="button is-primary" @click="loadRelease(release.id)">Show</button>
+											<p>
+												{{ release.title }} -
+												{{ release.date }} -
+												{{ release.country }} -
+												{{ release.packaging }} -
+												{{ release.status }}
+											</p>
+										</div>
+										<div
+											class="flex flex-column w-full release"
+											v-if="
+												releaseResponse[
+													release.id
+												]
+											"
+										>
+											<div
+												v-for="track in releaseResponse[
+													release.id
+												]['media'][0].tracks"
+												:key="track.id"
+												class="flex flex-column"
+											>
+												<div class="flex flex-row mrow">
+													<div class="four-buttons">
+														<button class="button u" @click="toggleButtonStatus(track.id, 'U')" :class="!buttonStatus[track.id] ? 'active' : ''">U</button>
+														<button class="button y" @click="toggleButtonStatus(track.id, 'Y')" :class="buttonStatus[track.id] === 'Y' ? 'active' : ''">Y</button>
+														<button class="button m" @click="toggleButtonStatus(track.id, 'M')" :class="buttonStatus[track.id] === 'M' ? 'active' : ''">M</button>
+														<button class="button n" @click="toggleButtonStatus(track.id, 'N')" :class="buttonStatus[track.id] === 'N' ? 'active' : ''">N</button>
+													</div>
+													<!- <button class="button is-primary" @click="loadRelease(track.id)">Show</button> ->
+													<p>
+														{{ track.title }} -
+														{{ track.position }}
+													</p>
+												</div>
+											</div>
+										</div>
+									</div>
 								</div>
-							</template>
+							</div>
 						</div>
-						<button
-							class="button is-primary"
-							@click="addSoundcloudArtist()"
-						>
-							Add Soundcloud artist
-						</button>
 					</div>
-				</div>
-				<div class="flex flex-column w-1/3"></div>
+				</div> -->
 			</div>
 		</template>
 		<template #footer>
 			<div>
 				<save-button
+					v-if="!importMode"
 					:default-message="`${createArtist ? 'Create' : 'Update'} Artist`"
 					@clicked="saveArtist()"
 				/>
 				<save-button
+					v-if="!importMode"
 					:default-message="`${createArtist ? 'Create' : 'Update'} and close`"
 					@clicked="saveArtist(true)"
 				/>
+				<button
+					class="button is-primary"
+					@click="
+						openModal({
+							modal: 'importArtistMB',
+							props: { artistId }
+						})
+					"
+				>
+					<span>Import MB</span>
+				</button>
 			</div>
 		</template>
 	</modal>
@@ -582,4 +745,60 @@ const fillMissingUrls = musicbrainzIdentifier => {
 .w-full {
 	width: 100%;
 }
+
+.release-groups {
+	gap: 8px;
+}
+
+.four-buttons {
+	.u.active {
+		background-color: gray;
+	}
+
+	.n.active {
+		background-color: red;
+	}
+
+	.m.active {
+		background-color: orange;
+	}
+
+	.y.active {
+		background-color: green;
+	}
+
+	button {
+		border: 0;
+		padding: 4px 8px;
+		border-radius: 0;
+
+		&:first-child {
+			border-radius: 8px 0 0 8px;
+		}
+
+		&:last-child {
+			border-radius: 0 8px 8px 0;
+		}
+
+		&.active {
+			outline: 1px solid blue;
+		}
+	}
+}
+
+.release-group,
+.release {
+	margin-left: 16px;
+}
+
+.release-groups,
+.release-group,
+.release {
+	gap: 8px;
+	margin-top: 8px;
+}
+
+.mrow {
+	gap: 8px;
+}
 </style>

+ 2 - 1
frontend/src/types/global.d.ts

@@ -15,9 +15,10 @@ declare global {
 				itemIndex: number;
 				itemListUuid: string;
 				itemGroup: string;
-				itemOnMove?: (index: number) => any;
+				itemOnMove?: (index: number, remove: boolean) => any;
 				initialItemIndex: number;
 				initialItemListUuid: string;
+				itemReadOnly: boolean;
 		  };
 	var soundcloudIframeLockUuid: string;
 	var soundcloudIframeLockUuids: Set<string>;

+ 21 - 0
frontend/src/utils.ts

@@ -76,5 +76,26 @@ export default {
 		const hour = `${date.getHours()}`.padStart(2, "0");
 		const minute = `${date.getMinutes()}`.padStart(2, "0");
 		return `${year}-${month}-${day} ${hour}:${minute}`;
+	},
+	// Turns for example 125953 into 125.95K
+	getNumberRounded: (number: number) => {
+		if (number > 1000000000 - 1)
+			return `${(number / 1000000000).toFixed(2)}B`;
+		if (number > 1000000 - 1) return `${(number / 1000000).toFixed(2)}M`;
+		if (number > 1000 - 1) return `${(number / 1000).toFixed(2)}K`;
+		return number;
+	},
+	// Based on https://stackoverflow.com/a/42235254 - ported to JavaScript
+	getEmojiFlagForCountryCode: (countryCode: string) => {
+		const flagOffset = 0x1f1e6;
+		const asciiOffset = 0x41;
+
+		const firstChar = countryCode.codePointAt(0) - asciiOffset + flagOffset;
+		const secondChar =
+			countryCode.codePointAt(1) - asciiOffset + flagOffset;
+
+		return (
+			String.fromCodePoint(firstChar) + String.fromCodePoint(secondChar)
+		);
 	}
 };