diff --git a/app/components/Track/ImageRow.vue b/app/components/Track/ImageRow.vue index b3d338f..eb9d92f 100644 --- a/app/components/Track/ImageRow.vue +++ b/app/components/Track/ImageRow.vue @@ -12,8 +12,9 @@ const route = useRoute() const hovering = ref(false) const toast = useToast() +const { isSaved, toggleSaved } = useTrackLibrary(() => track._id) -const dropdownMenuItems: DropdownMenuItem[][] = [ +const dropdownMenuItems = computed(() => [ [ { label: "Play only this", @@ -35,6 +36,27 @@ const dropdownMenuItems: DropdownMenuItem[][] = [ }, }, ], + [ + { + label: + isSaved.value === null + ? "Checking Library…" + : isSaved.value + ? "Remove from Library" + : "Save to Library", + icon: isSaved.value ? "lucide:bookmark-minus" : "lucide:bookmark-plus", + async onSelect() { + await toggleSaved() + }, + }, + { + label: "Add to playlist", + icon: "lucide:list-plus", + onSelect() { + toast.show("Playlists are not implemented") + }, + }, + ], [ { label: "Go to artist", @@ -52,10 +74,10 @@ const dropdownMenuItems: DropdownMenuItem[][] = [ }, }, ], -] +]) const visibleDropdownMenuItems = computed(() => - dropdownMenuItems.map((group) => + dropdownMenuItems.value.map((group) => group.filter( (item) => !(item.label === "Go to album" && route.path.startsWith("/album/")), diff --git a/app/components/Track/Row.vue b/app/components/Track/Row.vue index d3f75de..9ad4ecd 100644 --- a/app/components/Track/Row.vue +++ b/app/components/Track/Row.vue @@ -18,14 +18,20 @@ interface TrackRowProps { } const props = defineProps() -const emit = defineEmits<{ (e: "playAlbumAtIndex"): void }>() +const emit = defineEmits<{ + (e: "playAlbumAtIndex"): void + (e: "removeFromLibrary", track: Track): void +}>() const route = useRoute() const hovering = ref(false) const { playAlbum, addToFrontOfQueue, addToQueue } = usePlayer() const toast = useToast() +const { isSaved, toggleSaved } = useTrackLibrary(() => + props.queueMode ? undefined : props.track?._id, +) -const dropdownMenuItems: DropdownMenuItem[][] = [ +const dropdownMenuItems = computed(() => [ [ { label: "Play only this", @@ -53,6 +59,28 @@ const dropdownMenuItems: DropdownMenuItem[][] = [ }, }, ], + [ + { + label: + isSaved.value === null + ? "Checking Library…" + : isSaved.value + ? "Remove from Library" + : "Save to Library", + icon: isSaved.value ? "lucide:bookmark-minus" : "lucide:bookmark-plus", + async onSelect() { + const removed = await toggleSaved() + if (removed && props.track) emit("removeFromLibrary", props.track) + }, + }, + { + label: "Add to playlist", + icon: "lucide:list-plus", + onSelect() { + toast.show("Playlists are not implemented") + }, + }, + ], [ { label: "Go to artist", @@ -70,13 +98,12 @@ const dropdownMenuItems: DropdownMenuItem[][] = [ }, }, ], -] +]) const visibleDropdownMenuItems = computed(() => - dropdownMenuItems.map((group) => + dropdownMenuItems.value.map((group) => group.filter( - (item) => - !(item.label === "Go to album" && route.path.startsWith("/album/")), + (item) => !(item.label === "Go to album" && route.path.startsWith("/album/")), ), ), ) diff --git a/app/composables/useTrackLibrary.ts b/app/composables/useTrackLibrary.ts new file mode 100644 index 0000000..bbd7cf0 --- /dev/null +++ b/app/composables/useTrackLibrary.ts @@ -0,0 +1,64 @@ +import { cfetch } from "@/utils/cfetch" +import { toValue, type MaybeRefOrGetter } from "vue" + +interface TrackMembership { + contains: boolean +} + +export const useTrackLibrary = (trackId: MaybeRefOrGetter) => { + const toast = useToast() + const isSaved = ref(null) + const saving = ref(false) + + const checkMembership = async () => { + const id = toValue(trackId) + if (!id) return + + try { + const result = await cfetch("/user/tracks/contains", { + params: { id }, + }) + isSaved.value = result.contains + } catch (error: any) { + toast.show( + error?.data?.message || "Could not check Library status", + "error", + ) + } + } + + onMounted(checkMembership) + + const toggleSaved = async () => { + const id = toValue(trackId) + if (!id || isSaved.value === null || saving.value) return false + + const wasSaved = isSaved.value + saving.value = true + try { + await cfetch("/user/tracks", { + method: wasSaved ? "DELETE" : "PATCH", + params: { id }, + }) + isSaved.value = !wasSaved + toast.show( + wasSaved ? "Removed from Library" : "Saved to Library", + "success", + ) + return wasSaved + } catch (error: any) { + toast.show( + error?.data?.message || + (wasSaved + ? "Could not remove song from Library" + : "Could not save song to Library"), + "error", + ) + return false + } finally { + saving.value = false + } + } + + return { isSaved, saving, toggleSaved } +} diff --git a/app/pages/library/songs.vue b/app/pages/library/songs.vue index 7ff1282..c99e801 100644 --- a/app/pages/library/songs.vue +++ b/app/pages/library/songs.vue @@ -1,5 +1,100 @@ - + + + diff --git a/app/ui/DropdownMenu.vue b/app/ui/DropdownMenu.vue index b9c8d9f..d65e185 100644 --- a/app/ui/DropdownMenu.vue +++ b/app/ui/DropdownMenu.vue @@ -12,6 +12,7 @@ interface DropdownMenuProps { const props = defineProps() const open = ref(false) +const mounted = ref(false) const placement = ref<"bottom" | "top">("bottom") const root = useTemplateRef("root") const menuPosition = ref({ top: 0, left: 0 }) @@ -64,6 +65,7 @@ onMounted(() => { document.addEventListener("keydown", handleKey) window.addEventListener("resize", handleReposition) window.addEventListener("scroll", handleReposition, true) + mounted.value = true }) onBeforeMount(() => { @@ -81,7 +83,7 @@ onBeforeMount(() => { - + @@ -126,30 +128,30 @@ onBeforeMount(() => { } .dropdown-menu { - position: fixed; - z-index: 1000; + position: fixed; + z-index: 1000; - padding: 0.5rem; - min-width: 180px; + padding: 0.5rem; + min-width: 180px; - background-color: $color-secondary-background; - border-radius: $border-radius-standard; - box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25); + background-color: $color-secondary-background; + border-radius: $border-radius-standard; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25); - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; - transform: translateX(-100%); + transform: translateX(-100%); - &.bottom { - transform: translate(-100%, 0.5rem); - transform-origin: top right; - } + &.bottom { + transform: translate(-100%, 0.5rem); + transform-origin: top right; + } - &.top { - transform: translate(-100%, calc(-100% - 0.5rem)); - transform-origin: bottom right; - } + &.top { + transform: translate(-100%, calc(-100% - 0.5rem)); + transform-origin: bottom right; + } .group { width: 100%; @@ -184,6 +186,10 @@ onBeforeMount(() => { cursor: pointer; transition: background-color 0.2s ease; + + span { + white-space: nowrap; + } } .item:hover {