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

View File

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

View File

@ -3,7 +3,7 @@ import type { Playlist } from '~/types'
import PlayButton from '~/components/audio/PlayButton.vue'
import defaultCover from '~/assets/audio/default-cover.png'
import { computed } from 'vue'
import { ref, computed } from 'vue'
import { useStore } from '~/store'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
@ -29,6 +29,28 @@ const images = computed(() => {
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 = () => {
router.push({name: 'library.playlists.detail', params: {id: props.playlist.id}})
}
@ -45,7 +67,8 @@ const goToPlaylist = () => {
v-for="(url, idx) in images"
:key="idx"
v-lazy="url"
alt=""
:alt="playlist.name"
:style="{ backgroundColor: randomizedColors[idx % randomizedColors.length] }"
/>
</div>
</template>

View File

@ -1,7 +1,8 @@
<script setup lang="ts">
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 {
playlists: Playlist[]
@ -11,13 +12,11 @@ defineProps<Props>()
</script>
<template>
<div v-if="playlists.length > 0">
<div class="ui app-cards cards">
<playlist-card
v-for="playlist in playlists"
:key="playlist.id"
:playlist="playlist"
/>
</div>
</div>
<Layout grid v-if="playlists.length > 0">
<PlaylistsCard
v-for="playlist in playlists"
:key="playlist.id"
:playlist="playlist"
/>
</Layout>
</template>

View File

@ -4,8 +4,17 @@ import type { Playlist } from '~/types'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useStore } from '~/store'
import { useRouter } from 'vue-router'
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 {
(e: 'import'): void
(e: 'export'): void
@ -53,6 +62,18 @@ const exportPlaylist = async () => {
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 url = exportUrl.value
@ -75,37 +96,60 @@ const triggerFileInput = () => {
fileInputRef.value?.click()
}
const open = ref(false)
const showEmbedModal = ref(false)
const showDeleteModal = ref(false)
</script>
<template>
<span>
<button
v-dropdown="{direction: 'downward'}"
class="ui floating dropdown circular icon basic button"
:title="labels.more"
>
<i class="ellipsis vertical icon" />
<div class="menu">
<div
role="button"
class="basic item"
:title="t('components.playlists.PlaylistDropdown.button.export.description')"
@click="exportPlaylist"
>
<i class="upload icon" />
{{ labels.export }}
</div>
<div
v-if="store.state.auth.authenticated && playlist.actor.full_username !== store.state.auth.fullUsername"
role="button"
class="basic item"
:title="t('components.playlists.PlaylistDropdown.button.import.description')"
@click="triggerFileInput"
>
<i class="download icon" />
{{ labels.import }}
</div>
</div>
</button>
<Popover v-model:open="open">
<template #default="{ toggleOpen }">
<OptionsButton @click="toggleOpen" />
</template>
<template #items>
<PopoverItem
v-if="store.state.auth.profile && playlist.actor.full_username === store.state.auth.fullUsername"
@click="edit = !edit"
icon="bi-pencil"
>
<template v-if="edit">
{{ t('views.playlists.Detail.button.stopEdit') }}
</template>
<template v-else>
{{ t('views.playlists.Detail.button.edit') }}
</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"
>
{{ labels.export }}
</PopoverItem>
<PopoverItem
v-if="store.state.auth.authenticated && playlist.actor.full_username === store.state.auth.fullUsername"
icon="bi-upload"
@click="triggerFileInput"
>
{{ labels.import }}
</PopoverItem>
</template>
</Popover>
<!-- Hidden file input, triggered by the button click -->
<input
@ -115,4 +159,50 @@ const triggerFileInput = () => {
@change="patchPlaylist"
>
</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>

View File

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

View File

@ -9,12 +9,16 @@
flex-grow: 1;
}
.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 {
align-content: center;
&:last-child {
border-bottom: 1px solid var(--border-color);
}
}
.track-row,
@ -39,7 +43,6 @@
visibility: visible;
}
.actions {
display: block;
max-width: 2rem;
width: 100%;
}

View File

@ -1,24 +1,35 @@
<script setup lang="ts">
import type { PlaylistTrack, Playlist } from '~/types'
import type { PlaylistTrack, Playlist, Library } from '~/types'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { ref, computed } from 'vue'
import { useStore } from '~/store'
import { momentFormat } from '~/utils/filters'
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 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 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 Modal from '~/components/ui/Modal.vue'
import PlaylistDropdown from '~/components/playlists/PlaylistDropdown.vue'
import useErrorHandler from '~/composables/useErrorHandler'
interface Events {
(e: 'libraries-loaded', libraries: Library[]): void
}
interface Props {
id: number
defaultEdit?: boolean
@ -65,6 +76,41 @@ const fetchData = async () => {
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 () => {
try {
await axios.delete(`playlists/${props.id}/`)
@ -77,116 +123,130 @@ const deletePlaylist = async () => {
</script>
<template>
<main>
<div
<Layout stack main>
<Loader
v-if="isLoading"
v-title="labels.playlist"
class="ui vertical segment"
>
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" />
</div>
<section
/>
<Layout flex
v-if="!isLoading && playlist"
v-title="playlist.name"
class="ui head vertical center aligned stripe segment"
>
<div class="segment-content">
<h2 class="ui center aligned icon header">
<i class="circular inverted list warning icon" />
<div class="content">
{{ playlist.name }}
<div class="sub header">
{{ t('views.playlists.Detail.meta.tracks', { username: playlist.actor.name }, playlist.tracks_count) }}
<br>
<duration :seconds="playlist.duration" />
</div>
</div>
</h2>
<div class="ui hidden divider" />
<div class="header-buttons">
<div class="ui buttons">
<play-button
class="vibrant"
:is-playable="playlist.is_playable"
:tracks="tracks"
>
{{ t('views.playlists.Detail.button.playAll') }}
</play-button>
</div>
<div class="ui buttons">
<Button
v-if="store.state.auth.profile && playlist.actor.full_username === store.state.auth.fullUsername"
@click="edit = !edit"
icon="bi-pencil"
>
<template v-if="edit">
{{ t('views.playlists.Detail.button.stopEdit') }}
</template>
<template v-else>
{{ t('views.playlists.Detail.button.edit') }}
</template>
</Button>
</div>
<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"
class="ui labeled danger icon button"
:action="deletePlaylist"
>
{{ t('views.playlists.Detail.button.delete') }}
<template #modal-header>
<p>
{{ t('views.playlists.Detail.modal.delete.header', {playlist: playlist.name}) }}
</p>
</template>
<template #modal-content>
<div class="playlist-grid">
<img
v-for="(url, idx) in images"
:key="idx"
v-lazy="url"
:alt="playlist.name"
:style="{ backgroundColor: randomizedColors[idx % randomizedColors.length] }"
/>
</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 class="meta">
<!-- TODO: Translations -->
by
<ActorLink
: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"
:is-playable="playlist.is_playable"
:tracks="tracks"
>
{{ t('views.playlists.Detail.button.playAll') }}
</PlayButton>
<Button
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
v-if="store.state.auth.profile && playlist.actor.full_username === store.state.auth.fullUsername"
secondary
@click="edit = !edit"
icon="bi-pencil"
>
<template v-if="edit">
{{ t('views.playlists.Detail.button.stopEdit') }}
</template>
<template v-else>
{{ t('views.playlists.Detail.button.edit') }}
</template>
</Button>
<DangerousButton
v-if="store.state.auth.profile && playlist.actor.full_username === store.state.auth.fullUsername"
:action="deletePlaylist"
>
{{ t('views.playlists.Detail.button.delete') }}
<template #alert>
<Alert red>
<p>
{{ t('views.playlists.Detail.modal.delete.content.warning') }}
</p>
</template>
<template #modal-confirm>
<div>
{{ t('views.playlists.Detail.button.confirm') }}
</div>
</template>
</dangerous-button>
<div class="ui hidden horizontal divider" />
<playlist-dropdown
:playlist="playlist"
@import="fetchData"
</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>
</DangerousButton>
<Spacer h grow />
<playlist-dropdown
:playlist="playlist"
@import="fetchData"
/>
</Layout>
</Layout>
<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>
<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>
</div>
</section>
<section class="ui vertical stripe segment">
<template #actions>
<Button variant="outline">
{{ t('views.playlists.Detail.button.cancel') }}
</Button>
</template>
</Modal>
</Layout>
<Layout stack>
<template v-if="edit">
<playlist-editor
v-model:playlist="playlist"
@ -194,30 +254,61 @@ const deletePlaylist = async () => {
/>
</template>
<template v-else-if="tracks.length > 0">
<h2>
{{ t('views.playlists.Detail.header.tracks') }}
</h2>
<track-table
:display-position="true"
:tracks="tracks"
:unique="false"
/>
</template>
<div
<Alert blue
v-else
class="ui placeholder segment"
>
<div class="ui icon header">
<i class="list icon" />
{{ t('views.playlists.Detail.empty.noTracks') }}
</div>
<i class="bi bi-music-note-list" />
{{ t('views.playlists.Detail.empty.noTracks') }}
<Button
icon="bi-pencil"
@click="edit = !edit"
>
{{ t('views.playlists.Detail.button.edit') }}
</Button>
</div>
</section>
</main>
</Alert>
<library-widget
:url="'playlists/' + playlist?.id + '/libraries/'"
@loaded="emit('libraries-loaded', $event)"
>
{{ t('components.library.AlbumDetail.description.libraries') }}
</library-widget>
</Layout>
</Layout>
</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>
</select>
</Layout>
<Layout grid
<template
v-if="result && result.results.length > 0"
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"
:playlists="result.results"
/>
</Layout>
</template>
<Layout
stack
v-else-if="result && result.results.length === 0"