Browse Source

feat: start of new import artist modal with basic recordings/videos views, with parts copied from WIP linking modal

Kristian Vos 4 days ago
parent
commit
ba9802db86

+ 252 - 0
frontend/src/components/TempMusicBrainzRecordingCard.vue

@@ -0,0 +1,252 @@
+<script setup lang="ts">
+import { ref, PropType, computed } from "vue";
+import utils from "@/utils";
+import { RecordingTemp, ReleaseGroupTemp } from "@/types/artist";
+
+const props = defineProps({
+	recording: {
+		type: Object as PropType<RecordingTemp>,
+		required: true,
+		default: () => ({})
+	},
+	recordingImage: { type: String, required: true, default: "" },
+	containerClass: { type: String, default: "" },
+	enableReleaseGroups: { type: Boolean, default: false },
+	releaseGroupIds: { type: Array as PropType<string[]>, default: () => [] },
+	releaseGroupMap: {
+		type: Object as PropType<{
+			[releaseGroupId: string]: ReleaseGroupTemp;
+		}>,
+		default: () => {}
+	},
+	releaseGroupImageMap: { type: Object, default: () => ({}) },
+	hideMusicbrainzInfo: { type: Boolean, default: true }
+});
+
+// const emit = defineEmits(["clicked"]);
+
+const showRecordingReleaseGroup = ref(false);
+
+const releaseGroups = computed(() =>
+	props.releaseGroupIds.map(
+		releaseGroupId => props.releaseGroupMap[releaseGroupId]
+	)
+);
+</script>
+
+<template>
+	<div class="recording-container flex flex-column" :class="containerClass">
+		<div class="recording flex flex-row">
+			<slot name="top-left-pill"></slot>
+			<img
+				class="recording-image"
+				height="48"
+				width="48"
+				loading="lazy"
+				:src="recordingImage"
+				alt="Cover art"
+				onerror="this.src='/assets/notes.png'"
+				@click="showRecordingReleaseGroup = !showRecordingReleaseGroup"
+			/>
+			<i v-if="recording.video" class="material-icons">smart_display</i>
+			<p>
+				<a
+					:href="`https://musicbrainz.org/recording/${recording.id}`"
+					target="_blank"
+				>
+					<span>{{ recording.title }}</span>
+					<span v-if="recording.disambiguation"
+						>({{ recording.disambiguation }})</span
+					>
+				</a>
+				<!-- <span>
+					<a
+						v-for="artistCredit in recording[
+							'artist-credit'
+						]"
+						:key="artistCredit.artist.id"
+						:href="`https://musicbrainz.org/artist/${artistCredit.artist.id}`"
+						target="_blank"
+					>
+						{{ artistCredit.name }}
+					</a>
+				</span> -->
+			</p>
+			<span>{{ utils.formatTime(recording.length / 1000) }}</span>
+			<div class="icons">
+				<tippy theme="info" v-if="!hideMusicbrainzInfo">
+					<i class="material-icons">info</i>
+
+					<template #content>
+						<div>
+							<ul>
+								<li>
+									<p>
+										Title:
+										{{ recording.title }}
+									</p>
+								</li>
+								<li>
+									<p>
+										Video:
+										{{ recording.video ? "true" : "false" }}
+									</p>
+								</li>
+								<li>
+									<p>
+										ID:
+										{{ recording.id }}
+									</p>
+								</li>
+								<li>
+									<p>
+										Length:
+										{{ recording.length }}
+									</p>
+								</li>
+								<li v-if="recording.disambiguation">
+									<p>
+										Disambiguation:
+										{{ recording.disambiguation }}
+									</p>
+								</li>
+							</ul>
+						</div>
+					</template>
+				</tippy>
+				<!-- <i class="material-icons" @click="noop()"
+					>lock_open</i
+				>
+				<i class="material-icons" @click="noop()"
+					>lock</i
+				> -->
+				<!-- <i
+					class="material-icons"
+					v-if="manualHideRecordingMap[recording.id] === undefined"
+					@click="emit('toggleHideRecording')"
+					>hdr_auto</i
+				>
+				<i
+					class="material-icons"
+					v-else-if="recordingHideMap[recording.id]"
+					@click="emit('toggleHideRecording')"
+					>visibility_off</i
+				>
+				<i
+					class="material-icons"
+					v-else-if="!recordingHideMap[recording.id]"
+					@click="emit('toggleHideRecording')"
+					>visibility</i
+				> -->
+			</div>
+		</div>
+		<div
+			class="release-groups flex flex-column"
+			v-if="enableReleaseGroups && showRecordingReleaseGroup"
+		>
+			<div
+				class="release-group flex flex-row"
+				v-if="releaseGroups.length === 0"
+			>
+				<p>No release groups</p>
+			</div>
+			<div
+				class="release-group flex flex-row"
+				v-for="releaseGroup in releaseGroups"
+				:key="releaseGroup.id"
+			>
+				<img
+					class="release-group-image"
+					height="32"
+					width="32"
+					loading="lazy"
+					:src="releaseGroupImageMap[releaseGroup.id]"
+					alt="Cover art"
+					onerror="this.src='/assets/notes.png'"
+				/>
+				<p class="flex flex-column">
+					<a
+						:href="`https://musicbrainz.org/release-group/${releaseGroup.id}`"
+						target="_blank"
+					>
+						{{ releaseGroup.title }}
+						<span v-if="releaseGroup.disambiguation"
+							>({{ releaseGroup.disambiguation }})</span
+						>
+					</a>
+					<span>
+						{{
+							[
+								releaseGroup["primary-type"],
+								...releaseGroup["secondary-types"]
+							].join(" + ")
+						}}
+					</span>
+				</p>
+			</div>
+		</div>
+	</div>
+</template>
+
+<style lang="less" scoped>
+.flex {
+	display: flex;
+}
+
+.flex-column {
+	flex-direction: column;
+}
+
+.flex-row {
+	flex-direction: row;
+}
+
+.w-full {
+	width: 100%;
+}
+
+.recording-container {
+	background-color: var(--dark-grey-2);
+	border-radius: @border-radius;
+	padding: 8px;
+	font-size: 16px;
+	gap: 8px;
+
+	.recording {
+		gap: 8px;
+		align-items: center;
+		position: relative;
+
+		.recording-image {
+			cursor: pointer;
+		}
+
+		> p {
+			flex: 1;
+			// gap: 4px;
+
+			a {
+				display: inline-flex;
+				flex-direction: column;
+				line-height: 24px;
+
+				span:nth-child(2) {
+					font-size: 12px;
+					line-height: 16px;
+				}
+			}
+		}
+	}
+
+	.release-groups {
+		gap: 8px;
+
+		.release-group {
+			gap: 8px;
+			align-items: center;
+			font-size: 12px;
+			line-height: 16px;
+		}
+	}
+}
+</style>

+ 15 - 1
frontend/src/components/TempYoutubeChannelCard.vue

@@ -34,6 +34,7 @@ defineProps({
 			</p>
 		</div>
 		<div class="flex flex-column button-icons">
+			<slot></slot>
 			<a
 				target="_blank"
 				:href="`https://www.youtube.com/channel/${youtubeChannel.channelId}`"
@@ -107,7 +108,20 @@ defineProps({
 	}
 
 	.button-icons {
-		button {
+		display: flex;
+		flex-direction: column;
+		align-items: center;
+		gap: 2px;
+		justify-content: center;
+
+		.youtube-icon {
+			margin: 0;
+		}
+
+		:deep(button),
+		span {
+			display: flex;
+			flex: 0;
 		}
 	}
 }

+ 39 - 21
frontend/src/components/TempYoutubeVideoCard.vue

@@ -1,9 +1,13 @@
 <script setup lang="ts">
 import utils from "@/utils";
+import { useModalsStore } from "@/stores/modals";
+
+const { openModal } = useModalsStore();
 
 defineProps({
 	youtubeVideo: { type: Object, default: () => ({}) },
-	hideYoutubeInfo: { type: Boolean, default: true }
+	hideYoutubeInfo: { type: Boolean, default: true },
+	durationClass: { type: String, default: "" }
 });
 </script>
 
@@ -13,22 +17,13 @@ defineProps({
 		v-if="youtubeVideo.rawData"
 		:class="{ 'youtube-video-hide': youtubeVideo.hide }"
 	>
+		<slot name="top-left-pill"> </slot>
 		<img
 			:src="youtubeVideo.rawData.snippet.thumbnails.medium.url"
 			loading="lazy"
 			alt=""
 		/>
 		<div class="text flex flex-column">
-			<!-- <p>{{ youtubeVideo.title }}</p>
-			<p>
-				{{
-					utils.getNumberRounded(
-						youtubeVideo.rawData.statistics.viewCount
-					)
-				}}
-				views - {{ youtubeVideo.author }}
-			</p> -->
-
 			<p class="title">{{ youtubeVideo.title }}</p>
 			<p class="small-title">{{ youtubeVideo.rawData.snippet.title }}</p>
 			<p>
@@ -37,21 +32,25 @@ defineProps({
 						youtubeVideo.rawData.statistics.viewCount
 					)
 				}}
-				views - {{ youtubeVideo.author }}
+				views - {{ youtubeVideo.author }} -
+				{{ youtubeVideo.uploadedAt.substring(0, 10) }}
 			</p>
 		</div>
 		<div class="button-icons flex flex-column">
-			<a
-				target="_blank"
-				:href="`https://www.youtube.com/watch?v=${youtubeVideo.youtubeId}`"
-				content="View on Youtube"
+			<i
+				@click="
+					openModal({
+						modal: 'viewMedia',
+						props: {
+							mediaSource: `youtube:${youtubeVideo.youtubeId}`
+						}
+					})
+				"
+				content="View Media"
 				v-tippy
 			>
 				<div class="youtube-icon"></div>
-			</a>
-			<p class="youtube-video-duration">
-				{{ utils.formatTime(youtubeVideo.duration) }}
-			</p>
+			</i>
 			<tippy theme="info" v-if="!hideYoutubeInfo">
 				<i class="material-icons">info</i>
 
@@ -160,7 +159,11 @@ defineProps({
 					</div>
 				</template>
 			</tippy>
+			<p class="youtube-video-duration" :class="durationClass">
+				{{ utils.formatTime(youtubeVideo.duration) }}
+			</p>
 		</div>
+		<slot name="top-right-pill"> </slot>
 	</div>
 </template>
 
@@ -170,6 +173,7 @@ defineProps({
 	border-radius: @border-radius;
 	gap: 8px;
 	min-width: 350px;
+	position: relative;
 
 	img {
 		height: 72px;
@@ -195,8 +199,22 @@ defineProps({
 	}
 
 	.button-icons {
+		display: flex;
+		flex-direction: row;
+		align-items: center;
+		gap: 4px;
+		padding: 4px;
+
 		.youtube-video-duration {
-			font-size: 10px;
+			font-size: 13px;
+		}
+
+		span {
+			display: inline-flex;
+		}
+
+		.youtube-icon {
+			margin: 0;
 		}
 	}
 

+ 103 - 0
frontend/src/components/modals/ImportArtist2/Views/MusicbrainzRecordings.vue

@@ -0,0 +1,103 @@
+<script setup lang="ts">
+import { defineAsyncComponent, onMounted, ref } from "vue";
+import { storeToRefs } from "pinia";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useImportArtistStore } from "@/stores/importArtist";
+import { ReleaseTemp, RecordingTemp } from "@/types/artist";
+
+const TempMusicBrainzRecordingCard = defineAsyncComponent(
+	() => import("@/components/TempMusicBrainzRecordingCard.vue")
+);
+
+const props = defineProps({
+	modalUuid: { type: String, required: true }
+});
+
+const { socket } = useWebsocketsStore();
+const importArtistStore = useImportArtistStore({
+	modalUuid: props.modalUuid
+})();
+const {
+	artist,
+	sortedRecordings,
+	imageForRecordingIdMap,
+	releaseGroupIdsForRecordingIdMap,
+	releaseGroupMap,
+	imageForReleaseGroupIdMap
+} = storeToRefs(importArtistStore);
+const { setMusicBrainzRecordingsReleasesReleaseGroups } = importArtistStore;
+
+const loadedMusicBrainzData = ref(false);
+
+onMounted(() => {
+	socket.onConnect(async () => {
+		const { res } = (await socket.dispatchAsync(
+			`albums.getMusicBrainzRecordingsReleasesReleaseGroups`,
+			artist.value.musicbrainzIdentifier
+		)) as {
+			res: {
+				data: {
+					artistReleases: ReleaseTemp[];
+					trackArtistReleases: ReleaseTemp[];
+					recordings: RecordingTemp[];
+				};
+			};
+		};
+		const { data } = res;
+
+		console.log("Got MusicBrainz data", res);
+
+		setMusicBrainzRecordingsReleasesReleaseGroups(
+			data.artistReleases,
+			data.trackArtistReleases,
+			data.recordings
+		);
+
+		loadedMusicBrainzData.value = true;
+	});
+});
+</script>
+
+<template>
+	<div class="musicbrainz-recordings-view section">
+		<div class="flex flex-column recordings">
+			<temp-music-brainz-recording-card
+				v-for="recording in sortedRecordings"
+				:key="recording.id"
+				:recording="recording"
+				:recording-image="imageForRecordingIdMap[recording.id]"
+				:enable-release-groups="true"
+				:release-group-ids="
+					releaseGroupIdsForRecordingIdMap[recording.id]
+				"
+				:release-group-map="releaseGroupMap"
+				:release-group-image-map="imageForReleaseGroupIdMap"
+				:hide-musicbrainz-info="true"
+			/>
+		</div>
+	</div>
+</template>
+
+<style lang="less" scoped>
+.flex {
+	display: flex;
+}
+
+.flex-column {
+	flex-direction: column;
+}
+
+.flex-row {
+	flex-direction: row;
+}
+
+.w-full {
+	width: 100%;
+}
+
+.musicbrainz-recordings-view {
+	.recordings {
+		gap: 8px;
+	}
+}
+</style>

+ 99 - 0
frontend/src/components/modals/ImportArtist2/Views/YoutubeVideos.vue

@@ -0,0 +1,99 @@
+<script setup lang="ts">
+import { defineAsyncComponent, onMounted, ref } from "vue";
+import { storeToRefs } from "pinia";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useImportArtistStore } from "@/stores/importArtist";
+
+const TempYoutubeChannelCard = defineAsyncComponent(
+	() => import("@/components/TempYoutubeChannelCard.vue")
+);
+
+const TempYoutubeVideoCard = defineAsyncComponent(
+	() => import("@/components/TempYoutubeVideoCard.vue")
+);
+
+const props = defineProps({
+	modalUuid: { type: String, required: true }
+});
+
+const { socket } = useWebsocketsStore();
+const importArtistStore = useImportArtistStore({
+	modalUuid: props.modalUuid
+})();
+const { youtubeChannelIds, youtubeChannels, youtubeVideoIds, youtubeVideoMap } =
+	storeToRefs(importArtistStore);
+const { setYoutubeChannels, setYoutubeVideos } = importArtistStore;
+
+const loadedYoutubeVideos = ref(false);
+
+onMounted(() => {
+	socket.onConnect(async () => {
+		const { res: getChannelsRes } = await socket.dispatchAsync(
+			`youtube.getChannelsById`,
+			youtubeChannelIds.value
+		);
+		// TODO handle fail
+		const { data: getChannelsData } = getChannelsRes;
+		console.log("Got YouTube channels", getChannelsRes);
+		setYoutubeChannels(getChannelsData);
+
+		const { res: getVideosRes } = await socket.dispatchAsync(
+			`youtube.getVideosForChannelIds`,
+			youtubeChannelIds.value
+		);
+		const { data: getVideosData } = getVideosRes;
+		console.log("Got YouTube videos", getVideosRes);
+		setYoutubeVideos(getVideosData);
+
+		loadedYoutubeVideos.value = true;
+	});
+});
+</script>
+
+<template>
+	<div class="youtube-videos-view section flex flex-column">
+		<div class="flex flex-column channels">
+			<temp-youtube-channel-card
+				v-for="youtubeChannel in youtubeChannels"
+				:key="youtubeChannel.channelId"
+				:youtube-channel="youtubeChannel"
+			></temp-youtube-channel-card>
+		</div>
+		<hr />
+		<div class="flex flex-column videos">
+			<temp-youtube-video-card
+				v-for="youtubeVideoId in youtubeVideoIds"
+				:key="youtubeVideoId"
+				:youtube-video="youtubeVideoMap[youtubeVideoId]"
+				:hide-youtube-info="true"
+			></temp-youtube-video-card>
+		</div>
+	</div>
+</template>
+
+<style lang="less" scoped>
+.flex {
+	display: flex;
+}
+
+.flex-column {
+	flex-direction: column;
+}
+
+.flex-row {
+	flex-direction: row;
+}
+
+.w-full {
+	width: 100%;
+}
+
+.youtube-videos-view {
+	gap: 8px;
+
+	.channels,
+	.videos {
+		gap: 8px;
+	}
+}
+</style>

+ 115 - 0
frontend/src/components/modals/ImportArtist2/index.vue

@@ -0,0 +1,115 @@
+<script setup lang="ts">
+import { defineAsyncComponent, onMounted, ref } from "vue";
+import { storeToRefs } from "pinia";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useImportArtistStore } from "@/stores/importArtist";
+
+const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
+const MusicbrainzRecordingsView = defineAsyncComponent(
+	() =>
+		import(
+			"@/components/modals/ImportArtist2/Views/MusicbrainzRecordings.vue"
+		)
+);
+const YoutubeVideosView = defineAsyncComponent(
+	() => import("@/components/modals/ImportArtist2/Views/YoutubeVideos.vue")
+);
+
+const props = defineProps({
+	modalUuid: { type: String, required: true },
+	artistId: { type: String, required: true }
+});
+
+const { socket } = useWebsocketsStore();
+const importArtistStore = useImportArtistStore({
+	modalUuid: props.modalUuid
+})();
+const { artist } = storeToRefs(importArtistStore);
+const { setArtist } = importArtistStore;
+
+onMounted(() => {
+	socket.onConnect(async () => {
+		const { res } = await socket.dispatchAsync(
+			`artists.getArtistFromId`,
+			props.artistId
+		);
+		// res: GetArtistResponse
+		if (res.status === "success") {
+			setArtist(res.data.artist);
+		}
+	});
+});
+
+const selectedView = ref<
+	"musicbrainz-recordings" | "youtube-videos" | "linking"
+>("musicbrainz-recordings");
+</script>
+
+<template>
+	<modal
+		class="import-artist-2-modal"
+		title="Import Artist 2"
+		:size="'wide'"
+		:split="true"
+	>
+		<template #body>
+			<div class="flex flex-column w-full">
+				<musicbrainz-recordings-view
+					v-if="
+						artist.musicbrainzIdentifier &&
+						selectedView === 'musicbrainz-recordings'
+					"
+					:modal-uuid="modalUuid"
+				/>
+				<youtube-videos-view
+					v-if="
+						artist.musicbrainzIdentifier &&
+						selectedView === 'youtube-videos'
+					"
+					:modal-uuid="modalUuid"
+				/>
+				<div v-if="selectedView === 'linking'">TODO</div>
+			</div>
+		</template>
+		<template #footer>
+			<div>
+				<button
+					class="button is-primary"
+					@click="selectedView = 'musicbrainz-recordings'"
+				>
+					View recordings
+				</button>
+				<button
+					class="button is-primary"
+					@click="selectedView = 'youtube-videos'"
+				>
+					View videos
+				</button>
+				<button
+					class="button is-primary"
+					@click="selectedView = 'linking'"
+				>
+					View linking
+				</button>
+			</div>
+		</template>
+	</modal>
+</template>
+
+<style lang="less" scoped>
+.flex {
+	display: flex;
+}
+
+.flex-column {
+	flex-direction: column;
+}
+
+.flex-row {
+	flex-direction: row;
+}
+
+.w-full {
+	width: 100%;
+}
+</style>