521 lines
18 KiB
Vue
521 lines
18 KiB
Vue
<script setup lang="ts">
|
|
import type { Channel } from '~/types'
|
|
|
|
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
|
import { computed, ref, reactive, watch, watchEffect } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useStore } from '~/store'
|
|
import { useModal } from '~/ui/composables/useModal.ts'
|
|
|
|
import axios from 'axios'
|
|
|
|
import useErrorHandler from '~/composables/useErrorHandler'
|
|
import useReport from '~/composables/moderation/useReport'
|
|
|
|
import DangerousButton from '~/components/common/DangerousButton.vue'
|
|
import SubscribeButton from '~/components/channels/SubscribeButton.vue'
|
|
import ChannelForm from '~/components/audio/ChannelForm.vue'
|
|
import EmbedWizard from '~/components/audio/EmbedWizard.vue'
|
|
import HumanDuration from '~/components/common/HumanDuration.vue'
|
|
import PlayButton from '~/components/audio/PlayButton.vue'
|
|
import TagsList from '~/components/tags/List.vue'
|
|
import RadioButton from '~/components/radios/Button.vue'
|
|
|
|
import Loader from '~/components/ui/Loader.vue'
|
|
import Button from '~/components/ui/Button.vue'
|
|
import Link from '~/components/ui/Link.vue'
|
|
import Tabs from '~/components/ui/Tabs.vue'
|
|
import Tab from '~/components/ui/Tab.vue'
|
|
import OptionsButton from '~/components/ui/button/Options.vue'
|
|
import Popover from '~/components/ui/Popover.vue'
|
|
import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
|
|
import Layout from '~/components/ui/Layout.vue'
|
|
import Spacer from '~/components/ui/Spacer.vue'
|
|
import Modal from '~/components/ui/Modal.vue'
|
|
|
|
interface Events {
|
|
(e: 'deleted'): void
|
|
}
|
|
|
|
interface Props {
|
|
id: number
|
|
}
|
|
|
|
const emit = defineEmits<Events>()
|
|
const props = defineProps<Props>()
|
|
const { report, getReportableObjects } = useReport()
|
|
const store = useStore()
|
|
|
|
const object = ref<Channel | null>(null)
|
|
const editForm = ref()
|
|
const totalTracks = ref(0)
|
|
|
|
const edit = reactive({
|
|
submittable: false,
|
|
loading: false
|
|
})
|
|
|
|
const showEmbedModal = ref(false)
|
|
const showEditModal = ref(false)
|
|
const showSubscribeModal = ref(false)
|
|
|
|
const isOwner = computed(() => store.state.auth.authenticated && object.value?.attributed_to.full_username === store.state.auth.fullUsername)
|
|
const isPodcast = computed(() => object.value?.artist?.content_category === 'podcast')
|
|
const isPlayable = computed(() => totalTracks.value > 0)
|
|
const externalDomain = computed(() => {
|
|
const parser = document.createElement('a')
|
|
parser.href = object.value?.url ?? object.value?.rss_url ?? ''
|
|
return parser.hostname
|
|
})
|
|
|
|
const { t } = useI18n()
|
|
const labels = computed(() => ({
|
|
title: t('views.channels.DetailBase.title')
|
|
}))
|
|
|
|
onBeforeRouteUpdate((to) => {
|
|
to.meta.preserveScrollPosition = true
|
|
})
|
|
|
|
const router = useRouter()
|
|
const isLoading = ref(false)
|
|
const fetchData = async () => {
|
|
showEditModal.value = false
|
|
edit.loading = false
|
|
isLoading.value = true
|
|
|
|
try {
|
|
const response = await axios.get(`channels/${props.id}`, { params: { refresh: 'true' } })
|
|
object.value = response.data
|
|
totalTracks.value = response.data.artist.tracks_count
|
|
|
|
if (props.id === response.data.uuid && response.data.actor) {
|
|
// replace with the pretty channel url if possible
|
|
const actor = response.data.actor
|
|
if (actor.is_local) {
|
|
await router.replace({ name: 'channels.detail', params: { id: actor.preferred_username } })
|
|
} else {
|
|
await router.replace({ name: 'channels.detail', params: { id: actor.full_username } })
|
|
}
|
|
}
|
|
} catch (error) {
|
|
useErrorHandler(error as Error)
|
|
}
|
|
|
|
isLoading.value = false
|
|
}
|
|
|
|
watch(() => props.id, fetchData, { immediate: true })
|
|
|
|
const uuid = computed(() => store.state.channels.latestPublication?.channel.uuid)
|
|
|
|
watch([uuid, object], ([uuid, object], [lastUuid, lastObject]) => {
|
|
if (object?.uuid && object.uuid === lastObject?.uuid) return
|
|
|
|
if (uuid && uuid === object?.uuid) {
|
|
fetchData()
|
|
}
|
|
})
|
|
|
|
const route = useRoute()
|
|
watchEffect(() => {
|
|
if (!object.value) {
|
|
store.state.channels.uploadModalConfig.channel = null
|
|
return
|
|
} else {
|
|
store.state.channels.uploadModalConfig.channel = object.value
|
|
}
|
|
|
|
if (!store.state.auth.authenticated && store.getters['instance/domain'] !== object.value.actor.domain) {
|
|
router.push({ name: 'login', query: { next: route.fullPath } })
|
|
}
|
|
})
|
|
|
|
const remove = async () => {
|
|
isLoading.value = true
|
|
try {
|
|
await axios.delete(`channels/${object.value?.uuid}`)
|
|
emit('deleted')
|
|
return router.push({ name: 'profile.overview', params: { username: store.state.auth.username } })
|
|
} catch (error) {
|
|
useErrorHandler(error as Error)
|
|
}
|
|
}
|
|
|
|
const updateSubscriptionCount = (delta: number) => {
|
|
if (object.value) {
|
|
object.value.subscriptions_count += delta
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Layout
|
|
v-title="labels.title"
|
|
stack
|
|
main
|
|
>
|
|
<Loader v-if="isLoading" />
|
|
<template v-if="object && !isLoading">
|
|
<section
|
|
v-title="object.artist?.name"
|
|
>
|
|
<Layout flex>
|
|
<img
|
|
v-if="object.artist?.cover"
|
|
alt=""
|
|
class="huge 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;"
|
|
/>
|
|
<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
|
|
flex
|
|
no-gap
|
|
>
|
|
<template v-if="totalTracks > 0">
|
|
<span
|
|
v-if="object.artist?.content_category === 'podcast'"
|
|
>
|
|
{{ t('views.channels.DetailBase.meta.episodes', totalTracks) }}
|
|
</span>
|
|
<span
|
|
v-else
|
|
>
|
|
{{ t('views.channels.DetailBase.meta.tracks', totalTracks) }}
|
|
</span>
|
|
</template>
|
|
<template v-if="object.attributed_to.full_username === store.state.auth.fullUsername || store.getters['channels/isSubscribed'](object.uuid)">
|
|
<i class="bi bi-dot" />
|
|
{{ 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) }}
|
|
</template>
|
|
|
|
<div v-if="totalTracks > 0">
|
|
<i class="bi bi-dot" />
|
|
<human-duration
|
|
v-if="totalTracks > 0"
|
|
: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="false"
|
|
: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
|
|
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
|
|
: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:show="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="store.state.auth.authenticated">
|
|
<h3>
|
|
<i class="user icon" />
|
|
{{ t('views.channels.DetailBase.modal.subscribe.funkwhale.header') }}
|
|
</h3>
|
|
<subscribe-button
|
|
:channel="object"
|
|
@subscribed="updateSubscriptionCount(1)"
|
|
@unsubscribed="updateSubscriptionCount(-1)"
|
|
/>
|
|
</template>
|
|
<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>
|
|
<hr>
|
|
<TagsList
|
|
v-if="object.artist?.tags && object.artist?.tags.length > 0"
|
|
:tags="object.artist.tags"
|
|
:limit="5"
|
|
show-more="true"
|
|
/>
|
|
<Tabs>
|
|
<Tab
|
|
:title="t('views.channels.DetailBase.link.channelOverview')"
|
|
:to="{name: 'channels.detail', params: {id: id}}"
|
|
>
|
|
<router-view
|
|
v-if="object"
|
|
:object="object"
|
|
@tracks-loaded="totalTracks = $event"
|
|
/>
|
|
</Tab>
|
|
<Tab
|
|
:title="t('views.channels.DetailBase.link.channelEpisodes')"
|
|
:to="{name: 'channels.detail.episodes', params: {id: id}}"
|
|
>
|
|
<router-view
|
|
v-if="object"
|
|
:object="object"
|
|
@tracks-loaded="totalTracks = $event"
|
|
/>
|
|
</Tab>
|
|
</Tabs>
|
|
</section>
|
|
</template>
|
|
</Layout>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.channel-image {
|
|
border-radius: 50%;
|
|
}
|
|
.meta {
|
|
line-height: 24px;
|
|
font-size: 15px;
|
|
}
|
|
</style>
|