fix(front): consistent pixel perfect header with description modal on all detail pages
This commit is contained in:
parent
a05e509d36
commit
dcb664162c
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
|
@ -113,8 +113,8 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.channel-image {
|
.channel-image {
|
||||||
width: 300px;
|
width: 200px;
|
||||||
height: 300px;
|
height: 200px;
|
||||||
|
|
||||||
&.large {
|
&.large {
|
||||||
width: 8em !important;
|
width: 8em !important;
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
Loading…
Reference in New Issue