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

View File

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

View File

@ -9,11 +9,20 @@ import axios from 'axios'
import FetchButton from '~/components/federation/FetchButton.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 DangerousButton from '~/components/common/DangerousButton.vue'
interface Props {
id: number
}
@ -24,18 +33,22 @@ const store = useStore()
const { t } = useI18n()
const router = useRouter()
const channel = ref()
const isLoading = ref(false)
const stats = ref()
const isLoadingStats = ref(false)
const open = ref(false)
const labels = computed(() => ({
statsWarning: t('views.admin.ChannelDetail.warning.stats')
}))
const isLoading = ref(false)
const object = ref()
const fetchData = async () => {
isLoading.value = true
try {
const response = await axios.get(`manage/channels/${props.id}/`)
object.value = response.data
channel.value = response.data
} catch (error) {
useErrorHandler(error as Error)
}
@ -43,8 +56,6 @@ const fetchData = async () => {
isLoading.value = false
}
const isLoadingStats = ref(false)
const stats = ref()
const fetchStats = async () => {
isLoadingStats.value = true
@ -58,8 +69,8 @@ const fetchStats = async () => {
isLoadingStats.value = false
}
fetchStats()
fetchData()
fetchStats()
const remove = async () => {
isLoading.value = true
@ -78,25 +89,17 @@ const getQuery = (field: string, value: string) => `${field}:"${value}"`
</script>
<template>
<main>
<div
v-if="isLoading"
class="ui vertical segment"
<Loader v-if="isLoading" />
<Header
v-if="channel"
v-title="channel?.artist?.name"
:h1="truncate(channel?.artist?.name)"
page-heading
>
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" />
</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">
<template #image>
<img
v-if="object.artist.cover && object.artist.cover.urls.medium_square_crop"
v-lazy="store.getters['instance/absoluteUrl'](object.artist.cover.urls.medium_square_crop)"
v-if="channel?.artist?.cover?.urls.medium_square_crop"
v-lazy="store.getters['instance/absoluteUrl'](channel?.artist?.cover?.urls.medium_square_crop)"
alt=""
>
<img
@ -104,77 +107,55 @@ const getQuery = (field: string, value: string) => `${field}:"${value}"`
alt=""
src="../../assets/audio/default-cover.png"
>
<div class="content">
{{ truncate(object.artist.name) }}
</template>
<div class="sub header">
<template v-if="object.artist.is_local">
<span class="ui tiny accent label">
<i class="home icon" />
<template v-if="channel?.artist?.is_local">
<Pill>
<i class="bi bi-house-fill" />
{{ t('views.admin.ChannelDetail.label.local') }}
</span>
&nbsp;
</Pill>
</template>
<template v-else>
<Pill>
<i class="bi bi-box-arrow-up-right" />
{{ t('views.admin.ChannelDetail.label.federated') }}
</Pill>
</template>
</div>
</div>
</h2>
<template v-if="object.artist.tags && object.artist.tags.length > 0">
<TagsList
v-if="channel?.artist?.tags && channel?.artist?.tags.length > 0"
:limit="5"
detail-route="manage.library.tags.detail"
:tags="object.artist.tags"
:tags="channel?.artist?.tags"
/>
<div class="ui hidden divider" />
</template>
<div class="header-buttons">
<div class="ui icon buttons">
<router-link
class="ui labeled icon button"
:to="{name: 'channels.detail', params: {id: object.uuid }}"
<Spacer />
<Layout
flex
class="header-buttons"
>
<Link
solid
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') }}
</router-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>
</Link>
<fetch-button
v-if="!object.actor.is_local"
v-if="!channel?.actor?.is_local"
class="basic item"
:url="`channels/${object.uuid}/fetches/`"
:url="`channels/${channel?.uuid}/fetches/`"
@refresh="fetchData"
>
<i class="refresh icon" />&nbsp;
{{ t('views.admin.ChannelDetail.button.refresh') }}
</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
:is-loading="isLoading"
low-height
icon="bi-trash"
:action="remove"
: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') }}
</template>
</dangerous-button>
</div>
</div>
</div>
</div>
</div>
</section>
<div class="ui vertical stripe segment">
<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"
<Spacer grow />
<Popover v-model="open">
<template #default="{ toggleOpen }">
<OptionsButton
:title="labels.more"
is-square-small
@click="toggleOpen()"
/>
</tr>
<tr v-if="object.actor.url">
<td>
{{ t('views.admin.ChannelDetail.table.channelData.url') }}
</td>
<td>
<a
:href="object.actor.url"
rel="noreferrer noopener"
</template>
<template #items>
<PopoverItem
v-if="store.state.auth.profile?.is_superuser"
:to="store.getters['instance/absoluteUrl'](`/api/admin/audio/channel/${channel?.id}`)"
icon="bi-wrench"
target="_blank"
>{{ object.actor.url }}</a>
</td>
</tr>
<tr v-if="object.rss_url">
<td>
{{ t('views.admin.ChannelDetail.table.channelData.rss') }}
</td>
<td>
<a
:href="object.rss_url"
rel="noreferrer noopener"
>
{{ t('views.admin.ChannelDetail.link.django') }}
</PopoverItem>
<PopoverItem
v-if="channel?.rss_url"
:to="channel?.rss_url"
icon="bi-box-arrow-up-right"
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" />
<div class="short line" />
<div class="medium line" />
<div class="long line" />
</div>
<table
v-else
class="ui very basic table"
{{ t('views.admin.ChannelDetail.link.rss') }}
</PopoverItem>
<PopoverItem
v-if="!channel?.artist?.is_local"
:to="channel?.actor?.url || channel?.actor?.fid"
icon="bi-box-arrow-up-right"
target="_blank"
>
<tbody>
<tr>
<td>
{{ t('views.admin.ChannelDetail.button.openRemote') }}
</PopoverItem>
</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') }}
</td>
<td>
<human-date :date="object.creation_date" />
</td>
</tr>
<tr>
<td>
</span>
<Spacer
h
grow
/>
<human-date :date="channel?.creation_date" />
</Layout>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.ChannelDetail.table.activity.listenings') }}
</td>
<td>
{{ stats.listenings }}
</td>
</tr>
<tr>
<td>
</span>
<Spacer
h
grow
/>
<span class="value">{{ stats?.listenings }}</span>
</Layout>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.ChannelDetail.table.activity.favorited') }}
</td>
<td>
{{ stats.track_favorites }}
</td>
</tr>
<tr>
<td>
{{ t('views.admin.ChannelDetail.table.activity.playlists') }}
</td>
<td>
{{ stats.playlists }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.moderation.reports.list', query: {q: getQuery('target', `channel:${object.uuid}`) }}">
</span>
<Spacer
h
grow
/>
<span class="value">{{ stats?.track_favorites }}</span>
</Layout>
<Layout
flex
class="details"
>
<Link
class="label"
:to="{ name: 'manage.moderation.reports.list', query: { q: getQuery('target', `channel:${channel?.uuid}`) } }"
>
{{ t('views.admin.ChannelDetail.table.activity.linkedReports') }}
</router-link>
</td>
<td>
{{ stats.reports }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.edits', query: {q: getQuery('target', 'artist ' + object.artist.id)}}">
{{ t('views.admin.ChannelDetail.table.activity.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.ChannelDetail.header.audioContent') }}&nbsp;
<span :data-tooltip="labels.statsWarning"><i class="question circle icon" /></span>
</div>
</h3>
<div
v-if="isLoadingStats"
class="ui placeholder"
</Link>
<Spacer
h
grow
/>
<span class="value">{{ stats?.reports }}</span>
</Layout>
</Layout>
<Layout
stack
style="flex: 1; gap: 0;"
>
<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"
<Heading
:h3="t('views.admin.ChannelDetail.header.audioContent')"
class="category"
/>
<Layout
flex
class="details"
>
<tbody>
<tr>
<td>
<span class="label">
{{ t('views.admin.ChannelDetail.table.audioContent.cachedSize') }}
</td>
<td>
{{ humanSize(stats.media_downloaded_size) }}
</td>
</tr>
<tr>
<td>
</span>
<Spacer
h
grow
/>
<span class="value">{{ humanSize(stats?.media_downloaded_size) }}</span>
</Layout>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.ChannelDetail.table.audioContent.totalSize') }}
</td>
<td>
{{ humanSize(stats.media_total_size) }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('channel_id', object.uuid) }}">
</span>
<Spacer
h
grow
/>
<span class="value">{{ humanSize(stats?.media_total_size) }}</span>
</Layout>
<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') }}
</router-link>
</td>
<td>
{{ stats.uploads }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.albums', query: {q: getQuery('channel_id', object.uuid) }}">
</Link>
<Spacer
h
grow
/>
<span class="value">{{ stats?.uploads }}</span>
</Layout>
<Layout
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') }}
</router-link>
</td>
<td>
{{ object.artist.albums_count }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.tracks', query: {q: getQuery('channel_id', object.uuid) }}">
</Link>
<Spacer
h
grow
/>
<span class="value">{{ channel?.artist?.albums_count }}</span>
</Layout>
<Layout
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') }}
</router-link>
</td>
<td>
{{ object.artist.tracks_count }}
</td>
</tr>
</tbody>
</table>
</section>
</div>
</div>
</div>
</template>
</main>
</Link>
<Spacer
h
grow
/>
<span class="value">{{ channel?.artist?.tracks_count }}</span>
</Layout>
</Layout>
</Layout>
</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 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 {
id: number
@ -24,7 +35,8 @@ const { t } = useI18n()
const router = useRouter()
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)
@ -74,25 +86,19 @@ const remove = async () => {
}
const getQuery = (field: string, value: string) => `${field}:"${value}"`
const open = ref(false)
</script>
<template>
<main>
<div
v-if="isLoading"
class="ui vertical segment"
>
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" />
</div>
<template v-if="object">
<section
<Loader v-if="isLoading" />
<Header
v-if="object"
v-title="object.title"
:class="['ui', 'head', 'vertical', 'stripe', 'segment']"
:h1="truncate(object.title)"
page-heading
>
<div class="ui stackable one column grid">
<div class="ui column">
<div class="segment-content">
<h2 class="ui header">
<template #image>
<img
v-if="object.cover?.urls.original"
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=""
src="../../../assets/audio/default-cover.png"
>
<div class="content">
{{ truncate(object.title) }}
</template>
<div class="sub header">
<template v-if="object.is_local">
<span class="ui tiny accent label">
<i class="home icon" />
<Pill>
<i class="bi bi-house-fill" />
{{ t('views.admin.library.AlbumDetail.header.local') }}
</span>
&nbsp;
</Pill>
</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>
</div>
</div>
</h2>
<template v-if="object.tags && object.tags.length > 0">
<TagsList
v-if="object.tags && object.tags.length > 0"
:limit="5"
detail-route="manage.library.tags.detail"
:tags="object.tags"
/>
<div class="ui hidden divider" />
</template>
<div class="header-buttons">
<div class="ui icon buttons">
<router-link
class="ui labeled icon button"
<Spacer />
<Layout
flex
class="header-buttons"
>
<Link
solid
primary
low-height
icon="bi-info-circle"
:to="{name: 'library.albums.detail', params: {id: object.id }}"
>
<i class="info icon" />
{{ t('views.admin.library.AlbumDetail.link.localProfile') }}
</router-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>
</Link>
<fetch-button
v-if="!object.is_local"
class="basic item"
@ -170,31 +154,20 @@ const getQuery = (field: string, value: string) => `${field}:"${value}"`
<i class="refresh icon" />&nbsp;
{{ t('views.admin.library.AlbumDetail.button.remoteRefresh') }}
</fetch-button>
<a
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
<Link
v-if="object.is_local"
solid
primary
low-height
icon="bi-pencil-fill"
: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') }}
</router-link>
</div>
<div class="ui buttons">
</Link>
<dangerous-button
:is-loading="isLoading"
low-height
icon="bi-trash"
:action="remove"
: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') }}
</template>
</dangerous-button>
</div>
</div>
</div>
</div>
</div>
</section>
<div class="ui vertical stripe segment">
<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.AlbumDetail.header.albumData') }}
</div>
</h3>
<table class="ui very basic table">
<tbody>
<tr>
<td>
<Spacer grow />
<Popover v-model="open">
<template #default="{ toggleOpen }">
<OptionsButton
:title="labels.more"
is-square-small
@click="toggleOpen()"
/>
</template>
<template #items>
<PopoverItem
v-if="store.state.auth.profile && store.state.auth.profile.is_superuser"
:to="store.getters['instance/absoluteUrl'](`/api/admin/music/album/${object.id}`)"
icon="bi-wrench"
target="_blank"
>
{{ t('views.admin.library.AlbumDetail.link.django') }}
</PopoverItem>
<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') }}
</td>
<td>
{{ object.title }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.artists.detail', params: {id: object.artist.id }}">
</span>
<Spacer
h
grow
/>
<span class="value">{{ object?.title }}</span>
</Layout>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.AlbumDetail.link.artist') }}
</router-link>
</td>
<td>
{{ object.artist.name }}
</td>
</tr>
<tr v-if="!object.is_local">
<td>
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}">
</span>
<Spacer
h
grow
/>
<Link
v-for="a in object?.artist_credit"
:key="a.artist.id"
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') }}
</router-link>
</td>
<td>
{{ object.domain }}
</td>
</tr>
<tr v-if="object.description">
<td>
</Link>
<Spacer
h
grow
/>
<span class="value">{{ object?.domain }}</span>
</Layout>
<Layout
v-if="object?.description"
flex
class="details"
>
<span
class="label"
>
{{ t('views.admin.library.AlbumDetail.table.album.description') }}
</td>
</span>
<Spacer
h
grow
/>
<sanitized-html
tag="td"
tag="span"
class="value"
:html="object.description.html"
/>
</tr>
</tbody>
</table>
</section>
</div>
<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"
</Layout>
</Layout>
<Layout
stack
style="flex: 1; gap: 0;"
>
<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"
<Heading
:h3="t('views.admin.library.AlbumDetail.header.activity')"
class="category"
/>
<Layout
flex
class="details"
>
<span
class="label"
>
<tbody>
<tr>
<td>
{{ t('views.admin.library.AlbumDetail.table.activity.firstSeen') }}
</td>
<td>
<human-date :date="object.creation_date" />
</td>
</tr>
<tr>
<td>
</span>
<Spacer
h
grow
/>
<human-date :date="object?.creation_date" />
</Layout>
<Layout
flex
class="details"
>
<span
class="label"
>
{{ t('views.admin.library.AlbumDetail.table.activity.listenings') }}
</td>
<td>
{{ stats.listenings }}
</td>
</tr>
<tr>
<td>
</span>
<Spacer
h
grow
/>
<span
class="label"
>
{{ stats?.listenings }}
</span>
</Layout>
<Layout
flex
class="details"
>
<span
class="label"
>
{{ t('views.admin.library.AlbumDetail.table.activity.favorited') }}
</td>
<td>
{{ stats.track_favorites }}
</td>
</tr>
<tr>
<td>
</span>
<Spacer
h
grow
/>
<span
class="value"
>
{{ stats?.track_favorites ?? 0 }}
</span>
</Layout>
<Layout
flex
class="details"
>
<span
class="label"
>
{{ t('views.admin.library.AlbumDetail.table.activity.playlists') }}
</td>
<td>
{{ stats.playlists }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.moderation.reports.list', query: {q: getQuery('target', `album:${object.id}`) }}">
</span>
<Spacer
h
grow
/>
<span
class="value"
>
{{ 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') }}
</router-link>
</td>
<td>
{{ stats.reports }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.edits', query: {q: getQuery('target', 'album ' + object.id)}}">
</Link>
<Spacer
h
grow
/>
<span class="value">
{{ stats?.reports }}
</span>
</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') }}
</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.AlbumDetail.header.audioContent') }}&nbsp;
<span :data-tooltip="labels.statsWarning"><i class="question circle icon" /></span>
</div>
</h3>
<div
v-if="isLoadingStats"
class="ui placeholder"
</Link>
<Spacer grow />
<span class="value">
{{ stats?.mutations }}
</span>
</Layout>
</Layout>
<Layout
stack
style="flex: 1; gap: 0;"
>
<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"
<Heading
:h3="t('views.admin.library.AlbumDetail.header.audioContent')"
class="category"
align-left
/>
<Layout
flex
class="details"
>
<span
class="label"
>
<tbody>
<tr>
<td>
{{ t('views.admin.library.AlbumDetail.table.audioContent.cachedSize') }}
</td>
<td>
{{ humanSize(stats.media_downloaded_size) }}
</td>
</tr>
<tr>
<td>
</span>
<Spacer
h
grow
/>
<span class="value">
{{ humanSize(stats?.media_downloaded_size) }}
</span>
</Layout>
<Layout
flex
class="details"
>
<span
class="label"
>
{{ t('views.admin.library.AlbumDetail.table.audioContent.totalSize') }}
</td>
<td>
{{ humanSize(stats.media_total_size) }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.libraries', query: {q: getQuery('album_id', object.id) }}">
</span>
<Spacer
h
grow
/>
<span class="value">
{{ humanSize(stats?.media_total_size) }}
</span>
</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') }}
</router-link>
</td>
<td>
{{ stats.libraries }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('album_id', object.id) }}">
</Link>
<Spacer
h
grow
/>
<span class="value">
{{ stats?.libraries }}
</span>
</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') }}
</router-link>
</td>
<td>
{{ stats.uploads }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.tracks', query: {q: getQuery('album_id', object.id) }}">
</Link>
<Spacer
h
grow
/>
<span class="value">
{{ stats?.uploads }}
</span>
</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') }}
</router-link>
</td>
<td>
{{ object.tracks_count }}
</td>
</tr>
</tbody>
</table>
</section>
</div>
</div>
</div>
</template>
</main>
</Link>
<Spacer
h
grow
/>
<span class="value">
{{ object?.tracks_count }}
</span>
</Layout>
</Layout>
</Layout>
</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 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 {
id: number
}
@ -74,27 +86,21 @@ const remove = async () => {
}
const getQuery = (field: string, value: string) => `${field}:"${value}"`
const open = ref(false)
</script>
<template>
<main>
<div
v-if="isLoading"
class="ui vertical segment"
>
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" />
</div>
<template v-if="object">
<section
<Loader v-if="isLoading" />
<Header
v-if="object"
v-title="object.name"
:class="['ui', 'head', 'vertical', 'stripe', 'segment']"
:h1="truncate(object.name)"
page-heading
>
<div class="ui stackable one column grid">
<div class="ui column">
<div class="segment-content">
<h2 class="ui header">
<template #image>
<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)"
alt=""
>
@ -103,63 +109,42 @@ const getQuery = (field: string, value: string) => `${field}:"${value}"`
alt=""
src="../../../assets/audio/default-cover.png"
>
<div class="content">
{{ truncate(object.name) }}
</template>
<div class="sub header">
<template v-if="object.is_local">
<span class="ui tiny accent label">
<i class="home icon" />
<Pill>
<i class="bi bi-house-fill" />
{{ t('views.admin.library.ArtistDetail.header.local') }}
</span>
&nbsp;
</Pill>
</template>
<template v-else>
<Pill>
<i class="bi bi-box-arrow-up-right" />
{{ t('views.admin.library.ArtistDetail.header.federated') }}
</Pill>
</template>
</div>
</div>
</h2>
<template v-if="object.tags && object.tags.length > 0">
<TagsList
v-if="object.tags && object.tags.length > 0"
:limit="5"
detail-route="manage.library.tags.detail"
:tags="object.tags"
/>
<div class="ui hidden divider" />
</template>
<div class="header-buttons">
<div class="ui icon buttons">
<router-link
class="ui labeled icon button"
<Spacer />
<Layout
flex
class="header-buttons"
>
<Link
solid
primary
low-height
icon="bi-info-circle"
:to="{name: 'library.artists.detail', params: {id: object.id }}"
>
<i class="info icon" />
{{ t('views.admin.library.ArtistDetail.link.localProfile') }}
</router-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>
</Link>
<fetch-button
v-if="!object.is_local"
class="basic item"
@ -169,33 +154,20 @@ const getQuery = (field: string, value: string) => `${field}:"${value}"`
<i class="refresh icon" />&nbsp;
{{ t('views.admin.library.ArtistDetail.button.remoteRefresh') }}
</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
v-if="object.is_local"
solid
secondary
:to="{name: 'library.artists.edit', params: {id: object.id }}"
class="ui labeled icon button"
primary
low-height
icon="bi-pencil-fill"
:to="{name: 'library.artists.edit', params: {id: object.id }}"
>
{{ t('views.admin.library.ArtistDetail.button.edit') }}
</Link>
</div>
<div class="ui buttons">
<dangerous-button
:is-loading="isLoading"
low-height
icon="bi-trash"
:action="remove"
: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') }}
</template>
</dangerous-button>
</div>
</div>
</div>
</div>
</div>
</section>
<div class="ui vertical stripe segment">
<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.ArtistDetail.header.artistData') }}
</div>
</h3>
<table class="ui very basic table">
<tbody>
<tr>
<td>
<Spacer grow />
<Popover v-model="open">
<template #default="{ toggleOpen }">
<OptionsButton
:title="labels.more"
is-square-small
@click="toggleOpen()"
/>
</template>
<template #items>
<PopoverItem
v-if="store.state.auth.profile && store.state.auth.profile.is_superuser"
:to="store.getters['instance/absoluteUrl'](`/api/admin/music/album/${object.id}`)"
icon="bi-wrench"
target="_blank"
>
{{ t('views.admin.library.AlbumDetail.link.django') }}
</PopoverItem>
<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') }}
</td>
<td>
{{ object.name }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.artists', query: {q: getQuery('category', object.content_category) }}">
</span>
<Spacer
h
grow
/>
<span class="value">{{ object?.name }}</span>
</Layout>
<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') }}
</router-link>
</td>
<td>
{{ object.content_category }}
</td>
</tr>
<tr v-if="!object.is_local">
<td>
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}">
</Link>
<Spacer
h
grow
/>
<span class="value">{{ object?.content_category }}</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.ArtistDetail.link.domain') }}
</router-link>
</td>
<td>
{{ object.domain }}
</td>
</tr>
<tr v-if="object.description">
<td>
</Link>
<Spacer
h
grow
/>
<span class="value">{{ object?.domain }}</span>
</Layout>
<Layout
v-if="object?.description"
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.ArtistDetail.table.artist.description') }}
</td>
</span>
<Spacer
h
grow
/>
<sanitized-html
tag="td"
tag="span"
class="value"
:html="object.description.html"
/>
</tr>
</tbody>
</table>
</section>
</div>
<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"
</Layout>
</Layout>
<Layout
stack
style="flex: 1; gap: 0;"
>
<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"
<Heading
:h3="t('views.admin.library.ArtistDetail.header.activity')"
class="category"
/>
<Layout
flex
class="details"
>
<tbody>
<tr>
<td>
<span class="label">
{{ t('views.admin.library.ArtistDetail.table.activity.firstSeen') }}
</td>
<td>
<human-date :date="object.creation_date" />
</td>
</tr>
<tr>
<td>
</span>
<Spacer
h
grow
/>
<human-date :date="object?.creation_date" />
</Layout>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.ArtistDetail.table.activity.listenings') }}
</td>
<td>
{{ stats.listenings }}
</td>
</tr>
<tr>
<td>
</span>
<Spacer
h
grow
/>
<span class="value">{{ stats?.listenings }}</span>
</Layout>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.ArtistDetail.table.activity.favorited') }}
</td>
<td>
{{ stats.track_favorites }}
</td>
</tr>
<tr>
<td>
</span>
<Spacer
h
grow
/>
<span class="value">{{ stats?.track_favorites }}</span>
</Layout>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.ArtistDetail.table.activity.playlists') }}
</td>
<td>
{{ stats.playlists }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.moderation.reports.list', query: {q: getQuery('target', `artist:${object.id}`) }}">
</span>
<Spacer
h
grow
/>
<span class="value">{{ stats?.playlists }}</span>
</Layout>
<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') }}
</router-link>
</td>
<td>
{{ stats.reports }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.edits', query: {q: getQuery('target', 'artist ' + object.id)}}">
</Link>
<Spacer
h
grow
/>
<span class="value">{{ stats?.reports }}</span>
</Layout>
<Layout
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') }}
</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.ArtistDetail.header.audioContent') }}&nbsp;
<span :data-tooltip="labels.statsWarning"><i class="question circle icon" /></span>
</div>
</h3>
<div
v-if="isLoadingStats"
class="ui placeholder"
</Link>
<Spacer
h
grow
/>
<span class="value">{{ stats?.mutations }}</span>
</Layout>
</Layout>
<Layout
stack
style="flex: 1; gap: 0;"
>
<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"
<Heading
:h3="t('views.admin.library.ArtistDetail.header.audioContent')"
class="category"
/>
<Layout
flex
class="details"
>
<tbody>
<tr>
<td>
<span class="label">
{{ t('views.admin.library.ArtistDetail.table.audioContent.cachedSize') }}
</td>
<td>
{{ humanSize(stats.media_downloaded_size) }}
</td>
</tr>
<tr>
<td>
</span>
<Spacer
h
grow
/>
<span class="value">{{ humanSize(stats?.media_downloaded_size) }}</span>
</Layout>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.ArtistDetail.table.audioContent.totalSize') }}
</td>
<td>
{{ humanSize(stats.media_total_size) }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.libraries', query: {q: getQuery('artist_id', object.id) }}">
</span>
<Spacer
h
grow
/>
<span class="value">{{ humanSize(stats?.media_total_size) }}</span>
</Layout>
<Layout
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') }}
</router-link>
</td>
<td>
{{ stats.libraries }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('artist_id', object.id) }}">
</Link>
<Spacer
h
grow
/>
<span class="value">{{ stats?.libraries }}</span>
</Layout>
<Layout
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') }}
</router-link>
</td>
<td>
{{ stats.uploads }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.albums', query: {q: getQuery('artist_id', object.id) }}">
</Link>
<Spacer
h
grow
/>
<span class="value">{{ stats?.uploads }}</span>
</Layout>
<Layout
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') }}
</router-link>
</td>
<td>
{{ object.albums_count }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.tracks', query: {q: getQuery('artist_id', object.id) }}">
</Link>
<Spacer
h
grow
/>
<span class="value">{{ object?.albums_count }}</span>
</Layout>
<Layout
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') }}
</router-link>
</td>
<td>
{{ object.tracks_count }}
</td>
</tr>
</tbody>
</table>
</section>
</div>
</div>
</div>
</template>
</main>
</Link>
<Spacer
h
grow
/>
<span class="value">{{ object?.tracks_count }}</span>
</Layout>
</Layout>
</Layout>
</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 useErrorHandler from '~/composables/useErrorHandler'
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 {
id: number
@ -24,6 +35,8 @@ const router = useRouter()
const isLoading = ref(false)
const object = ref()
const open = ref(false)
const fetchData = async () => {
isLoading.value = true
@ -56,59 +69,31 @@ const getQuery = (field: string, value: string) => `${field}:"${value}"`
</script>
<template>
<main>
<div
v-if="isLoading"
class="ui vertical segment"
<Loader v-if="isLoading" />
<Header
v-if="object"
v-title="object?.name"
:h1="'# ' + truncate(object?.name)"
page-heading
>
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" />
</div>
<template v-if="object">
<section
v-title="object.name"
:class="['ui', 'head', 'vertical', 'stripe', 'segment']"
<Layout
flex
class="header-buttons"
>
<div class="ui stackable one column grid">
<div class="ui column">
<div class="segment-content">
<h2 class="ui header">
<i class="circular inverted hashtag icon" />
<div class="content">
{{ 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 }}"
<Link
solid
primary
low-height
icon="bi-info-circle"
:to="{ name: 'library.tags.detail', params: { id: object?.name } }"
>
<i class="info icon" />
{{ t('views.admin.library.TagDetail.link.localProfile') }}
</router-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">
</Link>
<dangerous-button
:is-loading="isLoading"
:action="remove"
icon="bi-trash"
low-height
:title="t('views.admin.library.TagDetail.modal.delete.header')"
>
{{ 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') }}
</template>
</dangerous-button>
</div>
</div>
</div>
</div>
</div>
</section>
<div class="ui vertical stripe segment">
<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.TagDetail.header.tagData') }}
</div>
</h3>
<table class="ui very basic table">
<tbody>
<tr>
<td>
<Spacer grow />
<Popover v-model="open">
<template #default="{ toggleOpen }">
<OptionsButton
:title="t('views.admin.library.TagDetail.button.more')"
is-square-small
@click="toggleOpen()"
/>
</template>
<template #items>
<PopoverItem
v-if="store.state.auth.profile?.is_superuser"
:to="store.getters['instance/absoluteUrl'](`/api/admin/tags/tag/${object?.id}`)"
icon="bi-wrench"
target="_blank"
>
{{ t('views.admin.library.TagDetail.link.django') }}
</PopoverItem>
</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') }}
</td>
<td>
{{ object.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.TagDetail.header.activity') }}&nbsp;
</div>
</h3>
<table class="ui very basic table">
<tbody>
<tr>
<td>
</span>
<Spacer
h
grow
/>
<span class="value">{{ object?.name }}</span>
</Layout>
</Layout>
<Layout
stack
style="flex: 1; gap: 0;"
>
<Heading
:h3="t('views.admin.library.TagDetail.header.activity')"
class="category"
/>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.TagDetail.table.activity.firstSeen') }}
</td>
<td>
<human-date :date="object.creation_date" />
</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.TagDetail.header.audioContent') }}&nbsp;
</div>
</h3>
<table class="ui very basic table">
<tbody>
<tr>
<td>
<router-link :to="{name: 'manage.library.artists', query: {q: getQuery('tag', object.name) }}">
</span>
<Spacer
h
grow
/>
<human-date :date="object?.creation_date" />
</Layout>
</Layout>
<Layout
stack
style="flex: 1; gap: 0;"
>
<Heading
:h3="t('views.admin.library.TagDetail.header.audioContent')"
class="category"
/>
<Layout
flex
class="details"
>
<Link
class="label"
:to="{ name: 'manage.library.artists', query: { q: getQuery('tag', object?.name) } }"
>
{{ t('views.admin.library.TagDetail.link.artists') }}
</router-link>
</td>
<td>
{{ object.artists_count }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.albums', query: {q: getQuery('tag', object.name) }}">
</Link>
<Spacer
h
grow
/>
<span class="value">{{ object?.artists_count }}</span>
</Layout>
<Layout
flex
class="details"
>
<Link
class="label"
:to="{ name: 'manage.library.albums', query: { q: getQuery('tag', object?.name) } }"
>
{{ t('views.admin.library.TagDetail.link.albums') }}
</router-link>
</td>
<td>
{{ object.albums_count }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.tracks', query: {q: getQuery('tag', object.name) }}">
</Link>
<Spacer
h
grow
/>
<span class="value">{{ object?.albums_count }}</span>
</Layout>
<Layout
flex
class="details"
>
<Link
class="label"
:to="{ name: 'manage.library.tracks', query: { q: getQuery('tag', object?.name) } }"
>
{{ t('views.admin.library.TagDetail.link.tracks') }}
</router-link>
</td>
<td>
{{ object.tracks_count }}
</td>
</tr>
</tbody>
</table>
</section>
</div>
</div>
</div>
</template>
</main>
</Link>
<Spacer
h
grow
/>
<span class="value">{{ object?.tracks_count }}</span>
</Layout>
</Layout>
</Layout>
</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 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 DangerousButton from '~/components/common/DangerousButton.vue'
interface Props {
id: number
}
@ -21,12 +30,19 @@ interface Props {
const props = defineProps<Props>()
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(() => ({
statsWarning: t('views.admin.library.TrackDetail.warning.stats')
}))
const track = ref()
const isLoading = ref(false)
const fetchData = async () => {
isLoading.value = true
@ -40,8 +56,6 @@ const fetchData = async () => {
isLoading.value = false
}
const stats = ref()
const isLoadingStats = ref(false)
const fetchStats = async () => {
isLoadingStats.value = true
@ -58,9 +72,6 @@ const fetchStats = async () => {
fetchData()
fetchStats()
const router = useRouter()
const store = useStore()
const remove = async () => {
isLoading.value = true
@ -78,25 +89,17 @@ const getQuery = (field: string, value: string) => `${field}:"${value}"`
</script>
<template>
<main>
<div
v-if="isLoading"
class="ui vertical segment"
<Loader v-if="isLoading" />
<Header
v-if="track"
v-title="track?.title"
:h1="truncate(track?.title)"
page-heading
>
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" />
</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">
<template #image>
<img
v-if="track.cover && track.cover.urls.medium_square_crop"
v-lazy="store.getters['instance/absoluteUrl'](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)"
alt=""
>
<img
@ -104,98 +107,65 @@ const getQuery = (field: string, value: string) => `${field}:"${value}"`
alt=""
src="../../../assets/audio/default-cover.png"
>
<div class="content">
{{ truncate(track.title) }}
</template>
<div class="sub header">
<template v-if="track.is_local">
<span class="ui tiny accent label">
<i class="home icon" />
<template v-if="track?.is_local">
<Pill>
<i class="bi bi-house-fill" />
{{ t('views.admin.library.TrackDetail.header.local') }}
</span>
&nbsp;
</Pill>
</template>
<template v-else>
<Pill>
<i class="bi bi-box-arrow-up-right" />
{{ t('views.admin.library.TrackDetail.header.federated') }}
</Pill>
</template>
</div>
</div>
</h2>
<template v-if="track.tags && track.tags.length > 0">
<TagsList
v-if="track?.tags && track?.tags.length > 0"
:limit="5"
detail-route="manage.library.tags.detail"
:tags="track.tags"
:tags="track?.tags"
/>
<div class="ui hidden divider" />
</template>
<div class="header-buttons">
<div class="ui icon buttons">
<router-link
class="ui icon labeled button"
:to="{name: 'library.tracks.detail', params: {id: track.id }}"
<Spacer />
<Layout
flex
class="header-buttons"
>
<Link
solid
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') }}
</router-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>
</Link>
<fetch-button
v-if="!track.is_local"
v-if="!track?.is_local"
class="basic item"
:url="`tracks/${track.id}/fetches/`"
:url="`tracks/${track?.id}/fetches/`"
@refresh="fetchData"
>
<i class="refresh icon" />&nbsp;
{{ t('views.admin.library.TrackDetail.button.remoteRefresh') }}
</fetch-button>
<a
class="basic item"
:href="track.url || track.fid"
target="_blank"
rel="noopener noreferrer"
<Link
v-if="track?.is_local"
solid
primary
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') }}
</router-link>
</div>
<div class="ui buttons">
</Link>
<dangerous-button
:is-loading="isLoading"
low-height
icon="bi-trash"
:action="remove"
: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') }}
</template>
</dangerous-button>
</div>
</div>
</div>
</div>
</div>
</section>
<div class="ui vertical stripe segment">
<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"
<Spacer grow />
<Popover v-model="open">
<template #default="{ toggleOpen }">
<OptionsButton
:title="labels.more"
is-square-small
@click="toggleOpen()"
/>
</tr>
</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>
</template>
<tr>
<td>
<router-link :to="{name: 'manage.library.libraries', query: {q: getQuery('track_id', track.id) }}">
{{ t('views.admin.library.TrackDetail.link.libraries') }}
</router-link>
</td>
<td>
{{ stats.libraries }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('track_id', track.id) }}">
{{ t('views.admin.library.TrackDetail.link.uploads') }}
</router-link>
</td>
<td>
{{ stats.uploads }}
</td>
</tr>
</tbody>
</table>
</section>
</div>
</div>
</div>
<template #items>
<PopoverItem
v-if="store.state.auth.profile?.is_superuser"
:to="store.getters['instance/absoluteUrl'](`/api/admin/music/track/${track?.id}`)"
icon="bi-wrench"
target="_blank"
>
{{ t('views.admin.library.TrackDetail.link.django') }}
</PopoverItem>
<PopoverItem
v-if="track?.mbid"
:to="`https://musicbrainz.org/recording/${track?.mbid}`"
icon="bi-box-arrow-up-right"
target="_blank"
>
{{ t('views.admin.library.TrackDetail.link.musicbrainz') }}
</PopoverItem>
<PopoverItem
:to="track?.url || track?.fid"
icon="bi-box-arrow-up-right"
target="_blank"
>
{{ t('views.admin.library.TrackDetail.link.remoteProfile') }}
</PopoverItem>
</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>
<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 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 useErrorHandler from '~/composables/useErrorHandler'
import DangerousButton from '~/components/common/DangerousButton.vue'
interface Props {
id: number
}
const props = defineProps<Props>()
const router = useRouter()
const store = useStore()
const { t } = useI18n()
const props = defineProps<Props>()
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 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 () => {
isLoading.value = true
@ -63,99 +81,61 @@ const remove = async () => {
}
const getQuery = (field: string, value: string) => `${field}:"${value}"`
const displayName = (object: any) => object.filename ?? object.source ?? object.uuid
const showUploadDetailModal = ref(false)
const displayName = (object: any) => object?.filename ?? object?.source ?? object?.uuid
</script>
<template>
<main>
<div
v-if="isLoading"
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
<Loader v-if="isLoading" />
<Header
v-if="object"
v-title="displayName(object)"
:class="['ui', 'head', 'vertical', 'stripe', 'segment']"
:h1="truncate(displayName(object))"
page-heading
>
<div class="ui stackable one column grid">
<div class="ui column">
<div class="segment-content">
<h2 class="ui header">
<i class="circular inverted file icon" />
<div class="content">
{{ truncate(displayName(object)) }}
<template #image>
<i class="avatar circular bi bi-file-earmark-music" />
</template>
<div class="sub header">
<template v-if="object.is_local">
<span class="ui tiny accent label">
<i class="home icon" />
<template v-if="object?.is_local">
<Pill>
<i class="bi bi-house-fill" />
{{ t('views.admin.library.UploadDetail.header.local') }}
</span>
&nbsp;
</Pill>
</template>
<template v-else>
<Pill>
<i class="bi bi-box-arrow-up-right" />
{{ t('views.admin.library.UploadDetail.header.federated') }}
</Pill>
</template>
</div>
</div>
</h2>
<div class="header-buttons">
<div class="ui icon buttons">
<a
v-if="store.state.auth.profile && store.state.auth.profile.is_superuser"
class="ui labeled icon button"
:href="store.getters['instance/absoluteUrl'](`/api/admin/music/upload/${object.id}`)"
<Layout
flex
class="header-buttons"
>
<Link
v-if="store.state.auth.profile?.is_superuser"
solid
primary
:to="store.getters['instance/absoluteUrl'](`/api/admin/music/upload/${object?.id}`)"
target="_blank"
rel="noopener noreferrer"
>
<i class="wrench icon" />
<i class="bi bi-wrench" />
{{ t('views.admin.library.UploadDetail.link.django') }}
</a>
<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/upload/${object.id}`)"
</Link>
<Link
v-if="object?.audio_file"
solid
primary
:to="store.getters['instance/absoluteUrl'](object?.audio_file)"
target="_blank"
rel="noopener noreferrer"
>
<i class="wrench icon" />
{{ 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" />
<i class="bi bi-download" />
{{ t('views.admin.library.UploadDetail.button.download') }}
</a>
</div>
<div class="ui buttons">
</Link>
<dangerous-button
:is-loading="isLoading"
:action="remove"
@ -169,227 +149,349 @@ const showUploadDetailModal = ref(false)
{{ t('views.admin.library.UploadDetail.button.delete') }}
</template>
</dangerous-button>
</div>
</div>
</div>
</div>
</div>
</section>
<div class="ui vertical stripe segment">
<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"
<Spacer grow />
<Popover v-model="open">
<template #default="{ toggleOpen }">
<OptionsButton
:title="t('views.admin.library.UploadDetail.button.more')"
is-square-small
@click="toggleOpen()"
/>
<span
v-else
</template>
<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') }}
</span>
</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.UploadDetail.header.audioContent') }}&nbsp;
</div>
</h3>
<table class="ui very basic table">
<tbody>
<tr v-if="object.track">
<td>
<router-link :to="{name: 'manage.library.tracks.detail', params: {id: object.track.id }}">
</span>
</Layout>
</Layout>
<Layout
stack
style="flex: 1; gap: 0;"
>
<Heading
:h3="t('views.admin.library.UploadDetail.header.audioContent')"
class="category"
/>
<Layout
v-if="object?.track"
flex
class="details"
>
<Link
class="label"
:to="{ name: 'manage.library.tracks.detail', params: { id: object?.track?.id } }"
>
{{ t('views.admin.library.UploadDetail.table.audioContent.track') }}
</router-link>
</td>
<td>
{{ object.track.title }}
</td>
</tr>
<tr>
<td>
</Link>
<Spacer
h
grow
/>
<span class="value">{{ object?.track?.title }}</span>
</Layout>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.UploadDetail.table.audioContent.cachedSize') }}
</td>
<td>
<template v-if="object.audio_file">
{{ humanSize(object.size) }}
</span>
<Spacer
h
grow
/>
<span class="value">
<template v-if="object?.audio_file">
{{ humanSize(object?.size) }}
</template>
<span
v-else
>
<span v-else>
{{ t('views.admin.library.UploadDetail.notApplicable') }}
</span>
</td>
</tr>
<tr>
<td>
</span>
</Layout>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.UploadDetail.table.audioContent.size') }}
</td>
<td>
{{ humanSize(object.size) }}
</td>
</tr>
<tr>
<td>
</span>
<Spacer
h
grow
/>
<span class="value">{{ humanSize(object?.size) }}</span>
</Layout>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.UploadDetail.table.audioContent.bitrate.label') }}
</td>
<td>
<template v-if="object.bitrate">
{{ t('views.admin.library.UploadDetail.table.audioContent.bitrate.value', {bitrate: humanSize(object.bitrate)}) }}
</span>
<Spacer
h
grow
/>
<span class="value">
<template v-if="object?.bitrate">
{{ t('views.admin.library.UploadDetail.table.audioContent.bitrate.value', { bitrate: humanSize(object?.bitrate) }) }}
</template>
<span
v-else
>
<span v-else>
{{ t('views.admin.library.UploadDetail.notApplicable') }}
</span>
</td>
</tr>
<tr>
<td>
</span>
</Layout>
<Layout
flex
class="details"
>
<span class="label">
{{ t('views.admin.library.UploadDetail.table.audioContent.duration') }}
</td>
<td>
<template v-if="object.duration">
{{ time.parse(object.duration) }}
</span>
<Spacer
h
grow
/>
<span class="value">
<template v-if="object?.duration">
{{ time.parse(object?.duration) }}
</template>
<span
v-else
>
<span v-else>
{{ t('views.admin.library.UploadDetail.notApplicable') }}
</span>
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('type', object.mimetype) }}">
</span>
</Layout>
<Layout
flex
class="details"
>
<Link
class="label"
:to="{ name: 'manage.library.uploads', query: { q: getQuery('type', object?.mimetype) } }"
>
{{ t('views.admin.library.UploadDetail.link.type') }}
</router-link>
</td>
<td>
<template v-if="object.mimetype">
{{ object.mimetype }}
</Link>
<Spacer
h
grow
/>
<span class="value">
<template v-if="object?.mimetype">
{{ object?.mimetype }}
</template>
<span
v-else
>
<span v-else>
{{ t('views.admin.library.UploadDetail.notApplicable') }}
</span>
</td>
</tr>
</tbody>
</table>
</section>
</div>
</div>
</div>
</template>
</main>
</span>
</Layout>
</Layout>
</Layout>
<import-status-modal
v-model:show="showUploadDetailModal"
:upload="object"
/>
</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>