Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions src/components/HomeView/Browse.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
class="browseitem rounded-sm"
:to="{ name: i.route || '', params: i.params }"
:style="{ width: `${album_card_with - 24}px` }"
@click="i.action && i.action()"
:class="i.class"
@click="i.action && i.action()"
>
<div class="icon" v-html="i.icon"></div>
<div style="width: 100%">
Expand All @@ -25,17 +25,23 @@ import {
AlbumIcon,
ArtistIcon,
FolderIcon,
GridIcon,
HeartIcon,
PlaylistIcon,
SettingsIcon,
ReloadIcon
PlaylistIcon
} from "@/icons";
import { triggerScan } from "@/requests/settings/rootdirs";
import { Routes } from "@/router";
import { album_card_with } from "@/stores/content-width";
import useDialog from "@/stores/modal";

const browselist = [
type BrowseItem = {
title: string;
route: string | null;
icon: string;
params?: Record<string, string>;
action?: () => void;
class?: string;
};

const browselist: BrowseItem[] = [
{
title: "Folders",
route: Routes.folder,
Expand All @@ -59,6 +65,11 @@ const browselist = [
route: Routes.playlists,
icon: PlaylistIcon,
},
{
title: "Collections",
route: Routes.CollectionList,
icon: GridIcon,
},
{
title: "Favorites",
route: Routes.favorites,
Expand Down
9 changes: 8 additions & 1 deletion src/components/LeftSidebar/navitems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ const home = {
icon: HomeSvg,
};

const collections = {
name: "Collections",
route_name: Routes.CollectionList,
icon: HomeSvg,
};

export const menus = [
home,
folder,
Expand All @@ -60,6 +66,7 @@ export const menus = [
useDialog().showSettingsModal()
}
},
collections,
];
Comment on lines 37 to 70
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

collections is appended as the last menus entry. In LeftSidebar/NavButtons.vue the CSS hides .circular.nav-item:last-child on phones, so this will likely hide the new Collections nav item instead of the Settings item. Reorder menus (or adjust the selector) so the intended item is hidden on mobile.

Copilot uses AI. Check for mistakes.

export const topnavitems = [home, folder, favorites, playlists];
export const topnavitems = [home, folder, favorites, playlists, collections];
40 changes: 31 additions & 9 deletions src/components/PlaylistsList/PlaylistCard.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
<template>
<router-link :to="{ name: 'PlaylistView', params: { pid: playlist.id } }" class="p-card rounded no-scroll">
<router-link
:to="{ name: 'PlaylistView', params: { pid: playlist.id } }"
class="p-card rounded no-scroll"
:class="{ 'context-menu-open': contextMenuFlag }"
@contextmenu.prevent="showMenu"
>
<div v-if="!playlist.has_image && playlist.images.length" class="image-grid rounded-sm no-scroll">
<img v-for="(img, index) in playlist.images" :key="index" :src="paths.images.thumb.smallish + (img['image'] || img)" />
<img
v-for="(img, index) in playlist.images"
:key="index"
:src="paths.images.thumb.smallish + (img['image'] || img)"
alt="playlist image"
/>
<PlayBtn :source="playSources.playlist" :playlist="playlist.id.toString()"/>
</div>
<div v-else class="image">
<img :src="imguri + playlist.thumb" class="rounded-sm" :class="{ border: !playlist.thumb }" />
<img :src="imguri + playlist.thumb" class="rounded-sm" :class="{ border: !playlist.thumb }" alt="playlist image" />
<PlayBtn :source="playSources.playlist" :playlist="playlist.id.toString()"/>
</div>
<div class="overlay rounded">
Expand All @@ -22,15 +32,23 @@
</template>

<script setup lang="ts">
import { paths } from "../../config";
import { Playlist } from "../../interfaces";
import { paths } from '@/config'
import { Playlist } from '@/interfaces'
import { playSources } from '@/enums'
import PlayBtn from '../shared/PlayBtn.vue'
import { ref } from 'vue'
import { showPlaylistContextMenu } from '@/helpers/contextMenuHandler'

const imguri = paths.images.playlist;
defineProps<{
playlist: Playlist;
}>();
const imguri = paths.images.playlist
const props = defineProps<{
playlist: Playlist
}>()

const contextMenuFlag = ref(false)

function showMenu(e: MouseEvent) {
showPlaylistContextMenu(e, contextMenuFlag, props.playlist)
}
</script>

<style lang="scss">
Expand All @@ -44,6 +62,10 @@ defineProps<{
height: max-content;
transition: background-color 0.2s ease-out;

&.context-menu-open {
background-color: $gray5;
}

.image {
position: relative;
}
Expand Down
19 changes: 12 additions & 7 deletions src/components/modals/CrudPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,18 @@ async function submit(e: Event) {

// If the page is null, we are creating a new page
if (props.collection == null) {
const created = await createNewCollection(name, description, [
{
hash: props.hash as string,
type: props.type as string,
extra: props.extra,
},
])
const items =
props.hash && props.type
? [
{
hash: props.hash,
type: props.type,
extra: props.extra,
},
]
: []

const created = await createNewCollection(name, description, items)

if (created) {
new Notification('New collection created', NotifType.Success)
Expand Down
24 changes: 21 additions & 3 deletions src/components/shared/CardRow.vue
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
<template>
<div class="cardlistrow">
<component v-for="item in items" :key="item.key" :is="item.component" v-bind="item.props" />
<component v-for="item in items" :is="item.component" :key="item.key" v-bind="item.props" />
</div>
</template>

<script setup lang="ts">
import { Album, Artist, Mix } from '@/interfaces'
import { Album, Artist, Mix, Playlist, Track } from '@/interfaces'
import AlbumCard from './AlbumCard.vue'
import ArtistCard from './ArtistCard.vue'
import MixCard from '../Mixes/MixCard.vue'
import PlaylistCard from '../PlaylistsList/PlaylistCard.vue'
import TrackCard from './TrackCard.vue'
import { playSources } from '@/enums'
import { computed } from 'vue'

const props = defineProps<{
items: Album[] | Artist[] | Mix[]
items: Album[] | Artist[] | Mix[] | Playlist[] | Track[]
}>()

const items = computed(() => {
Expand Down Expand Up @@ -45,6 +48,21 @@ const items = computed(() => {
mix: item,
}
break
case 'playlist':
i.component = PlaylistCard
i.key = item.id
i.props = {
playlist: item,
}
break
case 'track':
i.component = TrackCard
i.key = item.trackhash
i.props = {
track: item,
playSource: playSources.track,
}
break
}

return i
Expand Down
62 changes: 39 additions & 23 deletions src/context_menus/playlist.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,44 @@
import { Option } from "../interfaces";
import { Option, Playlist, Collection } from '@/interfaces'
import { addOrRemoveItemFromCollection } from '@/requests/collections'
import { Routes, router } from '@/router'
import useCollection from '@/stores/pages/collections'
import { DeleteIcon, PlusIcon } from '@/icons'
import { getAddToCollectionOptions } from './utils'

export default async () => {
const deletePlaylist: Option = {
label: "Delete playlist",
critical: true,
action: () => {
console.log("delete playlist");
},
};
export default async (playlist: Playlist): Promise<Option[]> => {
const addToCollectionAction = (collection: Collection) => {
addOrRemoveItemFromCollection(collection.id, playlist, 'playlist', 'add')
}

const playNext: Option = {
label: "Play next",
action: () => {
console.log("play next");
},
};
const add_to_collection: Option = {
label: 'Add to Collection',
children: () =>
getAddToCollectionOptions(addToCollectionAction, {
collection: null,
hash: playlist.id.toString(),
type: 'playlist',
extra: {},
}),
icon: PlusIcon,
}

const addToQueue: Option = {
label: "Add to queue",
action: () => {
console.log("add to queue");
},
};
const remove_from_collection: Option = {
label: 'Remove item',
action: async () => {
const success = await addOrRemoveItemFromCollection(
parseInt(router.currentRoute.value.params.collection as string),
playlist,
'playlist',
'remove'
)

if (success) {
useCollection().removeLocalItem(playlist as any, 'playlist' as any)
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removeLocalItem has been updated to accept (playlist, 'playlist'), so the as any casts are unnecessary here and can mask type mismatches. Call removeLocalItem(playlist, 'playlist') directly.

Suggested change
useCollection().removeLocalItem(playlist as any, 'playlist' as any)
useCollection().removeLocalItem(playlist, 'playlist')

Copilot uses AI. Check for mistakes.
}
},
icon: DeleteIcon,
critical: true,
}

return [playNext, addToQueue, deletePlaylist];
};
return [...[router.currentRoute.value.name === Routes.Page ? remove_from_collection : add_to_collection]]
}
41 changes: 39 additions & 2 deletions src/context_menus/track.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { router } from '@/router'
import { Artist, Playlist, Track } from '@/interfaces'
import { Artist, Collection, Playlist, Track } from '@/interfaces'
import { router as Router, Routes } from '@/router'

import { Option } from '@/interfaces'
import { openInFiles } from '@/requests/folders'
import { addTracksToPlaylist, removeTracks } from '@/requests/playlists'
import { addOrRemoveItemFromCollection } from '@/requests/collections'

import { AddToQueueIcon, AlbumIcon, ArtistIcon, DeleteIcon, FolderIcon, PlayNextIcon, PlusIcon } from '@/icons'
import usePlaylistStore from '@/stores/pages/playlist'
import useCollection from '@/stores/pages/collections'
import useQueueStore from '@/stores/queue'
import useTracklist from '@/stores/queue/tracklist'
import { getAddToPlaylistOptions, get_find_on_social } from './utils'
import { getAddToCollectionOptions, getAddToPlaylistOptions, get_find_on_social } from './utils'

/**
* Returns a list of context menu items for a track.
Expand Down Expand Up @@ -69,6 +71,40 @@ export default async (track: Track): Promise<Option[]> => {
icon: PlusIcon,
}

const addToCollectionAction = (collection: Collection) => {
addOrRemoveItemFromCollection(collection.id, track, 'track', 'add')
}

const add_to_collection: Option = {
label: 'Add to Collection',
children: () =>
getAddToCollectionOptions(addToCollectionAction, {
collection: null,
hash: track.trackhash,
type: 'track',
extra: {},
}),
icon: PlusIcon,
}

const remove_from_collection: Option = {
label: 'Remove item',
action: async () => {
const success = await addOrRemoveItemFromCollection(
parseInt(route.params.collection as string),
track,
'track',
'remove'
)

if (success) {
useCollection().removeLocalItem(track as any, 'track' as any)
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that removeLocalItem accepts (track, 'track'), the as any casts here are no longer needed and hide real typing issues. Remove the casts and let TypeScript enforce the correct item/type pairing.

Suggested change
useCollection().removeLocalItem(track as any, 'track' as any)
useCollection().removeLocalItem(track, 'track')

Copilot uses AI. Check for mistakes.
}
},
icon: DeleteIcon,
critical: true,
}

const add_to_q: Option = {
label: 'Add to Queue',
action: () => {
Expand Down Expand Up @@ -159,6 +195,7 @@ export default async (track: Track): Promise<Option[]> => {
play_next,
add_to_q,
add_to_playlist,
...[route.name === Routes.Page ? remove_from_collection : add_to_collection],
go_to_album,
go_to_folder,
go_to_artist,
Expand Down
13 changes: 11 additions & 2 deletions src/helpers/contextMenuHandler.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { Store } from 'pinia'
import { Ref } from 'vue'
import { useRoute } from 'vue-router'

import { ContextSrc } from '@/enums'
import { Album, Track } from '@/interfaces'
import { Album, Playlist, Track } from '@/interfaces'
import useContextStore from '@/stores/context'

import albumContextItems from '@/context_menus/album'
import artistContextItems from '@/context_menus/artist'
import folderContextItems from '@/context_menus/folder'
import playlistContextItems from '@/context_menus/playlist'
import trackContextItems from '@/context_menus/track'
import queueContextItems from '@/context_menus/queue'

Expand Down Expand Up @@ -73,6 +73,15 @@ export const showQueueContextMenu = (e: MouseEvent, flag: Ref<boolean>) => {
flagWatcher(menu, flag)
}

export const showPlaylistContextMenu = (e: MouseEvent, flag: Ref<boolean>, playlist: Playlist) => {
const menu = useContextStore()

const options = () => playlistContextItems(playlist)
menu.showContextMenu(e, options, ContextSrc.AlbumHeader)

flagWatcher(menu, flag)
}

// export const showAlbumCardContextMenu = (e: MouseEvent, flag: Ref<boolean>, album: Album) => {

// }
Loading
Loading