feat(front): admin detail pages

This commit is contained in:
ArneBo 2025-04-10 16:05:13 +02:00
parent 729bd07b32
commit cb6a6a7ff5
8 changed files with 2357 additions and 1962 deletions

View File

@ -2,13 +2,15 @@
import type { BackendError } from '~/types' import type { BackendError } from '~/types'
import axios from 'axios' import axios from 'axios'
import Modal from '~/components/ui/Modal.vue'
import Button from '~/components/ui/Button.vue'
import { useTimeoutFn } from '@vueuse/core' import { useTimeoutFn } from '@vueuse/core'
import { ref } from 'vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Modal from '~/components/ui/Modal.vue'
import Button from '~/components/ui/Button.vue'
const { t } = useI18n() const { t } = useI18n()
interface Events { interface Events {
@ -73,8 +75,11 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
</script> </script>
<template> <template>
<div <Button
role="button" secondary
icon="bi-arrow-clockwise"
:loading="isLoading"
low-height
@click="fetch" @click="fetch"
> >
<div> <div>
@ -220,5 +225,5 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
</Button> </Button>
</div> </div>
</Modal> </Modal>
</div> </Button>
</template> </template>

View File

@ -3352,11 +3352,13 @@
"channelData": "Channel data" "channelData": "Channel data"
}, },
"label": { "label": {
"local": "Local" "local": "Local",
"federated": "Federated"
}, },
"link": { "link": {
"django": "View in Django's admin", "django": "View in Django's admin",
"localProfile": "Open local profile" "localProfile": "Open local profile",
"rss": "RSS feed"
}, },
"modal": { "modal": {
"delete": { "delete": {
@ -3439,7 +3441,8 @@
"activity": "Activity", "activity": "Activity",
"albumData": "Album data", "albumData": "Album data",
"audioContent": "Audio content", "audioContent": "Audio content",
"local": "Local" "local": "Local",
"federated": "Federated"
}, },
"link": { "link": {
"artist": "Artist", "artist": "Artist",
@ -3492,7 +3495,8 @@
"activity": "Activity", "activity": "Activity",
"artistData": "Artist data", "artistData": "Artist data",
"audioContent": "Audio content", "audioContent": "Audio content",
"local": "Local" "local": "Local",
"federated": "Federated"
}, },
"link": { "link": {
"albums": "Albums", "albums": "Albums",
@ -3608,7 +3612,8 @@
}, },
"TagDetail": { "TagDetail": {
"button": { "button": {
"delete": "Delete" "delete": "Delete",
"more": "Show more"
}, },
"header": { "header": {
"activity": "Activity", "activity": "Activity",
@ -3648,6 +3653,7 @@
"header": { "header": {
"activity": "Activity", "activity": "Activity",
"local": "Local", "local": "Local",
"federated": "Federated",
"trackData": "Track data" "trackData": "Track data"
}, },
"link": { "link": {
@ -3699,11 +3705,13 @@
"UploadDetail": { "UploadDetail": {
"button": { "button": {
"delete": "Delete", "delete": "Delete",
"download": "Download" "download": "Download",
"more": "Show more"
}, },
"header": { "header": {
"activity": "Activity", "activity": "Activity",
"audioContent": "Audio content", "audioContent": "Audio content",
"federated": "Federated",
"local": "Local", "local": "Local",
"uploadData": "Upload data" "uploadData": "Upload data"
}, },

View File

@ -9,11 +9,20 @@ import axios from 'axios'
import FetchButton from '~/components/federation/FetchButton.vue' import FetchButton from '~/components/federation/FetchButton.vue'
import TagsList from '~/components/tags/List.vue' import TagsList from '~/components/tags/List.vue'
import DangerousButton from '~/components/common/DangerousButton.vue'
import Header from '~/components/ui/Header.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import HumanDate from '~/components/common/HumanDate.vue'
import Link from '~/components/ui/Link.vue'
import Heading from '~/components/ui/Heading.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 Loader from '~/components/ui/Loader.vue'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
import DangerousButton from '~/components/common/DangerousButton.vue'
interface Props { interface Props {
id: number id: number
} }
@ -24,18 +33,22 @@ const store = useStore()
const { t } = useI18n() const { t } = useI18n()
const router = useRouter() const router = useRouter()
const channel = ref()
const isLoading = ref(false)
const stats = ref()
const isLoadingStats = ref(false)
const open = ref(false)
const labels = computed(() => ({ const labels = computed(() => ({
statsWarning: t('views.admin.ChannelDetail.warning.stats') statsWarning: t('views.admin.ChannelDetail.warning.stats')
})) }))
const isLoading = ref(false)
const object = ref()
const fetchData = async () => { const fetchData = async () => {
isLoading.value = true isLoading.value = true
try { try {
const response = await axios.get(`manage/channels/${props.id}/`) const response = await axios.get(`manage/channels/${props.id}/`)
object.value = response.data channel.value = response.data
} catch (error) { } catch (error) {
useErrorHandler(error as Error) useErrorHandler(error as Error)
} }
@ -43,8 +56,6 @@ const fetchData = async () => {
isLoading.value = false isLoading.value = false
} }
const isLoadingStats = ref(false)
const stats = ref()
const fetchStats = async () => { const fetchStats = async () => {
isLoadingStats.value = true isLoadingStats.value = true
@ -58,8 +69,8 @@ const fetchStats = async () => {
isLoadingStats.value = false isLoadingStats.value = false
} }
fetchStats()
fetchData() fetchData()
fetchStats()
const remove = async () => { const remove = async () => {
isLoading.value = true isLoading.value = true
@ -78,25 +89,17 @@ const getQuery = (field: string, value: string) => `${field}:"${value}"`
</script> </script>
<template> <template>
<main> <Loader v-if="isLoading" />
<div <Header
v-if="isLoading" v-if="channel"
class="ui vertical segment" v-title="channel?.artist?.name"
:h1="truncate(channel?.artist?.name)"
page-heading
> >
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" /> <template #image>
</div>
<template v-if="object">
<section
v-title="object.artist.name"
:class="['ui', 'head', 'vertical', 'stripe', 'segment']"
>
<div class="ui stackable one column grid">
<div class="ui column">
<div class="segment-content">
<h2 class="ui header">
<img <img
v-if="object.artist.cover && object.artist.cover.urls.medium_square_crop" v-if="channel?.artist?.cover?.urls.medium_square_crop"
v-lazy="store.getters['instance/absoluteUrl'](object.artist.cover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](channel?.artist?.cover?.urls.medium_square_crop)"
alt="" alt=""
> >
<img <img
@ -104,77 +107,55 @@ const getQuery = (field: string, value: string) => `${field}:"${value}"`
alt="" alt=""
src="../../assets/audio/default-cover.png" src="../../assets/audio/default-cover.png"
> >
<div class="content"> </template>
{{ truncate(object.artist.name) }}
<div class="sub header"> <div class="sub header">
<template v-if="object.artist.is_local"> <template v-if="channel?.artist?.is_local">
<span class="ui tiny accent label"> <Pill>
<i class="home icon" /> <i class="bi bi-house-fill" />
{{ t('views.admin.ChannelDetail.label.local') }} {{ t('views.admin.ChannelDetail.label.local') }}
</span> </Pill>
&nbsp; </template>
<template v-else>
<Pill>
<i class="bi bi-box-arrow-up-right" />
{{ t('views.admin.ChannelDetail.label.federated') }}
</Pill>
</template> </template>
</div> </div>
</div>
</h2>
<template v-if="object.artist.tags && object.artist.tags.length > 0">
<TagsList <TagsList
v-if="channel?.artist?.tags && channel?.artist?.tags.length > 0"
:limit="5" :limit="5"
detail-route="manage.library.tags.detail" detail-route="manage.library.tags.detail"
:tags="object.artist.tags" :tags="channel?.artist?.tags"
/> />
<div class="ui hidden divider" /> <Spacer />
</template> <Layout
flex
<div class="header-buttons"> class="header-buttons"
<div class="ui icon buttons"> >
<router-link <Link
class="ui labeled icon button" solid
:to="{name: 'channels.detail', params: {id: object.uuid }}" primary
low-height
icon="bi-info-circle"
:to="{ name: 'channels.detail', params: { id: channel?.uuid } }"
> >
<i class="info icon" />
{{ t('views.admin.ChannelDetail.link.localProfile') }} {{ t('views.admin.ChannelDetail.link.localProfile') }}
</router-link> </Link>
<button
v-dropdown
class="ui floating dropdown icon button"
>
<i class="dropdown icon" />
<div class="menu">
<a
v-if="store.state.auth.profile && store.state.auth.profile.is_superuser"
class="basic item"
:href="store.getters['instance/absoluteUrl'](`/api/admin/audio/channel/${object.id}`)"
target="_blank"
rel="noopener noreferrer"
>
<i class="wrench icon" />
{{ t('views.admin.ChannelDetail.link.django') }}
</a>
<fetch-button <fetch-button
v-if="!object.actor.is_local" v-if="!channel?.actor?.is_local"
class="basic item" class="basic item"
:url="`channels/${object.uuid}/fetches/`" :url="`channels/${channel?.uuid}/fetches/`"
@refresh="fetchData" @refresh="fetchData"
> >
<i class="refresh icon" />&nbsp; <i class="refresh icon" />&nbsp;
{{ t('views.admin.ChannelDetail.button.refresh') }} {{ t('views.admin.ChannelDetail.button.refresh') }}
</fetch-button> </fetch-button>
<a
class="basic item"
:href="object.actor.url || object.actor.fid"
target="_blank"
rel="noopener noreferrer"
>
<i class="external icon" />
{{ t('views.admin.ChannelDetail.button.openRemote') }}
</a>
</div>
</button>
</div>
<div class="ui buttons">
<dangerous-button <dangerous-button
:is-loading="isLoading" :is-loading="isLoading"
low-height
icon="bi-trash"
:action="remove" :action="remove"
:title="t('views.admin.ChannelDetail.modal.delete.header')" :title="t('views.admin.ChannelDetail.modal.delete.header')"
> >
@ -186,253 +167,317 @@ const getQuery = (field: string, value: string) => `${field}:"${value}"`
{{ t('views.admin.ChannelDetail.button.delete') }} {{ t('views.admin.ChannelDetail.button.delete') }}
</template> </template>
</dangerous-button> </dangerous-button>
</div> <Spacer grow />
</div> <Popover v-model="open">
</div> <template #default="{ toggleOpen }">
</div> <OptionsButton
</div> :title="labels.more"
</section> is-square-small
<div class="ui vertical stripe segment"> @click="toggleOpen()"
<div class="ui stackable three column grid">
<div class="column">
<section>
<h3 class="ui header">
<i class="info icon" />
<div class="content">
{{ t('views.admin.ChannelDetail.header.channelData') }}
</div>
</h3>
<table class="ui very basic table">
<tbody>
<tr>
<td>
{{ t('views.admin.ChannelDetail.table.channelData.name') }}
</td>
<td>
{{ object.artist.name }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.channels', query: {q: getQuery('category', object.artist.content_category) }}">
{{ t('views.admin.ChannelDetail.table.channelData.category') }}
</router-link>
</td>
<td>
{{ object.artist.content_category }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.moderation.accounts.detail', params: {id: object.attributed_to.full_username }}">
{{ t('views.admin.ChannelDetail.table.channelData.account') }}
</router-link>
</td>
<td>
{{ object.attributed_to.preferred_username }}
</td>
</tr>
<tr v-if="!object.actor.is_local">
<td>
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.actor.domain }}">
{{ t('views.admin.ChannelDetail.table.channelData.domain') }}
</router-link>
</td>
<td>
{{ object.actor.domain }}
</td>
</tr>
<tr v-if="object.artist.description">
<td>
{{ t('views.admin.ChannelDetail.table.channelData.description') }}
</td>
<sanitized-html
tag="td"
:html="object.artist.description.html"
/> />
</tr> </template>
<tr v-if="object.actor.url">
<td> <template #items>
{{ t('views.admin.ChannelDetail.table.channelData.url') }} <PopoverItem
</td> v-if="store.state.auth.profile?.is_superuser"
<td> :to="store.getters['instance/absoluteUrl'](`/api/admin/audio/channel/${channel?.id}`)"
<a icon="bi-wrench"
:href="object.actor.url"
rel="noreferrer noopener"
target="_blank" target="_blank"
>{{ object.actor.url }}</a> >
</td> {{ t('views.admin.ChannelDetail.link.django') }}
</tr> </PopoverItem>
<tr v-if="object.rss_url"> <PopoverItem
<td> v-if="channel?.rss_url"
{{ t('views.admin.ChannelDetail.table.channelData.rss') }} :to="channel?.rss_url"
</td> icon="bi-box-arrow-up-right"
<td>
<a
:href="object.rss_url"
rel="noreferrer noopener"
target="_blank" target="_blank"
>{{ object.rss_url }}</a>
</td>
</tr>
</tbody>
</table>
</section>
</div>
<div class="column">
<section>
<h3 class="ui header">
<i class="feed icon" />
<div class="content">
{{ t('views.admin.ChannelDetail.header.activity') }}&nbsp;
<span :data-tooltip="labels.statsWarning"><i class="question circle icon" /></span>
</div>
</h3>
<div
v-if="isLoadingStats"
class="ui placeholder"
> >
<div class="full line" /> {{ t('views.admin.ChannelDetail.link.rss') }}
<div class="short line" /> </PopoverItem>
<div class="medium line" /> <PopoverItem
<div class="long line" /> v-if="!channel?.artist?.is_local"
</div> :to="channel?.actor?.url || channel?.actor?.fid"
<table icon="bi-box-arrow-up-right"
v-else target="_blank"
class="ui very basic table"
> >
<tbody> {{ t('views.admin.ChannelDetail.button.openRemote') }}
<tr> </PopoverItem>
<td> </template>
</Popover>
</Layout>
</Header>
<Layout
flex
gap-64
>
<Layout
stack
style="flex: 1; gap: 0;"
>
<Heading
:h3="t('views.admin.ChannelDetail.header.channelData')"
class="category"
/>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.ChannelDetail.table.channelData.name') }}
</span>
<Spacer
h
grow
/>
<span class="value">{{ channel?.artist?.name }}</span>
</Layout>
<Layout
flex
class="details"
>
<Link
class="label"
:to="{ name: 'manage.channels', query: { q: getQuery('category', channel?.artist?.content_category) } }"
>
{{ t('views.admin.ChannelDetail.table.channelData.category') }}
</Link>
<Spacer
h
grow
/>
<span class="value">{{ channel?.artist?.content_category }}</span>
</Layout>
<Layout
v-if="!channel?.actor?.is_local"
flex
class="details"
>
<Link
class="label"
:to="{ name: 'manage.moderation.domains.detail', params: { id: channel?.actor?.domain } }"
>
{{ t('views.admin.ChannelDetail.table.channelData.domain') }}
</Link>
<Spacer
h
grow
/>
<span class="value">{{ channel?.actor?.domain }}</span>
</Layout>
<Layout
v-if="channel?.artist?.description"
flex
class="details"
>
<span class="label">
{{ t('views.admin.ChannelDetail.table.channelData.description') }}
</span>
<Spacer
h
grow
/>
<sanitized-html
tag="span"
class="value"
:html="channel?.artist?.description?.html"
/>
</Layout>
</Layout>
<Layout
stack
style="flex: 1; gap: 0;"
>
<Heading
:h3="t('views.admin.ChannelDetail.header.activity')"
class="category"
/>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.ChannelDetail.table.activity.firstSeen') }} {{ t('views.admin.ChannelDetail.table.activity.firstSeen') }}
</td> </span>
<td> <Spacer
<human-date :date="object.creation_date" /> h
</td> grow
</tr> />
<tr> <human-date :date="channel?.creation_date" />
<td> </Layout>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.ChannelDetail.table.activity.listenings') }} {{ t('views.admin.ChannelDetail.table.activity.listenings') }}
</td> </span>
<td> <Spacer
{{ stats.listenings }} h
</td> grow
</tr> />
<tr> <span class="value">{{ stats?.listenings }}</span>
<td> </Layout>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.ChannelDetail.table.activity.favorited') }} {{ t('views.admin.ChannelDetail.table.activity.favorited') }}
</td> </span>
<td> <Spacer
{{ stats.track_favorites }} h
</td> grow
</tr> />
<tr> <span class="value">{{ stats?.track_favorites }}</span>
<td> </Layout>
{{ t('views.admin.ChannelDetail.table.activity.playlists') }} <Layout
</td> flex
<td> class="details"
{{ stats.playlists }} >
</td> <Link
</tr> class="label"
<tr> :to="{ name: 'manage.moderation.reports.list', query: { q: getQuery('target', `channel:${channel?.uuid}`) } }"
<td> >
<router-link :to="{name: 'manage.moderation.reports.list', query: {q: getQuery('target', `channel:${object.uuid}`) }}">
{{ t('views.admin.ChannelDetail.table.activity.linkedReports') }} {{ t('views.admin.ChannelDetail.table.activity.linkedReports') }}
</router-link> </Link>
</td> <Spacer
<td> h
{{ stats.reports }} grow
</td> />
</tr> <span class="value">{{ stats?.reports }}</span>
<tr> </Layout>
<td> </Layout>
<router-link :to="{name: 'manage.library.edits', query: {q: getQuery('target', 'artist ' + object.artist.id)}}"> <Layout
{{ t('views.admin.ChannelDetail.table.activity.edits') }} stack
</router-link> style="flex: 1; gap: 0;"
</td>
<td>
{{ stats.mutations }}
</td>
</tr>
</tbody>
</table>
</section>
</div>
<div class="column">
<section>
<h3 class="ui header">
<i class="music icon" />
<div class="content">
{{ t('views.admin.ChannelDetail.header.audioContent') }}&nbsp;
<span :data-tooltip="labels.statsWarning"><i class="question circle icon" /></span>
</div>
</h3>
<div
v-if="isLoadingStats"
class="ui placeholder"
> >
<div class="full line" /> <Heading
<div class="short line" /> :h3="t('views.admin.ChannelDetail.header.audioContent')"
<div class="medium line" /> class="category"
<div class="long line" /> />
</div> <Layout
<table flex
v-else class="details"
class="ui very basic table"
> >
<tbody> <span class="label">
<tr>
<td>
{{ t('views.admin.ChannelDetail.table.audioContent.cachedSize') }} {{ t('views.admin.ChannelDetail.table.audioContent.cachedSize') }}
</td> </span>
<td> <Spacer
{{ humanSize(stats.media_downloaded_size) }} h
</td> grow
</tr> />
<tr> <span class="value">{{ humanSize(stats?.media_downloaded_size) }}</span>
<td> </Layout>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.ChannelDetail.table.audioContent.totalSize') }} {{ t('views.admin.ChannelDetail.table.audioContent.totalSize') }}
</td> </span>
<td> <Spacer
{{ humanSize(stats.media_total_size) }} h
</td> grow
</tr> />
<tr> <span class="value">{{ humanSize(stats?.media_total_size) }}</span>
<td> </Layout>
<router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('channel_id', object.uuid) }}"> <Layout
flex
class="details"
>
<Link
class="label"
:to="{ name: 'manage.library.uploads', query: { q: getQuery('channel_id', channel?.uuid) } }"
>
{{ t('views.admin.ChannelDetail.table.audioContent.uploads') }} {{ t('views.admin.ChannelDetail.table.audioContent.uploads') }}
</router-link> </Link>
</td> <Spacer
<td> h
{{ stats.uploads }} grow
</td> />
</tr> <span class="value">{{ stats?.uploads }}</span>
<tr> </Layout>
<td> <Layout
<router-link :to="{name: 'manage.library.albums', query: {q: getQuery('channel_id', object.uuid) }}"> flex
class="details"
>
<Link
class="label"
:to="{ name: 'manage.library.albums', query: { q: getQuery('channel_id', channel?.uuid) } }"
>
{{ t('views.admin.ChannelDetail.table.audioContent.albums') }} {{ t('views.admin.ChannelDetail.table.audioContent.albums') }}
</router-link> </Link>
</td> <Spacer
<td> h
{{ object.artist.albums_count }} grow
</td> />
</tr> <span class="value">{{ channel?.artist?.albums_count }}</span>
<tr> </Layout>
<td> <Layout
<router-link :to="{name: 'manage.library.tracks', query: {q: getQuery('channel_id', object.uuid) }}"> flex
class="details"
>
<Link
class="label"
:to="{ name: 'manage.library.tracks', query: { q: getQuery('channel_id', channel?.uuid) } }"
>
{{ t('views.admin.ChannelDetail.table.audioContent.tracks') }} {{ t('views.admin.ChannelDetail.table.audioContent.tracks') }}
</router-link> </Link>
</td> <Spacer
<td> h
{{ object.artist.tracks_count }} grow
</td> />
</tr> <span class="value">{{ channel?.artist?.tracks_count }}</span>
</tbody> </Layout>
</table> </Layout>
</section> </Layout>
</div>
</div>
</div>
</template>
</main>
</template> </template>
<style scoped lang="scss">
.channel-image {
width: 200px;
height: 200px;
border: none;
}
h3.category {
margin-bottom: 16px;
}
.details {
padding: 0 16px;
height: 72px;
align-items: center;
border-top: 1px solid;
min-width: 280px;
@include light-theme {
border-color: var(--fw-gray-300);
}
@include dark-theme {
border-color: var(--fw-gray-800);
}
.label {
font-weight: 800;
@include light-theme {
color: var(--fw-gray-600);
}
@include dark-theme {
color: var(--fw-gray-500);
}
}
a.label,
a.value {
text-decoration: underline;
}
&:last-child {
border-bottom: 1px solid;
}
}
</style>

View File

@ -12,6 +12,17 @@ import FetchButton from '~/components/federation/FetchButton.vue'
import TagsList from '~/components/tags/List.vue' import TagsList from '~/components/tags/List.vue'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
import Link from '~/components/ui/Link.vue'
import Popover from '~/components/ui/Popover.vue'
import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
import OptionsButton from '~/components/ui/button/Options.vue'
import Loader from '~/components/ui/Loader.vue'
import Header from '~/components/ui/Header.vue'
import Heading from '~/components/ui/Heading.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Pill from '~/components/ui/Pill.vue'
interface Props { interface Props {
id: number id: number
@ -24,7 +35,8 @@ const { t } = useI18n()
const router = useRouter() const router = useRouter()
const labels = computed(() => ({ const labels = computed(() => ({
statsWarning: t('views.admin.library.AlbumDetail.warning.stats') statsWarning: t('views.admin.library.AlbumDetail.warning.stats'),
more: t('components.library.AlbumDropdown.button.more')
})) }))
const isLoading = ref(false) const isLoading = ref(false)
@ -74,25 +86,19 @@ const remove = async () => {
} }
const getQuery = (field: string, value: string) => `${field}:"${value}"` const getQuery = (field: string, value: string) => `${field}:"${value}"`
const open = ref(false)
</script> </script>
<template> <template>
<main> <Loader v-if="isLoading" />
<div <Header
v-if="isLoading" v-if="object"
class="ui vertical segment"
>
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" />
</div>
<template v-if="object">
<section
v-title="object.title" v-title="object.title"
:class="['ui', 'head', 'vertical', 'stripe', 'segment']" :h1="truncate(object.title)"
page-heading
> >
<div class="ui stackable one column grid"> <template #image>
<div class="ui column">
<div class="segment-content">
<h2 class="ui header">
<img <img
v-if="object.cover?.urls.original" v-if="object.cover?.urls.original"
v-lazy="store.getters['instance/absoluteUrl'](object.cover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](object.cover.urls.medium_square_crop)"
@ -103,64 +109,42 @@ const getQuery = (field: string, value: string) => `${field}:"${value}"`
alt="" alt=""
src="../../../assets/audio/default-cover.png" src="../../../assets/audio/default-cover.png"
> >
<div class="content"> </template>
{{ truncate(object.title) }}
<div class="sub header"> <div class="sub header">
<template v-if="object.is_local"> <template v-if="object.is_local">
<span class="ui tiny accent label"> <Pill>
<i class="home icon" /> <i class="bi bi-house-fill" />
{{ t('views.admin.library.AlbumDetail.header.local') }} {{ t('views.admin.library.AlbumDetail.header.local') }}
</span> </Pill>
&nbsp; </template>
<template v-if="!object.is_local">
<Pill>
<i class="bi bi-box-arrow-up-right" />
{{ t('views.admin.library.AlbumDetail.header.federated') }}
</Pill>
</template> </template>
</div> </div>
</div>
</h2>
<template v-if="object.tags && object.tags.length > 0">
<TagsList <TagsList
v-if="object.tags && object.tags.length > 0"
:limit="5" :limit="5"
detail-route="manage.library.tags.detail" detail-route="manage.library.tags.detail"
:tags="object.tags" :tags="object.tags"
/> />
<div class="ui hidden divider" /> <Spacer />
</template> <Layout
flex
<div class="header-buttons"> class="header-buttons"
<div class="ui icon buttons"> >
<router-link <Link
class="ui labeled icon button" solid
primary
low-height
icon="bi-info-circle"
:to="{name: 'library.albums.detail', params: {id: object.id }}" :to="{name: 'library.albums.detail', params: {id: object.id }}"
> >
<i class="info icon" />
{{ t('views.admin.library.AlbumDetail.link.localProfile') }} {{ t('views.admin.library.AlbumDetail.link.localProfile') }}
</router-link> </Link>
<button
v-dropdown
class="ui floating dropdown icon button"
>
<i class="dropdown icon" />
<div class="menu">
<a
v-if="store.state.auth.profile && store.state.auth.profile.is_superuser"
class="basic item"
:href="store.getters['instance/absoluteUrl'](`/api/admin/music/album/${object.id}`)"
target="_blank"
rel="noopener noreferrer"
>
<i class="wrench icon" />
{{ t('views.admin.library.AlbumDetail.link.django') }}
</a>
<a
v-if="object.mbid"
class="basic item"
:href="`https://musicbrainz.org/release/${object.mbid}`"
target="_blank"
rel="noopener noreferrer"
>
<i class="external icon" />
{{ t('views.admin.library.AlbumDetail.link.musicbrainz') }}
</a>
<fetch-button <fetch-button
v-if="!object.is_local" v-if="!object.is_local"
class="basic item" class="basic item"
@ -170,31 +154,20 @@ const getQuery = (field: string, value: string) => `${field}:"${value}"`
<i class="refresh icon" />&nbsp; <i class="refresh icon" />&nbsp;
{{ t('views.admin.library.AlbumDetail.button.remoteRefresh') }} {{ t('views.admin.library.AlbumDetail.button.remoteRefresh') }}
</fetch-button> </fetch-button>
<a <Link
class="basic item"
:href="object.url || object.fid"
target="_blank"
rel="noopener noreferrer"
>
<i class="external icon" />
{{ t('views.admin.library.AlbumDetail.link.remoteProfile') }}
</a>
</div>
</button>
</div>
<div class="ui buttons">
<router-link
v-if="object.is_local" v-if="object.is_local"
solid
primary
low-height
icon="bi-pencil-fill"
:to="{name: 'library.albums.edit', params: {id: object.id }}" :to="{name: 'library.albums.edit', params: {id: object.id }}"
class="ui labeled icon button"
> >
<i class="edit icon" />
{{ t('views.admin.library.AlbumDetail.button.edit') }} {{ t('views.admin.library.AlbumDetail.button.edit') }}
</router-link> </Link>
</div>
<div class="ui buttons">
<dangerous-button <dangerous-button
:is-loading="isLoading" :is-loading="isLoading"
low-height
icon="bi-trash"
:action="remove" :action="remove"
:title="t('views.admin.library.AlbumDetail.modal.delete.header')" :title="t('views.admin.library.AlbumDetail.modal.delete.header')"
> >
@ -206,220 +179,390 @@ const getQuery = (field: string, value: string) => `${field}:"${value}"`
{{ t('views.admin.library.AlbumDetail.button.delete') }} {{ t('views.admin.library.AlbumDetail.button.delete') }}
</template> </template>
</dangerous-button> </dangerous-button>
</div> <Spacer grow />
</div> <Popover v-model="open">
</div> <template #default="{ toggleOpen }">
</div> <OptionsButton
</div> :title="labels.more"
</section> is-square-small
<div class="ui vertical stripe segment"> @click="toggleOpen()"
<div class="ui stackable three column grid"> />
<div class="column"> </template>
<section>
<h3 class="ui header"> <template #items>
<i class="info icon" /> <PopoverItem
<div class="content"> v-if="store.state.auth.profile && store.state.auth.profile.is_superuser"
{{ t('views.admin.library.AlbumDetail.header.albumData') }} :to="store.getters['instance/absoluteUrl'](`/api/admin/music/album/${object.id}`)"
</div> icon="bi-wrench"
</h3> target="_blank"
<table class="ui very basic table"> >
<tbody> {{ t('views.admin.library.AlbumDetail.link.django') }}
<tr> </PopoverItem>
<td> <PopoverItem
v-if="!object.is_local"
:to="`albums/${object.id}/fetches/`"
icon="bi-arrow-clockwise"
@click="fetchData()"
>
{{ t('views.admin.library.AlbumDetail.button.remoteRefresh') }}
</PopoverItem>
<PopoverItem
v-if="object.mbid"
:to="`https://musicbrainz.org/release/${object.mbid}`"
icon="bi-box-arrow-up-right"
target="_blank"
>
{{ t('views.admin.library.AlbumDetail.link.musicbrainz') }}
</PopoverItem>
<PopoverItem
:to="object.url || object.fid"
icon="bi-box-arrow-up-right"
target="_blank"
>
{{ t('views.admin.library.AlbumDetail.link.remoteProfile') }}
</PopoverItem>
</template>
</Popover>
</Layout>
</Header>
<Layout
flex
gap-64
>
<Layout
stack
style="flex: 1; gap: 0;"
>
<Heading
:h3="t('views.admin.library.AlbumDetail.header.albumData')"
class="category"
/>
<Layout
flex
class="details"
>
<span
class="label"
>
{{ t('views.admin.library.AlbumDetail.table.album.title') }} {{ t('views.admin.library.AlbumDetail.table.album.title') }}
</td> </span>
<td> <Spacer
{{ object.title }} h
</td> grow
</tr> />
<tr> <span class="value">{{ object?.title }}</span>
<td> </Layout>
<router-link :to="{name: 'manage.library.artists.detail', params: {id: object.artist.id }}"> <Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.AlbumDetail.link.artist') }} {{ t('views.admin.library.AlbumDetail.link.artist') }}
</router-link> </span>
</td> <Spacer
<td> h
{{ object.artist.name }} grow
</td> />
</tr> <Link
<tr v-if="!object.is_local"> v-for="a in object?.artist_credit"
<td> :key="a.artist.id"
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}"> class="value"
:to="{name: 'manage.library.artists.detail', params: {id: a.artist.id }}"
>
{{ a.artist.name }}
</Link>
</Layout>
<Layout
v-if="!object?.is_local"
flex
class="details"
>
<Link :to="{name: 'manage.moderation.domains.detail', params: {id: object?.domain }}">
{{ t('views.admin.library.AlbumDetail.link.domain') }} {{ t('views.admin.library.AlbumDetail.link.domain') }}
</router-link> </Link>
</td> <Spacer
<td> h
{{ object.domain }} grow
</td> />
</tr> <span class="value">{{ object?.domain }}</span>
<tr v-if="object.description"> </Layout>
<td> <Layout
v-if="object?.description"
flex
class="details"
>
<span
class="label"
>
{{ t('views.admin.library.AlbumDetail.table.album.description') }} {{ t('views.admin.library.AlbumDetail.table.album.description') }}
</td> </span>
<Spacer
h
grow
/>
<sanitized-html <sanitized-html
tag="td" tag="span"
class="value"
:html="object.description.html" :html="object.description.html"
/> />
</tr> </Layout>
</tbody> </Layout>
</table> <Layout
</section> stack
</div> style="flex: 1; gap: 0;"
<div class="column">
<section>
<h3 class="ui header">
<i class="feed icon" />
<div class="content">
{{ t('views.admin.library.AlbumDetail.header.activity') }}&nbsp;
<span :data-tooltip="labels.statsWarning"><i class="question circle icon" /></span>
</div>
</h3>
<div
v-if="isLoadingStats"
class="ui placeholder"
> >
<div class="full line" /> <Heading
<div class="short line" /> :h3="t('views.admin.library.AlbumDetail.header.activity')"
<div class="medium line" /> class="category"
<div class="long line" /> />
</div> <Layout
<table flex
v-else class="details"
class="ui very basic table" >
<span
class="label"
> >
<tbody>
<tr>
<td>
{{ t('views.admin.library.AlbumDetail.table.activity.firstSeen') }} {{ t('views.admin.library.AlbumDetail.table.activity.firstSeen') }}
</td> </span>
<td> <Spacer
<human-date :date="object.creation_date" /> h
</td> grow
</tr> />
<tr> <human-date :date="object?.creation_date" />
<td> </Layout>
<Layout
flex
class="details"
>
<span
class="label"
>
{{ t('views.admin.library.AlbumDetail.table.activity.listenings') }} {{ t('views.admin.library.AlbumDetail.table.activity.listenings') }}
</td> </span>
<td> <Spacer
{{ stats.listenings }} h
</td> grow
</tr> />
<tr> <span
<td> class="label"
>
{{ stats?.listenings }}
</span>
</Layout>
<Layout
flex
class="details"
>
<span
class="label"
>
{{ t('views.admin.library.AlbumDetail.table.activity.favorited') }} {{ t('views.admin.library.AlbumDetail.table.activity.favorited') }}
</td> </span>
<td> <Spacer
{{ stats.track_favorites }} h
</td> grow
</tr> />
<tr> <span
<td> class="value"
>
{{ stats?.track_favorites ?? 0 }}
</span>
</Layout>
<Layout
flex
class="details"
>
<span
class="label"
>
{{ t('views.admin.library.AlbumDetail.table.activity.playlists') }} {{ t('views.admin.library.AlbumDetail.table.activity.playlists') }}
</td> </span>
<td> <Spacer
{{ stats.playlists }} h
</td> grow
</tr> />
<tr> <span
<td> class="value"
<router-link :to="{name: 'manage.moderation.reports.list', query: {q: getQuery('target', `album:${object.id}`) }}"> >
{{ stats?.playlists }}
</span>
</Layout>
<Layout
flex
class="details"
>
<Link
class="label"
:to="{name: 'manage.moderation.reports.list', query: {q: getQuery('target', `album:${object?.id}`) }}"
>
{{ t('views.admin.library.AlbumDetail.link.reports') }} {{ t('views.admin.library.AlbumDetail.link.reports') }}
</router-link> </Link>
</td> <Spacer
<td> h
{{ stats.reports }} grow
</td> />
</tr> <span class="value">
<tr> {{ stats?.reports }}
<td> </span>
<router-link :to="{name: 'manage.library.edits', query: {q: getQuery('target', 'album ' + object.id)}}"> </Layout>
<Layout
flex
class="details"
>
<Link
class="label"
:to="{name: 'manage.library.edits', query: {q: getQuery('target', 'album ' + object?.id)}}"
>
{{ t('views.admin.library.AlbumDetail.link.edits') }} {{ t('views.admin.library.AlbumDetail.link.edits') }}
</router-link> </Link>
</td> <Spacer grow />
<td> <span class="value">
{{ stats.mutations }} {{ stats?.mutations }}
</td> </span>
</tr> </Layout>
</tbody> </Layout>
</table> <Layout
</section> stack
</div> style="flex: 1; gap: 0;"
<div class="column">
<section>
<h3 class="ui header">
<i class="music icon" />
<div class="content">
{{ t('views.admin.library.AlbumDetail.header.audioContent') }}&nbsp;
<span :data-tooltip="labels.statsWarning"><i class="question circle icon" /></span>
</div>
</h3>
<div
v-if="isLoadingStats"
class="ui placeholder"
> >
<div class="full line" /> <Heading
<div class="short line" /> :h3="t('views.admin.library.AlbumDetail.header.audioContent')"
<div class="medium line" /> class="category"
<div class="long line" /> align-left
</div> />
<table <Layout
v-else flex
class="ui very basic table" class="details"
>
<span
class="label"
> >
<tbody>
<tr>
<td>
{{ t('views.admin.library.AlbumDetail.table.audioContent.cachedSize') }} {{ t('views.admin.library.AlbumDetail.table.audioContent.cachedSize') }}
</td> </span>
<td> <Spacer
{{ humanSize(stats.media_downloaded_size) }} h
</td> grow
</tr> />
<tr> <span class="value">
<td> {{ humanSize(stats?.media_downloaded_size) }}
</span>
</Layout>
<Layout
flex
class="details"
>
<span
class="label"
>
{{ t('views.admin.library.AlbumDetail.table.audioContent.totalSize') }} {{ t('views.admin.library.AlbumDetail.table.audioContent.totalSize') }}
</td> </span>
<td> <Spacer
{{ humanSize(stats.media_total_size) }} h
</td> grow
</tr> />
<span class="value">
<tr> {{ humanSize(stats?.media_total_size) }}
<td> </span>
<router-link :to="{name: 'manage.library.libraries', query: {q: getQuery('album_id', object.id) }}"> </Layout>
<Layout
flex
class="details"
>
<Link
:to="{name: 'manage.library.libraries', query: {q: getQuery('album_id', object?.id) }}"
>
{{ t('views.admin.library.AlbumDetail.link.libraries') }} {{ t('views.admin.library.AlbumDetail.link.libraries') }}
</router-link> </Link>
</td> <Spacer
<td> h
{{ stats.libraries }} grow
</td> />
</tr> <span class="value">
<tr> {{ stats?.libraries }}
<td> </span>
<router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('album_id', object.id) }}"> </Layout>
<Layout
flex
class="details"
>
<Link
:to="{name: 'manage.library.uploads', query: {q: getQuery('album_id', object?.id) }}"
>
{{ t('views.admin.library.AlbumDetail.link.uploads') }} {{ t('views.admin.library.AlbumDetail.link.uploads') }}
</router-link> </Link>
</td> <Spacer
<td> h
{{ stats.uploads }} grow
</td> />
</tr> <span class="value">
<tr> {{ stats?.uploads }}
<td> </span>
<router-link :to="{name: 'manage.library.tracks', query: {q: getQuery('album_id', object.id) }}"> </Layout>
<Layout
flex
class="details"
>
<Link
:to="{name: 'manage.library.tracks', query: {q: getQuery('album_id', object?.id) }}"
>
{{ t('views.admin.library.AlbumDetail.link.tracks') }} {{ t('views.admin.library.AlbumDetail.link.tracks') }}
</router-link> </Link>
</td> <Spacer
<td> h
{{ object.tracks_count }} grow
</td> />
</tr> <span class="value">
</tbody> {{ object?.tracks_count }}
</table> </span>
</section> </Layout>
</div> </Layout>
</div> </Layout>
</div>
</template>
</main>
</template> </template>
<style scoped lang="scss">
.channel-image {
width: 200px;
height: 200px;
border: none;
}
h3.category {
margin-bottom: 16px;
}
.details {
padding: 0 16px;
height: 72px;
align-items: center;
border-top: 1px solid;
min-width: 280px;
@include light-theme {
border-color: var(--fw-gray-300);
}
@include dark-theme {
border-color: var(--fw-gray-800);
}
.label {
font-weight: 800;
@include light-theme {
color: var(--fw-gray-600);
}
@include dark-theme {
color: var(--fw-gray-500);
}
}
a.value {
text-decoration: underline;
}
&:last-child {
border-bottom: 1px solid;
}
}
</style>

View File

@ -13,6 +13,18 @@ import TagsList from '~/components/tags/List.vue'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
import Header from '~/components/ui/Header.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Pill from '~/components/ui/Pill.vue'
import HumanDate from '~/components/common/HumanDate.vue'
import Link from '~/components/ui/Link.vue'
import Heading from '~/components/ui/Heading.vue'
import Popover from '~/components/ui/Popover.vue'
import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
import OptionsButton from '~/components/ui/button/Options.vue'
import Loader from '~/components/ui/Loader.vue'
interface Props { interface Props {
id: number id: number
} }
@ -74,27 +86,21 @@ const remove = async () => {
} }
const getQuery = (field: string, value: string) => `${field}:"${value}"` const getQuery = (field: string, value: string) => `${field}:"${value}"`
const open = ref(false)
</script> </script>
<template> <template>
<main> <Loader v-if="isLoading" />
<div <Header
v-if="isLoading" v-if="object"
class="ui vertical segment"
>
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" />
</div>
<template v-if="object">
<section
v-title="object.name" v-title="object.name"
:class="['ui', 'head', 'vertical', 'stripe', 'segment']" :h1="truncate(object.name)"
page-heading
> >
<div class="ui stackable one column grid"> <template #image>
<div class="ui column">
<div class="segment-content">
<h2 class="ui header">
<img <img
v-if="object.cover && object.cover.urls.medium_square_crop" v-if="object.cover?.urls.medium_square_crop"
v-lazy="store.getters['instance/absoluteUrl'](object.cover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](object.cover.urls.medium_square_crop)"
alt="" alt=""
> >
@ -103,63 +109,42 @@ const getQuery = (field: string, value: string) => `${field}:"${value}"`
alt="" alt=""
src="../../../assets/audio/default-cover.png" src="../../../assets/audio/default-cover.png"
> >
<div class="content"> </template>
{{ truncate(object.name) }}
<div class="sub header"> <div class="sub header">
<template v-if="object.is_local"> <template v-if="object.is_local">
<span class="ui tiny accent label"> <Pill>
<i class="home icon" /> <i class="bi bi-house-fill" />
{{ t('views.admin.library.ArtistDetail.header.local') }} {{ t('views.admin.library.ArtistDetail.header.local') }}
</span> </Pill>
&nbsp; </template>
<template v-else>
<Pill>
<i class="bi bi-box-arrow-up-right" />
{{ t('views.admin.library.ArtistDetail.header.federated') }}
</Pill>
</template> </template>
</div> </div>
</div>
</h2>
<template v-if="object.tags && object.tags.length > 0">
<TagsList <TagsList
v-if="object.tags && object.tags.length > 0"
:limit="5" :limit="5"
detail-route="manage.library.tags.detail" detail-route="manage.library.tags.detail"
:tags="object.tags" :tags="object.tags"
/> />
<div class="ui hidden divider" /> <Spacer />
</template> <Layout
flex
<div class="header-buttons"> class="header-buttons"
<div class="ui icon buttons"> >
<router-link <Link
class="ui labeled icon button" solid
primary
low-height
icon="bi-info-circle"
:to="{name: 'library.artists.detail', params: {id: object.id }}" :to="{name: 'library.artists.detail', params: {id: object.id }}"
> >
<i class="info icon" />
{{ t('views.admin.library.ArtistDetail.link.localProfile') }} {{ t('views.admin.library.ArtistDetail.link.localProfile') }}
</router-link> </Link>
<button
v-dropdown
class="ui floating dropdown icon button"
>
<i class="dropdown icon" />
<div class="menu">
<a
v-if="store.state.auth.profile && store.state.auth.profile.is_superuser"
class="basic item"
:href="store.getters['instance/absoluteUrl'](`/api/admin/music/artist/${object.id}`)"
target="_blank"
rel="noopener noreferrer"
>
<i class="wrench icon" />
{{ t('views.admin.library.ArtistDetail.link.django') }}
</a>
<a
v-if="object.mbid"
class="basic item"
:href="`https://musicbrainz.org/artist/${object.mbid}`"
target="_blank"
rel="noopener noreferrer"
>
<i class="external icon" />
{{ t('views.admin.library.ArtistDetail.link.musicbrainz') }}
</a>
<fetch-button <fetch-button
v-if="!object.is_local" v-if="!object.is_local"
class="basic item" class="basic item"
@ -169,33 +154,20 @@ const getQuery = (field: string, value: string) => `${field}:"${value}"`
<i class="refresh icon" />&nbsp; <i class="refresh icon" />&nbsp;
{{ t('views.admin.library.ArtistDetail.button.remoteRefresh') }} {{ t('views.admin.library.ArtistDetail.button.remoteRefresh') }}
</fetch-button> </fetch-button>
<a
class="basic item"
:href="object.url || object.fid"
target="_blank"
rel="noopener noreferrer"
>
<i class="external icon" />
{{ t('views.admin.library.ArtistDetail.link.remoteProfile') }}
</a>
</div>
</button>
</div>
<div class="ui buttons">
<Link <Link
v-if="object.is_local" v-if="object.is_local"
solid solid
secondary primary
:to="{name: 'library.artists.edit', params: {id: object.id }}" low-height
class="ui labeled icon button"
icon="bi-pencil-fill" icon="bi-pencil-fill"
:to="{name: 'library.artists.edit', params: {id: object.id }}"
> >
{{ t('views.admin.library.ArtistDetail.button.edit') }} {{ t('views.admin.library.ArtistDetail.button.edit') }}
</Link> </Link>
</div>
<div class="ui buttons">
<dangerous-button <dangerous-button
:is-loading="isLoading" :is-loading="isLoading"
low-height
icon="bi-trash"
:action="remove" :action="remove"
:title="t('views.admin.library.ArtistDetail.modal.delete.header')" :title="t('views.admin.library.ArtistDetail.modal.delete.header')"
> >
@ -207,230 +179,369 @@ const getQuery = (field: string, value: string) => `${field}:"${value}"`
{{ t('views.admin.library.ArtistDetail.button.delete') }} {{ t('views.admin.library.ArtistDetail.button.delete') }}
</template> </template>
</dangerous-button> </dangerous-button>
</div> <Spacer grow />
</div> <Popover v-model="open">
</div> <template #default="{ toggleOpen }">
</div> <OptionsButton
</div> :title="labels.more"
</section> is-square-small
<div class="ui vertical stripe segment"> @click="toggleOpen()"
<div class="ui stackable three column grid"> />
<div class="column"> </template>
<section>
<h3 class="ui header"> <template #items>
<i class="info icon" /> <PopoverItem
<div class="content"> v-if="store.state.auth.profile && store.state.auth.profile.is_superuser"
{{ t('views.admin.library.ArtistDetail.header.artistData') }} :to="store.getters['instance/absoluteUrl'](`/api/admin/music/album/${object.id}`)"
</div> icon="bi-wrench"
</h3> target="_blank"
<table class="ui very basic table"> >
<tbody> {{ t('views.admin.library.AlbumDetail.link.django') }}
<tr> </PopoverItem>
<td> <PopoverItem
v-if="!object.is_local"
:to="`albums/${object.id}/fetches/`"
icon="bi-arrow-clockwise"
@click="fetchData()"
>
{{ t('views.admin.library.AlbumDetail.button.remoteRefresh') }}
</PopoverItem>
<PopoverItem
v-if="object.mbid"
:to="`https://musicbrainz.org/release/${object.mbid}`"
icon="bi-box-arrow-up-right"
target="_blank"
>
{{ t('views.admin.library.AlbumDetail.link.musicbrainz') }}
</PopoverItem>
<PopoverItem
:to="object.url || object.fid"
icon="bi-box-arrow-up-right"
target="_blank"
>
{{ t('views.admin.library.AlbumDetail.link.remoteProfile') }}
</PopoverItem>
</template>
</Popover>
</Layout>
</Header>
<Layout
flex
gap-64
>
<Layout
stack
style="flex: 1; gap: 0;"
>
<Heading
:h3="t('views.admin.library.ArtistDetail.header.artistData')"
class="category"
/>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.ArtistDetail.table.artist.name') }} {{ t('views.admin.library.ArtistDetail.table.artist.name') }}
</td> </span>
<td> <Spacer
{{ object.name }} h
</td> grow
</tr> />
<tr> <span class="value">{{ object?.name }}</span>
<td> </Layout>
<router-link :to="{name: 'manage.library.artists', query: {q: getQuery('category', object.content_category) }}"> <Layout
flex
class="details"
>
<Link
class="label"
:to="{name: 'manage.library.artists', query: {q: getQuery('category', object?.content_category) }}"
>
{{ t('views.admin.library.ArtistDetail.link.category') }} {{ t('views.admin.library.ArtistDetail.link.category') }}
</router-link> </Link>
</td> <Spacer
<td> h
{{ object.content_category }} grow
</td> />
</tr> <span class="value">{{ object?.content_category }}</span>
<tr v-if="!object.is_local"> </Layout>
<td> <Layout
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}"> v-if="!object?.is_local"
flex
class="details"
>
<Link
class="label"
:to="{name: 'manage.moderation.domains.detail', params: {id: object?.domain }}"
>
{{ t('views.admin.library.ArtistDetail.link.domain') }} {{ t('views.admin.library.ArtistDetail.link.domain') }}
</router-link> </Link>
</td> <Spacer
<td> h
{{ object.domain }} grow
</td> />
</tr> <span class="value">{{ object?.domain }}</span>
<tr v-if="object.description"> </Layout>
<td> <Layout
v-if="object?.description"
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.ArtistDetail.table.artist.description') }} {{ t('views.admin.library.ArtistDetail.table.artist.description') }}
</td> </span>
<Spacer
h
grow
/>
<sanitized-html <sanitized-html
tag="td" tag="span"
class="value"
:html="object.description.html" :html="object.description.html"
/> />
</tr> </Layout>
</tbody> </Layout>
</table> <Layout
</section> stack
</div> style="flex: 1; gap: 0;"
<div class="column">
<section>
<h3 class="ui header">
<i class="feed icon" />
<div class="content">
{{ t('views.admin.library.ArtistDetail.header.activity') }}&nbsp;
<span :data-tooltip="labels.statsWarning"><i class="question circle icon" /></span>
</div>
</h3>
<div
v-if="isLoadingStats"
class="ui placeholder"
> >
<div class="full line" /> <Heading
<div class="short line" /> :h3="t('views.admin.library.ArtistDetail.header.activity')"
<div class="medium line" /> class="category"
<div class="long line" /> />
</div> <Layout
<table flex
v-else class="details"
class="ui very basic table"
> >
<tbody> <span class="label">
<tr>
<td>
{{ t('views.admin.library.ArtistDetail.table.activity.firstSeen') }} {{ t('views.admin.library.ArtistDetail.table.activity.firstSeen') }}
</td> </span>
<td> <Spacer
<human-date :date="object.creation_date" /> h
</td> grow
</tr> />
<tr> <human-date :date="object?.creation_date" />
<td> </Layout>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.ArtistDetail.table.activity.listenings') }} {{ t('views.admin.library.ArtistDetail.table.activity.listenings') }}
</td> </span>
<td> <Spacer
{{ stats.listenings }} h
</td> grow
</tr> />
<tr> <span class="value">{{ stats?.listenings }}</span>
<td> </Layout>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.ArtistDetail.table.activity.favorited') }} {{ t('views.admin.library.ArtistDetail.table.activity.favorited') }}
</td> </span>
<td> <Spacer
{{ stats.track_favorites }} h
</td> grow
</tr> />
<tr> <span class="value">{{ stats?.track_favorites }}</span>
<td> </Layout>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.ArtistDetail.table.activity.playlists') }} {{ t('views.admin.library.ArtistDetail.table.activity.playlists') }}
</td> </span>
<td> <Spacer
{{ stats.playlists }} h
</td> grow
</tr> />
<tr> <span class="value">{{ stats?.playlists }}</span>
<td> </Layout>
<router-link :to="{name: 'manage.moderation.reports.list', query: {q: getQuery('target', `artist:${object.id}`) }}"> <Layout
flex
class="details"
>
<Link
class="label"
:to="{name: 'manage.moderation.reports.list', query: {q: getQuery('target', `artist:${object?.id}`) }}"
>
{{ t('views.admin.library.ArtistDetail.link.reports') }} {{ t('views.admin.library.ArtistDetail.link.reports') }}
</router-link> </Link>
</td> <Spacer
<td> h
{{ stats.reports }} grow
</td> />
</tr> <span class="value">{{ stats?.reports }}</span>
<tr> </Layout>
<td> <Layout
<router-link :to="{name: 'manage.library.edits', query: {q: getQuery('target', 'artist ' + object.id)}}"> flex
class="details"
>
<Link
class="label"
:to="{name: 'manage.library.edits', query: {q: getQuery('target', 'artist ' + object?.id) }}"
>
{{ t('views.admin.library.ArtistDetail.link.edits') }} {{ t('views.admin.library.ArtistDetail.link.edits') }}
</router-link> </Link>
</td> <Spacer
<td> h
{{ stats.mutations }} grow
</td> />
</tr> <span class="value">{{ stats?.mutations }}</span>
</tbody> </Layout>
</table> </Layout>
</section> <Layout
</div> stack
<div class="column"> style="flex: 1; gap: 0;"
<section>
<h3 class="ui header">
<i class="music icon" />
<div class="content">
{{ t('views.admin.library.ArtistDetail.header.audioContent') }}&nbsp;
<span :data-tooltip="labels.statsWarning"><i class="question circle icon" /></span>
</div>
</h3>
<div
v-if="isLoadingStats"
class="ui placeholder"
> >
<div class="full line" /> <Heading
<div class="short line" /> :h3="t('views.admin.library.ArtistDetail.header.audioContent')"
<div class="medium line" /> class="category"
<div class="long line" /> />
</div> <Layout
<table flex
v-else class="details"
class="ui very basic table"
> >
<tbody> <span class="label">
<tr>
<td>
{{ t('views.admin.library.ArtistDetail.table.audioContent.cachedSize') }} {{ t('views.admin.library.ArtistDetail.table.audioContent.cachedSize') }}
</td> </span>
<td> <Spacer
{{ humanSize(stats.media_downloaded_size) }} h
</td> grow
</tr> />
<tr> <span class="value">{{ humanSize(stats?.media_downloaded_size) }}</span>
<td> </Layout>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.ArtistDetail.table.audioContent.totalSize') }} {{ t('views.admin.library.ArtistDetail.table.audioContent.totalSize') }}
</td> </span>
<td> <Spacer
{{ humanSize(stats.media_total_size) }} h
</td> grow
</tr> />
<span class="value">{{ humanSize(stats?.media_total_size) }}</span>
<tr> </Layout>
<td> <Layout
<router-link :to="{name: 'manage.library.libraries', query: {q: getQuery('artist_id', object.id) }}"> flex
class="details"
>
<Link
class="label"
:to="{name: 'manage.library.libraries', query: {q: getQuery('artist_id', object?.id) }}"
>
{{ t('views.admin.library.ArtistDetail.link.libraries') }} {{ t('views.admin.library.ArtistDetail.link.libraries') }}
</router-link> </Link>
</td> <Spacer
<td> h
{{ stats.libraries }} grow
</td> />
</tr> <span class="value">{{ stats?.libraries }}</span>
<tr> </Layout>
<td> <Layout
<router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('artist_id', object.id) }}"> flex
class="details"
>
<Link
class="label"
:to="{name: 'manage.library.uploads', query: {q: getQuery('artist_id', object?.id) }}"
>
{{ t('views.admin.library.ArtistDetail.link.uploads') }} {{ t('views.admin.library.ArtistDetail.link.uploads') }}
</router-link> </Link>
</td> <Spacer
<td> h
{{ stats.uploads }} grow
</td> />
</tr> <span class="value">{{ stats?.uploads }}</span>
<tr> </Layout>
<td> <Layout
<router-link :to="{name: 'manage.library.albums', query: {q: getQuery('artist_id', object.id) }}"> flex
class="details"
>
<Link
class="label"
:to="{name: 'manage.library.albums', query: {q: getQuery('artist_id', object?.id) }}"
>
{{ t('views.admin.library.ArtistDetail.link.albums') }} {{ t('views.admin.library.ArtistDetail.link.albums') }}
</router-link> </Link>
</td> <Spacer
<td> h
{{ object.albums_count }} grow
</td> />
</tr> <span class="value">{{ object?.albums_count }}</span>
<tr> </Layout>
<td> <Layout
<router-link :to="{name: 'manage.library.tracks', query: {q: getQuery('artist_id', object.id) }}"> flex
class="details"
>
<Link
class="label"
:to="{name: 'manage.library.tracks', query: {q: getQuery('artist_id', object?.id) }}"
>
{{ t('views.admin.library.ArtistDetail.link.tracks') }} {{ t('views.admin.library.ArtistDetail.link.tracks') }}
</router-link> </Link>
</td> <Spacer
<td> h
{{ object.tracks_count }} grow
</td> />
</tr> <span class="value">{{ object?.tracks_count }}</span>
</tbody> </Layout>
</table> </Layout>
</section> </Layout>
</div>
</div>
</div>
</template>
</main>
</template> </template>
<style scoped lang="scss">
.channel-image {
width: 200px;
height: 200px;
border: none;
}
h3.category {
margin-bottom: 16px;
}
.details {
padding: 0 16px;
height: 72px;
align-items: center;
border-top: 1px solid;
min-width: 280px;
@include light-theme {
border-color: var(--fw-gray-300);
}
@include dark-theme {
border-color: var(--fw-gray-800);
}
.label {
font-weight: 800;
@include light-theme {
color: var(--fw-gray-600);
}
@include dark-theme {
color: var(--fw-gray-500);
}
}
a.label,
a.value {
text-decoration: underline;
}
&:last-child {
border-bottom: 1px solid;
}
}
</style>

View File

@ -7,9 +7,20 @@ import { useStore } from '~/store'
import axios from 'axios' import axios from 'axios'
import useErrorHandler from '~/composables/useErrorHandler'
import DangerousButton from '~/components/common/DangerousButton.vue' import DangerousButton from '~/components/common/DangerousButton.vue'
import Header from '~/components/ui/Header.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import HumanDate from '~/components/common/HumanDate.vue'
import Link from '~/components/ui/Link.vue'
import Pill from '~/components/ui/Pill.vue'
import Heading from '~/components/ui/Heading.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 Loader from '~/components/ui/Loader.vue'
import useErrorHandler from '~/composables/useErrorHandler'
interface Props { interface Props {
id: number id: number
@ -24,6 +35,8 @@ const router = useRouter()
const isLoading = ref(false) const isLoading = ref(false)
const object = ref() const object = ref()
const open = ref(false)
const fetchData = async () => { const fetchData = async () => {
isLoading.value = true isLoading.value = true
@ -56,59 +69,31 @@ const getQuery = (field: string, value: string) => `${field}:"${value}"`
</script> </script>
<template> <template>
<main> <Loader v-if="isLoading" />
<div <Header
v-if="isLoading" v-if="object"
class="ui vertical segment" v-title="object?.name"
:h1="'# ' + truncate(object?.name)"
page-heading
> >
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" /> <Layout
</div> flex
<template v-if="object"> class="header-buttons"
<section
v-title="object.name"
:class="['ui', 'head', 'vertical', 'stripe', 'segment']"
> >
<div class="ui stackable one column grid"> <Link
<div class="ui column"> solid
<div class="segment-content"> primary
<h2 class="ui header"> low-height
<i class="circular inverted hashtag icon" /> icon="bi-info-circle"
<div class="content"> :to="{ name: 'library.tags.detail', params: { id: object?.name } }"
{{ truncate(object.name) }}
</div>
</h2>
<div class="header-buttons">
<div class="ui icon buttons">
<router-link
class="ui labeled icon button"
:to="{name: 'library.tags.detail', params: {id: object.name }}"
> >
<i class="info icon" />
{{ t('views.admin.library.TagDetail.link.localProfile') }} {{ t('views.admin.library.TagDetail.link.localProfile') }}
</router-link> </Link>
<button
v-dropdown
class="ui floating dropdown icon button"
>
<i class="dropdown icon" />
<div class="menu">
<a
v-if="store.state.auth.profile && store.state.auth.profile.is_superuser"
class="basic item"
:href="store.getters['instance/absoluteUrl'](`/api/admin/tags/tag/${object.id}`)"
target="_blank"
rel="noopener noreferrer"
>
<i class="wrench icon" />
{{ t('views.admin.library.TagDetail.link.django') }}
</a>
</div>
</button>
</div>
<div class="ui buttons">
<dangerous-button <dangerous-button
:is-loading="isLoading" :is-loading="isLoading"
:action="remove" :action="remove"
icon="bi-trash"
low-height
:title="t('views.admin.library.TagDetail.modal.delete.header')" :title="t('views.admin.library.TagDetail.modal.delete.header')"
> >
{{ t('views.admin.library.TagDetail.button.delete') }} {{ t('views.admin.library.TagDetail.button.delete') }}
@ -119,104 +104,176 @@ const getQuery = (field: string, value: string) => `${field}:"${value}"`
{{ t('views.admin.library.TagDetail.button.delete') }} {{ t('views.admin.library.TagDetail.button.delete') }}
</template> </template>
</dangerous-button> </dangerous-button>
</div> <Spacer grow />
</div> <Popover v-model="open">
</div> <template #default="{ toggleOpen }">
</div> <OptionsButton
</div> :title="t('views.admin.library.TagDetail.button.more')"
</section> is-square-small
<div class="ui vertical stripe segment"> @click="toggleOpen()"
<div class="ui stackable three column grid"> />
<div class="column"> </template>
<section>
<h3 class="ui header"> <template #items>
<i class="info icon" /> <PopoverItem
<div class="content"> v-if="store.state.auth.profile?.is_superuser"
{{ t('views.admin.library.TagDetail.header.tagData') }} :to="store.getters['instance/absoluteUrl'](`/api/admin/tags/tag/${object?.id}`)"
</div> icon="bi-wrench"
</h3> target="_blank"
<table class="ui very basic table"> >
<tbody> {{ t('views.admin.library.TagDetail.link.django') }}
<tr> </PopoverItem>
<td> </template>
</Popover>
</Layout>
</Header>
<Layout
flex
gap-64
>
<Layout
stack
style="flex: 1; gap: 0;"
>
<Heading
:h3="t('views.admin.library.TagDetail.header.tagData')"
class="category"
/>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.TagDetail.table.tag.name') }} {{ t('views.admin.library.TagDetail.table.tag.name') }}
</td> </span>
<td> <Spacer
{{ object.name }} h
</td> grow
</tr> />
</tbody> <span class="value">{{ object?.name }}</span>
</table> </Layout>
</section> </Layout>
</div> <Layout
<div class="column"> stack
<section> style="flex: 1; gap: 0;"
<h3 class="ui header"> >
<i class="feed icon" /> <Heading
<div class="content"> :h3="t('views.admin.library.TagDetail.header.activity')"
{{ t('views.admin.library.TagDetail.header.activity') }}&nbsp; class="category"
</div> />
</h3> <Layout
<table class="ui very basic table"> flex
<tbody> class="details"
<tr> >
<td> <span class="label">
{{ t('views.admin.library.TagDetail.table.activity.firstSeen') }} {{ t('views.admin.library.TagDetail.table.activity.firstSeen') }}
</td> </span>
<td> <Spacer
<human-date :date="object.creation_date" /> h
</td> grow
</tr> />
</tbody> <human-date :date="object?.creation_date" />
</table> </Layout>
</section> </Layout>
</div> <Layout
<div class="column"> stack
<section> style="flex: 1; gap: 0;"
<h3 class="ui header"> >
<i class="music icon" /> <Heading
<div class="content"> :h3="t('views.admin.library.TagDetail.header.audioContent')"
{{ t('views.admin.library.TagDetail.header.audioContent') }}&nbsp; class="category"
</div> />
</h3> <Layout
<table class="ui very basic table"> flex
<tbody> class="details"
<tr> >
<td> <Link
<router-link :to="{name: 'manage.library.artists', query: {q: getQuery('tag', object.name) }}"> class="label"
:to="{ name: 'manage.library.artists', query: { q: getQuery('tag', object?.name) } }"
>
{{ t('views.admin.library.TagDetail.link.artists') }} {{ t('views.admin.library.TagDetail.link.artists') }}
</router-link> </Link>
</td> <Spacer
<td> h
{{ object.artists_count }} grow
</td> />
</tr> <span class="value">{{ object?.artists_count }}</span>
<tr> </Layout>
<td> <Layout
<router-link :to="{name: 'manage.library.albums', query: {q: getQuery('tag', object.name) }}"> flex
class="details"
>
<Link
class="label"
:to="{ name: 'manage.library.albums', query: { q: getQuery('tag', object?.name) } }"
>
{{ t('views.admin.library.TagDetail.link.albums') }} {{ t('views.admin.library.TagDetail.link.albums') }}
</router-link> </Link>
</td> <Spacer
<td> h
{{ object.albums_count }} grow
</td> />
</tr> <span class="value">{{ object?.albums_count }}</span>
<tr> </Layout>
<td> <Layout
<router-link :to="{name: 'manage.library.tracks', query: {q: getQuery('tag', object.name) }}"> flex
class="details"
>
<Link
class="label"
:to="{ name: 'manage.library.tracks', query: { q: getQuery('tag', object?.name) } }"
>
{{ t('views.admin.library.TagDetail.link.tracks') }} {{ t('views.admin.library.TagDetail.link.tracks') }}
</router-link> </Link>
</td> <Spacer
<td> h
{{ object.tracks_count }} grow
</td> />
</tr> <span class="value">{{ object?.tracks_count }}</span>
</tbody> </Layout>
</table> </Layout>
</section> </Layout>
</div>
</div>
</div>
</template>
</main>
</template> </template>
<style scoped lang="scss">
h3.category {
margin-bottom: 16px;
}
.details {
padding: 0 16px;
height: 72px;
align-items: center;
border-top: 1px solid;
min-width: 280px;
@include light-theme {
border-color: var(--fw-gray-300);
}
@include dark-theme {
border-color: var(--fw-gray-800);
}
.label {
font-weight: 800;
@include light-theme {
color: var(--fw-gray-600);
}
@include dark-theme {
color: var(--fw-gray-500);
}
}
a.label,
a.value {
text-decoration: underline;
}
&:last-child {
border-bottom: 1px solid;
}
}
</style>

View File

@ -9,11 +9,20 @@ import axios from 'axios'
import FetchButton from '~/components/federation/FetchButton.vue' import FetchButton from '~/components/federation/FetchButton.vue'
import TagsList from '~/components/tags/List.vue' import TagsList from '~/components/tags/List.vue'
import DangerousButton from '~/components/common/DangerousButton.vue'
import Header from '~/components/ui/Header.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import HumanDate from '~/components/common/HumanDate.vue'
import Link from '~/components/ui/Link.vue'
import Heading from '~/components/ui/Heading.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 Loader from '~/components/ui/Loader.vue'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
import DangerousButton from '~/components/common/DangerousButton.vue'
interface Props { interface Props {
id: number id: number
} }
@ -21,12 +30,19 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
const { t } = useI18n() const { t } = useI18n()
const router = useRouter()
const store = useStore()
const track = ref()
const isLoading = ref(false)
const stats = ref()
const isLoadingStats = ref(false)
const open = ref(false)
const labels = computed(() => ({ const labels = computed(() => ({
statsWarning: t('views.admin.library.TrackDetail.warning.stats') statsWarning: t('views.admin.library.TrackDetail.warning.stats')
})) }))
const track = ref()
const isLoading = ref(false)
const fetchData = async () => { const fetchData = async () => {
isLoading.value = true isLoading.value = true
@ -40,8 +56,6 @@ const fetchData = async () => {
isLoading.value = false isLoading.value = false
} }
const stats = ref()
const isLoadingStats = ref(false)
const fetchStats = async () => { const fetchStats = async () => {
isLoadingStats.value = true isLoadingStats.value = true
@ -58,9 +72,6 @@ const fetchStats = async () => {
fetchData() fetchData()
fetchStats() fetchStats()
const router = useRouter()
const store = useStore()
const remove = async () => { const remove = async () => {
isLoading.value = true isLoading.value = true
@ -78,25 +89,17 @@ const getQuery = (field: string, value: string) => `${field}:"${value}"`
</script> </script>
<template> <template>
<main> <Loader v-if="isLoading" />
<div <Header
v-if="isLoading" v-if="track"
class="ui vertical segment" v-title="track?.title"
:h1="truncate(track?.title)"
page-heading
> >
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" /> <template #image>
</div>
<template v-if="track">
<section
v-title="track.title"
:class="['ui', 'head', 'vertical', 'stripe', 'segment']"
>
<div class="ui stackable one column grid">
<div class="ui column">
<div class="segment-content">
<h2 class="ui header">
<img <img
v-if="track.cover && track.cover.urls.medium_square_crop" v-if="track?.cover?.urls.medium_square_crop"
v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](track?.cover?.urls.medium_square_crop)"
alt="" alt=""
> >
<img <img
@ -104,98 +107,65 @@ const getQuery = (field: string, value: string) => `${field}:"${value}"`
alt="" alt=""
src="../../../assets/audio/default-cover.png" src="../../../assets/audio/default-cover.png"
> >
<div class="content"> </template>
{{ truncate(track.title) }}
<div class="sub header"> <div class="sub header">
<template v-if="track.is_local"> <template v-if="track?.is_local">
<span class="ui tiny accent label"> <Pill>
<i class="home icon" /> <i class="bi bi-house-fill" />
{{ t('views.admin.library.TrackDetail.header.local') }} {{ t('views.admin.library.TrackDetail.header.local') }}
</span> </Pill>
&nbsp; </template>
<template v-else>
<Pill>
<i class="bi bi-box-arrow-up-right" />
{{ t('views.admin.library.TrackDetail.header.federated') }}
</Pill>
</template> </template>
</div> </div>
</div>
</h2>
<template v-if="track.tags && track.tags.length > 0">
<TagsList <TagsList
v-if="track?.tags && track?.tags.length > 0"
:limit="5" :limit="5"
detail-route="manage.library.tags.detail" detail-route="manage.library.tags.detail"
:tags="track.tags" :tags="track?.tags"
/> />
<div class="ui hidden divider" /> <Spacer />
</template> <Layout
flex
<div class="header-buttons"> class="header-buttons"
<div class="ui icon buttons"> >
<router-link <Link
class="ui icon labeled button" solid
:to="{name: 'library.tracks.detail', params: {id: track.id }}" primary
low-height
icon="bi-info-circle"
:to="{ name: 'library.tracks.detail', params: { id: track?.id } }"
> >
<i class="info icon" />
{{ t('views.admin.library.TrackDetail.link.localProfile') }} {{ t('views.admin.library.TrackDetail.link.localProfile') }}
</router-link> </Link>
<button
v-dropdown
class="ui floating dropdown icon button"
>
<i class="dropdown icon" />
<div class="menu">
<a
v-if="store.state.auth.profile && store.state.auth.profile.is_superuser"
class="basic item"
:href="store.getters['instance/absoluteUrl'](`/api/admin/music/track/${track.id}`)"
target="_blank"
rel="noopener noreferrer"
>
<i class="wrench icon" />
{{ t('views.admin.library.TrackDetail.link.django') }}
</a>
<a
v-if="track.mbid"
class="basic item"
:href="`https://musicbrainz.org/recording/${track.mbid}`"
target="_blank"
rel="noopener noreferrer"
>
<i class="external icon" />
{{ t('views.admin.library.TrackDetail.link.musicbrainz') }}
</a>
<fetch-button <fetch-button
v-if="!track.is_local" v-if="!track?.is_local"
class="basic item" class="basic item"
:url="`tracks/${track.id}/fetches/`" :url="`tracks/${track?.id}/fetches/`"
@refresh="fetchData" @refresh="fetchData"
> >
<i class="refresh icon" />&nbsp; <i class="refresh icon" />&nbsp;
{{ t('views.admin.library.TrackDetail.button.remoteRefresh') }} {{ t('views.admin.library.TrackDetail.button.remoteRefresh') }}
</fetch-button> </fetch-button>
<a <Link
class="basic item" v-if="track?.is_local"
:href="track.url || track.fid" solid
target="_blank" primary
rel="noopener noreferrer" low-height
icon="bi-pencil-fill"
:to="{ name: 'library.tracks.edit', params: { id: track?.id } }"
> >
<i class="external icon" />
{{ t('views.admin.library.TrackDetail.link.remoteProfile') }}
</a>
</div>
</button>
</div>
<div class="ui buttons">
<router-link
v-if="track.is_local"
:to="{name: 'library.tracks.edit', params: {id: track.id }}"
class="ui labeled icon button"
>
<i class="edit icon" />
{{ t('views.admin.library.TrackDetail.button.edit') }} {{ t('views.admin.library.TrackDetail.button.edit') }}
</router-link> </Link>
</div>
<div class="ui buttons">
<dangerous-button <dangerous-button
:is-loading="isLoading" :is-loading="isLoading"
low-height
icon="bi-trash"
:action="remove" :action="remove"
:title="t('views.admin.library.TrackDetail.modal.delete.header')" :title="t('views.admin.library.TrackDetail.modal.delete.header')"
> >
@ -207,263 +177,217 @@ const getQuery = (field: string, value: string) => `${field}:"${value}"`
{{ t('views.admin.library.TrackDetail.button.delete') }} {{ t('views.admin.library.TrackDetail.button.delete') }}
</template> </template>
</dangerous-button> </dangerous-button>
</div> <Spacer grow />
</div> <Popover v-model="open">
</div> <template #default="{ toggleOpen }">
</div> <OptionsButton
</div> :title="labels.more"
</section> is-square-small
<div class="ui vertical stripe segment"> @click="toggleOpen()"
<div class="ui stackable three column grid">
<div class="column">
<section>
<h3 class="ui header">
<i class="info icon" />
<div class="content">
{{ t('views.admin.library.TrackDetail.header.trackData') }}
</div>
</h3>
<table class="ui very basic table">
<tbody>
<tr>
<td>
{{ t('views.admin.library.TrackDetail.table.track.title') }}
</td>
<td>
{{ track.title }}
</td>
</tr>
<tr v-if="track.album">
<td>
<router-link :to="{name: 'manage.library.albums.detail', params: {id: track.album.id }}">
{{ t('views.admin.library.TrackDetail.link.album') }}
</router-link>
</td>
<td>
{{ track.album.title }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.artists.detail', params: {id: track.artist_credit[0].artist.id }}">
{{ t('views.admin.library.TrackDetail.link.artist') }}
</router-link>
</td>
<td>
{{ track.artist_credit[0].artist.name }}
</td>
</tr>
<tr v-if="track.album">
<td>
<router-link :to="{name: 'manage.library.artists.detail', params: {id: track.album.artist_credit[0].artist.id }}">
{{ t('views.admin.library.TrackDetail.link.albumArtist') }}
</router-link>
</td>
<td>
{{ track.album.artist_credit[0].artist.name }}
</td>
</tr>
<tr>
<td>
{{ t('views.admin.library.TrackDetail.table.track.position') }}
</td>
<td>
{{ track.position }}
</td>
</tr>
<tr v-if="track.disc_number">
<td>
{{ t('views.admin.library.TrackDetail.table.track.discNumber') }}
</td>
<td>
{{ track.disc_number }}
</td>
</tr>
<tr v-if="track.copyright">
<td>
{{ t('views.admin.library.TrackDetail.table.track.copyright') }}
</td>
<td>{{ track.copyright }}</td>
</tr>
<tr v-if="track.license">
<td>
{{ t('views.admin.library.TrackDetail.table.track.license') }}
</td>
<td>
<router-link :to="{name: 'manage.library.tracks', query: {q: getQuery('license', track.license)}}">
{{ track.license }}
</router-link>
</td>
</tr>
<tr v-if="!track.is_local">
<td>
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: track.domain }}">
{{ t('views.admin.library.TrackDetail.link.domain') }}
</router-link>
</td>
<td>
{{ track.domain }}
</td>
</tr>
<tr v-if="track.description">
<td>
{{ t('views.admin.library.TrackDetail.table.track.description') }}
</td>
<sanitized-html
tag="td"
:html="track.description.html"
/> />
</tr> </template>
</tbody>
</table>
</section>
</div>
<div class="column">
<section>
<h3 class="ui header">
<i class="feed icon" />
<div class="content">
{{ t('views.admin.library.TrackDetail.header.activity') }}&nbsp;
<span :data-tooltip="labels.statsWarning"><i class="question circle icon" /></span>
</div>
</h3>
<div
v-if="isLoadingStats"
class="ui placeholder"
>
<div class="full line" />
<div class="short line" />
<div class="medium line" />
<div class="long line" />
</div>
<table
v-else
class="ui very basic table"
>
<tbody>
<tr>
<td>
{{ t('views.admin.library.TrackDetail.table.activity.firstSeen') }}
</td>
<td>
<human-date :date="track.creation_date" />
</td>
</tr>
<tr>
<td>
{{ t('views.admin.library.TrackDetail.table.activity.listenings') }}
</td>
<td>
{{ stats.listenings }}
</td>
</tr>
<tr>
<td>
{{ t('views.admin.library.TrackDetail.table.activity.favorited') }}
</td>
<td>
{{ stats.track_favorites }}
</td>
</tr>
<tr>
<td>
{{ t('views.admin.library.TrackDetail.table.activity.playlists') }}
</td>
<td>
{{ stats.playlists }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.moderation.reports.list', query: {q: getQuery('target', `track:${track.id}`) }}">
{{ t('views.admin.library.TrackDetail.link.reports') }}
</router-link>
</td>
<td>
{{ stats.reports }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.edits', query: {q: getQuery('target', 'track ' + track.id)}}">
{{ t('views.admin.library.TrackDetail.link.edits') }}
</router-link>
</td>
<td>
{{ stats.mutations }}
</td>
</tr>
</tbody>
</table>
</section>
</div>
<div class="column">
<section>
<h3 class="ui header">
<i class="music icon" />
<div class="content">
{{ t('views.admin.library.TrackDetail.header.trackData') }}&nbsp;
<span :data-tooltip="labels.statsWarning"><i class="question circle icon" /></span>
</div>
</h3>
<div
v-if="isLoadingStats"
class="ui placeholder"
>
<div class="full line" />
<div class="short line" />
<div class="medium line" />
<div class="long line" />
</div>
<table
v-else
class="ui very basic table"
>
<tbody>
<tr>
<td>
{{ t('views.admin.library.TrackDetail.table.trackData.cachedSize') }}
</td>
<td>
{{ humanSize(stats.media_downloaded_size) }}
</td>
</tr>
<tr>
<td>
{{ t('views.admin.library.TrackDetail.table.trackData.totalSize') }}
</td>
<td>
{{ humanSize(stats.media_total_size) }}
</td>
</tr>
<tr> <template #items>
<td> <PopoverItem
<router-link :to="{name: 'manage.library.libraries', query: {q: getQuery('track_id', track.id) }}"> v-if="store.state.auth.profile?.is_superuser"
{{ t('views.admin.library.TrackDetail.link.libraries') }} :to="store.getters['instance/absoluteUrl'](`/api/admin/music/track/${track?.id}`)"
</router-link> icon="bi-wrench"
</td> target="_blank"
<td> >
{{ stats.libraries }} {{ t('views.admin.library.TrackDetail.link.django') }}
</td> </PopoverItem>
</tr> <PopoverItem
<tr> v-if="track?.mbid"
<td> :to="`https://musicbrainz.org/recording/${track?.mbid}`"
<router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('track_id', track.id) }}"> icon="bi-box-arrow-up-right"
{{ t('views.admin.library.TrackDetail.link.uploads') }} target="_blank"
</router-link> >
</td> {{ t('views.admin.library.TrackDetail.link.musicbrainz') }}
<td> </PopoverItem>
{{ stats.uploads }} <PopoverItem
</td> :to="track?.url || track?.fid"
</tr> icon="bi-box-arrow-up-right"
</tbody> target="_blank"
</table> >
</section> {{ t('views.admin.library.TrackDetail.link.remoteProfile') }}
</div> </PopoverItem>
</div>
</div>
</template> </template>
</main> </Popover>
</Layout>
</Header>
<Layout
flex
gap-64
>
<Layout
stack
style="flex: 1; gap: 0;"
>
<Heading
:h3="t('views.admin.library.TrackDetail.header.trackData')"
class="category"
/>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.TrackDetail.table.track.title') }}
</span>
<Spacer
h
grow
/>
<span class="value">{{ track?.title }}</span>
</Layout>
<Layout
v-if="track?.album"
flex
class="details"
>
<Link
class="label"
:to="{ name: 'manage.library.albums.detail', params: { id: track?.album?.id } }"
>
{{ t('views.admin.library.TrackDetail.link.album') }}
</Link>
<Spacer
h
grow
/>
<span class="value">{{ track?.album?.title }}</span>
</Layout>
<Layout
flex
class="details"
>
<Link
class="label"
:to="{ name: 'manage.library.artists.detail', params: { id: track?.artist_credit[0]?.artist?.id } }"
>
{{ t('views.admin.library.TrackDetail.link.artist') }}
</Link>
<Spacer
h
grow
/>
<span class="value">{{ track?.artist_credit[0]?.artist?.name }}</span>
</Layout>
<Layout
v-if="track?.description"
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.TrackDetail.table.track.description') }}
</span>
<Spacer
h
grow
/>
<sanitized-html
tag="span"
class="value"
:html="track?.description?.html"
/>
</Layout>
</Layout>
<Layout
stack
style="flex: 1; gap: 0;"
>
<Heading
:h3="t('views.admin.library.TrackDetail.header.activity')"
class="category"
/>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.TrackDetail.table.activity.firstSeen') }}
</span>
<Spacer
h
grow
/>
<human-date :date="track?.creation_date" />
</Layout>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.TrackDetail.table.activity.listenings') }}
</span>
<Spacer
h
grow
/>
<span class="value">{{ stats?.listenings }}</span>
</Layout>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.TrackDetail.table.activity.favorited') }}
</span>
<Spacer
h
grow
/>
<span class="value">{{ stats?.track_favorites }}</span>
</Layout>
</Layout>
</Layout>
</template> </template>
<style scoped lang="scss">
.channel-image {
width: 200px;
height: 200px;
border: none;
}
h3.category {
margin-bottom: 16px;
}
.details {
padding: 0 16px;
height: 72px;
align-items: center;
border-top: 1px solid;
min-width: 280px;
@include light-theme {
border-color: var(--fw-gray-300);
}
@include dark-theme {
border-color: var(--fw-gray-800);
}
.label {
font-weight: 800;
@include light-theme {
color: var(--fw-gray-600);
}
@include dark-theme {
color: var(--fw-gray-500);
}
}
a.label,
a.value {
text-decoration: underline;
}
&:last-child {
border-bottom: 1px solid;
}
}
</style>

View File

@ -11,29 +11,47 @@ import time from '~/utils/time'
import axios from 'axios' import axios from 'axios'
import ImportStatusModal from '~/components/library/ImportStatusModal.vue' import ImportStatusModal from '~/components/library/ImportStatusModal.vue'
import DangerousButton from '~/components/common/DangerousButton.vue'
import Header from '~/components/ui/Header.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import HumanDate from '~/components/common/HumanDate.vue'
import Link from '~/components/ui/Link.vue'
import Button from '~/components/ui/Button.vue'
import Heading from '~/components/ui/Heading.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 Loader from '~/components/ui/Loader.vue'
import Pill from '~/components/ui/Pill.vue'
import useSharedLabels from '~/composables/locale/useSharedLabels' import useSharedLabels from '~/composables/locale/useSharedLabels'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
import DangerousButton from '~/components/common/DangerousButton.vue'
interface Props { interface Props {
id: number id: number
} }
const props = defineProps<Props>()
const router = useRouter() const router = useRouter()
const store = useStore() const store = useStore()
const { t } = useI18n() const { t } = useI18n()
const props = defineProps<Props>()
const sharedLabels = useSharedLabels() const sharedLabels = useSharedLabels()
const privacyLevels = computed(() => sharedLabels.fields.privacy_level.shortChoices[object.value.library.privacy_level as PrivacyLevel])
const importStatus = computed(() => sharedLabels.fields.import_status.choices[object.value.import_status as ImportStatus].label)
const isLoading = ref(false) const isLoading = ref(false)
const object = ref() const object = ref()
const showUploadDetailModal = ref(false)
const open = ref(false)
const privacyLevels = computed(() =>
sharedLabels.fields.privacy_level.shortChoices[object.value?.library?.privacy_level as PrivacyLevel]
)
const importStatus = computed(() =>
sharedLabels.fields.import_status.choices[object.value?.import_status as ImportStatus]?.label
)
const fetchData = async () => { const fetchData = async () => {
isLoading.value = true isLoading.value = true
@ -63,99 +81,61 @@ const remove = async () => {
} }
const getQuery = (field: string, value: string) => `${field}:"${value}"` const getQuery = (field: string, value: string) => `${field}:"${value}"`
const displayName = (object: any) => object.filename ?? object.source ?? object.uuid const displayName = (object: any) => object?.filename ?? object?.source ?? object?.uuid
const showUploadDetailModal = ref(false)
</script> </script>
<template> <template>
<main> <Loader v-if="isLoading" />
<div <Header
v-if="isLoading" v-if="object"
class="ui vertical segment"
>
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" />
</div>
<template v-if="object">
<import-status-modal
v-model:show="showUploadDetailModal"
:upload="object"
/>
<section
v-title="displayName(object)" v-title="displayName(object)"
:class="['ui', 'head', 'vertical', 'stripe', 'segment']" :h1="truncate(displayName(object))"
page-heading
> >
<div class="ui stackable one column grid"> <template #image>
<div class="ui column"> <i class="avatar circular bi bi-file-earmark-music" />
<div class="segment-content"> </template>
<h2 class="ui header">
<i class="circular inverted file icon" />
<div class="content">
{{ truncate(displayName(object)) }}
<div class="sub header"> <div class="sub header">
<template v-if="object.is_local"> <template v-if="object?.is_local">
<span class="ui tiny accent label"> <Pill>
<i class="home icon" /> <i class="bi bi-house-fill" />
{{ t('views.admin.library.UploadDetail.header.local') }} {{ t('views.admin.library.UploadDetail.header.local') }}
</span> </Pill>
&nbsp; </template>
<template v-else>
<Pill>
<i class="bi bi-box-arrow-up-right" />
{{ t('views.admin.library.UploadDetail.header.federated') }}
</Pill>
</template> </template>
</div> </div>
</div>
</h2> <Layout
<div class="header-buttons"> flex
<div class="ui icon buttons"> class="header-buttons"
<a >
v-if="store.state.auth.profile && store.state.auth.profile.is_superuser" <Link
class="ui labeled icon button" v-if="store.state.auth.profile?.is_superuser"
:href="store.getters['instance/absoluteUrl'](`/api/admin/music/upload/${object.id}`)" solid
primary
:to="store.getters['instance/absoluteUrl'](`/api/admin/music/upload/${object?.id}`)"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<i class="wrench icon" /> <i class="bi bi-wrench" />
{{ t('views.admin.library.UploadDetail.link.django') }} {{ t('views.admin.library.UploadDetail.link.django') }}
</a> </Link>
<button <Link
v-dropdown v-if="object?.audio_file"
class="ui floating dropdown icon button" solid
> primary
<i class="dropdown icon" /> :to="store.getters['instance/absoluteUrl'](object?.audio_file)"
<div class="menu">
<a
v-if="store.state.auth.profile && store.state.auth.profile.is_superuser"
class="basic item"
:href="store.getters['instance/absoluteUrl'](`/api/admin/music/upload/${object.id}`)"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<i class="wrench icon" /> <i class="bi bi-download" />
{{ t('views.admin.library.UploadDetail.link.django') }}
</a>
<a
class="basic item"
:href="object.url || object.fid"
target="_blank"
rel="noopener noreferrer"
>
<i class="external icon" />
{{ t('views.admin.library.UploadDetail.link.remoteProfile') }}
</a>
</div>
</button>
</div>
<div class="ui buttons">
<a
v-if="object.audio_file"
class="ui labeled icon button"
:href="store.getters['instance/absoluteUrl'](object.audio_file)"
target="_blank"
rel="noopener noreferrer"
>
<i class="download icon" />
{{ t('views.admin.library.UploadDetail.button.download') }} {{ t('views.admin.library.UploadDetail.button.download') }}
</a> </Link>
</div>
<div class="ui buttons">
<dangerous-button <dangerous-button
:is-loading="isLoading" :is-loading="isLoading"
:action="remove" :action="remove"
@ -169,227 +149,349 @@ const showUploadDetailModal = ref(false)
{{ t('views.admin.library.UploadDetail.button.delete') }} {{ t('views.admin.library.UploadDetail.button.delete') }}
</template> </template>
</dangerous-button> </dangerous-button>
</div> <Spacer grow />
</div> <Popover v-model="open">
</div> <template #default="{ toggleOpen }">
</div> <OptionsButton
</div> :title="t('views.admin.library.UploadDetail.button.more')"
</section> is-square-small
<div class="ui vertical stripe segment"> @click="toggleOpen()"
<div class="ui stackable three column grid">
<div class="column">
<section>
<h3 class="ui header">
<i class="info icon" />
<div class="content">
{{ t('views.admin.library.UploadDetail.header.uploadData') }}
</div>
</h3>
<table class="ui very basic table">
<tbody>
<tr>
<td>
{{ t('views.admin.library.UploadDetail.table.upload.name') }}
</td>
<td>
{{ displayName(object) }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('privacy_level', object.library.privacy_level) }}">
{{ t('views.admin.library.UploadDetail.link.visibility') }}
</router-link>
</td>
<td>
{{ privacyLevels }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.moderation.accounts.detail', params: {id: object.library.actor.full_username }}">
{{ t('views.admin.library.UploadDetail.link.account') }}
</router-link>
</td>
<td>
{{ object.library.actor.preferred_username }}
</td>
</tr>
<tr v-if="!object.is_local">
<td>
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}">
{{ t('views.admin.library.UploadDetail.link.domain') }}
</router-link>
</td>
<td>
{{ object.domain }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('status', object.import_status) }}">
{{ t('views.admin.library.UploadDetail.link.importStatus') }}
</router-link>
</td>
<td>
{{ importStatus }}
<button
class="ui tiny basic icon button"
:title="sharedLabels.fields.import_status.label"
@click="showUploadDetailModal = true"
>
<i class="question circle outline icon" />
</button>
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.libraries.detail', params: {id: object.library.uuid }}">
{{ t('views.admin.library.UploadDetail.link.library') }}
</router-link>
</td>
<td>
{{ object.library.name }}
</td>
</tr>
</tbody>
</table>
</section>
</div>
<div class="column">
<section>
<h3 class="ui header">
<i class="feed icon" />
<div class="content">
{{ t('views.admin.library.UploadDetail.header.activity') }}&nbsp;
</div>
</h3>
<table class="ui very basic table">
<tbody>
<tr>
<td>
{{ t('views.admin.library.UploadDetail.table.activity.firstSeen') }}
</td>
<td>
<human-date :date="object.creation_date" />
</td>
</tr>
<tr>
<td>
{{ t('views.admin.library.UploadDetail.table.activity.accessedDate') }}
</td>
<td>
<human-date
v-if="object.accessed_date"
:date="object.accessed_date"
/> />
<span </template>
v-else
<template #items>
<PopoverItem
v-if="store.state.auth.profile?.is_superuser"
:to="store.getters['instance/absoluteUrl'](`/api/admin/music/upload/${object?.id}`)"
icon="bi-wrench"
target="_blank"
> >
{{ t('views.admin.library.UploadDetail.link.django') }}
</PopoverItem>
<PopoverItem
:to="object?.url || object?.fid"
icon="bi-box-arrow-up-right"
target="_blank"
>
{{ t('views.admin.library.UploadDetail.link.remoteProfile') }}
</PopoverItem>
</template>
</Popover>
</Layout>
</Header>
<Layout
flex
gap-64
>
<Layout
stack
style="flex: 1; gap: 0;"
>
<Heading
:h3="t('views.admin.library.UploadDetail.header.uploadData')"
class="category"
/>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.UploadDetail.table.upload.name') }}
</span>
<Spacer
h
grow
/>
<span class="value">{{ displayName(object) }}</span>
</Layout>
<Layout
flex
class="details"
>
<Link
class="label"
:to="{ name: 'manage.library.uploads', query: { q: getQuery('privacy_level', object?.library?.privacy_level) } }"
>
{{ t('views.admin.library.UploadDetail.link.visibility') }}
</Link>
<Spacer
h
grow
/>
<span class="value">{{ privacyLevels }}</span>
</Layout>
<Layout
flex
class="details"
>
<Link
class="label"
:to="{ name: 'manage.moderation.accounts.detail', params: { id: object?.library?.actor?.full_username } }"
>
{{ t('views.admin.library.UploadDetail.link.account') }}
</Link>
<Spacer
h
grow
/>
<span class="value">{{ object?.library?.actor?.preferred_username }}</span>
</Layout>
<Layout
v-if="!object?.is_local"
flex
class="details"
>
<Link
class="label"
:to="{ name: 'manage.moderation.domains.detail', params: { id: object?.domain } }"
>
{{ t('views.admin.library.UploadDetail.link.domain') }}
</Link>
<Spacer
h
grow
/>
<span class="value">{{ object?.domain }}</span>
</Layout>
<Layout
flex
class="details"
>
<Link
class="label"
:to="{ name: 'manage.library.uploads', query: { q: getQuery('status', object?.import_status) } }"
>
{{ t('views.admin.library.UploadDetail.link.importStatus') }}
</Link>
<Spacer
h
grow
/>
<span class="value">
{{ importStatus }}
<Button
:title="sharedLabels.fields.import_status.label"
icon="bi-question-circle"
@click="showUploadDetailModal = true"
/>
</span>
</Layout>
</Layout>
<Layout
stack
style="flex: 1; gap: 0;"
>
<Heading
:h3="t('views.admin.library.UploadDetail.header.activity')"
class="category"
/>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.UploadDetail.table.activity.firstSeen') }}
</span>
<Spacer
h
grow
/>
<human-date :date="object?.creation_date" />
</Layout>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.UploadDetail.table.activity.accessedDate') }}
</span>
<Spacer
h
grow
/>
<span class="value">
<human-date
v-if="object?.accessed_date"
:date="object?.accessed_date"
/>
<span v-else>
{{ t('views.admin.library.UploadDetail.notApplicable') }} {{ t('views.admin.library.UploadDetail.notApplicable') }}
</span> </span>
</td> </span>
</tr> </Layout>
</tbody> </Layout>
</table> <Layout
</section> stack
</div> style="flex: 1; gap: 0;"
<div class="column"> >
<section> <Heading
<h3 class="ui header"> :h3="t('views.admin.library.UploadDetail.header.audioContent')"
<i class="music icon" /> class="category"
<div class="content"> />
{{ t('views.admin.library.UploadDetail.header.audioContent') }}&nbsp; <Layout
</div> v-if="object?.track"
</h3> flex
<table class="ui very basic table"> class="details"
<tbody> >
<tr v-if="object.track"> <Link
<td> class="label"
<router-link :to="{name: 'manage.library.tracks.detail', params: {id: object.track.id }}"> :to="{ name: 'manage.library.tracks.detail', params: { id: object?.track?.id } }"
>
{{ t('views.admin.library.UploadDetail.table.audioContent.track') }} {{ t('views.admin.library.UploadDetail.table.audioContent.track') }}
</router-link> </Link>
</td> <Spacer
<td> h
{{ object.track.title }} grow
</td> />
</tr> <span class="value">{{ object?.track?.title }}</span>
<tr> </Layout>
<td> <Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.UploadDetail.table.audioContent.cachedSize') }} {{ t('views.admin.library.UploadDetail.table.audioContent.cachedSize') }}
</td> </span>
<td> <Spacer
<template v-if="object.audio_file"> h
{{ humanSize(object.size) }} grow
/>
<span class="value">
<template v-if="object?.audio_file">
{{ humanSize(object?.size) }}
</template> </template>
<span <span v-else>
v-else
>
{{ t('views.admin.library.UploadDetail.notApplicable') }} {{ t('views.admin.library.UploadDetail.notApplicable') }}
</span> </span>
</td> </span>
</tr> </Layout>
<tr> <Layout
<td> flex
class="details"
>
<span class="label">
{{ t('views.admin.library.UploadDetail.table.audioContent.size') }} {{ t('views.admin.library.UploadDetail.table.audioContent.size') }}
</td> </span>
<td> <Spacer
{{ humanSize(object.size) }} h
</td> grow
</tr> />
<tr> <span class="value">{{ humanSize(object?.size) }}</span>
<td> </Layout>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.UploadDetail.table.audioContent.bitrate.label') }} {{ t('views.admin.library.UploadDetail.table.audioContent.bitrate.label') }}
</td> </span>
<td> <Spacer
<template v-if="object.bitrate"> h
{{ t('views.admin.library.UploadDetail.table.audioContent.bitrate.value', {bitrate: humanSize(object.bitrate)}) }} grow
/>
<span class="value">
<template v-if="object?.bitrate">
{{ t('views.admin.library.UploadDetail.table.audioContent.bitrate.value', { bitrate: humanSize(object?.bitrate) }) }}
</template> </template>
<span <span v-else>
v-else
>
{{ t('views.admin.library.UploadDetail.notApplicable') }} {{ t('views.admin.library.UploadDetail.notApplicable') }}
</span> </span>
</td> </span>
</tr> </Layout>
<tr> <Layout
<td> flex
class="details"
>
<span class="label">
{{ t('views.admin.library.UploadDetail.table.audioContent.duration') }} {{ t('views.admin.library.UploadDetail.table.audioContent.duration') }}
</td> </span>
<td> <Spacer
<template v-if="object.duration"> h
{{ time.parse(object.duration) }} grow
/>
<span class="value">
<template v-if="object?.duration">
{{ time.parse(object?.duration) }}
</template> </template>
<span <span v-else>
v-else
>
{{ t('views.admin.library.UploadDetail.notApplicable') }} {{ t('views.admin.library.UploadDetail.notApplicable') }}
</span> </span>
</td> </span>
</tr> </Layout>
<tr> <Layout
<td> flex
<router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('type', object.mimetype) }}"> class="details"
>
<Link
class="label"
:to="{ name: 'manage.library.uploads', query: { q: getQuery('type', object?.mimetype) } }"
>
{{ t('views.admin.library.UploadDetail.link.type') }} {{ t('views.admin.library.UploadDetail.link.type') }}
</router-link> </Link>
</td> <Spacer
<td> h
<template v-if="object.mimetype"> grow
{{ object.mimetype }} />
<span class="value">
<template v-if="object?.mimetype">
{{ object?.mimetype }}
</template> </template>
<span <span v-else>
v-else
>
{{ t('views.admin.library.UploadDetail.notApplicable') }} {{ t('views.admin.library.UploadDetail.notApplicable') }}
</span> </span>
</td> </span>
</tr> </Layout>
</tbody> </Layout>
</table> </Layout>
</section> <import-status-modal
</div> v-model:show="showUploadDetailModal"
</div> :upload="object"
</div> />
</template>
</main>
</template> </template>
<style scoped lang="scss">
.avatar {
font-size: 64px;
}
h3.category {
margin-bottom: 16px;
}
.details {
padding: 0 16px;
height: 72px;
align-items: center;
border-top: 1px solid;
min-width: 280px;
@include light-theme {
border-color: var(--fw-gray-300);
}
@include dark-theme {
border-color: var(--fw-gray-800);
}
.label {
font-weight: 800;
@include light-theme {
color: var(--fw-gray-600);
}
@include dark-theme {
color: var(--fw-gray-500);
}
}
a.label,
a.value {
text-decoration: underline;
}
&:last-child {
border-bottom: 1px solid;
}
}
</style>