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
154 changes: 141 additions & 13 deletions src/components/PlaylistView/AfterHeader.vue
Original file line number Diff line number Diff line change
@@ -1,33 +1,161 @@
<template>
<div class="p-after-header">
<div>All Tracks</div>
<div class="heading">All Tracks</div>

<div class="actions">
<div class="sort-wrap">
<DropDown
:items="sortItems"
:current="currentSort"
component_key="playlist-sortbar"
:reverse="playlist.trackSortReverse"
@item-clicked="handleSortKeySet"
/>
</div>

<button v-if="canManage" class="action-btn" type="button" @click="downloadPlaylist">Export CSV</button>
<button v-if="canManage" class="action-btn" type="button" @click="openSpotifyImport">Import Spotify CSV</button>
<input
ref="fileInput"
type="file"
accept=".csv,text/csv"
class="hidden-input"
@change="handleFileSelected"
/>
</div>
</div>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue'

import DropDown from '@/components/shared/DropDown.vue'
import { exportPlaylist, importSpotifyCsv } from '@/requests/playlists'
import usePlaylistStore from '@/stores/pages/playlist'

const playlist = usePlaylistStore()
const fileInput = ref<HTMLInputElement | null>(null)

interface SortItem {
key: string
title: string
}

const sortItems: SortItem[] = [
{ key: 'default', title: 'Playlist Order' },
{ key: 'title', title: 'Title' },
{ key: 'filepath', title: 'File Name' },
{ key: 'album', title: 'Album' },
{ key: 'artists', title: 'Artist' },
{ key: 'date', title: 'Release Date' },
{ key: 'last_mod', title: 'Date Added' },
{ key: 'lastplayed', title: 'Last Played' },
{ key: 'playcount', title: 'Play Count' },
{ key: 'playduration', title: 'Play Duration' },
]

const currentSort = computed(() => {
return sortItems.find(item => item.key === playlist.trackSortBy) || sortItems[0]
})

const canManage = computed(() => Number.isInteger(playlist.info.id))

function handleSortKeySet(item: SortItem) {
playlist.setPlaylistTrackSortKey(item.key)
}

async function downloadPlaylist() {
await exportPlaylist(playlist.info.id, playlist.info.name)
}

function openSpotifyImport() {
fileInput.value?.click()
}

async function handleFileSelected(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]

if (!file) return

const res = await importSpotifyCsv(playlist.info.id, file)
if (res) {
playlist.allTracks = []
await playlist.fetchAll(playlist.info.id)
}

input.value = ''
}
</script>

<style lang="scss">
.isSmall .p-after-header {
padding-left: 0.5rem;
.hidden-input {
display: none;
}

.p-after-header {
display: flex;
align-items: center;
height: 64px;
justify-content: space-between;
gap: 1rem;
min-height: 64px;
padding: 0 1rem;
margin-top: $small;

font-size: 14px;
font-weight: 500;
color: $gray1;

@media only screen and (max-width: 724px) {
@media only screen and (max-width: 900px) {
flex-direction: column;
align-items: flex-start;
padding-left: 0.5rem;
}

/* Somehow has to be replaced by above now
@include largePhones {
padding-left: 0.5rem;
}
*/
.heading {
font-size: 14px;
font-weight: 500;
}

.actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
justify-content: flex-end;
}

.sort-wrap {
position: relative;
z-index: 40;
}

.action-btn {
border: 1px solid $gray4;
background: $gray5;
color: $gray1;
border-radius: 999px;
padding: 0.5rem 0.9rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.15s ease;

&:hover {
background: $gray4;
}
}

.playlist-sortbar {
position: static !important;
width: 10rem;

.selected {
background-color: transparent;
opacity: 1;
}

.options {
background-color: $gray;
opacity: 1;
}
}
}
</style>
4 changes: 4 additions & 0 deletions src/components/shared/DropDown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ onClickOutside(dropOptionsRef, e => {

<style lang="scss">
.smdropdown {
position: relative;
z-index: 1000;

.dropdown-arrow {
Expand Down Expand Up @@ -118,6 +119,9 @@ onClickOutside(dropOptionsRef, e => {
padding: $small;
display: grid;
width: 100%;
z-index: 1002;
opacity: 1;
isolation: isolate;
}

.option {
Expand Down
1 change: 1 addition & 0 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ export interface FetchProps {
props?: {}
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
headers?: {}
responseType?: 'json' | 'blob'
}

export interface FuseResult {
Expand Down
71 changes: 69 additions & 2 deletions src/requests/playlists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,24 @@ export async function getAllPlaylists(no_images = false): Promise<Playlist[]> {
return []
}

export async function getPlaylist(pid: number | string, no_tracks = false, start: number = 0, limit: number = 50) {
const uri = `${basePlaylistUrl}/${pid}?no_tracks=${no_tracks}&start=${start}&limit=${limit}`
export async function getPlaylist(
pid: number | string,
no_tracks = false,
start: number = 0,
limit: number = 50,
options: {
sorttracksby?: string
tracksort_reverse?: boolean
} = {}
) {
const params = new URLSearchParams({
no_tracks: String(no_tracks),
start: String(start),
limit: String(limit),
sorttracksby: options.sorttracksby || 'default',
tracksort_reverse: String(options.tracksort_reverse || false),
})
const uri = `${basePlaylistUrl}/${pid}?${params.toString()}`

interface PlaylistData {
info: Playlist
Expand Down Expand Up @@ -286,3 +302,54 @@ export async function pinUnpinPlaylist(pid: number) {

return false
}

export async function exportPlaylist(pid: number, playlistName: string) {
const { data, status } = await useAxios({
url: `${basePlaylistUrl}/${pid}/export`,
method: 'GET',
responseType: 'blob',
})

if (status !== 200 || !data) {
new Notification('Unable to export playlist', NotifType.Error)
return false
}

const safeName = playlistName.trim().replace(/[\\/:*?"<>|]+/g, '_') || 'playlist'
const url = window.URL.createObjectURL(data as Blob)
const link = document.createElement('a')
link.href = url
link.download = `${safeName}.csv`
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
new Notification('Playlist exported', NotifType.Success)
return true
}

export async function importSpotifyCsv(pid: number, file: File) {
const form = new FormData()
form.append('csv_file', file)

const { data, status } = await useAxios({
url: `${basePlaylistUrl}/${pid}/import-spotify`,
props: form,
headers: {
'Content-Type': 'multipart/form-data',
},
})

if (status === 200) {
const added = data.added ?? 0
const unmatched = data.unmatched?.length ?? 0
new Notification(
`Imported ${added} tracks${unmatched ? `, ${unmatched} unmatched` : ''}`,
NotifType.Success
)
return data
}

new Notification(data?.error || 'Unable to import Spotify CSV', NotifType.Error)
return null
}
1 change: 1 addition & 0 deletions src/requests/useAxios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export default async (args: FetchProps, withCredentials: boolean = true) => {
data: args.props,
// INFO: Most requests use POST
method: args.method || 'POST',
responseType: args.responseType || 'json',
// INFO: Add ngrok header and provided headers
headers: { ...args.headers, ...(on_ngrok ? ngrok_config : {}) },
withCredentials: withCredentials,
Expand Down
39 changes: 36 additions & 3 deletions src/stores/pages/playlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export default defineStore('playlist-tracks', {
query: '',
initialBannerPos: 0,
allTracks: <Track[]>[],
trackSortBy: 'default',
trackSortReverse: false,
colors: {
bg: '',
btn: '',
Expand All @@ -29,10 +31,27 @@ export default defineStore('playlist-tracks', {
* @param id The id of the playlist to fetch
*/
async fetchAll(id: number, no_tracks = false, fetchAll = false) {
const isNewPlaylist = this.info.id !== id

if (isNewPlaylist) {
this.allTracks = []
this.query = ''
this.resetColors()
}

this.resetBannerPos()
// if fetchAll, use -1 to fetch all tracks
const playlist = await getPlaylist(id, no_tracks, this.allTracks.length, fetchAll ? -1 : track_limit.value)
if (this.allTracks.length !== 0) {
const playlist = await getPlaylist(
id,
no_tracks,
this.allTracks.length,
fetchAll ? -1 : track_limit.value,
{
sorttracksby: this.trackSortBy,
tracksort_reverse: this.trackSortReverse,
}
)
if (!isNewPlaylist && this.allTracks.length !== 0) {
this.allTracks.push(...(playlist?.tracks || []))
return
}
Expand All @@ -41,7 +60,6 @@ export default defineStore('playlist-tracks', {
this.initialBannerPos = this.info.settings.banner_pos
this.createImageLink()

this.resetColors()
this.extractColors()

if (no_tracks) return
Expand Down Expand Up @@ -120,6 +138,17 @@ export default defineStore('playlist-tracks', {
addTrack(track: Track) {
this.allTracks.push(track)
},
async setPlaylistTrackSortKey(key: string) {
if (key === this.trackSortBy) {
this.trackSortReverse = !this.trackSortReverse
} else {
this.trackSortBy = key
this.trackSortReverse = false
}

this.allTracks = []
await this.fetchAll(this.info.id)
},
resetBannerPos() {
try {
this.info.settings.banner_pos = 50
Expand Down Expand Up @@ -155,4 +184,8 @@ export default defineStore('playlist-tracks', {
return this.info.settings.banner_pos - this.initialBannerPos !== 0
},
},
persist: {
paths: ['trackSortBy', 'trackSortReverse'],
storage: localStorage,
},
})
Loading