Added library track menus

This commit is contained in:
lucast committed 2026-10-05 18:20:34 +02:00
1 parent 0526e83a9b
commit af07bf2249
5 files changed
+262 -48

No files matched your search

+25 -3
View File
@@ -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<DropdownMenuItem[][]>(() => [
[
{
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/")),
+33 -6
View File
@@ -18,14 +18,20 @@ interface 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 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<DropdownMenuItem[][]>(() => [
[
{
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/")),
),
),
)
+64
View File
@@ -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 }
}
+96 -1
View File
@@ -1,5 +1,100 @@
<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" })
</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
View File
@@ -12,6 +12,7 @@ interface DropdownMenuProps {
const props = defineProps<DropdownMenuProps>()
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(() => {
</div>
<Transition name="dropdown">
<Teleport to="body">
<Teleport v-if="mounted" to="body">
<div
v-show="open"
class="dropdown-menu"
@@ -91,26 +93,26 @@ onBeforeMount(() => {
left: `${menuPosition.left}px`,
}"
>
<div
v-for="(group, i) in items"
:key="`group-${i}`"
class="group"
>
<button
v-for="(item, i) in group"
:key="`item-${i}`"
class="item"
@click="
() => {
item.onSelect?.()
close()
}
"
<div
v-for="(group, i) in items"
:key="`group-${i}`"
class="group"
>
<Icon :name="item.icon" class="icon" />
<span>{{ item.label }}</span>
</button>
</div>
<button
v-for="(item, i) in group"
:key="`item-${i}`"
class="item"
@click="
() => {
item.onSelect?.()
close()
}
"
>
<Icon :name="item.icon" class="icon" />
<span>{{ item.label }}</span>
</button>
</div>
</div>
</Teleport>
</Transition>
@@ -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 {