fix(front): consistent pixel perfect header with description modal on all detail pages

This commit is contained in:
ArneBo 2025-04-08 05:39:38 +02:00
parent a05e509d36
commit dcb664162c
18 changed files with 1223 additions and 885 deletions

View File

@ -66,5 +66,6 @@ const getRoute = (ac: ArtistCredit) => {
<style lang="scss" scoped> <style lang="scss" scoped>
a.username { a.username {
text-decoration: none; text-decoration: none;
height: 25px;
} }
</style> </style>

View File

@ -1,79 +1,78 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Album } from '~/types' import { computed, ref } from 'vue'
import { useStore } from '~/store'
import { useI18n } from 'vue-i18n'
import { momentFormat } from '~/utils/filters'
import defaultCover from '~/assets/audio/default-cover.png'
import PlayButton from '~/components/audio/PlayButton.vue' import PlayButton from '~/components/audio/PlayButton.vue'
import { computed } from 'vue' import Layout from '~/components/ui/Layout.vue'
import { useI18n } from 'vue-i18n' import Card from '~/components/ui/Card.vue'
import { useStore } from '~/store' import ArtistCreditLabel from '~/components/audio/ArtistCreditLabel.vue'
import { useRouter } from 'vue-router' import Spacer from '~/components/ui/Spacer.vue'
const { t } = useI18n() import { type Album, type ArtistCredit } from '~/types'
const store = useStore()
const router = useRouter()
interface Props { interface Props {
serie: Album serie: Album
} }
const { t } = useI18n()
const props = defineProps<Props>() const props = defineProps<Props>()
const cover = computed(() => props.serie?.cover ?? null) const { serie } = props
const artistCredit = serie.artist_credit || []
const store = useStore()
const imageUrl = computed(() => serie?.cover?.urls.original
? store.getters['instance/absoluteUrl'](serie.cover?.urls.medium_square_crop)
: defaultCover
)
</script> </script>
<template> <template>
<div class="channel-serie-card"> <Card
<div class="two-images"> :title="serie?.title"
<img :image="imageUrl"
v-if="cover && cover.urls.original" :tags="serie?.tags"
v-lazy="store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)" :to="{name: 'library.albums.detail', params: {id: serie?.id}}"
alt="" small
class="channel-image" >
@click="router.push({name: 'library.albums.detail', params: {id: serie.id}})" <template #topright>
> <PlayButton
<img icon-only
v-else :is-playable="serie?.is_playable"
alt=""
class="channel-image"
src="../../assets/audio/default-cover.png"
@click="router.push({name: 'library.albums.detail', params: {id: serie.id}})"
>
<img
v-if="cover && cover.urls.original"
v-lazy="store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)"
alt=""
class="channel-image"
@click="router.push({name: 'library.albums.detail', params: {id: serie.id}})"
>
<img
v-else
alt=""
class="channel-image"
src="../../assets/audio/default-cover.png"
@click="router.push({name: 'library.albums.detail', params: {id: serie.id}})"
>
</div>
<div class="content ellipsis">
<strong>
<router-link
class="discrete link"
:to="{name: 'library.albums.detail', params: {id: serie.id}}"
>
{{ serie.title }}
</router-link>
</strong>
<div class="description">
<span>
{{ t('components.audio.ChannelSerieCard.meta.episodes', serie.tracks_count) }}
</span>
</div>
</div>
<div class="controls">
<play-button
:icon-only="true"
:is-playable="true"
:button-classes="['ui', 'circular', 'vibrant', 'icon', 'button']"
:album="serie" :album="serie"
/> />
</div> </template>
</div>
<template #footer>
<span v-if="serie?.release_date">
{{ momentFormat(new Date(serie?.release_date), 'Y') }}
</span>
<i class="bi bi-dot" />
<span>
{{ t('components.audio.album.Card.meta.tracks', serie?.tracks_count) }}
</span>
<Spacer
h
grow
/>
<PlayButton
:dropdown-only="true"
discrete
:is-playable="serie?.is_playable"
:album="serie"
/>
</template>
</Card>
</template> </template>
<style lang="scss" scoped>
.play-button {
top: 16px;
right: 16px;
}
</style>

View File

@ -25,6 +25,7 @@ interface Props extends PlayOptionsProps {
iconOnly?: boolean iconOnly?: boolean
playing?: boolean playing?: boolean
paused?: boolean paused?: boolean
lowHeight?: boolean
// TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged
isPlayable?: boolean isPlayable?: boolean
@ -56,7 +57,8 @@ const props = withDefaults(defineProps<Props>(), {
iconOnly: () => false, iconOnly: () => false,
isPlayable: () => false, isPlayable: () => false,
playing: () => false, playing: () => false,
paused: () => false paused: () => false,
lowHeight: () => false
}) })
// (1) Create a PlayButton // (1) Create a PlayButton
@ -125,6 +127,8 @@ const isOpen = ref(false)
:class="[...buttonClasses, 'play-button']" :class="[...buttonClasses, 'play-button']"
:isloading="isLoading" :isloading="isLoading"
:dropdown-only="dropdownOnly" :dropdown-only="dropdownOnly"
:low-height="lowHeight || undefined"
style="align-self: start;"
@click.stop.prevent="replacePlay()" @click.stop.prevent="replacePlay()"
@split-click="isOpen = !isOpen" @split-click="isOpen = !isOpen"
> >
@ -238,6 +242,7 @@ const isOpen = ref(false)
:round="iconOnly" :round="iconOnly"
:primary="iconOnly && !discrete" :primary="iconOnly && !discrete"
:ghost="discrete" :ghost="discrete"
:low-height="lowHeight || undefined"
@click.stop.prevent="replacePlay()" @click.stop.prevent="replacePlay()"
> >
<template v-if="!discrete && !iconOnly"> <template v-if="!discrete && !iconOnly">

View File

@ -110,8 +110,8 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
{{ generateTrackCreditString(track) }} {{ generateTrackCreditString(track) }}
<span class="middle middledot symbol" /> <span class="middle middledot symbol" />
<human-duration <human-duration
v-if="track.uploads[0] && track.uploads[0].duration" v-if="track.uploads?.[0]?.duration"
:duration="track.uploads[0].duration" :duration="track.uploads[0]?.duration"
/> />
</p> </p>
</div> </div>

View File

@ -8,6 +8,7 @@ import { useI18n } from 'vue-i18n'
import axios from 'axios' import axios from 'axios'
import clip from 'text-clipper' import clip from 'text-clipper'
import Layout from '~/components/ui/Layout.vue'
import Button from '~/components/ui/Button.vue' import Button from '~/components/ui/Button.vue'
import Alert from '~/components/ui/Alert.vue' import Alert from '~/components/ui/Alert.vue'
@ -23,6 +24,7 @@ interface Props {
fetchHtml?: boolean fetchHtml?: boolean
permissive?: boolean permissive?: boolean
truncateLength?: number truncateLength?: number
moreLink?: boolean
} }
const { t } = useI18n() const { t } = useI18n()
@ -35,7 +37,8 @@ const props = withDefaults(defineProps<Props>(), {
canUpdate: true, canUpdate: true,
fetchHtml: false, fetchHtml: false,
permissive: false, permissive: false,
truncateLength: 200 truncateLength: 200,
moreLink: true
}) })
const preview = ref('') const preview = ref('')
@ -89,34 +92,40 @@ const submit = async () => {
</script> </script>
<template> <template>
<template v-if="content && !isUpdating"> <Layout
v-if="content && !isUpdating"
flex
gap-4
>
<!-- Render the truncated or full description --> <!-- Render the truncated or full description -->
<sanitized-html
<sanitized-html :html="html" /> :html="html"
:class="['description', isTruncated ? 'truncated' : '']"
/>
<!-- Display the `show more` / `show less` button --> <!-- Display the `show more` / `show less` button -->
<template v-if="isTruncated"> <template v-if="isTruncated">
<a <a
v-if="showMore === false" v-if="showMore === false && props.moreLink !== false"
class="more" class="more"
style="align-self: end; color: var(--fw-primary);" style="align-self: flex-end; color: var(--fw-primary);"
href="" href=""
@click.stop.prevent="showMore = true" @click.stop.prevent="showMore = true"
> >
{{ t('components.common.RenderedDescription.button.more') }} {{ t('components.common.RenderedDescription.button.more') }}
</a> </a>
<a <a
v-else v-else-if="props.moreLink !== false"
class="more" class="more"
style="align-self: end; color: var(--fw-primary);" style="align-self: center; color: var(--fw-primary);"
href="" href=""
@click.stop.prevent="showMore = false" @click.stop.prevent="showMore = false"
> >
{{ t('components.common.RenderedDescription.button.less') }} {{ t('components.common.RenderedDescription.button.less') }}
</a> </a>
</template> </template>
</template> </Layout>
<span v-else-if="!isUpdating"> <span v-else-if="!isUpdating">
{{ t('components.common.RenderedDescription.empty.noDescription') }} {{ t('components.common.RenderedDescription.empty.noDescription') }}
</span> </span>
@ -166,3 +175,19 @@ const submit = async () => {
</Button> </Button>
</form> </form>
</template> </template>
<style lang="scss" scoped>
.description {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
white-space: normal;
&.truncated {
-webkit-line-clamp: 1; /* Number of lines to show */
line-clamp: 1;
max-height: 72px;
flex-shrink: 1;
}
}
</style>

View File

@ -17,6 +17,7 @@ import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
import PlayButton from '~/components/audio/PlayButton.vue' import PlayButton from '~/components/audio/PlayButton.vue'
import AlbumDropdown from './AlbumDropdown.vue' import AlbumDropdown from './AlbumDropdown.vue'
import Layout from '~/components/ui/Layout.vue' import Layout from '~/components/ui/Layout.vue'
import Header from '~/components/ui/Header.vue'
import Spacer from '~/components/ui/Spacer.vue' import Spacer from '~/components/ui/Spacer.vue'
import Loader from '~/components/ui/Loader.vue' import Loader from '~/components/ui/Loader.vue'
import Button from '~/components/ui/Button.vue' import Button from '~/components/ui/Button.vue'
@ -147,147 +148,154 @@ const remove = async () => {
</script> </script>
<template> <template>
<Layout <Loader
stack v-if="isLoading"
main v-title="labels.title"
/>
<Header
v-if="object"
:h1="object.title"
page-heading
> >
<Loader <template #image>
v-if="isLoading" <img
v-title="labels.title" v-if="object.cover && object.cover.urls.original"
/> v-lazy="store.getters['instance/absoluteUrl'](object.cover.urls.large_square_crop)"
<template v-if="object"> :alt="object.title"
<Layout flex> class="channel-image"
<img >
v-if="object.cover && object.cover.urls.original" <img
v-lazy="store.getters['instance/absoluteUrl'](object.cover.urls.large_square_crop)" v-else
:alt="object.title" alt=""
class="channel-image" class="channel-image"
> src="../../assets/audio/default-cover.png"
<img >
v-else
alt=""
class="channel-image"
src="../../assets/audio/default-cover.png"
>
<Layout
stack
no-gap
style="flex: 1;"
>
<h1 style="margin-top: 64px; margin-bottom: 8px;">
{{ object.title }}
</h1>
<artist-credit-label
v-if="artistCredit"
:artist-credit="artistCredit"
/>
<!-- Metadata: -->
<div class="meta">
<template v-if="object.release_date">
{{ momentFormat(new Date(object.release_date ?? '1970-01-01'), 'Y') }}
<i class="bi bi-dot" />
</template>
<template v-if="totalTracks > 0">
<span v-if="isSerie">
{{ t('components.library.AlbumBase.meta.episodes', totalTracks) }}
</span>
<span v-else>
{{ t('components.library.AlbumBase.meta.tracks', totalTracks) }}
</span>
</template>
<i
v-if="totalDuration > 0"
class="bi bi-dot"
/>
<human-duration
v-if="totalDuration > 0"
:duration="totalDuration"
/>
<!--TODO: License -->
</div>
<Layout flex>
<rendered-description
v-if="object.description"
:content="object.description"
:can-update="true"
/>
</Layout>
<Layout flex>
<PlayButton
v-if="object.tracks"
split
:tracks="object.tracks"
:is-playable="object.is_playable"
/>
<Button
v-if="object?.tracks?.length && object?.tracks?.length > 2"
primary
icon="bi-shuffle"
:aria-label="labels.shuffle"
@click.prevent.stop="shuffle()"
>
{{ labels.shuffle }}
</Button>
<DangerousButton
v-if="artistCredit[0] &&
store.state.auth.authenticated &&
artistCredit[0].artist.channel
/* TODO: Re-implement once attributed_to is not only a number
&& artistCredit[0].artist.attributed_to?.full_username === store.state.auth.fullUsername
*/"
:is-loading="isLoading"
icon="bi-trash"
@confirm="remove()"
>
{{ t('components.library.AlbumDropdown.button.delete') }}
</DangerousButton>
<Spacer
h
grow
/>
<TrackFavoriteIcon
v-if="store.state.auth.authenticated"
:album="object"
/>
<TrackPlaylistIcon
v-if="store.state.auth.authenticated"
:album="object"
/>
<!-- TODO: Share Button -->
<album-dropdown
:object="object"
:public-libraries="publicLibraries"
:is-loading="isLoading"
:is-album="isAlbum"
:is-serie="isSerie"
:is-channel="isChannel"
:artist-credit="artistCredit"
@remove="remove"
/>
</Layout>
</Layout>
</Layout>
<div style="flex 1;">
<router-view
v-if="object"
:key="route.fullPath"
:paginate-by="paginateBy"
:total-tracks="totalTracks"
:is-serie="isSerie"
:artist-credit="artistCredit"
:object="object"
:is-loading-tracks="isLoadingTracks"
object-type="album"
@libraries-loaded="libraries = $event"
/>
</div>
</template> </template>
</Layout> <artist-credit-label
v-if="artistCredit"
:artist-credit="artistCredit"
/>
<!-- Metadata: -->
<Layout
gap-4
class="meta"
>
<Layout
flex
gap-4
>
<template v-if="object.release_date">
{{ momentFormat(new Date(object.release_date ?? '1970-01-01'), 'Y') }}
<i class="bi bi-dot" />
</template>
<template v-if="totalTracks > 0">
<span v-if="isSerie">
{{ t('components.library.AlbumBase.meta.episodes', totalTracks) }}
</span>
<span v-else>
{{ t('components.library.AlbumBase.meta.tracks', totalTracks) }}
</span>
</template>
<i
v-if="totalDuration > 0"
class="bi bi-dot"
/>
<human-duration
v-if="totalDuration > 0"
:duration="totalDuration"
/>
<!--TODO: License -->
</Layout>
</Layout>
<RenderedDescription
v-if="object.description"
:content="{ html: object.description.html }"
:truncate-length="50"
/>
<Layout flex>
<PlayButton
v-if="object.tracks"
split
:tracks="object.tracks"
low-height
:is-playable="object.is_playable"
/>
<Button
v-if="object?.tracks?.length && object?.tracks?.length > 2"
primary
icon="bi-shuffle"
low-height
:aria-label="labels.shuffle"
@click.prevent.stop="shuffle()"
>
{{ labels.shuffle }}
</Button>
<DangerousButton
v-if="artistCredit[0] &&
store.state.auth.authenticated &&
artistCredit[0].artist.channel
/* TODO: Re-implement once attributed_to is not only a number
&& artistCredit[0].artist.attributed_to?.full_username === store.state.auth.fullUsername
*/"
:is-loading="isLoading"
low-height
icon="bi-trash"
@confirm="remove()"
>
{{ t('components.library.AlbumDropdown.button.delete') }}
</DangerousButton>
<Spacer
h
grow
/>
<TrackFavoriteIcon
v-if="store.state.auth.authenticated"
square-small
:album="object"
/>
<TrackPlaylistIcon
v-if="store.state.auth.authenticated"
square-small
:album="object"
/>
<!-- TODO: Share Button -->
<album-dropdown
:object="object"
:public-libraries="publicLibraries"
:is-loading="isLoading"
:is-album="isAlbum"
:is-serie="isSerie"
:is-channel="isChannel"
:artist-credit="artistCredit"
@remove="remove"
/>
</Layout>
</Header>
<div style="flex 1;">
<router-view
v-if="object"
:key="route.fullPath"
:paginate-by="paginateBy"
:total-tracks="totalTracks"
:is-serie="isSerie"
:artist-credit="artistCredit"
:object="object"
:is-loading-tracks="isLoadingTracks"
object-type="album"
@libraries-loaded="libraries = $event"
/>
</div>
</template> </template>
<style scopen> <style scoped lang="scss">
.meta { .meta {
line-height: 48px; font-size: 15px;
@include light-theme {
color: var(--fw-gray-700);
}
@include dark-theme {
color: var(--fw-gray-500);
}
} }
</style> </style>

View File

@ -16,10 +16,6 @@ import Popover from '~/components/ui/Popover.vue'
import PopoverItem from '~/components/ui/popover/PopoverItem.vue' import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
import OptionsButton from '~/components/ui/button/Options.vue' import OptionsButton from '~/components/ui/button/Options.vue'
interface Events {
(e: 'remove'): void
}
interface Props { interface Props {
isLoading: boolean isLoading: boolean
artistCredit: ArtistCredit[] artistCredit: ArtistCredit[]
@ -28,10 +24,10 @@ interface Props {
isAlbum: boolean isAlbum: boolean
isChannel: boolean isChannel: boolean
isSerie: boolean isSerie: boolean
} }
const store = useStore() const store = useStore()
const emit = defineEmits<Events>()
const props = defineProps<Props>() const props = defineProps<Props>()
const { report, getReportableObjects } = useReport() const { report, getReportableObjects } = useReport()
@ -75,7 +71,7 @@ const open = ref(false)
<template #default="{ toggleOpen }"> <template #default="{ toggleOpen }">
<OptionsButton <OptionsButton
:title="labels.more" :title="labels.more"
is-square is-square-small
@click="toggleOpen()" @click="toggleOpen()"
/> />
</template> </template>

View File

@ -11,11 +11,14 @@ import { useStore } from '~/store'
import axios from 'axios' import axios from 'axios'
import useReport from '~/composables/moderation/useReport' import useReport from '~/composables/moderation/useReport'
import useLogger from '~/composables/useLogger' import useLogger from '~/composables/useLogger'
import { useModal } from '~/ui/composables/useModal.ts'
import HumanDuration from '~/components/common/HumanDuration.vue' import HumanDuration from '~/components/common/HumanDuration.vue'
import EmbedWizard from '~/components/audio/EmbedWizard.vue' import EmbedWizard from '~/components/audio/EmbedWizard.vue'
import Loader from '~/components/ui/Loader.vue' import Loader from '~/components/ui/Loader.vue'
import Header from '~/components/ui/Header.vue'
import Button from '~/components/ui/Button.vue' import Button from '~/components/ui/Button.vue'
import Link from '~/components/ui/Link.vue'
import OptionsButton from '~/components/ui/button/Options.vue' import OptionsButton from '~/components/ui/button/Options.vue'
import PlayButton from '~/components/audio/PlayButton.vue' import PlayButton from '~/components/audio/PlayButton.vue'
import RadioButton from '~/components/radios/Button.vue' import RadioButton from '~/components/radios/Button.vue'
@ -24,6 +27,7 @@ import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
import Layout from '~/components/ui/Layout.vue' import Layout from '~/components/ui/Layout.vue'
import Modal from '~/components/ui/Modal.vue' import Modal from '~/components/ui/Modal.vue'
import Spacer from '~/components/ui/Spacer.vue' import Spacer from '~/components/ui/Spacer.vue'
import RenderedDescription from '../common/RenderedDescription.vue'
interface Props { interface Props {
id: number | string id: number | string
@ -125,192 +129,246 @@ const fetchData = async () => {
const totalDuration = computed(() => sum((tracks.value ?? []).map(track => track.uploads[0]?.duration ?? 0))) const totalDuration = computed(() => sum((tracks.value ?? []).map(track => track.uploads[0]?.duration ?? 0)))
watch(() => props.id, fetchData, { immediate: true }) watch(() => props.id, fetchData, { immediate: true })
const isOpen = useModal('artist-description').isOpen
</script> </script>
<template> <template>
<Layout <Loader v-if="isLoading" />
<Header
v-if="object && !isLoading"
v-title="labels.title" v-title="labels.title"
stack :h1="object.name"
main page-heading
> >
<Loader v-if="isLoading" /> <template #image>
<template v-if="object && !isLoading"> <img
<Layout flex> v-lazy="cover.urls.large_square_crop"
<img :alt="object.name"
v-lazy="cover.urls.large_square_crop" class="channel-image"
:alt="object.name"
class="channel-image"
>
<Layout
stack
style="flex: 1; gap: 8px;"
>
<h1 style="margin-top: 64px; margin-bottom: 8px;">
{{ object.name }}
</h1>
<Layout
flex
class="meta"
style="gap: 0;"
>
<div
v-if="albums"
>
{{ t('components.library.ArtistBase.meta.tracks', totalTracks) }}
{{ t('components.library.ArtistBase.meta.albums', totalAlbums) }}
</div>
<div v-if="totalDuration > 0">
<i class="bi bi-dot" />
<human-duration
v-if="totalDuration > 0"
:duration="totalDuration"
/>
</div>
</Layout>
<Spacer />
<Layout flex>
<PlayButton
:is-playable="isPlayable"
split
:artist="object"
>
{{ t('components.library.ArtistBase.button.play') }}
</PlayButton>
<radio-button
type="artist"
:object-id="object.id"
/>
<Spacer grow />
<Popover>
<template #default="{ toggleOpen }">
<OptionsButton
@click="toggleOpen"
/>
</template>
<template #items>
<PopoverItem
v-if="object.fid && domain != store.getters['instance/domain']"
:to="object.fid"
target="_blank"
icon="bi-box-arrow-up-right"
>
{{ t('components.library.ArtistBase.link.domain', {domain: domain}) }}
</PopoverItem>
<PopoverItem
v-if="publicLibraries.length > 0"
icon="bi-code-square"
@click="showEmbedModal = true"
>
{{ t('components.library.ArtistBase.button.embed') }}
</PopoverItem>
<PopoverItem
:to="wikipediaUrl"
target="_blank"
rel="noreferrer noopener"
icon="bi-wikipedia"
>
{{ t('components.library.ArtistBase.link.wikipedia') }}
</PopoverItem>
<PopoverItem
v-if="musicbrainzUrl"
:to="musicbrainzUrl"
target="_blank"
rel="noreferrer noopener"
icon="bi-box-arrow-up-right"
>
{{ t('components.library.ArtistBase.link.musicbrainz') }}
</PopoverItem>
<PopoverItem
:to="discogsUrl"
target="_blank"
rel="noreferrer noopener"
icon="bi-box-arrow-up-right"
>
{{ t('components.library.ArtistBase.link.discogs') }}
</PopoverItem>
<PopoverItem
v-if="object.is_local"
:to="{name: 'library.artists.edit', params: {id: object.id }}"
icon="bi-pencil-fill"
>
{{ t('components.library.ArtistBase.button.edit') }}
</PopoverItem>
<hr v-if="getReportableObjects({artist: object}).length>0">
<PopoverItem
v-for="obj in getReportableObjects({artist: object})"
:key="obj.target.type + obj.target.id"
icon="bi-share-fill"
@click="report(obj)"
>
{{ obj.label }}
</PopoverItem>
<hr v-if="getReportableObjects({artist: object}).length>0">
<PopoverItem
v-if="store.state.auth.availablePermissions['library']"
:to="{name: 'manage.library.artists.detail', params: {id: object.id}}"
icon="bi-wrench"
>
{{ t('components.library.ArtistBase.link.moderation') }}
</PopoverItem>
<PopoverItem
v-if="store.state.auth.profile && store.state.auth.profile.is_superuser"
:to="store.getters['instance/absoluteUrl'](`/api/admin/music/artist/${object.id}`)"
target="_blank"
rel="noopener noreferrer"
icon="bi-wrench"
>
{{ t('components.library.ArtistBase.link.django') }}
</PopoverItem>
</template>
</Popover>
</Layout>
</Layout>
</Layout>
<Modal
v-if="publicLibraries.length > 0"
v-model="showEmbedModal"
:title="t('components.library.ArtistBase.modal.embed.header')"
> >
<embed-wizard
:id="object.id"
type="artist"
/>
<template #actions>
<Button secondary>
{{ t('components.library.ArtistBase.button.cancel') }}
</Button>
</template>
</Modal>
<hr>
<router-view
:key="route.fullPath"
:tracks="tracks"
:next-tracks-url="nextTracksUrl"
:next-albums-url="nextAlbumsUrl"
:albums="albums"
:is-loading-albums="isLoading"
:object="object"
object-type="artist"
@libraries-loaded="libraries = $event"
/>
</template> </template>
</Layout> <Layout
flex
class="meta"
no-gap
>
<div
v-if="albums"
>
{{ t('components.library.ArtistBase.meta.tracks', totalTracks) }}
{{ t('components.library.ArtistBase.meta.albums', totalAlbums) }}
</div>
<div v-if="totalDuration > 0">
<i class="bi bi-dot" />
<human-duration
v-if="totalDuration > 0"
:duration="totalDuration"
/>
</div>
</Layout>
<Layout
flex
gap-4
>
<RenderedDescription
v-if="object.description"
class="description"
:content="{ ...object.description, text: object.description.text ?? undefined }"
:truncate-length="100"
:more-link="false"
/>
<Spacer grow />
<Link
v-if="object.description"
:to="useModal('artist-description').to"
style="color: var(--fw-primary); text-decoration: underline;"
thin-font
force-underline
>
{{ t('components.common.RenderedDescription.button.more') }}
</Link>
</Layout>
<Modal
v-if="object.description"
v-model="isOpen"
:title="object.name"
>
<img
v-if="object.cover"
v-lazy="object.cover.urls.original"
:alt="object.name"
style="object-fit: cover; width: 100%; height: 100%;"
>
<sanitized-html
v-if="object.description"
:html="object.description.html"
/>
</Modal>
<Layout flex>
<PlayButton
:is-playable="isPlayable"
split
:artist="object"
low-height
>
{{ t('components.library.ArtistBase.button.play') }}
</PlayButton>
<radio-button
type="artist"
:object-id="object.id"
low-height
/>
<Spacer grow />
<Popover>
<template #default="{ toggleOpen }">
<OptionsButton
is-square-small
@click="toggleOpen"
/>
</template>
<template #items>
<PopoverItem
v-if="object.fid && domain != store.getters['instance/domain']"
:to="object.fid"
target="_blank"
icon="bi-box-arrow-up-right"
>
{{ t('components.library.ArtistBase.link.domain', {domain: domain}) }}
</PopoverItem>
<PopoverItem
v-if="publicLibraries.length > 0"
icon="bi-code-square"
@click="showEmbedModal = true"
>
{{ t('components.library.ArtistBase.button.embed') }}
</PopoverItem>
<PopoverItem
:to="wikipediaUrl"
target="_blank"
rel="noreferrer noopener"
icon="bi-wikipedia"
>
{{ t('components.library.ArtistBase.link.wikipedia') }}
</PopoverItem>
<PopoverItem
v-if="musicbrainzUrl"
:to="musicbrainzUrl"
target="_blank"
rel="noreferrer noopener"
icon="bi-box-arrow-up-right"
>
{{ t('components.library.ArtistBase.link.musicbrainz') }}
</PopoverItem>
<PopoverItem
:to="discogsUrl"
target="_blank"
rel="noreferrer noopener"
icon="bi-box-arrow-up-right"
>
{{ t('components.library.ArtistBase.link.discogs') }}
</PopoverItem>
<PopoverItem
v-if="object.is_local"
:to="{name: 'library.artists.edit', params: {id: object.id }}"
icon="bi-pencil-fill"
>
{{ t('components.library.ArtistBase.button.edit') }}
</PopoverItem>
<hr v-if="getReportableObjects({artist: object}).length>0">
<PopoverItem
v-for="obj in getReportableObjects({artist: object})"
:key="obj.target.type + obj.target.id"
icon="bi-share-fill"
@click="report(obj)"
>
{{ obj.label }}
</PopoverItem>
<hr v-if="getReportableObjects({artist: object}).length>0">
<PopoverItem
v-if="store.state.auth.availablePermissions['library']"
:to="{name: 'manage.library.artists.detail', params: {id: object.id}}"
icon="bi-wrench"
>
{{ t('components.library.ArtistBase.link.moderation') }}
</PopoverItem>
<PopoverItem
v-if="store.state.auth.profile && store.state.auth.profile.is_superuser"
:to="store.getters['instance/absoluteUrl'](`/api/admin/music/artist/${object.id}`)"
target="_blank"
rel="noopener noreferrer"
icon="bi-wrench"
>
{{ t('components.library.ArtistBase.link.django') }}
</PopoverItem>
</template>
</Popover>
</Layout>
<Modal
v-if="publicLibraries.length > 0"
v-model="showEmbedModal"
:title="t('components.library.ArtistBase.modal.embed.header')"
>
<embed-wizard
:id="object.id"
type="artist"
/>
<template #actions>
<Button secondary>
{{ t('components.library.ArtistBase.button.cancel') }}
</Button>
</template>
</Modal>
</Header>
<hr>
<router-view
:key="route.fullPath"
:tracks="tracks"
:next-tracks-url="nextTracksUrl"
:next-albums-url="nextAlbumsUrl"
:albums="albums"
:is-loading-albums="isLoading"
:object="object"
object-type="artist"
@libraries-loaded="libraries = $event"
/>
</template> </template>
<style scoped> <style scoped lang="scss">
.channel-image { .channel-image {
border-radius: 50%; border-radius: 50%;
} }
.meta {
font-size: 15px;
@include light-theme {
color: var(--fw-gray-700);
}
@include dark-theme {
color: var(--fw-gray-500);
}
}
.description {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
white-space: normal;
-webkit-line-clamp: 1; /* Number of lines to show */
line-clamp: 1;
}
</style> </style>

View File

@ -242,8 +242,8 @@ const trackDetails: {
<style lang="scss"> <style lang="scss">
.channel-image { .channel-image {
width: 300px; width: 200px;
height: 300px; height: 200px;
border: none; border: none;
} }

View File

@ -9,6 +9,7 @@ import axios from 'axios'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
import OptionsButton from '~/components/ui/button/Options.vue' import OptionsButton from '~/components/ui/button/Options.vue'
import EmbedWizard from '~/components/audio/EmbedWizard.vue'
import Modal from '~/components/ui/Modal.vue' import Modal from '~/components/ui/Modal.vue'
import Alert from '~/components/ui/Alert.vue' import Alert from '~/components/ui/Alert.vue'
import Button from '~/components/ui/Button.vue' import Button from '~/components/ui/Button.vue'
@ -104,7 +105,10 @@ const showDeleteModal = ref(false)
<span> <span>
<Popover v-model="open"> <Popover v-model="open">
<template #default="{ toggleOpen }"> <template #default="{ toggleOpen }">
<OptionsButton @click="toggleOpen" /> <OptionsButton
is-square-small
@click="toggleOpen"
/>
</template> </template>
<template #items> <template #items>
<PopoverItem <PopoverItem

View File

@ -15,6 +15,7 @@ const props = defineProps<{
alignLeft?: boolean alignLeft?: boolean
action?: { text: string } & (ComponentProps<typeof Link> | ComponentProps<typeof Button>) action?: { text: string } & (ComponentProps<typeof Link> | ComponentProps<typeof Button>)
icon?: string icon?: string
noGap?: false
} & { } & {
[H in `h${ '1' | '2' | '3' | '4' | '5' | '6' }`]? : string [H in `h${ '1' | '2' | '3' | '4' | '5' | '6' }`]? : string
} & { } & {
@ -35,7 +36,8 @@ const props = defineProps<{
</div> </div>
<Layout <Layout
stack stack
gap-8 :gap-8="!(props.noGap as boolean)"
:no-gap="props.noGap"
style="flex-grow: 1;" style="flex-grow: 1;"
> >
<Layout <Layout
@ -46,7 +48,7 @@ const props = defineProps<{
<!-- Set distance between baseline and previous row --> <!-- Set distance between baseline and previous row -->
<Spacer <Spacer
v v
:size="68" :size="53"
style="align-self: baseline;" style="align-self: baseline;"
/> />
<div <div
@ -66,7 +68,7 @@ const props = defineProps<{
v-bind="props" v-bind="props"
style=" style="
align-self: baseline; align-self: baseline;
padding: 0 0 24px 0; padding: 0 0 0 0;
margin: 0; margin: 0;
" "
/> />
@ -93,6 +95,7 @@ const props = defineProps<{
<slot /> <slot />
</Layout> </Layout>
</Layout> </Layout>
<Spacer />
</template> </template>
<style module lang="scss"> <style module lang="scss">

View File

@ -4,6 +4,7 @@ import Button from '../Button.vue'
defineProps<{ defineProps<{
isSquare?: boolean isSquare?: boolean
isGhost?: boolean isGhost?: boolean
isSquareSmall?: boolean
}>() }>()
</script> </script>
@ -14,7 +15,8 @@ defineProps<{
:class="['options-button', {'is-ghost': isGhost}]" :class="['options-button', {'is-ghost': isGhost}]"
:secondary="!isGhost" :secondary="!isGhost"
:ghost="isGhost" :ghost="isGhost"
:round="!isSquare" :round="!isSquare && !isSquareSmall"
:square-small="isSquareSmall"
/> />
</template> </template>

View File

@ -2824,6 +2824,40 @@
"tracks": "No tracks | {n} track | {n} tracks" "tracks": "No tracks | {n} track | {n} tracks"
} }
}, },
"Detail": {
"button": {
"cancel": "Cancel",
"confirm": "Delete playlist",
"delete": "Delete",
"edit": "Edit",
"embed": "Embed",
"playAll": "Play all",
"stopEdit": "Stop Editing"
},
"empty": {
"noTracks": "There are no tracks in this playlist yet"
},
"header": {
"tracks": "Tracks"
},
"meta": {
"attribution": "by",
"tracks": "Playlist containing {n} track, by {username} | Playlist containing {n} tracks, by {username}",
"updated": "updated"
},
"modal": {
"delete": {
"content": {
"warning": "This will completely delete this playlist and cannot be undone."
},
"header": "Do you want to delete the playlist {playlist}?"
},
"embed": {
"header": "Embed this playlist on your website"
}
},
"title": "Playlist"
},
"Editor": { "Editor": {
"button": { "button": {
"addDuplicate": "Add anyway", "addDuplicate": "Add anyway",
@ -2877,6 +2911,50 @@
"name": "My awesome playlist" "name": "My awesome playlist"
} }
}, },
"List": {
"button": {
"create": "Create a playlist",
"manage": "Manage your playlists",
"search": "Search"
},
"empty": {
"noResults": "No results matching your query"
},
"header": {
"browse": "Browsing playlists",
"playlists": "Playlists"
},
"label": {
"search": "Search"
},
"ordering": {
"direction": {
"ascending": "Ascending",
"descending": "Descending",
"label": "Order"
},
"label": "Ordering"
},
"pagination": {
"results": "Results per page"
},
"placeholder": {
"search": "Enter playlist name…"
}
},
"PlaylistDropdown": {
"button": {
"import": {
"header": "Rebuild playlist",
"description": "This will update the playlist with the content of the xspf file. Existing playlist tracks will be deleted"
},
"export": {
"header": "Download playlist",
"description": "This will provide an xspf file with the playlist data"
}
},
"more": "More"
},
"PlaylistModal": { "PlaylistModal": {
"button": { "button": {
"addDuplicate": "Add anyway", "addDuplicate": "Add anyway",

View File

@ -2925,19 +2925,6 @@
"placeholder": { "placeholder": {
"noPlaylists": "No playlists have been created yet" "noPlaylists": "No playlists have been created yet"
} }
},
"PlaylistDropdown": {
"button": {
"import": {
"header": "Rebuild playlist",
"description": "This will update the playlist with the content of the xspf file. Existing playlist tracks will be deleted"
},
"export": {
"header": "Download playlist",
"description": "This will provide an xspf file with the playlist data"
}
},
"more": "More"
} }
}, },
"radios": { "radios": {
@ -4581,6 +4568,12 @@
} }
}, },
"playlists": { "playlists": {
"Card": {
"title": "Updated on {date}",
"meta": {
"tracks": "No tracks | {n} track | {n} tracks"
}
},
"Detail": { "Detail": {
"button": { "button": {
"cancel": "Cancel", "cancel": "Cancel",
@ -4615,6 +4608,59 @@
}, },
"title": "Playlist" "title": "Playlist"
}, },
"Editor": {
"button": {
"addDuplicate": "Add anyway",
"clear": "Clear playlist",
"copy": "Copy the current queue to this playlist",
"insertFromQueue": "Insert from queue ({n} track) | Insert from queue ({n} tracks"
},
"error": {
"sync": "An error occurred while saving your changes"
},
"header": {
"editor": "Playlist editor"
},
"help": {
"reorder": "Drag and drop rows to reorder tracks in the playlist"
},
"loading": {
"sync": "Syncing changes to server…"
},
"message": {
"sync": "Changes synced with server"
},
"modal": {
"clearPlaylist": {
"content": {
"warning": "This will remove all tracks from this playlist and cannot be undone."
},
"header": "Do you want to clear the playlist \"{playlist}\"?"
}
},
"warning": {
"duplicate": "Some tracks in your queue are already in this playlist:"
}
},
"Form": {
"button": {
"create": "Create playlist",
"update": "Update playlist"
},
"header": {
"createFailure": "The playlist could not be created",
"createPlaylist": "Create a new playlist",
"createSuccess": "Playlist created",
"updateSuccess": "Playlist updated"
},
"label": {
"name": "Playlist name",
"visibility": "Playlist visibility"
},
"placeholder": {
"name": "My awesome playlist"
}
},
"List": { "List": {
"button": { "button": {
"create": "Create a playlist", "create": "Create a playlist",
@ -4645,6 +4691,72 @@
"placeholder": { "placeholder": {
"search": "Enter playlist name…" "search": "Enter playlist name…"
} }
},
"PlaylistModal": {
"button": {
"addDuplicate": "Add anyway",
"addToPlaylist": "Add to this playlist",
"addTrack": "Add track",
"cancel": "Cancel",
"edit": "Edit"
},
"empty": {
"noPlaylists": "No playlists have been created yet"
},
"header": {
"addFailure": "The track can't be added to a playlist",
"addToPlaylist": "Add to playlist",
"available": "Available playlists",
"manage": "Manage playlists",
"noResults": "No results matching your filter",
"track": "{title}, by {artist}"
},
"label": {
"filter": "Filter"
},
"placeholder": {
"filterPlaylist": "Enter playlist name"
},
"table": {
"edit": {
"header": {
"edit": "Edit",
"lastModification": "Last modification",
"name": "Name",
"tracks": "Tracks"
}
}
},
"warning": {
"duplicate": "{ 0 } is already in { 1 }."
}
},
"TrackPlaylistIcon": {
"button": {
"add": "Add to playlist…"
}
},
"Widget": {
"button": {
"create": "Create playlist",
"more": "Show more"
},
"placeholder": {
"noPlaylists": "No playlists have been created yet"
}
},
"PlaylistDropdown": {
"button": {
"import": {
"header": "Rebuild playlist",
"description": "This will update the playlist with the content of the xspf file. Existing playlist tracks will be deleted"
},
"export": {
"header": "Download playlist",
"description": "This will provide an xspf file with the playlist data"
}
},
"more": "More"
} }
}, },
"radios": { "radios": {

View File

@ -113,8 +113,8 @@
} }
} }
.channel-image { .channel-image {
width: 300px; width: 200px;
height: 300px; height: 200px;
&.large { &.large {
width: 8em !important; width: 8em !important;

View File

@ -9,16 +9,21 @@ import { useStore } from '~/store'
import { hashCode, intToRGB } from '~/utils/color' import { hashCode, intToRGB } from '~/utils/color'
import UserFollowButton from '~/components/federation/UserFollowButton.vue' import UserFollowButton from '~/components/federation/UserFollowButton.vue'
import { useModal } from '~/ui/composables/useModal.ts'
import axios from 'axios' import axios from 'axios'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
import RenderedDescription from '~/components/common/RenderedDescription.vue' import RenderedDescription from '~/components/common/RenderedDescription.vue'
import Layout from '~/components/ui/Layout.vue' import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Header from '~/components/ui/Header.vue' import Header from '~/components/ui/Header.vue'
import Button from '~/components/ui/Button.vue' import Button from '~/components/ui/Button.vue'
import Link from '~/components/ui/Link.vue'
import Nav from '~/components/ui/Nav.vue' import Nav from '~/components/ui/Nav.vue'
import Alert from '~/components/ui/Alert.vue' import Alert from '~/components/ui/Alert.vue'
import Modal from '~/components/ui/Modal.vue'
interface Events { interface Events {
(e: 'updated', value: components['schemas']['FullActor']): void (e: 'updated', value: components['schemas']['FullActor']): void
@ -96,6 +101,8 @@ const tabs = ref([{
}] }]
: [] : []
)]) )])
const isOpen = useModal('artist-description').isOpen
</script> </script>
<template> <template>
@ -103,6 +110,7 @@ const tabs = ref([{
v-title="labels.usernameProfile" v-title="labels.usernameProfile"
stack stack
main main
no-gap
> >
<!-- TODO: Translate Edit Link --> <!-- TODO: Translate Edit Link -->
<!-- TODO: `yarn lint:tsc` doesn't understand the `Prop` type for `Header` while the language server does. It may be a question of typescript version... Investigate and fix! --> <!-- TODO: `yarn lint:tsc` doesn't understand the `Prop` type for `Header` while the language server does. It may be a question of typescript version... Investigate and fix! -->
@ -118,9 +126,11 @@ const tabs = ref([{
// @ts-ignore // @ts-ignore
solid: true, solid: true,
// @ts-ignore // @ts-ignore
icon: 'bi-pencil-fill' icon: 'bi-pencil-fill',
// @ts-ignore
lowHeight: true
}" }"
style="margin-top: 58px;" no-gap
page-heading page-heading
> >
<template #image> <template #image>
@ -148,6 +158,7 @@ const tabs = ref([{
:title="t('components.common.CopyInput.button.copy')" :title="t('components.common.CopyInput.button.copy')"
ghost ghost
secondary secondary
low-height
@click="copy(fullUsername)" @click="copy(fullUsername)"
/> />
</span> </span>
@ -174,19 +185,43 @@ const tabs = ref([{
flex flex
no-gap no-gap
> >
<!-- TODO: Fix error with `$event` not being the right type -->
<!-- @vue-ignore -->
<RenderedDescription <RenderedDescription
:content="{ html: object?.summary?.html || '' }" v-if="object?.summary"
:field-name="'summary'" class="description"
:update-url="`users/${store.state.auth.username}/`" :content="{ html: object?.summary.html || '' }"
:can-update="store.state.auth.authenticated && object?.full_username === store.state.auth.fullUsername"
:truncate-length="100" :truncate-length="100"
@updated="emit('updated', $event)" :more-link="false"
/> />
<Spacer grow />
<Link
v-if="object?.summary"
:to="useModal('artist-description').to"
style="color: var(--fw-primary); text-decoration: underline;"
thin-font
force-underline
>
{{ t('components.common.RenderedDescription.button.more') }}
</Link>
</Layout> </Layout>
<Modal
v-if="object?.summary"
v-model="isOpen"
:title="object?.name"
>
<img
v-if="object?.user.avatar"
v-lazy="object?.user.avatar.urls.original"
:alt="object?.name"
style="object-fit: cover; width: 100%; height: 100%;"
>
<sanitized-html
v-if="object?.summary"
:html="object?.summary.html"
/>
</Modal>
<UserFollowButton <UserFollowButton
v-if="store.state.auth.authenticated && object && object.full_username !== store.state.auth.fullUsername" v-if="store.state.auth.authenticated && object && object.full_username !== store.state.auth.fullUsername"
low-height
:actor="object" :actor="object"
/> />
</Header> </Header>
@ -201,8 +236,8 @@ const tabs = ref([{
<style scoped lang="scss"> <style scoped lang="scss">
img.avatar { img.avatar {
width: 300px; width: 200px;
height: 300px; height: 200px;
border-radius: 50%; border-radius: 50%;
} }
@ -215,8 +250,8 @@ const tabs = ref([{
align-content: center; align-content: center;
background-color: var(--fw-gray-500); background-color: var(--fw-gray-500);
border-radius: 50%; border-radius: 50%;
width: 300px; width: 200px;
height: 300px; height: 200px;
} }
h1 { h1 {

View File

@ -22,13 +22,14 @@ import TagsList from '~/components/tags/List.vue'
import RadioButton from '~/components/radios/Button.vue' import RadioButton from '~/components/radios/Button.vue'
import Loader from '~/components/ui/Loader.vue' import Loader from '~/components/ui/Loader.vue'
import Layout from '~/components/ui/Layout.vue'
import Header from '~/components/ui/Header.vue'
import Button from '~/components/ui/Button.vue' import Button from '~/components/ui/Button.vue'
import Link from '~/components/ui/Link.vue' import Link from '~/components/ui/Link.vue'
import Nav from '~/components/ui/Nav.vue' import Nav from '~/components/ui/Nav.vue'
import OptionsButton from '~/components/ui/button/Options.vue' import OptionsButton from '~/components/ui/button/Options.vue'
import Popover from '~/components/ui/Popover.vue' import Popover from '~/components/ui/Popover.vue'
import PopoverItem from '~/components/ui/popover/PopoverItem.vue' import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue' import Spacer from '~/components/ui/Spacer.vue'
import Modal from '~/components/ui/Modal.vue' import Modal from '~/components/ui/Modal.vue'
@ -166,345 +167,351 @@ const tabs = ref([
<Layout <Layout
v-title="labels.title" v-title="labels.title"
stack stack
no-gap
main main
> >
<Loader v-if="isLoading" /> <Loader v-if="isLoading" />
<template v-if="object && !isLoading"> <Header
<section v-if="object && !isLoading"
v-title="object.artist?.name" v-title="object.artist?.name"
:h1="object.artist?.name"
page-heading
>
<template #image>
<img
v-if="object.artist?.cover"
alt=""
:class="['huge', object.artist?.content_category === 'podcast' ? 'podcast-image' : 'channel-image']"
:src="store.getters['instance/absoluteUrl'](object.artist.cover.urls.large_square_crop)"
>
<i
v-else
class="bi bi-person-circle"
style="font-size: 300px; margin-top: -32px;"
/>
</template>
<Layout
stack
class="meta"
style="gap: 8px;"
> >
<Layout flex> <Layout
<img flex
v-if="object.artist?.cover" no-gap
alt="" >
:class="['huge', object.artist?.content_category === 'podcast' ? 'podcast-image' : 'channel-image']" <template v-if="totalTracks > 0">
:src="store.getters['instance/absoluteUrl'](object.artist.cover.urls.large_square_crop)" <span
> v-if="object.artist?.content_category === 'podcast'"
<i
v-else
class="bi bi-person-circle"
style="font-size: 300px; margin-top: -32px;"
/>
<Layout
stack
style="flex: 1; gap: 16px;"
>
<h1 style="margin-top: 64px; margin-bottom: 8px;">
{{ object.artist?.name }}
</h1>
<Layout
stack
class="meta"
style="gap: 8px;"
> >
<Layout {{ t('views.channels.DetailBase.meta.episodes', totalTracks) }}
flex </span>
no-gap <span
> v-else
<template v-if="totalTracks > 0"> >
<span {{ t('views.channels.DetailBase.meta.tracks', totalTracks) }}
v-if="object.artist?.content_category === 'podcast'" </span>
> <i class="bi bi-dot" />
{{ t('views.channels.DetailBase.meta.episodes', totalTracks) }} </template>
</span> {{ t('views.channels.DetailBase.meta.subscribers', object?.subscriptions_count ?? 0) }}
<span <i class="bi bi-dot" />
v-else {{ t('views.channels.DetailBase.meta.listenings', object?.downloads_count ?? 0) }}
>
{{ t('views.channels.DetailBase.meta.tracks', totalTracks) }}
</span>
<i class="bi bi-dot" />
</template>
{{ t('views.channels.DetailBase.meta.subscribers', object?.subscriptions_count ?? 0) }}
<i class="bi bi-dot" />
{{ t('views.channels.DetailBase.meta.listenings', object?.downloads_count ?? 0) }}
<div v-if="totalTracks > 0"> <div v-if="totalTracks > 0">
<i class="bi bi-dot" /> <i class="bi bi-dot" />
<human-duration <human-duration
v-if="totalTracks > 0" v-if="totalTracks > 0"
:duration="totalTracks" :duration="totalTracks"
/>
</div>
</Layout>
<Layout
flex
no-gap
>
<template v-if="object.artist?.content_category === 'podcast'">
<span>
{{ t('views.channels.DetailBase.header.podcastChannel') }}
</span>
<span
v-if="!object.actor"
>
<i class="bi bi-dot" />
<a
:href="object.url || object.rss_url"
rel="noopener noreferrer"
target="_blank"
>
<i class="bi bi-box-arrow-up-right" />
{{ t('views.channels.DetailBase.link.mirrored', {domain: externalDomain}) }}
</a>
</span>
</template>
<template v-else>
<span>
{{ t('views.channels.DetailBase.header.artistChannel') }}
</span>
</template>
<span v-if="object.actor">
<i class="bi bi-dot" />
{{ t('views.library.LibraryBase.link.owner') }}
</span>
<Spacer
h
:size="4"
/>
<ActorLink
v-if="object.actor"
discrete
:avatar="true"
:actor="object.attributed_to"
:display-name="true"
/>
</Layout>
</Layout>
<rendered-description
:content="object.artist?.description"
:update-url="`channels/${object.uuid}/`"
:can-update="false"
@updated="object = $event"
/> />
<Layout </div>
flex
class="header-buttons"
>
<Link
v-if="isOwner"
solid
primary
icon="bi-upload"
:to="useModal('upload').to"
>
{{ t('views.channels.DetailBase.button.upload') }}
</Link>
<PlayButton
:is-playable="isPlayable"
split
class="vibrant"
:artist="object.artist"
>
{{ t('views.channels.DetailBase.button.play') }}
</PlayButton>
<RadioButton
type="artist"
:object-id="object.artist.id"
/>
<Popover>
<template #default="{ toggleOpen }">
<OptionsButton
@click="toggleOpen"
/>
</template>
<template #items>
<PopoverItem
v-if="totalTracks > 0"
icon="bi-code-slash"
@click.prevent="showEmbedModal = !showEmbedModal"
>
{{ t('views.channels.DetailBase.button.embed') }}
</PopoverItem>
<PopoverItem
v-if="object.actor && object.actor.domain != store.getters['instance/domain']"
:href="object.url"
target="_blank"
icon="bi-box-arrow-up-right"
>
{{ t('views.channels.DetailBase.link.domainView', {domain: object.actor.domain}) }}
</PopoverItem>
<hr>
<PopoverItem
v-for="obj in getReportableObjects({account: object.attributed_to, channel: object})"
:key="obj.target.type + obj.target.id"
icon="bi-share"
@click.stop.prevent="report(obj)"
>
{{ obj.label }}
</PopoverItem>
<template v-if="isOwner">
<hr>
<PopoverItem
icon="bi-pencil"
@click.stop.prevent="showEditModal = true"
>
{{ t('views.channels.DetailBase.button.edit') }}
</PopoverItem>
<dangerous-button
v-if="object"
popover-item
:title="t('views.channels.DetailBase.button.confirm')"
:is-loading="isLoading"
icon="bi-trash"
@confirm="remove()"
>
{{ t('views.channels.DetailBase.button.confirm') }}
<template #modal-content>
{{ t('views.channels.DetailBase.modal.delete.content.warning') }}
</template>
<template #modal-confirm>
<p>
{{ t('views.channels.DetailBase.button.confirm') }}
</p>
</template>
</dangerous-button>
</template>
<template v-if="store.state.auth.availablePermissions['library']">
<hr>
<PopoverItem
:to="{ name: 'manage.channels.detail', params: { id: object.uuid } }"
icon="bi-wrench"
>
{{ t('views.channels.DetailBase.link.moderation') }}
</PopoverItem>
</template>
</template>
</Popover>
<Spacer
h
grow
/>
<subscribe-button
v-if="store.state.auth.authenticated && object?.attributed_to.full_username !== store.state.auth.fullUsername"
:channel="object"
@subscribed="updateSubscriptionCount(1)"
@unsubscribed="updateSubscriptionCount(-1)"
/>
<Modal
v-if="totalTracks > 0"
v-model="showEmbedModal"
:title="t('views.channels.DetailBase.modal.embed.header')"
:cancel="t('views.channels.DetailBase.button.cancel')"
>
<div class="scrolling content">
<div class="description">
<embed-wizard
:id="object.artist!.id"
type="artist"
/>
</div>
</div>
<template #actions>
<button class="ui basic deny button">
{{ t('views.channels.DetailBase.button.cancel') }}
</button>
</template>
</Modal>
<Modal
v-if="isOwner"
v-model="showEditModal"
:title="
object.artist?.content_category === 'podcast'
? t('views.channels.DetailBase.header.podcastChannel')
: t('views.channels.DetailBase.header.artistChannel')
"
>
<div class="scrolling content">
<channel-form
ref="editForm"
:object="object"
@loading="edit.loading = $event"
@submittable="edit.submittable = $event"
@updated="fetchData"
/>
<div class="ui hidden divider" />
</div>
<template #actions>
<Button
primary
autofocus
:is-loading="edit.loading"
:disabled="!edit.submittable"
@click.stop="editForm?.submit"
>
{{ t('views.channels.DetailBase.button.updateChannel') }}
</Button>
</template>
</Modal>
<Button
secondary
icon="bi-rss"
@click.stop.prevent="showSubscribeModal = true"
/>
<Modal
v-model="showSubscribeModal"
:title="t('views.channels.DetailBase.modal.subscribe.header')"
class="tiny"
:cancel="t('views.channels.DetailBase.button.cancel')"
>
<div class="scrollable content">
<div class="description">
<template v-if="object.rss_url">
<h3>
<i class="feed icon" />
{{ t('views.channels.DetailBase.modal.subscribe.rss.header') }}
</h3>
<p>
{{ t('views.channels.DetailBase.modal.subscribe.rss.content.help') }}
</p>
<copy-input :value="object.rss_url" />
</template>
<template v-if="object.actor">
<h3>
<i class="bell icon" />
{{ t('views.channels.DetailBase.modal.subscribe.fediverse.header') }}
</h3>
<p>
{{ t('views.channels.DetailBase.modal.subscribe.fediverse.content.help') }}
</p>
<copy-input
id="copy-tag"
:value="`@${object.actor.full_username}`"
/>
</template>
</div>
</div>
</Modal>
</Layout>
</Layout>
</Layout> </Layout>
<hr> <Layout
<TagsList flex
v-if="object.artist?.tags && object.artist?.tags.length > 0" no-gap
:tags="object.artist.tags" >
:limit="5" <template v-if="object.artist?.content_category === 'podcast'">
:show-more="true" <span>
{{ t('views.channels.DetailBase.header.podcastChannel') }}
</span>
<span
v-if="!object.actor"
>
<i class="bi bi-dot" />
<a
:href="object.url || object.rss_url"
rel="noopener noreferrer"
target="_blank"
>
<i class="bi bi-box-arrow-up-right" />
{{ t('views.channels.DetailBase.link.mirrored', {domain: externalDomain}) }}
</a>
</span>
</template>
<template v-else>
<span>
{{ t('views.channels.DetailBase.header.artistChannel') }}
</span>
</template>
<span v-if="object.actor">
<i class="bi bi-dot" />
{{ t('views.library.LibraryBase.link.owner') }}
</span>
<Spacer
h
:size="8"
/>
<ActorLink
v-if="object.actor"
discrete
:avatar="true"
:actor="object.attributed_to"
:display-name="true"
/>
</Layout>
</Layout>
<rendered-description
:content="object.artist?.description"
:update-url="`channels/${object.uuid}/`"
:can-update="false"
@updated="object = $event"
/>
<Layout
flex
class="header-buttons"
>
<Link
v-if="isOwner"
solid
primary
low-height
icon="bi-upload"
:to="useModal('upload').to"
>
{{ t('views.channels.DetailBase.button.upload') }}
</Link>
<PlayButton
:is-playable="isPlayable"
split
low-height
class="vibrant"
:artist="object.artist"
>
{{ t('views.channels.DetailBase.button.play') }}
</PlayButton>
<RadioButton
type="artist"
:object-id="object.artist.id"
low-height
/> />
<Nav v-model="tabs" />
<router-view <Popover>
v-if="object" <template #default="{ toggleOpen }">
:object="object" <OptionsButton
@tracks-loaded="totalTracks = $event" is-square-small
@click="toggleOpen"
/>
</template>
<template #items>
<PopoverItem
v-if="totalTracks > 0"
icon="bi-code-slash"
@click.prevent="showEmbedModal = !showEmbedModal"
>
{{ t('views.channels.DetailBase.button.embed') }}
</PopoverItem>
<PopoverItem
v-if="object.actor && object.actor.domain != store.getters['instance/domain']"
:href="object.url"
target="_blank"
icon="bi-box-arrow-up-right"
>
{{ t('views.channels.DetailBase.link.domainView', {domain: object.actor.domain}) }}
</PopoverItem>
<hr>
<PopoverItem
v-for="obj in getReportableObjects({account: object.attributed_to, channel: object})"
:key="obj.target.type + obj.target.id"
icon="bi-share"
@click.stop.prevent="report(obj)"
>
{{ obj.label }}
</PopoverItem>
<template v-if="isOwner">
<hr>
<PopoverItem
icon="bi-pencil"
@click.stop.prevent="showEditModal = true"
>
{{ t('views.channels.DetailBase.button.edit') }}
</PopoverItem>
<dangerous-button
v-if="object"
popover-item
:title="t('views.channels.DetailBase.button.confirm')"
:is-loading="isLoading"
icon="bi-trash"
@confirm="remove()"
>
{{ t('views.channels.DetailBase.button.confirm') }}
<template #modal-content>
{{ t('views.channels.DetailBase.modal.delete.content.warning') }}
</template>
<template #modal-confirm>
<p>
{{ t('views.channels.DetailBase.button.confirm') }}
</p>
</template>
</dangerous-button>
</template>
<template v-if="store.state.auth.availablePermissions['library']">
<hr>
<PopoverItem
:to="{ name: 'manage.channels.detail', params: { id: object.uuid } }"
icon="bi-wrench"
>
{{ t('views.channels.DetailBase.link.moderation') }}
</PopoverItem>
</template>
</template>
</Popover>
<Spacer
h
grow
/> />
</section> <subscribe-button
</template> v-if="store.state.auth.authenticated && object?.attributed_to.full_username !== store.state.auth.fullUsername"
low-height
:channel="object"
@subscribed="updateSubscriptionCount(1)"
@unsubscribed="updateSubscriptionCount(-1)"
/>
<Modal
v-if="totalTracks > 0"
v-model="showEmbedModal"
:title="t('views.channels.DetailBase.modal.embed.header')"
:cancel="t('views.channels.DetailBase.button.cancel')"
>
<div class="scrolling content">
<div class="description">
<embed-wizard
:id="object.artist!.id"
type="artist"
/>
</div>
</div>
<template #actions>
<button class="ui basic deny button">
{{ t('views.channels.DetailBase.button.cancel') }}
</button>
</template>
</Modal>
<Modal
v-if="isOwner"
v-model="showEditModal"
:title="
object.artist?.content_category === 'podcast'
? t('views.channels.DetailBase.header.podcastChannel')
: t('views.channels.DetailBase.header.artistChannel')
"
>
<div class="scrolling content">
<channel-form
ref="editForm"
:object="object"
@loading="edit.loading = $event"
@submittable="edit.submittable = $event"
@updated="fetchData"
/>
<div class="ui hidden divider" />
</div>
<template #actions>
<Button
primary
autofocus
low-height
:is-loading="edit.loading"
:disabled="!edit.submittable"
@click.stop="editForm?.submit"
>
{{ t('views.channels.DetailBase.button.updateChannel') }}
</Button>
</template>
</Modal>
<Button
secondary
icon="bi-rss"
square-small
@click.stop.prevent="showSubscribeModal = true"
/>
<Modal
v-model="showSubscribeModal"
:title="t('views.channels.DetailBase.modal.subscribe.header')"
class="tiny"
:cancel="t('views.channels.DetailBase.button.cancel')"
>
<div class="scrollable content">
<div class="description">
<template v-if="object.rss_url">
<h3>
<i class="feed icon" />
{{ t('views.channels.DetailBase.modal.subscribe.rss.header') }}
</h3>
<p>
{{ t('views.channels.DetailBase.modal.subscribe.rss.content.help') }}
</p>
<copy-input :value="object.rss_url" />
</template>
<template v-if="object.actor">
<h3>
<i class="bell icon" />
{{ t('views.channels.DetailBase.modal.subscribe.fediverse.header') }}
</h3>
<p>
{{ t('views.channels.DetailBase.modal.subscribe.fediverse.content.help') }}
</p>
<copy-input
id="copy-tag"
:value="`@${object.actor.full_username}`"
/>
</template>
</div>
</div>
</Modal>
</Layout>
</Header>
<hr>
<TagsList
v-if="object?.artist?.tags && object?.artist?.tags.length > 0"
:tags="object?.artist.tags"
:limit="5"
:show-more="true"
/>
<Nav v-model="tabs" />
<router-view
v-if="object"
:object="object"
@tracks-loaded="totalTracks = $event"
/>
</Layout> </Layout>
</template> </template>
<style scoped> <style scoped lang="scss">
.channel-image { .channel-image {
border-radius: 50%; border-radius: 50%;
} }
.huge { .huge {
width: 300px; width: 200px;
height: 300px; height: 200px;
} }
.meta { .meta {
line-height: 24px;
font-size: 15px; font-size: 15px;
@include light-theme {
color: var(--fw-gray-700);
}
@include dark-theme {
color: var(--fw-gray-500);
}
} }
</style> </style>

View File

@ -8,7 +8,6 @@ import { useStore } from '~/store'
import axios from 'axios' import axios from 'axios'
import defaultCover from '~/assets/audio/default-cover.png' 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 HumanDate from '~/components/common/HumanDate.vue' import HumanDate from '~/components/common/HumanDate.vue'
@ -134,160 +133,161 @@ const shuffle = () => {}
</script> </script>
<template> <template>
<Layout <Loader
v-title="playlist?.name" v-if="isLoading"
stack v-title="labels.playlist"
main />
<Header
v-if="!isLoading && playlist"
:h1="playlist.name"
page-heading
> >
<Loader <template #image>
v-if="isLoading" <div class="playlist-grid">
v-title="labels.playlist" <img
/> v-for="(url, idx) in images"
<Header :key="idx"
v-if="!isLoading && playlist" v-lazy="url"
:h1="playlist.name" :alt="playlist.name"
page-heading :style="{ backgroundColor: randomizedColors[idx % randomizedColors.length] }"
>
</div>
</template>
<Layout
gap-4
class="meta"
> >
<template #image> <Layout
<div class="playlist-grid"> flex
<img gap-4
v-for="(url, idx) in images" >
:key="idx"
v-lazy="url"
:alt="playlist.name"
:style="{ backgroundColor: randomizedColors[idx % randomizedColors.length] }"
>
</div>
</template>
<div class="meta">
{{ playlist.tracks_count }} {{ playlist.tracks_count }}
{{ t('views.playlists.Detail.header.tracks') }} {{ t('views.playlists.Detail.header.tracks') }}
<i class="bi bi-dot" /> <i class="bi bi-dot" />
<Duration :seconds="playlist.duration" /> <Duration :seconds="playlist.duration" />
</div> </Layout>
<Layout <Layout
flex flex
gap-8 gap-4
> >
{{ t('views.playlists.Detail.meta.attribution') }} {{ t('views.playlists.Detail.meta.attribution') }}
<ActorLink {{ playlist.actor.full_username }}
:actor="playlist.actor"
:avatar="false"
:discrete="true"
/>
<i class="bi bi-dot" /> <i class="bi bi-dot" />
{{ t('views.playlists.Detail.meta.updated') }} {{ t('views.playlists.Detail.meta.updated') }}
<HumanDate <HumanDate
:date="playlist.modification_date" :date="playlist.modification_date"
/> />
</Layout> </Layout>
<RenderedDescription </Layout>
:content="{ html: playlist.description }" <RenderedDescription
:truncate-length="200" :content="{ html: playlist.description }"
:show-more="true" :truncate-length="100"
:show-more="true"
/>
<Layout
flex
class="header-buttons"
>
<PlayButton
split
low-height
:is-playable="true"
:tracks="tracks"
>
{{ t('views.playlists.Detail.button.playAll') }}
</PlayButton>
<Button
v-if="playlist.tracks_count > 1"
primary
icon="bi-shuffle"
low-height
: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
low-height
icon="bi-pencil"
@click="edit = !edit"
>
<template v-if="edit">
{{ t('views.playlists.Detail.button.stopEdit') }}
</template>
<template v-else>
{{ t('views.playlists.Detail.button.edit') }}
</template>
</Button>
<Spacer
h
grow
/> />
<playlist-dropdown
:playlist="playlist"
@import="fetchData"
/>
</Layout>
</Header>
<Layout stack>
<template v-if="edit">
<playlist-editor
v-model:playlist="playlist"
v-model:playlist-tracks="playlistTracks"
/>
</template>
<template v-else-if="tracks.length > 0">
<track-table
:show-position="true"
:tracks="tracks"
:unique="false"
/>
</template>
<Alert
v-else-if="!isLoading"
blue
align-items="center"
>
<Layout <Layout
flex flex
class="header-buttons" :gap="8"
> >
<PlayButton <i class="bi bi-music-note-list" />
split {{ t('views.playlists.Detail.empty.noTracks') }}
: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
icon="bi-pencil"
@click="edit = !edit"
>
<template v-if="edit">
{{ t('views.playlists.Detail.button.stopEdit') }}
</template>
<template v-else>
{{ t('views.playlists.Detail.button.edit') }}
</template>
</Button>
<Spacer
h
grow
/>
<playlist-dropdown
:playlist="playlist"
@import="fetchData"
/>
</Layout> </Layout>
</Header> <Spacer size-16 />
<Button
<Layout stack> primary
<template v-if="edit"> icon="bi-pencil"
<playlist-editor align-self="center"
v-model:playlist="playlist" @click="edit = !edit"
v-model:playlist-tracks="playlistTracks"
/>
</template>
<template v-else-if="tracks.length > 0">
<track-table
:show-position="true"
:tracks="tracks"
:unique="false"
/>
</template>
<Alert
v-else-if="!isLoading"
blue
align-items="center"
> >
<Layout {{ t('views.playlists.Detail.button.edit') }}
flex </Button>
:gap="8" </Alert>
>
<i class="bi bi-music-note-list" />
{{ t('views.playlists.Detail.empty.noTracks') }}
</Layout>
<Spacer size-16 />
<Button
primary
icon="bi-pencil"
align-self="center"
@click="edit = !edit"
>
{{ t('views.playlists.Detail.button.edit') }}
</Button>
</Alert>
</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>
<template #actions>
<Button variant="outline">
{{ t('views.playlists.Detail.button.cancel') }}
</Button>
</template>
</Modal>
</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>
<template #actions>
<Button variant="outline">
{{ t('views.playlists.Detail.button.cancel') }}
</Button>
</template>
</Modal>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -297,8 +297,8 @@ const shuffle = () => {}
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr); grid-template-rows: repeat(2, 1fr);
gap: 2px; gap: 2px;
width: 300px; width: 200px;
height: 300px; height: 200px;
} }
.playlist-grid img { .playlist-grid img {
@ -307,9 +307,14 @@ const shuffle = () => {}
object-fit: cover; object-fit: cover;
} }
.playlist-meta { .meta {
display: flex; font-size: 15px;
align-items: center; @include light-theme {
color: var(--fw-gray-700);
}
@include dark-theme {
color: var(--fw-gray-500);
}
} }
.playlist-action { .playlist-action {