Added library track menus
This commit is contained in:
1 parent
0526e83a9b
commit
af07bf2249
5 files changed
+262
-48
No files matched your search
@@ -12,8 +12,9 @@ const route = useRoute()
|
|||||||
const hovering = ref(false)
|
const hovering = ref(false)
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const { isSaved, toggleSaved } = useTrackLibrary(() => track._id)
|
||||||
|
|
||||||
const dropdownMenuItems: DropdownMenuItem[][] = [
|
const dropdownMenuItems = computed<DropdownMenuItem[][]>(() => [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
label: "Play only this",
|
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",
|
label: "Go to artist",
|
||||||
@@ -52,10 +74,10 @@ const dropdownMenuItems: DropdownMenuItem[][] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]
|
])
|
||||||
|
|
||||||
const visibleDropdownMenuItems = computed(() =>
|
const visibleDropdownMenuItems = computed(() =>
|
||||||
dropdownMenuItems.map((group) =>
|
dropdownMenuItems.value.map((group) =>
|
||||||
group.filter(
|
group.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
!(item.label === "Go to album" && route.path.startsWith("/album/")),
|
!(item.label === "Go to album" && route.path.startsWith("/album/")),
|
||||||
|
|||||||
@@ -18,14 +18,20 @@ interface TrackRowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<TrackRowProps>()
|
const props = defineProps<TrackRowProps>()
|
||||||
const emit = defineEmits<{ (e: "playAlbumAtIndex"): void }>()
|
const emit = defineEmits<{
|
||||||
|
(e: "playAlbumAtIndex"): void
|
||||||
|
(e: "removeFromLibrary", track: Track): void
|
||||||
|
}>()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const hovering = ref(false)
|
const hovering = ref(false)
|
||||||
const { playAlbum, addToFrontOfQueue, addToQueue } = usePlayer()
|
const { playAlbum, addToFrontOfQueue, addToQueue } = usePlayer()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const { isSaved, toggleSaved } = useTrackLibrary(() =>
|
||||||
|
props.queueMode ? undefined : props.track?._id,
|
||||||
|
)
|
||||||
|
|
||||||
const dropdownMenuItems: DropdownMenuItem[][] = [
|
const dropdownMenuItems = computed<DropdownMenuItem[][]>(() => [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
label: "Play only this",
|
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",
|
label: "Go to artist",
|
||||||
@@ -70,13 +98,12 @@ const dropdownMenuItems: DropdownMenuItem[][] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]
|
])
|
||||||
|
|
||||||
const visibleDropdownMenuItems = computed(() =>
|
const visibleDropdownMenuItems = computed(() =>
|
||||||
dropdownMenuItems.map((group) =>
|
dropdownMenuItems.value.map((group) =>
|
||||||
group.filter(
|
group.filter(
|
||||||
(item) =>
|
(item) => !(item.label === "Go to album" && route.path.startsWith("/album/")),
|
||||||
!(item.label === "Go to album" && route.path.startsWith("/album/")),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { cfetch } from "@/utils/cfetch"
|
||||||
|
import { toValue, type MaybeRefOrGetter } from "vue"
|
||||||
|
|
||||||
|
interface TrackMembership {
|
||||||
|
contains: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTrackLibrary = (trackId: MaybeRefOrGetter<string | undefined>) => {
|
||||||
|
const toast = useToast()
|
||||||
|
const isSaved = ref<boolean | null>(null)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const checkMembership = async () => {
|
||||||
|
const id = toValue(trackId)
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await cfetch<TrackMembership>("/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 }
|
||||||
|
}
|
||||||
@@ -1,5 +1,100 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { Track } from "@/utils/types"
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
|
||||||
|
const {
|
||||||
|
data: tracksData,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
} = await useApiFetch<Track[]>("/user/tracks")
|
||||||
|
const tracks = computed(() => tracksData.value ?? [])
|
||||||
|
const playTracks = () => {
|
||||||
|
if (tracks.value.length) void playAlbum(tracks.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shuffleTracks = () => {
|
||||||
|
if (tracks.value.length) void playShuffledAlbum(tracks.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFromLibrary = (track: Track) => {
|
||||||
|
tracksData.value = tracks.value.filter(
|
||||||
|
(savedTrack) => savedTrack._id !== track._id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
useHead({ title: "Songs" })
|
useHead({ title: "Songs" })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template></template>
|
<template>
|
||||||
|
<section class="songs-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>Songs</h1>
|
||||||
|
<div v-if="tracks.length" class="page-actions">
|
||||||
|
<Button icon-name="lucide:play" @click="playTracks">Play</Button>
|
||||||
|
<Button icon-name="lucide:shuffle" @click="shuffleTracks">Shuffle</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="status === 'pending'" class="message" role="status">
|
||||||
|
Loading your songs…
|
||||||
|
</p>
|
||||||
|
<div v-else-if="error" class="message" role="alert">
|
||||||
|
<p>Your songs could not be loaded.</p>
|
||||||
|
<Button variant="ghost" @click="refresh">Try again</Button>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="tracks.length === 0" class="message">
|
||||||
|
You haven’t saved any songs yet.
|
||||||
|
</p>
|
||||||
|
<div v-else class="track-list">
|
||||||
|
<TrackRow
|
||||||
|
v-for="(track, index) in tracks"
|
||||||
|
:key="track._id"
|
||||||
|
:track="track"
|
||||||
|
:index="index"
|
||||||
|
:extended-info="true"
|
||||||
|
@play-album-at-index="() => playAlbumAtIndex(tracks, index)"
|
||||||
|
@remove-from-library="removeFromLibrary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use "@/styles/util" as *;
|
||||||
|
|
||||||
|
.songs-page {
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
@include fontSize(32px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
@include fontSize(14px);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+44
-38
@@ -12,6 +12,7 @@ interface DropdownMenuProps {
|
|||||||
const props = defineProps<DropdownMenuProps>()
|
const props = defineProps<DropdownMenuProps>()
|
||||||
|
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
|
const mounted = ref(false)
|
||||||
const placement = ref<"bottom" | "top">("bottom")
|
const placement = ref<"bottom" | "top">("bottom")
|
||||||
const root = useTemplateRef("root")
|
const root = useTemplateRef("root")
|
||||||
const menuPosition = ref({ top: 0, left: 0 })
|
const menuPosition = ref({ top: 0, left: 0 })
|
||||||
@@ -64,6 +65,7 @@ onMounted(() => {
|
|||||||
document.addEventListener("keydown", handleKey)
|
document.addEventListener("keydown", handleKey)
|
||||||
window.addEventListener("resize", handleReposition)
|
window.addEventListener("resize", handleReposition)
|
||||||
window.addEventListener("scroll", handleReposition, true)
|
window.addEventListener("scroll", handleReposition, true)
|
||||||
|
mounted.value = true
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeMount(() => {
|
onBeforeMount(() => {
|
||||||
@@ -81,7 +83,7 @@ onBeforeMount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Transition name="dropdown">
|
<Transition name="dropdown">
|
||||||
<Teleport to="body">
|
<Teleport v-if="mounted" to="body">
|
||||||
<div
|
<div
|
||||||
v-show="open"
|
v-show="open"
|
||||||
class="dropdown-menu"
|
class="dropdown-menu"
|
||||||
@@ -91,26 +93,26 @@ onBeforeMount(() => {
|
|||||||
left: `${menuPosition.left}px`,
|
left: `${menuPosition.left}px`,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(group, i) in items"
|
v-for="(group, i) in items"
|
||||||
:key="`group-${i}`"
|
:key="`group-${i}`"
|
||||||
class="group"
|
class="group"
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-for="(item, i) in group"
|
|
||||||
:key="`item-${i}`"
|
|
||||||
class="item"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
item.onSelect?.()
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<Icon :name="item.icon" class="icon" />
|
<button
|
||||||
<span>{{ item.label }}</span>
|
v-for="(item, i) in group"
|
||||||
</button>
|
:key="`item-${i}`"
|
||||||
</div>
|
class="item"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
item.onSelect?.()
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Icon :name="item.icon" class="icon" />
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</Transition>
|
</Transition>
|
||||||
@@ -126,30 +128,30 @@ onBeforeMount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
|
|
||||||
background-color: $color-secondary-background;
|
background-color: $color-secondary-background;
|
||||||
border-radius: $border-radius-standard;
|
border-radius: $border-radius-standard;
|
||||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
|
|
||||||
&.bottom {
|
&.bottom {
|
||||||
transform: translate(-100%, 0.5rem);
|
transform: translate(-100%, 0.5rem);
|
||||||
transform-origin: top right;
|
transform-origin: top right;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.top {
|
&.top {
|
||||||
transform: translate(-100%, calc(-100% - 0.5rem));
|
transform: translate(-100%, calc(-100% - 0.5rem));
|
||||||
transform-origin: bottom right;
|
transform-origin: bottom right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group {
|
.group {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -184,6 +186,10 @@ onBeforeMount(() => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
transition: background-color 0.2s ease;
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
|
span {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.item:hover {
|
.item:hover {
|
||||||
|
|||||||
Reference in new issue
Block a user