refactor(front): Playlist pages

This commit is contained in:
ArneBo 2025-01-23 01:18:08 +01:00
parent 9b758c1a7e
commit 8e5ddbfa1b
9 changed files with 371 additions and 162 deletions

View File

@ -13,13 +13,15 @@ interface Props {
admin?: boolean admin?: boolean
displayName?: boolean displayName?: boolean
truncateLength?: number truncateLength?: number
discrete?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
avatar: true, avatar: true,
admin: false, admin: false,
displayName: false, displayName: false,
truncateLength: 30 truncateLength: 30,
discrete: false
}) })
const { displayName, actor, truncateLength, admin, avatar } = toRefs(props) const { displayName, actor, truncateLength, admin, avatar } = toRefs(props)
@ -56,9 +58,9 @@ const url = computed(() => {
:to="url" :to="url"
:title="actor.full_username" :title="actor.full_username"
class="username" class="username"
solid :solid="!discrete"
secondary :secondary="!discrete"
round :round="!discrete"
> >
<span class="center"> <span class="center">
<actor-avatar <actor-avatar

View File

@ -46,7 +46,7 @@ const title = computed(() => isFavorite.value
</Button> </Button>
<Button <Button
v-else v-else
secondary ghost
icon="bi-heart-fill" icon="bi-heart-fill"
:class="['ui', 'favorite-icon', {'pink': isFavorite}, {'favorited': isFavorite}, 'basic', 'circular', 'icon', {'really': !border}, 'button']" :class="['ui', 'favorite-icon', {'pink': isFavorite}, {'favorited': isFavorite}, 'basic', 'circular', 'icon', {'really': !border}, 'button']"
:aria-label="title" :aria-label="title"

View File

@ -3,7 +3,7 @@ import type { Playlist } from '~/types'
import PlayButton from '~/components/audio/PlayButton.vue' import PlayButton from '~/components/audio/PlayButton.vue'
import defaultCover from '~/assets/audio/default-cover.png' import defaultCover from '~/assets/audio/default-cover.png'
import { computed } from 'vue' import { ref, computed } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@ -29,6 +29,28 @@ const images = computed(() => {
return urls return urls
}) })
const bgcolors = ref([
'#f2efef',
'#eee9e9',
'#ddd9d9',
'#cfcaca',
'#b3afaf',
'#908888',
'#605656',
'#4d4547',
'#292525',
'#403a3b',
'#322f2f'
]);
function shuffleArray(array: string[]): string[] {
return [...array].sort(() => Math.random() - 0.5);
}
const randomizedColors = computed(() => shuffleArray(bgcolors.value));
const goToPlaylist = () => { const goToPlaylist = () => {
router.push({name: 'library.playlists.detail', params: {id: props.playlist.id}}) router.push({name: 'library.playlists.detail', params: {id: props.playlist.id}})
} }
@ -45,7 +67,8 @@ const goToPlaylist = () => {
v-for="(url, idx) in images" v-for="(url, idx) in images"
:key="idx" :key="idx"
v-lazy="url" v-lazy="url"
alt="" :alt="playlist.name"
:style="{ backgroundColor: randomizedColors[idx % randomizedColors.length] }"
/> />
</div> </div>
</template> </template>

View File

@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Playlist } from '~/types' import type { Playlist } from '~/types'
import PlaylistCard from '~/components/playlists/Card.vue' import PlaylistsCard from '~/components/playlists/Card.vue'
import Layout from '~/components/ui/Layout.vue'
interface Props { interface Props {
playlists: Playlist[] playlists: Playlist[]
@ -11,13 +12,11 @@ defineProps<Props>()
</script> </script>
<template> <template>
<div v-if="playlists.length > 0"> <Layout grid v-if="playlists.length > 0">
<div class="ui app-cards cards"> <PlaylistsCard
<playlist-card
v-for="playlist in playlists" v-for="playlist in playlists"
:key="playlist.id" :key="playlist.id"
:playlist="playlist" :playlist="playlist"
/> />
</div> </Layout>
</div>
</template> </template>

View File

@ -4,8 +4,17 @@ import type { Playlist } from '~/types'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useStore } from '~/store' import { useStore } from '~/store'
import { useRouter } from 'vue-router'
import axios from 'axios' import axios from 'axios'
import useErrorHandler from '~/composables/useErrorHandler'
import OptionsButton from '~/components/ui/button/Options.vue'
import Modal from '~/components/ui/Modal.vue'
import Alert from '~/components/ui/Alert.vue'
import Button from '~/components/ui/Button.vue'
import Popover from '~/components/ui/Popover.vue'
import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
interface Events { interface Events {
(e: 'import'): void (e: 'import'): void
(e: 'export'): void (e: 'export'): void
@ -53,6 +62,18 @@ const exportPlaylist = async () => {
const fileInputRef = ref<HTMLInputElement | null>(null) const fileInputRef = ref<HTMLInputElement | null>(null)
const router = useRouter()
const deletePlaylist = async () => {
try {
await axios.delete(`playlists/${props.playlist.id}/`)
store.dispatch('playlists/fetchOwn')
return router.push({ path: '/library' })
} catch (error) {
useErrorHandler(error as Error)
}
}
const patchPlaylist = async () => { const patchPlaylist = async () => {
const url = exportUrl.value const url = exportUrl.value
@ -75,37 +96,60 @@ const triggerFileInput = () => {
fileInputRef.value?.click() fileInputRef.value?.click()
} }
const open = ref(false)
const showEmbedModal = ref(false)
const showDeleteModal = ref(false)
</script> </script>
<template> <template>
<span> <span>
<button <Popover v-model:open="open">
v-dropdown="{direction: 'downward'}" <template #default="{ toggleOpen }">
class="ui floating dropdown circular icon basic button" <OptionsButton @click="toggleOpen" />
:title="labels.more" </template>
<template #items>
<PopoverItem
v-if="store.state.auth.profile && playlist.actor.full_username === store.state.auth.fullUsername"
@click="edit = !edit"
icon="bi-pencil"
> >
<i class="ellipsis vertical icon" /> <template v-if="edit">
<div class="menu"> {{ t('views.playlists.Detail.button.stopEdit') }}
<div </template>
role="button" <template v-else>
class="basic item" {{ t('views.playlists.Detail.button.edit') }}
:title="t('components.playlists.PlaylistDropdown.button.export.description')" </template>
</PopoverItem>
<PopoverItem
v-if="playlist.privacy_level === 'everyone' && playlist.is_playable"
icon="bi-code-slash"
@click="showEmbedModal = !showEmbedModal"
>
{{ t('views.playlists.Detail.button.embed') }}
</PopoverItem>
<PopoverItem destructive
v-if="store.state.auth.profile && playlist.actor.full_username === store.state.auth.fullUsername"
icon="bi-trash"
:action="deletePlaylist"
@click="showDeleteModal = !showDeleteModal"
>
{{ t('views.playlists.Detail.button.delete') }}
</PopoverItem>
<PopoverItem
icon="bi-download"
@click="exportPlaylist" @click="exportPlaylist"
> >
<i class="upload icon" />
{{ labels.export }} {{ labels.export }}
</div> </PopoverItem>
<div
v-if="store.state.auth.authenticated && playlist.actor.full_username !== store.state.auth.fullUsername" <PopoverItem
role="button" v-if="store.state.auth.authenticated && playlist.actor.full_username === store.state.auth.fullUsername"
class="basic item" icon="bi-upload"
:title="t('components.playlists.PlaylistDropdown.button.import.description')"
@click="triggerFileInput" @click="triggerFileInput"
> >
<i class="download icon" />
{{ labels.import }} {{ labels.import }}
</div> </PopoverItem>
</div> </template>
</button> </Popover>
<!-- Hidden file input, triggered by the button click --> <!-- Hidden file input, triggered by the button click -->
<input <input
@ -115,4 +159,50 @@ const triggerFileInput = () => {
@change="patchPlaylist" @change="patchPlaylist"
> >
</span> </span>
<Modal
v-if="playlist.privacy_level === 'everyone' && playlist.is_playable"
v-model="showEmbedModal"
title="t('views.playlists.Detail.modal.embed.header')"
>
<div class="scrolling content">
<div class="description">
<embed-wizard
:id="playlist.id"
type="playlist"
/>
</div>
</div>
<template #actions>
<Button variant="outline">
{{ t('views.playlists.Detail.button.cancel') }}
</Button>
</template>
</Modal>
<Modal
v-model="showDeleteModal"
destructive
:title="t('views.playlists.Detail.modal.delete.header')"
>
<template #alert>
<Alert red>
<p>
{{ t('views.playlists.Detail.modal.delete.content.warning') }}
</p>
</Alert>
</template>
<template #actions>
<Button @click="showDeleteModal = false">
{{ t('views.playlists.Detail.button.cancel') }}
</Button>
<Button
destructive
@click="deletePlaylist"
>
{{ t('views.playlists.Detail.button.confirm') }}
</Button>
</template>
</Modal>
</template> </template>

View File

@ -76,6 +76,7 @@ const attributes = computed(() =>
<!-- Content --> <!-- Content -->
<!--TODO: Pass Alert colors through to card.vue -->
<Alert blue v-if="$slots.alert" :class="$style.alert"> <Alert blue v-if="$slots.alert" :class="$style.alert">
<slot name="alert" /> <slot name="alert" />
</Alert> </Alert>

View File

@ -9,12 +9,16 @@
flex-grow: 1; flex-grow: 1;
} }
.row:not(.mobile) { .row:not(.mobile) {
border-bottom: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
color: var(--fw-gray-500);
} }
} }
.track-row.row { .track-row.row {
align-content: center; align-content: center;
&:last-child {
border-bottom: 1px solid var(--border-color);
}
} }
.track-row, .track-row,
@ -39,7 +43,6 @@
visibility: visible; visibility: visible;
} }
.actions { .actions {
display: block;
max-width: 2rem; max-width: 2rem;
width: 100%; width: 100%;
} }

View File

@ -1,24 +1,35 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PlaylistTrack, Playlist } from '~/types' import type { PlaylistTrack, Playlist, Library } from '~/types'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import { momentFormat } from '~/utils/filters'
import axios from 'axios' import axios from 'axios'
import defaultCover from '~/assets/audio/default-cover.png'
import ActorLink from '~/components/common/ActorLink.vue'
import PlaylistEditor from '~/components/playlists/Editor.vue' import PlaylistEditor from '~/components/playlists/Editor.vue'
import EmbedWizard from '~/components/audio/EmbedWizard.vue' import EmbedWizard from '~/components/audio/EmbedWizard.vue'
import Modal from '~/components/ui/Modal.vue' import HumanDate from '~/components/common/HumanDate.vue'
import TrackTable from '~/components/audio/track/Table.vue' import TrackTable from '~/components/audio/track/Table.vue'
import PlayButton from '~/components/audio/PlayButton.vue' import PlayButton from '~/components/audio/PlayButton.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Loader from '~/components/ui/Loader.vue'
import Button from '~/components/ui/Button.vue' import Button from '~/components/ui/Button.vue'
import Modal from '~/components/ui/Modal.vue'
import PlaylistDropdown from '~/components/playlists/PlaylistDropdown.vue' import PlaylistDropdown from '~/components/playlists/PlaylistDropdown.vue'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
interface Events {
(e: 'libraries-loaded', libraries: Library[]): void
}
interface Props { interface Props {
id: number id: number
defaultEdit?: boolean defaultEdit?: boolean
@ -65,6 +76,41 @@ const fetchData = async () => {
fetchData() fetchData()
const images = computed(() => {
const urls = playlist.value?.album_covers.slice(0, 4).map(url => store.getters['instance/absoluteUrl'](url)) || []
while (urls.length < 4) {
urls.push(defaultCover)
}
return urls
})
const bgcolors = ref([
'#f2efef',
'#eee9e9',
'#ddd9d9',
'#cfcaca',
'#b3afaf',
'#908888',
'#605656',
'#4d4547',
'#292525',
'#403a3b',
'#322f2f'
]);
function shuffleArray(array: string[]): string[] {
return [...array].sort(() => Math.random() - 0.5);
}
const randomizedColors = computed(() => shuffleArray(bgcolors.value));
const updatedTitle = computed(() => {
const date = momentFormat(new Date(playlist.modification_date ?? '1970-01-01'))
return t('components.audio.ChannelCard.title', { date })
})
const deletePlaylist = async () => { const deletePlaylist = async () => {
try { try {
await axios.delete(`playlists/${props.id}/`) await axios.delete(`playlists/${props.id}/`)
@ -77,45 +123,67 @@ const deletePlaylist = async () => {
</script> </script>
<template> <template>
<main> <Layout stack main>
<div <Loader
v-if="isLoading" v-if="isLoading"
v-title="labels.playlist" v-title="labels.playlist"
class="ui vertical segment" />
> <Layout flex
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" />
</div>
<section
v-if="!isLoading && playlist" v-if="!isLoading && playlist"
v-title="playlist.name" v-title="playlist.name"
class="ui head vertical center aligned stripe segment"
> >
<div class="segment-content"> <div class="playlist-grid">
<h2 class="ui center aligned icon header"> <img
<i class="circular inverted list warning icon" /> v-for="(url, idx) in images"
<div class="content"> :key="idx"
{{ playlist.name }} v-lazy="url"
<div class="sub header"> :alt="playlist.name"
{{ t('views.playlists.Detail.meta.tracks', { username: playlist.actor.name }, playlist.tracks_count) }} :style="{ backgroundColor: randomizedColors[idx % randomizedColors.length] }"
<br> />
<duration :seconds="playlist.duration" />
</div> </div>
<Layout stack style="flex: 1; gap: 8px;">
<h1>{{ playlist.name }}</h1>
<div class="meta">
{{ playlist.tracks_count }}
{{ t('views.playlists.Detail.header.tracks') }}
<i class="bi bi-dot" />
<Duration :seconds="playlist.duration" />
</div> </div>
</h2> <div class="meta">
<div class="ui hidden divider" /> <!-- TODO: Translations -->
<div class="header-buttons"> by
<div class="ui buttons"> <ActorLink
<play-button :actor="playlist.actor"
:avatar="false"
:discrete="true"
/>
<i class="bi bi-dot" />
updated
<HumanDate
:date="playlist.modification_date"
/>
</div>
<Layout flex class="header-buttons">
<PlayButton
split
class="vibrant" class="vibrant"
:is-playable="playlist.is_playable" :is-playable="playlist.is_playable"
:tracks="tracks" :tracks="tracks"
> >
{{ t('views.playlists.Detail.button.playAll') }} {{ t('views.playlists.Detail.button.playAll') }}
</play-button> </PlayButton>
</div> <Button
<div class="ui buttons"> v-if="playlist.tracks_count > 1"
primary
icon="bi-shuffle"
:aria-label="t('components.audio.Player.label.shuffleQueue')"
@click.prevent.stop="shuffle()"
>
{{ t('components.audio.Player.label.shuffleQueue') }}
</Button>
<Button <Button
v-if="store.state.auth.profile && playlist.actor.full_username === store.state.auth.fullUsername" v-if="store.state.auth.profile && playlist.actor.full_username === store.state.auth.fullUsername"
secondary
@click="edit = !edit" @click="edit = !edit"
icon="bi-pencil" icon="bi-pencil"
> >
@ -126,45 +194,37 @@ const deletePlaylist = async () => {
{{ t('views.playlists.Detail.button.edit') }} {{ t('views.playlists.Detail.button.edit') }}
</template> </template>
</Button> </Button>
</div> <DangerousButton
<div class="ui buttons">
<Button
v-if="playlist.privacy_level === 'everyone' && playlist.is_playable"
icon="bi-code-slash"
@click="showEmbedModal = !showEmbedModal"
>
<i class="code icon" />
{{ t('views.playlists.Detail.button.embed') }}
</button>
<dangerous-button
v-if="store.state.auth.profile && playlist.actor.full_username === store.state.auth.fullUsername" v-if="store.state.auth.profile && playlist.actor.full_username === store.state.auth.fullUsername"
class="ui labeled danger icon button"
:action="deletePlaylist" :action="deletePlaylist"
> >
{{ t('views.playlists.Detail.button.delete') }} {{ t('views.playlists.Detail.button.delete') }}
<template #modal-header> <template #alert>
<p> <Alert red>
{{ t('views.playlists.Detail.modal.delete.header', {playlist: playlist.name}) }}
</p>
</template>
<template #modal-content>
<p> <p>
{{ t('views.playlists.Detail.modal.delete.content.warning') }} {{ t('views.playlists.Detail.modal.delete.content.warning') }}
</p> </p>
</Alert>
</template> </template>
<template #modal-confirm> <template #actions>
<div> <Button @click="showDeleteModal = false">
{{ t('views.playlists.Detail.button.cancel') }}
</Button>
<Button
destructive
@click="deletePlaylist"
>
{{ t('views.playlists.Detail.button.confirm') }} {{ t('views.playlists.Detail.button.confirm') }}
</div> </Button>
</template> </template>
</dangerous-button> </DangerousButton>
<div class="ui hidden horizontal divider" /> <Spacer h grow />
<playlist-dropdown <playlist-dropdown
:playlist="playlist" :playlist="playlist"
@import="fetchData" @import="fetchData"
/> />
</div> </Layout>
</div> </Layout>
<Modal <Modal
v-if="playlist.privacy_level === 'everyone' && playlist.is_playable" v-if="playlist.privacy_level === 'everyone' && playlist.is_playable"
v-model="showEmbedModal" v-model="showEmbedModal"
@ -184,9 +244,9 @@ const deletePlaylist = async () => {
</Button> </Button>
</template> </template>
</Modal> </Modal>
</div> </Layout>
</section>
<section class="ui vertical stripe segment"> <Layout stack>
<template v-if="edit"> <template v-if="edit">
<playlist-editor <playlist-editor
v-model:playlist="playlist" v-model:playlist="playlist"
@ -194,30 +254,61 @@ const deletePlaylist = async () => {
/> />
</template> </template>
<template v-else-if="tracks.length > 0"> <template v-else-if="tracks.length > 0">
<h2>
{{ t('views.playlists.Detail.header.tracks') }}
</h2>
<track-table <track-table
:display-position="true" :display-position="true"
:tracks="tracks" :tracks="tracks"
:unique="false" :unique="false"
/> />
</template> </template>
<div <Alert blue
v-else v-else
class="ui placeholder segment"
> >
<div class="ui icon header"> <i class="bi bi-music-note-list" />
<i class="list icon" />
{{ t('views.playlists.Detail.empty.noTracks') }} {{ t('views.playlists.Detail.empty.noTracks') }}
</div>
<Button <Button
icon="bi-pencil" icon="bi-pencil"
@click="edit = !edit" @click="edit = !edit"
> >
{{ t('views.playlists.Detail.button.edit') }} {{ t('views.playlists.Detail.button.edit') }}
</Button> </Button>
</div> </Alert>
</section> <library-widget
</main> :url="'playlists/' + playlist?.id + '/libraries/'"
@loaded="emit('libraries-loaded', $event)"
>
{{ t('components.library.AlbumDetail.description.libraries') }}
</library-widget>
</Layout>
</Layout>
</template> </template>
<style lang="scss" scoped>
.playlist-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 2px;
width: 300px;
height: 300px;
}
.playlist-grid img {
width: 100%;
height: 100%;
object-fit: cover;
}
.playlist-meta {
display: flex;
align-items: center;
}
.playlist-action {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 0 8px;
}
</style>

View File

@ -182,7 +182,7 @@ const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value]
</option> </option>
</select> </select>
</Layout> </Layout>
<Layout grid <template
v-if="result && result.results.length > 0" v-if="result && result.results.length > 0"
style="display:flex; flex-wrap:wrap; gap: 32px; margin-top:32px;" style="display:flex; flex-wrap:wrap; gap: 32px; margin-top:32px;"
> >
@ -191,7 +191,7 @@ const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value]
v-if="result && result.results.length > 0" v-if="result && result.results.length > 0"
:playlists="result.results" :playlists="result.results"
/> />
</Layout> </template>
<Layout <Layout
stack stack
v-else-if="result && result.results.length === 0" v-else-if="result && result.results.length === 0"