[WIP] refactor(front): channel detail page

This commit is contained in:
ArneBo 2025-01-25 23:22:21 +01:00
parent 07fdf734a9
commit a717d02133
4 changed files with 296 additions and 285 deletions

View File

@ -9,6 +9,7 @@ import { useRoute } from 'vue-router'
const route = useRoute() const route = useRoute()
import LoginModal from '~/components/common/LoginModal.vue' import LoginModal from '~/components/common/LoginModal.vue'
import Button from '~/components/ui/Button.vue'
interface Events { interface Events {
(e: 'unsubscribed'): void (e: 'unsubscribed'): void
@ -46,20 +47,21 @@ const loginModal = ref()
</script> </script>
<template> <template>
<button <Button
v-if="store.state.auth.authenticated" v-if="store.state.auth.authenticated"
:class="['ui', 'pink', {'inverted': isSubscribed}, {'favorited': isSubscribed}, 'icon', 'labeled', 'button']" :class="['pink', {'inverted': isSubscribed}, {'favorited': isSubscribed}]"
outline
icon="bi-heart-fill"
@click.stop="toggle" @click.stop="toggle"
> >
<i class="heart icon" />
{{ title }} {{ title }}
</button> </Button>
<button <Button
v-else v-else
:class="['ui', 'pink', 'icon', 'labeled', 'button']" outline
icon="bi-heart"
@click="loginModal.show = true" @click="loginModal.show = true"
> >
<i class="heart icon" />
{{ title }} {{ title }}
<login-modal <login-modal
ref="loginModal" ref="loginModal"
@ -69,5 +71,5 @@ const loginModal = ref()
:cover="channel.artist?.cover!" :cover="channel.artist?.cover!"
@created="loginModal.show = false" @created="loginModal.show = false"
/> />
</button> </Button>
</template> </template>

View File

@ -67,7 +67,7 @@ const url = computed(() => {
v-if="avatar" v-if="avatar"
:actor="actor" :actor="actor"
/> />
<slot>{{ repr }}</slot> <slot>@{{ repr }}</slot>
</span> </span>
</Link> </Link>
</template> </template>

View File

@ -3,7 +3,8 @@ import { truncate } from '~/utils/filters'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import TagsList from '~/components/tags/List.vue' import Layout from '~/components/ui/Layout.vue'
import Pill from '~/components/ui/Pill.vue'
interface Props { interface Props {
tags: string[] tags: string[]
@ -35,23 +36,23 @@ const tags = computed(() => {
</script> </script>
<template> <template>
<div class="component-tags-list"> <Layout flex style="gap: 8px" class="component-tags-list">
<router-link <router-link
v-for="tag in tags" v-for="tag in tags"
:key="tag" :key="tag"
:to="{name: props.detailRoute, params: { id: tag } }" :to="{name: props.detailRoute, params: { id: tag } }"
:class="['ui', 'circular', 'hashtag', 'label', props.labelClasses]" :class="props.labelClasses"
> >
<Pill solid raised secondary>
<span class="hashtag symbol" /> <span class="hashtag symbol" />
{{ truncate(tag, props.truncateSize) }} {{ truncate(tag, props.truncateSize) }}
</Pill>
</router-link> </router-link>
<div <Pill
v-if="props.showMore && tags.length < props.tags.length" v-if="props.showMore && tags.length < props.tags.length"
role="button"
class="ui circular inverted accent label"
@click.prevent="honorLimit = false" @click.prevent="honorLimit = false"
> >
{{ t('components.tags.List.button.more', props.tags.length - tags.length) }} {{ t('components.tags.List.button.more', props.tags.length - tags.length) }}
</div> </Pill>
</div> </Layout>
</template> </template>

View File

@ -11,9 +11,21 @@ import axios from 'axios'
import SubscribeButton from '~/components/channels/SubscribeButton.vue' import SubscribeButton from '~/components/channels/SubscribeButton.vue'
import ChannelForm from '~/components/audio/ChannelForm.vue' import ChannelForm from '~/components/audio/ChannelForm.vue'
import EmbedWizard from '~/components/audio/EmbedWizard.vue' import EmbedWizard from '~/components/audio/EmbedWizard.vue'
import SemanticModal from '~/components/semantic/Modal.vue' import HumanDuration from '~/components/common/HumanDuration.vue'
import PlayButton from '~/components/audio/PlayButton.vue' import PlayButton from '~/components/audio/PlayButton.vue'
import TagsList from '~/components/tags/List.vue' import TagsList from '~/components/tags/List.vue'
import SemanticModal from '~/components/semantic/Modal.vue'
import RadioButton from '~/components/radios/Button.vue'
import Loader from '~/components/ui/Loader.vue'
import Button from '~/components/ui/Button.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 Modal from '~/components/ui/Modal.vue'
import Spacer from '~/components/ui/Spacer.vue'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
import useReport from '~/composables/moderation/useReport' import useReport from '~/composables/moderation/useReport'
@ -92,6 +104,7 @@ const fetchData = async () => {
watch(() => props.id, fetchData, { immediate: true }) watch(() => props.id, fetchData, { immediate: true })
const uuid = computed(() => store.state.channels.latestPublication?.channel.uuid) const uuid = computed(() => store.state.channels.latestPublication?.channel.uuid)
watch([uuid, object], ([uuid, object], [lastUuid, lastObject]) => { watch([uuid, object], ([uuid, object], [lastUuid, lastObject]) => {
if (object?.uuid && object.uuid === lastObject?.uuid) return if (object?.uuid && object.uuid === lastObject?.uuid) return
@ -128,49 +141,36 @@ const updateSubscriptionCount = (delta: number) => {
</script> </script>
<template> <template>
<main <Layout stack main
v-title="labels.title" v-title="labels.title"
class="main"
> >
<div <Loader v-if="isLoading" />
v-if="isLoading"
class="ui vertical segment"
>
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" />
</div>
<template v-if="object && !isLoading"> <template v-if="object && !isLoading">
<section <section
v-title="object.artist?.name" v-title="object.artist?.name"
class="ui head vertical stripe segment container"
> >
<div class="ui stackable grid"> <Layout flex>
<div class="seven wide column">
<div class="ui two column grid">
<div class="column">
<img <img
v-if="object.artist?.cover" v-if="object.artist?.cover"
alt="" alt=""
class="huge channel-image" class="huge channel-image"
:src="store.getters['instance/absoluteUrl'](object.artist.cover.urls.medium_square_crop)" :src="store.getters['instance/absoluteUrl'](object.artist.cover.urls.large_square_crop)"
> >
<i <i
v-else v-else
class="huge circular inverted users violet icon" class="huge circular inverted bi bi-person-circle-fill violet"
/> />
<Layout stack noGap style="flex: 1;">
<h1>
<div
:title="object.artist?.name"
>
{{ object.artist?.name }}
</div> </div>
<div class="ui column right aligned"> </h1>
<TagsList <Layout stack class="meta" style="gap: 0;">
v-if="object.artist?.tags && object.artist?.tags.length > 0" <Layout flex noGap>
:tags="object.artist.tags"
/>
<actor-link
v-if="object.actor"
:avatar="false"
:actor="object.attributed_to"
:display-name="true"
/>
<template v-if="totalTracks > 0"> <template v-if="totalTracks > 0">
<div class="ui hidden very small divider" />
<span <span
v-if="object.artist?.content_category === 'podcast'" v-if="object.artist?.content_category === 'podcast'"
> >
@ -183,121 +183,132 @@ const updateSubscriptionCount = (delta: number) => {
</span> </span>
</template> </template>
<template v-if="object.attributed_to.full_username === store.state.auth.fullUsername || store.getters['channels/isSubscribed'](object.uuid)"> <template v-if="object.attributed_to.full_username === store.state.auth.fullUsername || store.getters['channels/isSubscribed'](object.uuid)">
<br> <i class="bi bi-dot" />
{{ t('views.channels.DetailBase.meta.subscribers', object?.subscriptions_count ?? 0) }} {{ t('views.channels.DetailBase.meta.subscribers', object?.subscriptions_count ?? 0) }}
<br> <i class="bi bi-dot" />
{{ t('views.channels.DetailBase.meta.listenings', object?.downloads_count ?? 0) }} {{ t('views.channels.DetailBase.meta.listenings', object?.downloads_count ?? 0) }}
</template> </template>
<div class="ui hidden small divider" />
<a <div v-if="totalTracks > 0">
class="ui icon small basic button" <i class="bi bi-dot" />
@click.stop.prevent="showSubscribeModal = true" <human-duration
>
<i class="feed icon" />
</a>
<semantic-modal
v-model:show="showSubscribeModal"
class="tiny"
>
<h4 class="header">
{{ t('views.channels.DetailBase.modal.subscribe.header') }}
</h4>
<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>
<div class="actions">
<button class="ui basic deny button">
{{ t('views.channels.DetailBase.button.cancel') }}
</button>
</div>
</semantic-modal>
<button
ref="dropdown"
v-dropdown="{direction: 'downward'}"
class="ui right floated pointing dropdown icon small basic button"
>
<i class="ellipsis vertical icon" />
<div class="menu">
<a
v-if="totalTracks > 0" v-if="totalTracks > 0"
href="" :duration="totalTracks"
class="basic item" />
</div>
</Layout>
<Layout flex noGap>
<span
v-if="object.artist?.content_category === 'podcast'"
>
{{ 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>
<span
v-else
>
{{ t('views.channels.DetailBase.header.artistChannel') }}
</span>
<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">
<Button
v-if="isOwner"
primary
icon="bi-upload"
@click.prevent.stop="store.commit('channels/showUploadModal', {show: true, config: {channel: object}})"
>
{{ t('views.channels.DetailBase.button.upload') }}
</Button>
<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" @click.prevent="showEmbedModal = !showEmbedModal"
> >
<i class="code icon" />
{{ t('views.channels.DetailBase.button.embed') }} {{ t('views.channels.DetailBase.button.embed') }}
</a> </PopoverItem>
<a <PopoverItem
v-if="object.actor && object.actor.domain != store.getters['instance/domain']" v-if="object.actor && object.actor.domain != store.getters['instance/domain']"
:href="object.url" :href="object.url"
target="_blank" target="_blank"
class="basic item" icon="bi-box-arrow-up-right"
> >
<i class="external icon" />
{{ t('views.channels.DetailBase.link.domainView', {domain: object.actor.domain}) }} {{ t('views.channels.DetailBase.link.domainView', {domain: object.actor.domain}) }}
</a> </PopoverItem>
<div class="divider" /> <hr>
<a <PopoverItem
v-for="obj in getReportableObjects({account: object.attributed_to, channel: object})" v-for="obj in getReportableObjects({account: object.attributed_to, channel: object})"
icon="bi-share"
:key="obj.target.type + obj.target.id" :key="obj.target.type + obj.target.id"
href=""
class="basic item"
@click.stop.prevent="report(obj)" @click.stop.prevent="report(obj)"
> >
<i class="share icon" /> {{ obj.label }} {{ obj.label }}
</a> </PopoverItem>
<template v-if="isOwner"> <template v-if="isOwner">
<div class="divider" /> <hr>
<a <PopoverItem
class="item" icon="bi-pencil"
href=""
@click.stop.prevent="showEditModal = true" @click.stop.prevent="showEditModal = true"
> >
<i class="edit icon" />
{{ t('views.channels.DetailBase.button.edit') }} {{ t('views.channels.DetailBase.button.edit') }}
</a> </PopoverItem>
<dangerous-button <dangerous-button
v-if="object" v-if="object"
:class="['ui', {loading: isLoading}, 'item']" :class="['ui', {loading: isLoading}, 'item']"
@confirm="remove()" @confirm="remove()"
> >
<i class="ui trash icon" /> <i class="bi bi-trash" />
{{ t('views.channels.DetailBase.button.delete') }} {{ t('views.channels.DetailBase.button.delete') }}
<template #modal-header> <template #modal-header>
<p> <p>
@ -319,77 +330,22 @@ const updateSubscriptionCount = (delta: number) => {
</dangerous-button> </dangerous-button>
</template> </template>
<template v-if="store.state.auth.availablePermissions['library']"> <template v-if="store.state.auth.availablePermissions['library']">
<div class="divider" /> <hr>
<router-link <PopoverItem
class="basic item"
:to="{name: 'manage.channels.detail', params: {id: object.uuid}}" :to="{name: 'manage.channels.detail', params: {id: object.uuid}}"
icon="bi-wrench"
> >
<i class="wrench icon" />
{{ t('views.channels.DetailBase.link.moderation') }} {{ t('views.channels.DetailBase.link.moderation') }}
</router-link> </PopoverItem>
</template> </template>
</div> </template>
</button> </Popover>
</div> <Spacer h grow />
</div>
<h1 class="ui header">
<div
class="left aligned"
:title="object.artist?.name"
>
{{ object.artist?.name }}
<div class="ui hidden very small divider" />
<div
v-if="object.actor"
class="sub header ellipsis"
:title="object.actor.full_username"
>
{{ object.actor.full_username }}
</div>
<div
v-else
class="sub header ellipsis"
>
<a
:href="object.url || object.rss_url"
rel="noopener noreferrer"
target="_blank"
>
<i class="external link icon" />
{{ t('views.channels.DetailBase.link.mirrored', {domain: externalDomain}) }}
</a>
</div>
</div>
</h1>
<div class="header-buttons">
<div
v-if="isOwner"
class="ui buttons"
>
<button
class="ui basic labeled icon button"
@click.prevent.stop="store.commit('channels/showUploadModal', {show: true, config: {channel: object}})"
>
<i class="upload icon" />
{{ t('views.channels.DetailBase.button.upload') }}
</button>
</div>
<div class="ui buttons">
<play-button
:is-playable="isPlayable"
class="vibrant"
:artist="object.artist"
>
{{ t('views.channels.DetailBase.button.play') }}
</play-button>
</div>
<div class="ui buttons">
<subscribe-button <subscribe-button
:channel="object" :channel="object"
@subscribed="updateSubscriptionCount(1)" @subscribed="updateSubscriptionCount(1)"
@unsubscribed="updateSubscriptionCount(-1)" @unsubscribed="updateSubscriptionCount(-1)"
/> />
</div>
<semantic-modal <semantic-modal
v-if="totalTracks > 0" v-if="totalTracks > 0"
@ -451,51 +407,103 @@ const updateSubscriptionCount = (delta: number) => {
</button> </button>
</div> </div>
</semantic-modal> </semantic-modal>
</div> <Button
<div v-if="store.getters['ui/layoutVersion'] === 'large'"> secondary
<rendered-description icon="bi-rss"
:content="object.artist?.description" @click.stop.prevent="showSubscribeModal = true"
:update-url="`channels/${object.uuid}/`"
:can-update="false"
@updated="object = $event"
/> />
<semantic-modal
v-model:show="showSubscribeModal"
class="tiny"
>
<h4 class="header">
{{ t('views.channels.DetailBase.modal.subscribe.header') }}
</h4>
<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>
</div> </div>
<div class="nine wide column"> <div class="actions">
<div class="ui secondary pointing center aligned menu"> <button class="ui basic deny button">
<router-link {{ t('views.channels.DetailBase.button.cancel') }}
class="item" </button>
</div>
</semantic-modal>
</Layout>
</Layout>
</Layout>
<hr>
<TagsList
v-if="object.artist?.tags && object.artist?.tags.length > 0"
:tags="object.artist.tags"
/>
<Tabs>
<Tab
:title="t('views.channels.DetailBase.link.channelOverview')"
:to="{name: 'channels.detail', params: {id: id}}" :to="{name: 'channels.detail', params: {id: id}}"
> >
{{ t('views.channels.DetailBase.link.channelOverview') }}
</router-link>
<router-link
class="item"
:to="{name: 'channels.detail.episodes', params: {id: id}}"
>
<span
v-if="isPodcast"
>
{{ t('views.channels.DetailBase.link.channelEpisodes') }}
</span>
<span
v-else
>
{{ t('views.channels.DetailBase.link.channelTracks') }}
</span>
</router-link>
</div>
<div class="ui hidden divider" />
<router-view <router-view
v-if="object" v-if="object"
:object="object" :object="object"
@tracks-loaded="totalTracks = $event" @tracks-loaded="totalTracks = $event"
/> />
</div> </Tab>
</div> <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> </section>
</template> </template>
</main> </Layout>
</template> </template>
<style scoped>
.channel-image {
border-radius: 50%;
}
.meta {
line-height: 24px;
font-size: 15px;
}
</style>