Feat(front): use new UI library, explicityly imported, in all views
closes #2359 #2367 #2091 Co-Authored-By: ArneBo <arne@ecobasa.org> Co-Authored-By: Flupsi <upsiflu@gmail.com> Co-Authored-By: jon r <jon@allmende.io> fix(front): null error in user profile
This commit is contained in:
parent
aa79610a22
commit
ae6ac1f624
|
@ -0,0 +1 @@
|
||||||
|
Improve visuals & layout (#2091)
|
|
@ -5,8 +5,16 @@ import { get } from 'lodash-es'
|
||||||
import { humanSize } from '~/utils/filters'
|
import { humanSize } from '~/utils/filters'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import type { components } from '~/generated/types.ts'
|
||||||
|
|
||||||
import SignupForm from '~/components/auth/SignupForm.vue'
|
import SignupForm from '~/components/auth/SignupForm.vue'
|
||||||
import LogoText from '~/components/LogoText.vue'
|
import LogoText from '~/components/LogoText.vue'
|
||||||
|
import useMarkdown from '~/composables/useMarkdown'
|
||||||
|
|
||||||
|
import Link from '~/components/ui/Link.vue'
|
||||||
|
import Card from '~/components/ui/Card.vue'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
const nodeinfo = computed(() => store.state.instance.nodeinfo)
|
const nodeinfo = computed(() => store.state.instance.nodeinfo)
|
||||||
|
@ -16,7 +24,8 @@ const labels = computed(() => ({
|
||||||
title: t('components.About.title')
|
title: t('components.About.title')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const podName = computed(() => get(nodeinfo.value, 'metadata.nodeName') ?? 'Funkwhale')
|
const podName = computed(() => (n => n === '' ? 'No name' : n ?? 'Funkwhale')(get(nodeinfo.value, 'metadata.nodeName')))
|
||||||
|
|
||||||
const banner = computed(() => get(nodeinfo.value, 'metadata.banner'))
|
const banner = computed(() => get(nodeinfo.value, 'metadata.banner'))
|
||||||
const shortDescription = computed(() => get(nodeinfo.value, 'metadata.shortDescription'))
|
const shortDescription = computed(() => get(nodeinfo.value, 'metadata.shortDescription'))
|
||||||
|
|
||||||
|
@ -28,10 +37,22 @@ const stats = computed(() => {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return { users, hours }
|
const info = nodeinfo.value ?? {} as components['schemas']['NodeInfo21']
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
users: info.usage.users.activeMonth || null,
|
||||||
|
hours: info.metadata.content.local.hoursOfContent || null,
|
||||||
|
artists: info.metadata.content.local.artists || null,
|
||||||
|
albums: info.metadata.content.local.releases || null,
|
||||||
|
tracks: info.metadata.content.local.recordings || null,
|
||||||
|
listenings: info.metadata.usage?.listenings.total || null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { users, hours, data }
|
||||||
})
|
})
|
||||||
|
|
||||||
const openRegistrations = computed(() => get(nodeinfo.value, 'openRegistrations'))
|
const openRegistrations = computed(() => get(nodeinfo.value, 'openRegistrations'))
|
||||||
|
|
||||||
const defaultUploadQuota = computed(() => humanSize(get(nodeinfo.value, 'metadata.defaultUploadQuota', 0) * 1000 * 1000))
|
const defaultUploadQuota = computed(() => humanSize(get(nodeinfo.value, 'metadata.defaultUploadQuota', 0) * 1000 * 1000))
|
||||||
|
|
||||||
const headerStyle = computed(() => {
|
const headerStyle = computed(() => {
|
||||||
|
@ -43,64 +64,76 @@ const headerStyle = computed(() => {
|
||||||
backgroundImage: `url(${store.getters['instance/absoluteUrl'](banner.value)})`
|
backgroundImage: `url(${store.getters['instance/absoluteUrl'](banner.value)})`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const longDescription = useMarkdown(() => get(nodeinfo.value, 'metadata.longDescription', ''))
|
||||||
|
const rules = useMarkdown(() => get(nodeinfo.value, 'metadata.rules', ''))
|
||||||
|
const terms = useMarkdown(() => get(nodeinfo.value, 'metadata.terms', ''))
|
||||||
|
const contactEmail = computed(() => get(nodeinfo.value, 'metadata.contactEmail'))
|
||||||
|
const anonymousCanListen = computed(() => {
|
||||||
|
const features = get(nodeinfo.value, 'metadata.metadata.feature', []) as string[]
|
||||||
|
const hasAnonymousCanListen = features.includes('anonymousCanListen')
|
||||||
|
return hasAnonymousCanListen
|
||||||
|
})
|
||||||
|
const allowListEnabled = computed(() => get(nodeinfo.value, 'metadata.allowList.enabled'))
|
||||||
|
const version = computed(() => get(nodeinfo.value, 'software.version'))
|
||||||
|
const federationEnabled = computed(() => {
|
||||||
|
const features = get(nodeinfo.value, 'metadata.metadata.feature', []) as string[]
|
||||||
|
const hasAnonymousCanListen = features.includes('federation')
|
||||||
|
return hasAnonymousCanListen
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main
|
<Layout
|
||||||
v-title="labels.title"
|
v-title="labels.title"
|
||||||
class="main pusher page-about"
|
stack
|
||||||
|
main
|
||||||
|
style="align-items: center;"
|
||||||
|
>
|
||||||
|
<!-- About funkwhale -->
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
width="full"
|
||||||
|
align-text="stretch"
|
||||||
|
style="width:min(480px, 100%)"
|
||||||
>
|
>
|
||||||
<div class="ui container">
|
|
||||||
<div class="ui horizontally fitted basic stripe segment">
|
|
||||||
<div class="ui horizontally fitted basic very padded segment">
|
|
||||||
<div class="ui center aligned text container">
|
|
||||||
<div class="ui text container">
|
|
||||||
<div class="ui equal width compact stackable grid">
|
|
||||||
<div class="column" />
|
|
||||||
<div class="ten wide column">
|
|
||||||
<div class="ui vertically fitted basic segment">
|
|
||||||
<router-link to="/">
|
|
||||||
<logo-text />
|
<logo-text />
|
||||||
</router-link>
|
</Link>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column" />
|
|
||||||
</div>
|
|
||||||
<h2 class="header">
|
<h2 class="header">
|
||||||
{{ $t('components.About.header.funkwhale') }}
|
{{ t('components.About.header.funkwhale') }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.About.description.funkwhale') }}
|
{{ t('components.About.description.funkwhale') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
<Layout
|
||||||
</div>
|
flex
|
||||||
<div class="ui hidden divider" />
|
style="justify-content: center;"
|
||||||
<div class="ui vertically fitted basic stripe segment">
|
>
|
||||||
<div class="ui two stackable cards">
|
<Card
|
||||||
<div class="ui card">
|
v-if="!store.state.auth.authenticated"
|
||||||
<div
|
:title="t('components.About.header.signup')"
|
||||||
v-if="!$store.state.auth.authenticated"
|
width="256px"
|
||||||
class="signup-form content"
|
|
||||||
>
|
>
|
||||||
<h3 class="header">
|
|
||||||
{{ $t('components.About.header.signup') }}
|
|
||||||
</h3>
|
|
||||||
<template v-if="openRegistrations">
|
<template v-if="openRegistrations">
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.About.description.signup') }}
|
{{ t('components.About.description.signup') }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="defaultUploadQuota">
|
<p v-if="defaultUploadQuota">
|
||||||
{{ $t('components.About.description.quota', {quota: defaultUploadQuota}) }}
|
{{ t('components.About.description.quota', {quota: defaultUploadQuota}) }}
|
||||||
</p>
|
</p>
|
||||||
<signup-form
|
<signup-form
|
||||||
button-classes="success"
|
button-classes="success"
|
||||||
:show-login="false"
|
:show-login="true"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.About.help.closedRegistrations') }}
|
{{ t('components.About.help.closedRegistrations') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
|
@ -108,29 +141,143 @@ const headerStyle = computed(() => {
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
href="https://funkwhale.audio/#get-started"
|
href="https://funkwhale.audio/#get-started"
|
||||||
>
|
>
|
||||||
{{ $t('components.About.link.findOtherPod') }}
|
{{ t('components.About.link.findOtherPod') }}
|
||||||
<i class="external alternate icon" />
|
<i class="external alternate icon" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-else
|
v-if="!(store.state.auth.authenticated || openRegistrations)"
|
||||||
class="signup-form content"
|
class="signup-form content"
|
||||||
>
|
>
|
||||||
<h3 class="header">
|
<h3 class="header">
|
||||||
{{ $t('components.About.header.signup') }}
|
{{ t('components.About.header.signup') }}
|
||||||
<div class="ui positive message">
|
<div class="ui positive message">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
{{ $t('components.About.message.loggedIn') }}
|
{{ t('components.About.message.loggedIn') }}
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.About.message.greeting', {username: $store.state.auth.username}) }}
|
{{ t('components.About.message.greeting', {username: store.state.auth.username}) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
v-else
|
||||||
|
:title="t('components.About.message.greeting', {username: store.state.auth.username})"
|
||||||
|
width="256px"
|
||||||
|
>
|
||||||
|
<p v-if="defaultUploadQuota">
|
||||||
|
{{ t('components.About.description.quota', {quota: defaultUploadQuota}) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template #action>
|
||||||
|
<Button
|
||||||
|
full
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
{{ t('components.About.message.loggedIn') }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
:title="podName"
|
||||||
|
width="256px"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
:class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
|
||||||
|
:style="headerStyle"
|
||||||
|
>
|
||||||
|
<h1>
|
||||||
|
<i class="music icon" />
|
||||||
|
</h1>
|
||||||
|
</section>
|
||||||
|
<div class="content pod-description">
|
||||||
|
<h3
|
||||||
|
id="description"
|
||||||
|
class="ui header"
|
||||||
|
>
|
||||||
|
{{ t('components.About.header.aboutPod') }}
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
v-if="shortDescription"
|
||||||
|
class="sub header"
|
||||||
|
>
|
||||||
|
{{ shortDescription }}
|
||||||
</div>
|
</div>
|
||||||
<div class="ui card">
|
<p v-else>
|
||||||
|
{{ t('components.About.placeholder.noDescription') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-if="stats">
|
||||||
|
<div class="statistics-container ui doubling grid">
|
||||||
|
<div class="two column row">
|
||||||
|
<div class="column">
|
||||||
|
<span class="statistics-figure ui text">
|
||||||
|
<span class="ui big text"><strong>{{ stats.users?.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||||
|
<br>
|
||||||
|
{{ stats.users ? t('components.About.stat.activeUsers', stats.users) : "" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<span class="statistics-figure ui text">
|
||||||
|
<span class="ui big text"><strong>{{ stats.hours ? stats.hours.toLocaleString(store.state.ui.momentLocale) : "" }}</strong></span>
|
||||||
|
<br>
|
||||||
|
{{ stats.hours ? t('components.About.stat.hoursOfMusic', stats.hours) : "" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #action>
|
||||||
|
<Link
|
||||||
|
align-text="center"
|
||||||
|
to="/about/pod"
|
||||||
|
>
|
||||||
|
{{ t('components.About.link.learnMore') }}
|
||||||
|
</Link>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<Layout
|
||||||
|
flex
|
||||||
|
style="justify-content: center;"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
width="256px"
|
||||||
|
to="/"
|
||||||
|
:title="t('components.About.header.publicContent')"
|
||||||
|
icon="bi-box-arrow-up-right"
|
||||||
|
>
|
||||||
|
<!-- TODO: Link to Explore page? -->
|
||||||
|
{{ t('components.About.description.publicContent') }}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
width="256px"
|
||||||
|
:title="t('components.About.link.findOtherPod')"
|
||||||
|
to="https://funkwhale.audio/#get-started"
|
||||||
|
icon="bi-box-arrow-up-right"
|
||||||
|
>
|
||||||
|
{{ t('components.About.description.publicContent') }}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
width="256px"
|
||||||
|
:title="t('components.About.header.findApp')"
|
||||||
|
to="https://funkwhale.audio/apps"
|
||||||
|
icon="bi-box-arrow-up-right"
|
||||||
|
>
|
||||||
|
{{ t('components.About.description.findApp') }}
|
||||||
|
</Card>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
:class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
|
:class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
|
||||||
:style="headerStyle"
|
:style="headerStyle"
|
||||||
|
@ -140,122 +287,331 @@ const headerStyle = computed(() => {
|
||||||
{{ podName }}
|
{{ podName }}
|
||||||
</h1>
|
</h1>
|
||||||
</section>
|
</section>
|
||||||
<div class="content pod-description">
|
|
||||||
<h3
|
<!-- About Pod -->
|
||||||
id="description"
|
<div class="about-pod-info-container">
|
||||||
|
<div class="about-pod-info-toc">
|
||||||
|
<div class="ui vertical pointing secondary menu">
|
||||||
|
<router-link
|
||||||
|
to="/about/pod"
|
||||||
|
class="item"
|
||||||
|
>
|
||||||
|
{{ t('components.AboutPod.link.about') }}
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
to="/about/pod#rules"
|
||||||
|
class="item"
|
||||||
|
>
|
||||||
|
{{ t('components.AboutPod.link.rules') }}
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
to="/about/pod#terms"
|
||||||
|
class="item"
|
||||||
|
>
|
||||||
|
{{ t('components.AboutPod.link.terms') }}
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
to="/about/pod#features"
|
||||||
|
class="item"
|
||||||
|
>
|
||||||
|
{{ t('components.AboutPod.link.features') }}
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
v-if="stats"
|
||||||
|
to="/about/pod#statistics"
|
||||||
|
class="item"
|
||||||
|
>
|
||||||
|
{{ t('components.AboutPod.link.statistics') }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="about-pod-info">
|
||||||
|
<h2
|
||||||
|
id="description about-this-pod"
|
||||||
class="ui header"
|
class="ui header"
|
||||||
>
|
>
|
||||||
{{ $t('components.About.header.aboutPod') }}
|
{{ t('components.AboutPod.header.about') }}
|
||||||
</h3>
|
</h2>
|
||||||
<div
|
<sanitized-html
|
||||||
v-if="shortDescription"
|
v-if="longDescription"
|
||||||
class="sub header"
|
:html="longDescription"
|
||||||
>
|
/>
|
||||||
{{ shortDescription }}
|
|
||||||
</div>
|
|
||||||
<p v-else>
|
<p v-else>
|
||||||
{{ $t('components.About.placeholder.noDescription') }}
|
{{ t('components.AboutPod.placeholder.noDescription') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h3
|
||||||
|
id="rules"
|
||||||
|
class="ui header"
|
||||||
|
>
|
||||||
|
{{ t('components.AboutPod.header.rules') }}
|
||||||
|
</h3>
|
||||||
|
<sanitized-html
|
||||||
|
v-if="rules"
|
||||||
|
:html="rules"
|
||||||
|
/>
|
||||||
|
<p v-else>
|
||||||
|
{{ t('components.AboutPod.placeholder.noRules') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3
|
||||||
|
id="terms"
|
||||||
|
class="ui header"
|
||||||
|
>
|
||||||
|
{{ t('components.AboutPod.header.terms') }}
|
||||||
|
</h3>
|
||||||
|
<sanitized-html
|
||||||
|
v-if="terms"
|
||||||
|
:html="terms"
|
||||||
|
/>
|
||||||
|
<p v-else>
|
||||||
|
{{ t('components.AboutPod.placeholder.noTerms') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3
|
||||||
|
id="features"
|
||||||
|
class="header"
|
||||||
|
>
|
||||||
|
{{ t('components.AboutPod.header.features') }}
|
||||||
|
</h3>
|
||||||
|
<div class="features-container ui two column stackable grid">
|
||||||
|
<div class="column">
|
||||||
|
<table class="ui very basic table unstackable">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ t('components.AboutPod.feature.version') }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-if="version"
|
||||||
|
class="right aligned"
|
||||||
|
>
|
||||||
|
<span class="features-status ui text">
|
||||||
|
{{ version }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-else
|
||||||
|
class="right aligned"
|
||||||
|
>
|
||||||
|
<span class="features-status ui text">
|
||||||
|
{{ t('components.AboutPod.notApplicable') }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ t('components.AboutPod.feature.federation') }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-if="federationEnabled"
|
||||||
|
class="right aligned"
|
||||||
|
>
|
||||||
|
<span class="features-status ui text">
|
||||||
|
<i class="check icon" />
|
||||||
|
{{ t('components.AboutPod.feature.status.enabled') }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-else
|
||||||
|
class="right aligned"
|
||||||
|
>
|
||||||
|
<span class="features-status ui text">
|
||||||
|
<i class="x icon" />
|
||||||
|
{{ t('components.AboutPod.feature.status.disabled') }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ t('components.AboutPod.feature.allowList') }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-if="allowListEnabled"
|
||||||
|
class="right aligned"
|
||||||
|
>
|
||||||
|
<span class="features-status ui text">
|
||||||
|
<i class="check icon" />
|
||||||
|
{{ t('components.AboutPod.feature.status.enabled') }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-else
|
||||||
|
class="right aligned"
|
||||||
|
>
|
||||||
|
<span class="features-status ui text">
|
||||||
|
<i class="x icon" />
|
||||||
|
{{ t('components.AboutPod.feature.status.disabled') }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<table class="ui very basic table unstackable">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ t('components.AboutPod.feature.anonymousAccess') }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-if="anonymousCanListen"
|
||||||
|
class="right aligned"
|
||||||
|
>
|
||||||
|
<span class="features-status ui text">
|
||||||
|
<i class="check icon" />
|
||||||
|
{{ t('components.AboutPod.feature.status.enabled') }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-else
|
||||||
|
class="right aligned"
|
||||||
|
>
|
||||||
|
<span class="features-status ui text">
|
||||||
|
<i class="x icon" />
|
||||||
|
{{ t('components.AboutPod.feature.status.disabled') }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ t('components.AboutPod.feature.registrations') }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-if="openRegistrations"
|
||||||
|
class="right aligned"
|
||||||
|
>
|
||||||
|
<span class="features-status ui text">
|
||||||
|
<i class="check icon" />
|
||||||
|
{{ t('components.AboutPod.feature.status.open') }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-else
|
||||||
|
class="right aligned"
|
||||||
|
>
|
||||||
|
<span class="features-status ui text">
|
||||||
|
<i class="x icon" />
|
||||||
|
{{ t('components.AboutPod.feature.status.closed') }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{{ t('components.AboutPod.feature.quota') }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-if="defaultUploadQuota"
|
||||||
|
class="right aligned"
|
||||||
|
>
|
||||||
|
<span class="features-status ui text">
|
||||||
|
{{ defaultUploadQuota }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-else
|
||||||
|
class="right aligned"
|
||||||
|
>
|
||||||
|
<span class="features-status ui text">
|
||||||
|
{{ t('components.AboutPod.notApplicable') }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template v-if="stats">
|
<template v-if="stats">
|
||||||
<div class="statistics-container ui doubling grid">
|
<h3
|
||||||
<div class="two column row">
|
id="statistics"
|
||||||
<div class="column">
|
class="header"
|
||||||
|
>
|
||||||
|
{{ t('components.AboutPod.header.statistics') }}
|
||||||
|
</h3>
|
||||||
|
<div class="statistics-container">
|
||||||
|
<div
|
||||||
|
v-if="stats.hours"
|
||||||
|
class="statistics-statistic"
|
||||||
|
>
|
||||||
<span class="statistics-figure ui text">
|
<span class="statistics-figure ui text">
|
||||||
<span class="ui big text"><strong>{{ stats.users.toLocaleString($store.state.ui.momentLocale) }}</strong></span>
|
<span class="ui big text"><strong>{{ stats.hours.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||||
<br>
|
<br>
|
||||||
{{ $t('components.About.stat.activeUsers', stats.users) }}
|
{{ t('components.AboutPod.stat.hoursOfMusic', stats.hours) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div
|
||||||
|
v-if="stats.data.artists"
|
||||||
|
class="statistics-statistic"
|
||||||
|
>
|
||||||
<span class="statistics-figure ui text">
|
<span class="statistics-figure ui text">
|
||||||
<span class="ui big text"><strong>{{ stats.hours.toLocaleString($store.state.ui.momentLocale) }}</strong></span>
|
<span class="ui big text"><strong>{{ stats.data.artists.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||||
<br>
|
<br>
|
||||||
{{ $t('components.About.stat.hoursOfMusic', stats.hours) }}
|
{{ t('components.AboutPod.stat.artistsCount', stats.data.artists) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="stats.data.albums"
|
||||||
|
class="statistics-statistic"
|
||||||
|
>
|
||||||
|
<span class="statistics-figure ui text">
|
||||||
|
<span class="ui big text"><strong>{{ stats.data.albums.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||||
|
<br>
|
||||||
|
{{ t('components.AboutPod.stat.albumsCount', stats.data.albums) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="stats.data.tracks"
|
||||||
|
class="statistics-statistic"
|
||||||
|
>
|
||||||
|
<span class="statistics-figure ui text">
|
||||||
|
<span class="ui big text"><strong>{{ stats.data.tracks.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||||
|
<br>
|
||||||
|
{{ t('components.AboutPod.stat.tracksCount', stats.data.tracks) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="stats.users"
|
||||||
|
class="statistics-statistic"
|
||||||
|
>
|
||||||
|
<span class="statistics-figure ui text">
|
||||||
|
<span class="ui big text"><strong>{{ stats.users.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||||
|
<br>
|
||||||
|
{{ t('components.AboutPod.stat.activeUsers', stats.users) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="stats.data.listenings"
|
||||||
|
class="statistics-statistic"
|
||||||
|
>
|
||||||
|
<span class="statistics-figure ui text">
|
||||||
|
<span class="ui big text"><strong>{{ stats.data.listenings.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||||
|
<br>
|
||||||
|
{{ t('components.AboutPod.stat.listeningsCount', stats.data.listenings) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<router-link
|
<template v-if="contactEmail">
|
||||||
to="/about/pod"
|
|
||||||
class="ui fluid basic secondary button"
|
|
||||||
>
|
|
||||||
{{ $t('components.About.link.learnMore') }}
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- TODO (wvffle): Remove style when migrate away from fomantic -->
|
|
||||||
<div
|
|
||||||
class="ui three stackable cards"
|
|
||||||
style="z-index: 1; position: relative;"
|
|
||||||
>
|
|
||||||
<router-link
|
|
||||||
to="/"
|
|
||||||
class="ui card"
|
|
||||||
>
|
|
||||||
<div class="content">
|
|
||||||
<h3
|
<h3
|
||||||
id="description"
|
id="contact"
|
||||||
class="ui header"
|
class="ui header"
|
||||||
>
|
>
|
||||||
{{ $t('components.About.header.publicContent') }}
|
{{ t('components.AboutPod.header.contact') }}
|
||||||
</h3>
|
</h3>
|
||||||
<p>
|
|
||||||
{{ $t('components.About.description.publicContent') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</router-link>
|
|
||||||
<a
|
<a
|
||||||
href="https://funkwhale.audio/#get-started"
|
v-if="contactEmail"
|
||||||
class="ui card"
|
:href="`mailto:${contactEmail}`"
|
||||||
target="_blank"
|
|
||||||
>
|
>
|
||||||
<div class="content">
|
{{ t('components.AboutPod.message.contact', { contactEmail }) }}
|
||||||
<h3
|
|
||||||
id="description"
|
|
||||||
class="ui header"
|
|
||||||
>
|
|
||||||
{{ $t('components.About.link.findOtherPod') }}
|
|
||||||
<i class="external alternate icon" />
|
|
||||||
</h3>
|
|
||||||
<p>
|
|
||||||
{{ $t('components.About.description.publicContent') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
</a>
|
||||||
<a
|
</template>
|
||||||
href="https://funkwhale.audio/apps"
|
|
||||||
class="ui card"
|
<div class="ui hidden divider" />
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<div class="content">
|
|
||||||
<h3
|
|
||||||
id="description"
|
|
||||||
class="ui header"
|
|
||||||
>
|
|
||||||
{{ $t('components.About.header.findApp') }}
|
|
||||||
<i class="external alternate icon" />
|
|
||||||
</h3>
|
|
||||||
<p>
|
|
||||||
{{ $t('components.About.description.findApp') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="ui fluid horizontally fitted basic clearing segment container">
|
|
||||||
<router-link
|
|
||||||
to="/about/pod"
|
|
||||||
class="ui right floated basic secondary button"
|
|
||||||
>
|
|
||||||
{{ $t('components.About.header.aboutPod') }}
|
|
||||||
<i class="icon arrow right" />
|
|
||||||
</router-link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Layout>
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -5,7 +5,6 @@ import { get } from 'lodash-es'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import useMarkdown from '~/composables/useMarkdown'
|
import useMarkdown from '~/composables/useMarkdown'
|
||||||
import type { NodeInfo } from '~/store/instance'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
@ -39,24 +38,14 @@ const federationEnabled = computed(() => {
|
||||||
|
|
||||||
const onDesktop = computed(() => window.innerWidth > 800)
|
const onDesktop = computed(() => window.innerWidth > 800)
|
||||||
|
|
||||||
const stats = computed(() => {
|
const stats = computed(() => ({
|
||||||
const info = nodeinfo.value ?? {} as NodeInfo
|
users: nodeinfo.value?.usage.users.activeMonth,
|
||||||
|
hours: nodeinfo.value?.metadata.content.local.hoursOfContent,
|
||||||
const data = {
|
artists: nodeinfo.value?.metadata.content.local.artists,
|
||||||
users: get(info, 'usage.users.activeMonth', null),
|
albums: nodeinfo.value?.metadata.content.local.releases, // TODO: Check where to get 'metadata.content.local.albums.total'
|
||||||
hours: get(info, 'metadata.content.local.hoursOfContent', null),
|
tracks: nodeinfo.value?.metadata.content.local.recordings, // TODO: 'metadata.content.local.tracks.total'
|
||||||
artists: get(info, 'metadata.content.local.artists.total', null),
|
listenings: nodeinfo.value?.metadata.usage?.listenings.total
|
||||||
albums: get(info, 'metadata.content.local.albums.total', null),
|
}))
|
||||||
tracks: get(info, 'metadata.content.local.tracks.total', null),
|
|
||||||
listenings: get(info, 'metadata.usage.listenings.total', null)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.users === null || data.artists === null) {
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
})
|
|
||||||
|
|
||||||
const headerStyle = computed(() => {
|
const headerStyle = computed(() => {
|
||||||
if (!banner.value) {
|
if (!banner.value) {
|
||||||
|
@ -72,7 +61,7 @@ const headerStyle = computed(() => {
|
||||||
<template>
|
<template>
|
||||||
<main
|
<main
|
||||||
v-title="labels.title"
|
v-title="labels.title"
|
||||||
class="main pusher page-about"
|
class="main page-about"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="ui"
|
class="ui"
|
||||||
|
@ -99,32 +88,32 @@ const headerStyle = computed(() => {
|
||||||
to="/about/pod"
|
to="/about/pod"
|
||||||
class="item"
|
class="item"
|
||||||
>
|
>
|
||||||
{{ $t('components.AboutPod.link.about') }}
|
{{ t('components.AboutPod.link.about') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
<router-link
|
||||||
to="/about/pod#rules"
|
to="/about/pod#rules"
|
||||||
class="item"
|
class="item"
|
||||||
>
|
>
|
||||||
{{ $t('components.AboutPod.link.rules') }}
|
{{ t('components.AboutPod.link.rules') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
<router-link
|
||||||
to="/about/pod#terms"
|
to="/about/pod#terms"
|
||||||
class="item"
|
class="item"
|
||||||
>
|
>
|
||||||
{{ $t('components.AboutPod.link.terms') }}
|
{{ t('components.AboutPod.link.terms') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
<router-link
|
||||||
to="/about/pod#features"
|
to="/about/pod#features"
|
||||||
class="item"
|
class="item"
|
||||||
>
|
>
|
||||||
{{ $t('components.AboutPod.link.features') }}
|
{{ t('components.AboutPod.link.features') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="stats"
|
v-if="stats"
|
||||||
to="/about/pod#statistics"
|
to="/about/pod#statistics"
|
||||||
class="item"
|
class="item"
|
||||||
>
|
>
|
||||||
{{ $t('components.AboutPod.link.statistics') }}
|
{{ t('components.AboutPod.link.statistics') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -134,49 +123,49 @@ const headerStyle = computed(() => {
|
||||||
id="description about-this-pod"
|
id="description about-this-pod"
|
||||||
class="ui header"
|
class="ui header"
|
||||||
>
|
>
|
||||||
{{ $t('components.AboutPod.header.about') }}
|
{{ t('components.AboutPod.header.about') }}
|
||||||
</h2>
|
</h2>
|
||||||
<sanitized-html
|
<sanitized-html
|
||||||
v-if="longDescription"
|
v-if="longDescription"
|
||||||
:html="longDescription"
|
:html="longDescription"
|
||||||
/>
|
/>
|
||||||
<p v-else>
|
<p v-else>
|
||||||
{{ $t('components.AboutPod.placeholder.noDescription') }}
|
{{ t('components.AboutPod.placeholder.noDescription') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3
|
<h3
|
||||||
id="rules"
|
id="rules"
|
||||||
class="ui header"
|
class="ui header"
|
||||||
>
|
>
|
||||||
{{ $t('components.AboutPod.header.rules') }}
|
{{ t('components.AboutPod.header.rules') }}
|
||||||
</h3>
|
</h3>
|
||||||
<sanitized-html
|
<sanitized-html
|
||||||
v-if="rules"
|
v-if="rules"
|
||||||
:html="rules"
|
:html="rules"
|
||||||
/>
|
/>
|
||||||
<p v-else>
|
<p v-else>
|
||||||
{{ $t('components.AboutPod.placeholder.noRules') }}
|
{{ t('components.AboutPod.placeholder.noRules') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3
|
<h3
|
||||||
id="terms"
|
id="terms"
|
||||||
class="ui header"
|
class="ui header"
|
||||||
>
|
>
|
||||||
{{ $t('components.AboutPod.header.terms') }}
|
{{ t('components.AboutPod.header.terms') }}
|
||||||
</h3>
|
</h3>
|
||||||
<sanitized-html
|
<sanitized-html
|
||||||
v-if="terms"
|
v-if="terms"
|
||||||
:html="terms"
|
:html="terms"
|
||||||
/>
|
/>
|
||||||
<p v-else>
|
<p v-else>
|
||||||
{{ $t('components.AboutPod.placeholder.noTerms') }}
|
{{ t('components.AboutPod.placeholder.noTerms') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3
|
<h3
|
||||||
id="features"
|
id="features"
|
||||||
class="header"
|
class="header"
|
||||||
>
|
>
|
||||||
{{ $t('components.AboutPod.header.features') }}
|
{{ t('components.AboutPod.header.features') }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="features-container ui two column stackable grid">
|
<div class="features-container ui two column stackable grid">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
|
@ -184,7 +173,7 @@ const headerStyle = computed(() => {
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
{{ $t('components.AboutPod.feature.version') }}
|
{{ t('components.AboutPod.feature.version') }}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
v-if="version"
|
v-if="version"
|
||||||
|
@ -199,13 +188,13 @@ const headerStyle = computed(() => {
|
||||||
class="right aligned"
|
class="right aligned"
|
||||||
>
|
>
|
||||||
<span class="features-status ui text">
|
<span class="features-status ui text">
|
||||||
{{ $t('components.AboutPod.notApplicable') }}
|
{{ t('components.AboutPod.notApplicable') }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
{{ $t('components.AboutPod.feature.federation') }}
|
{{ t('components.AboutPod.feature.federation') }}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
v-if="federationEnabled"
|
v-if="federationEnabled"
|
||||||
|
@ -213,7 +202,7 @@ const headerStyle = computed(() => {
|
||||||
>
|
>
|
||||||
<span class="features-status ui text">
|
<span class="features-status ui text">
|
||||||
<i class="check icon" />
|
<i class="check icon" />
|
||||||
{{ $t('components.AboutPod.feature.status.enabled') }}
|
{{ t('components.AboutPod.feature.status.enabled') }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
|
@ -222,13 +211,13 @@ const headerStyle = computed(() => {
|
||||||
>
|
>
|
||||||
<span class="features-status ui text">
|
<span class="features-status ui text">
|
||||||
<i class="x icon" />
|
<i class="x icon" />
|
||||||
{{ $t('components.AboutPod.feature.status.disabled') }}
|
{{ t('components.AboutPod.feature.status.disabled') }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
{{ $t('components.AboutPod.feature.allowList') }}
|
{{ t('components.AboutPod.feature.allowList') }}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
v-if="allowListEnabled"
|
v-if="allowListEnabled"
|
||||||
|
@ -236,7 +225,7 @@ const headerStyle = computed(() => {
|
||||||
>
|
>
|
||||||
<span class="features-status ui text">
|
<span class="features-status ui text">
|
||||||
<i class="check icon" />
|
<i class="check icon" />
|
||||||
{{ $t('components.AboutPod.feature.status.enabled') }}
|
{{ t('components.AboutPod.feature.status.enabled') }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
|
@ -245,7 +234,7 @@ const headerStyle = computed(() => {
|
||||||
>
|
>
|
||||||
<span class="features-status ui text">
|
<span class="features-status ui text">
|
||||||
<i class="x icon" />
|
<i class="x icon" />
|
||||||
{{ $t('components.AboutPod.feature.status.disabled') }}
|
{{ t('components.AboutPod.feature.status.disabled') }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -257,7 +246,7 @@ const headerStyle = computed(() => {
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
{{ $t('components.AboutPod.feature.anonymousAccess') }}
|
{{ t('components.AboutPod.feature.anonymousAccess') }}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
v-if="anonymousCanListen"
|
v-if="anonymousCanListen"
|
||||||
|
@ -265,7 +254,7 @@ const headerStyle = computed(() => {
|
||||||
>
|
>
|
||||||
<span class="features-status ui text">
|
<span class="features-status ui text">
|
||||||
<i class="check icon" />
|
<i class="check icon" />
|
||||||
{{ $t('components.AboutPod.feature.status.enabled') }}
|
{{ t('components.AboutPod.feature.status.enabled') }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
|
@ -274,13 +263,13 @@ const headerStyle = computed(() => {
|
||||||
>
|
>
|
||||||
<span class="features-status ui text">
|
<span class="features-status ui text">
|
||||||
<i class="x icon" />
|
<i class="x icon" />
|
||||||
{{ $t('components.AboutPod.feature.status.disabled') }}
|
{{ t('components.AboutPod.feature.status.disabled') }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
{{ $t('components.AboutPod.feature.registrations') }}
|
{{ t('components.AboutPod.feature.registrations') }}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
v-if="openRegistrations"
|
v-if="openRegistrations"
|
||||||
|
@ -288,7 +277,7 @@ const headerStyle = computed(() => {
|
||||||
>
|
>
|
||||||
<span class="features-status ui text">
|
<span class="features-status ui text">
|
||||||
<i class="check icon" />
|
<i class="check icon" />
|
||||||
{{ $t('components.AboutPod.feature.status.open') }}
|
{{ t('components.AboutPod.feature.status.open') }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
|
@ -297,13 +286,13 @@ const headerStyle = computed(() => {
|
||||||
>
|
>
|
||||||
<span class="features-status ui text">
|
<span class="features-status ui text">
|
||||||
<i class="x icon" />
|
<i class="x icon" />
|
||||||
{{ $t('components.AboutPod.feature.status.closed') }}
|
{{ t('components.AboutPod.feature.status.closed') }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
{{ $t('components.AboutPod.feature.quota') }}
|
{{ t('components.AboutPod.feature.quota') }}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
v-if="defaultUploadQuota"
|
v-if="defaultUploadQuota"
|
||||||
|
@ -318,7 +307,7 @@ const headerStyle = computed(() => {
|
||||||
class="right aligned"
|
class="right aligned"
|
||||||
>
|
>
|
||||||
<span class="features-status ui text">
|
<span class="features-status ui text">
|
||||||
{{ $t('components.AboutPod.notApplicable') }}
|
{{ t('components.AboutPod.notApplicable') }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -332,7 +321,7 @@ const headerStyle = computed(() => {
|
||||||
id="statistics"
|
id="statistics"
|
||||||
class="header"
|
class="header"
|
||||||
>
|
>
|
||||||
{{ $t('components.AboutPod.header.statistics') }}
|
{{ t('components.AboutPod.header.statistics') }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="statistics-container">
|
<div class="statistics-container">
|
||||||
<div
|
<div
|
||||||
|
@ -340,9 +329,9 @@ const headerStyle = computed(() => {
|
||||||
class="statistics-statistic"
|
class="statistics-statistic"
|
||||||
>
|
>
|
||||||
<span class="statistics-figure ui text">
|
<span class="statistics-figure ui text">
|
||||||
<span class="ui big text"><strong>{{ stats.hours.toLocaleString($store.state.ui.momentLocale) }}</strong></span>
|
<span class="ui big text"><strong>{{ stats.hours?.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||||
<br>
|
<br>
|
||||||
{{ $t('components.AboutPod.stat.hoursOfMusic', stats.hours) }}
|
{{ t('components.AboutPod.stat.hoursOfMusic', stats.hours) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -350,9 +339,9 @@ const headerStyle = computed(() => {
|
||||||
class="statistics-statistic"
|
class="statistics-statistic"
|
||||||
>
|
>
|
||||||
<span class="statistics-figure ui text">
|
<span class="statistics-figure ui text">
|
||||||
<span class="ui big text"><strong>{{ stats.artists.toLocaleString($store.state.ui.momentLocale) }}</strong></span>
|
<span class="ui big text"><strong>{{ stats.artists?.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||||
<br>
|
<br>
|
||||||
{{ $t('components.AboutPod.stat.artistsCount', stats.artists) }}
|
{{ t('components.AboutPod.stat.artistsCount', stats.artists) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -360,9 +349,9 @@ const headerStyle = computed(() => {
|
||||||
class="statistics-statistic"
|
class="statistics-statistic"
|
||||||
>
|
>
|
||||||
<span class="statistics-figure ui text">
|
<span class="statistics-figure ui text">
|
||||||
<span class="ui big text"><strong>{{ stats.albums.toLocaleString($store.state.ui.momentLocale) }}</strong></span>
|
<span class="ui big text"><strong>{{ stats.albums?.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||||
<br>
|
<br>
|
||||||
{{ $t('components.AboutPod.stat.albumsCount', stats.albums) }}
|
{{ t('components.AboutPod.stat.albumsCount', stats.albums) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -370,9 +359,9 @@ const headerStyle = computed(() => {
|
||||||
class="statistics-statistic"
|
class="statistics-statistic"
|
||||||
>
|
>
|
||||||
<span class="statistics-figure ui text">
|
<span class="statistics-figure ui text">
|
||||||
<span class="ui big text"><strong>{{ stats.tracks.toLocaleString($store.state.ui.momentLocale) }}</strong></span>
|
<span class="ui big text"><strong>{{ stats.tracks?.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||||
<br>
|
<br>
|
||||||
{{ $t('components.AboutPod.stat.tracksCount', stats.tracks) }}
|
{{ t('components.AboutPod.stat.tracksCount', stats.tracks) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -380,9 +369,9 @@ const headerStyle = computed(() => {
|
||||||
class="statistics-statistic"
|
class="statistics-statistic"
|
||||||
>
|
>
|
||||||
<span class="statistics-figure ui text">
|
<span class="statistics-figure ui text">
|
||||||
<span class="ui big text"><strong>{{ stats.users.toLocaleString($store.state.ui.momentLocale) }}</strong></span>
|
<span class="ui big text"><strong>{{ stats.users.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||||
<br>
|
<br>
|
||||||
{{ $t('components.AboutPod.stat.activeUsers', stats.users) }}
|
{{ t('components.AboutPod.stat.activeUsers', stats.users) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -390,9 +379,9 @@ const headerStyle = computed(() => {
|
||||||
class="statistics-statistic"
|
class="statistics-statistic"
|
||||||
>
|
>
|
||||||
<span class="statistics-figure ui text">
|
<span class="statistics-figure ui text">
|
||||||
<span class="ui big text"><strong>{{ stats.listenings.toLocaleString($store.state.ui.momentLocale) }}</strong></span>
|
<span class="ui big text"><strong>{{ stats.listenings.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||||
<br>
|
<br>
|
||||||
{{ $t('components.AboutPod.stat.listeningsCount', stats.listenings) }}
|
{{ t('components.AboutPod.stat.listeningsCount', stats.listenings) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -403,13 +392,13 @@ const headerStyle = computed(() => {
|
||||||
id="contact"
|
id="contact"
|
||||||
class="ui header"
|
class="ui header"
|
||||||
>
|
>
|
||||||
{{ $t('components.AboutPod.header.contact') }}
|
{{ t('components.AboutPod.header.contact') }}
|
||||||
</h3>
|
</h3>
|
||||||
<a
|
<a
|
||||||
v-if="contactEmail"
|
v-if="contactEmail"
|
||||||
:href="`mailto:${contactEmail}`"
|
:href="`mailto:${contactEmail}`"
|
||||||
>
|
>
|
||||||
{{ $t('components.AboutPod.message.contact', { contactEmail }) }}
|
{{ t('components.AboutPod.message.contact', { contactEmail }) }}
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -420,7 +409,7 @@ const headerStyle = computed(() => {
|
||||||
class="ui left floated basic secondary button"
|
class="ui left floated basic secondary button"
|
||||||
>
|
>
|
||||||
<i class="icon arrow left" />
|
<i class="icon arrow left" />
|
||||||
{{ $t('components.AboutPod.link.introduction') }}
|
{{ t('components.AboutPod.link.introduction') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { get } from 'lodash-es'
|
import { get } from 'lodash-es'
|
||||||
import AlbumWidget from '~/components/audio/album/Widget.vue'
|
import AlbumWidget from '~/components/album/Widget.vue'
|
||||||
import ChannelsWidget from '~/components/audio/ChannelsWidget.vue'
|
import ChannelsWidget from '~/components/audio/ChannelsWidget.vue'
|
||||||
import LoginForm from '~/components/auth/LoginForm.vue'
|
import LoginForm from '~/components/auth/LoginForm.vue'
|
||||||
import SignupForm from '~/components/auth/SignupForm.vue'
|
import SignupForm from '~/components/auth/SignupForm.vue'
|
||||||
|
@ -64,7 +64,7 @@ whenever(() => store.state.auth.authenticated, () => {
|
||||||
<template>
|
<template>
|
||||||
<main
|
<main
|
||||||
v-title="labels.title"
|
v-title="labels.title"
|
||||||
class="main pusher page-home"
|
class="main page-home"
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
:class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
|
:class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
|
||||||
|
@ -73,7 +73,7 @@ whenever(() => store.state.auth.authenticated, () => {
|
||||||
<div class="segment-content">
|
<div class="segment-content">
|
||||||
<h1 class="ui center aligned large header">
|
<h1 class="ui center aligned large header">
|
||||||
<span>
|
<span>
|
||||||
{{ $t('components.Home.header.welcome', {podName: podName}) }}
|
{{ t('components.Home.header.welcome', {podName: podName}) }}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
v-if="shortDescription"
|
v-if="shortDescription"
|
||||||
|
@ -88,7 +88,7 @@ whenever(() => store.state.auth.authenticated, () => {
|
||||||
<div class="ui stackable grid">
|
<div class="ui stackable grid">
|
||||||
<div class="ten wide column">
|
<div class="ten wide column">
|
||||||
<h2 class="header">
|
<h2 class="header">
|
||||||
{{ $t('components.Home.header.about') }}
|
{{ t('components.Home.header.about') }}
|
||||||
</h2>
|
</h2>
|
||||||
<div
|
<div
|
||||||
id="pod"
|
id="pod"
|
||||||
|
@ -97,7 +97,7 @@ whenever(() => store.state.auth.authenticated, () => {
|
||||||
<div class="ui stackable grid">
|
<div class="ui stackable grid">
|
||||||
<div class="eight wide column">
|
<div class="eight wide column">
|
||||||
<p v-if="!longDescription">
|
<p v-if="!longDescription">
|
||||||
{{ $t('components.Home.placeholder.noDescription') }}
|
{{ t('components.Home.placeholder.noDescription') }}
|
||||||
</p>
|
</p>
|
||||||
<template v-if="longDescription || rules">
|
<template v-if="longDescription || rules">
|
||||||
<sanitized-html
|
<sanitized-html
|
||||||
|
@ -120,7 +120,7 @@ whenever(() => store.state.auth.authenticated, () => {
|
||||||
class="ui link"
|
class="ui link"
|
||||||
:to="{name: 'about'}"
|
:to="{name: 'about'}"
|
||||||
>
|
>
|
||||||
{{ $t('components.Home.link.learnMore') }}
|
{{ t('components.Home.link.learnMore') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -135,7 +135,7 @@ whenever(() => store.state.auth.authenticated, () => {
|
||||||
class="ui link"
|
class="ui link"
|
||||||
:to="{name: 'about', hash: '#rules'}"
|
:to="{name: 'about', hash: '#rules'}"
|
||||||
>
|
>
|
||||||
{{ $t('components.Home.link.rules') }}
|
{{ t('components.Home.link.rules') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -145,20 +145,20 @@ whenever(() => store.state.auth.authenticated, () => {
|
||||||
<div class="eight wide column">
|
<div class="eight wide column">
|
||||||
<template v-if="stats">
|
<template v-if="stats">
|
||||||
<h3 class="sub header">
|
<h3 class="sub header">
|
||||||
{{ $t('components.Home.header.statistics') }}
|
{{ t('components.Home.header.statistics') }}
|
||||||
</h3>
|
</h3>
|
||||||
<p>
|
<p>
|
||||||
<i class="user icon" />
|
<i class="user icon" />
|
||||||
{{ $t('components.Home.stat.activeUsers', stats.users) }}
|
{{ t('components.Home.stat.activeUsers', stats.users) }}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<i class="music icon" />
|
<i class="music icon" />
|
||||||
{{ $t('components.Home.stat.hoursOfMusic', stats.hours) }}
|
{{ t('components.Home.stat.hoursOfMusic', stats.hours) }}
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="contactEmail">
|
<template v-if="contactEmail">
|
||||||
<h3 class="sub header">
|
<h3 class="sub header">
|
||||||
{{ $t('components.Home.header.contact') }}
|
{{ t('components.Home.header.contact') }}
|
||||||
</h3>
|
</h3>
|
||||||
<i class="at icon" />
|
<i class="at icon" />
|
||||||
<a :href="`mailto:${contactEmail}`">{{ contactEmail }}</a>
|
<a :href="`mailto:${contactEmail}`">{{ contactEmail }}</a>
|
||||||
|
@ -181,13 +181,13 @@ whenever(() => store.state.auth.authenticated, () => {
|
||||||
<div class="ui stackable grid">
|
<div class="ui stackable grid">
|
||||||
<div class="four wide column">
|
<div class="four wide column">
|
||||||
<h3 class="header">
|
<h3 class="header">
|
||||||
{{ $t('components.Home.header.aboutFunkwhale') }}
|
{{ t('components.Home.header.aboutFunkwhale') }}
|
||||||
</h3>
|
</h3>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.Home.description.funkwhale.paragraph1') }}
|
{{ t('components.Home.description.funkwhale.paragraph1') }}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.Home.description.funkwhale.paragraph2') }}
|
{{ t('components.Home.description.funkwhale.paragraph2') }}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
@ -195,12 +195,12 @@ whenever(() => store.state.auth.authenticated, () => {
|
||||||
href="https://funkwhale.audio"
|
href="https://funkwhale.audio"
|
||||||
>
|
>
|
||||||
<i class="external alternate icon" />
|
<i class="external alternate icon" />
|
||||||
{{ $t('components.Home.link.funkwhale') }}
|
{{ t('components.Home.link.funkwhale') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="four wide column">
|
<div class="four wide column">
|
||||||
<h3 class="header">
|
<h3 class="header">
|
||||||
{{ $t('components.Home.header.login') }}
|
{{ t('components.Home.header.login') }}
|
||||||
</h3>
|
</h3>
|
||||||
<login-form
|
<login-form
|
||||||
button-classes="success"
|
button-classes="success"
|
||||||
|
@ -210,14 +210,14 @@ whenever(() => store.state.auth.authenticated, () => {
|
||||||
</div>
|
</div>
|
||||||
<div class="four wide column">
|
<div class="four wide column">
|
||||||
<h3 class="header">
|
<h3 class="header">
|
||||||
{{ $t('components.Home.header.signup') }}
|
{{ t('components.Home.header.signup') }}
|
||||||
</h3>
|
</h3>
|
||||||
<template v-if="openRegistrations">
|
<template v-if="openRegistrations">
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.Home.description.signup') }}
|
{{ t('components.Home.description.signup') }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="defaultUploadQuota">
|
<p v-if="defaultUploadQuota">
|
||||||
{{ $t('components.Home.description.quota', { quota: humanSize(defaultUploadQuota * 1000 * 1000) }) }}
|
{{ t('components.Home.description.quota', { quota: humanSize(defaultUploadQuota * 1000 * 1000) }) }}
|
||||||
</p>
|
</p>
|
||||||
<signup-form
|
<signup-form
|
||||||
button-classes="success"
|
button-classes="success"
|
||||||
|
@ -226,7 +226,7 @@ whenever(() => store.state.auth.authenticated, () => {
|
||||||
</template>
|
</template>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.Home.help.registrationsClosed') }}
|
{{ t('components.Home.help.registrationsClosed') }}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
@ -234,14 +234,14 @@ whenever(() => store.state.auth.authenticated, () => {
|
||||||
href="https://funkwhale.audio/#get-started"
|
href="https://funkwhale.audio/#get-started"
|
||||||
>
|
>
|
||||||
<i class="external alternate icon" />
|
<i class="external alternate icon" />
|
||||||
{{ $t('components.Home.link.findOtherPod') }}
|
{{ t('components.Home.link.findOtherPod') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="four wide column">
|
<div class="four wide column">
|
||||||
<h3 class="header">
|
<h3 class="header">
|
||||||
{{ $t('components.Home.header.links') }}
|
{{ t('components.Home.header.links') }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="ui relaxed list">
|
<div class="ui relaxed list">
|
||||||
<div class="item">
|
<div class="item">
|
||||||
|
@ -252,10 +252,10 @@ whenever(() => store.state.auth.authenticated, () => {
|
||||||
class="header"
|
class="header"
|
||||||
to="/library"
|
to="/library"
|
||||||
>
|
>
|
||||||
{{ $t('components.Home.link.publicContent.label') }}
|
{{ t('components.Home.link.publicContent.label') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
{{ $t('components.Home.link.publicContent.description') }}
|
{{ t('components.Home.link.publicContent.description') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -268,10 +268,10 @@ whenever(() => store.state.auth.authenticated, () => {
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
>
|
>
|
||||||
{{ $t('components.Home.link.mobileApps.label') }}
|
{{ t('components.Home.link.mobileApps.label') }}
|
||||||
</a>
|
</a>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
{{ $t('components.Home.link.mobileApps.description') }}
|
{{ t('components.Home.link.mobileApps.description') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -284,10 +284,10 @@ whenever(() => store.state.auth.authenticated, () => {
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
>
|
>
|
||||||
{{ $t('components.Home.link.userGuides.label') }}
|
{{ t('components.Home.link.userGuides.label') }}
|
||||||
</a>
|
</a>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
{{ $t('components.Home.link.userGuides.description') }}
|
{{ t('components.Home.link.userGuides.description') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -304,16 +304,16 @@ whenever(() => store.state.auth.authenticated, () => {
|
||||||
:limit="10"
|
:limit="10"
|
||||||
>
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
{{ $t('components.Home.header.newAlbums') }}
|
{{ t('components.Home.header.newAlbums') }}
|
||||||
</template>
|
</template>
|
||||||
<router-link to="/library">
|
<router-link to="/library">
|
||||||
{{ $t('components.Home.link.viewMore') }}
|
{{ t('components.Home.link.viewMore') }}
|
||||||
<div class="ui hidden divider" />
|
<div class="ui hidden divider" />
|
||||||
</router-link>
|
</router-link>
|
||||||
</album-widget>
|
</album-widget>
|
||||||
<div class="ui hidden section divider" />
|
<div class="ui hidden section divider" />
|
||||||
<h3 class="ui header">
|
<h3 class="ui header">
|
||||||
{{ $t('components.Home.header.newChannels') }}
|
{{ t('components.Home.header.newChannels') }}
|
||||||
</h3>
|
</h3>
|
||||||
<channels-widget
|
<channels-widget
|
||||||
:show-modification-date="true"
|
:show-modification-date="true"
|
||||||
|
|
|
@ -4,7 +4,7 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
withDefaults(defineProps<Props>(), {
|
||||||
fill: '#222222'
|
fill: 'var(--color)'
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,7 @@ const labels = computed(() => ({
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main
|
<main
|
||||||
class="main pusher"
|
class="main"
|
||||||
:v-title="labels.title"
|
:v-title="labels.title"
|
||||||
>
|
>
|
||||||
<section class="ui vertical stripe segment">
|
<section class="ui vertical stripe segment">
|
||||||
|
@ -20,11 +20,11 @@ const labels = computed(() => ({
|
||||||
<h1 class="ui huge header">
|
<h1 class="ui huge header">
|
||||||
<i class="warning icon" />
|
<i class="warning icon" />
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{{ $t('components.PageNotFound.header.pageNotFound') }}
|
{{ t('components.PageNotFound.header.pageNotFound') }}
|
||||||
</div>
|
</div>
|
||||||
</h1>
|
</h1>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.PageNotFound.message.pageNotFound') }}
|
{{ t('components.PageNotFound.message.pageNotFound') }}
|
||||||
</p>
|
</p>
|
||||||
<a :href="path">{{ path }}</a>
|
<a :href="path">{{ path }}</a>
|
||||||
<div class="ui hidden divider" />
|
<div class="ui hidden divider" />
|
||||||
|
@ -32,7 +32,7 @@ const labels = computed(() => ({
|
||||||
class="ui icon labeled right button"
|
class="ui icon labeled right button"
|
||||||
to="/"
|
to="/"
|
||||||
>
|
>
|
||||||
{{ $t('components.PageNotFound.link.home') }}
|
{{ t('components.PageNotFound.link.home') }}
|
||||||
<i class="right arrow icon" />
|
<i class="right arrow icon" />
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -5,7 +5,6 @@ import { whenever, watchDebounced, useCurrentElement, useScrollLock, useFullscre
|
||||||
import { nextTick, ref, computed, watchEffect, defineAsyncComponent } from 'vue'
|
import { nextTick, ref, computed, watchEffect, defineAsyncComponent } from 'vue'
|
||||||
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
|
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
|
||||||
import { usePlayer } from '~/composables/audio/player'
|
import { usePlayer } from '~/composables/audio/player'
|
||||||
|
@ -14,6 +13,8 @@ import { useQueue } from '~/composables/audio/queue'
|
||||||
|
|
||||||
import time from '~/utils/time'
|
import time from '~/utils/time'
|
||||||
|
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
||||||
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
|
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
|
||||||
import PlayerControls from '~/components/audio/PlayerControls.vue'
|
import PlayerControls from '~/components/audio/PlayerControls.vue'
|
||||||
|
@ -21,6 +22,12 @@ import PlayerControls from '~/components/audio/PlayerControls.vue'
|
||||||
import VirtualList from '~/components/vui/list/VirtualList.vue'
|
import VirtualList from '~/components/vui/list/VirtualList.vue'
|
||||||
import QueueItem from '~/components/QueueItem.vue'
|
import QueueItem from '~/components/QueueItem.vue'
|
||||||
|
|
||||||
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
|
import Link from '~/components/ui/Link.vue'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import ArtistCreditLabel from '~/components/audio/ArtistCreditLabel.vue'
|
||||||
|
|
||||||
const MilkDrop = defineAsyncComponent(() => import('~/components/audio/visualizer/MilkDrop.vue'))
|
const MilkDrop = defineAsyncComponent(() => import('~/components/audio/visualizer/MilkDrop.vue'))
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
@ -173,7 +180,7 @@ if (!isWebGLSupported) {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section
|
<section
|
||||||
class="main with-background component-queue"
|
class="main opaque component-queue"
|
||||||
:aria-label="labels.queue"
|
:aria-label="labels.queue"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
@ -194,12 +201,12 @@ if (!isWebGLSupported) {
|
||||||
<img
|
<img
|
||||||
v-if="fullscreen"
|
v-if="fullscreen"
|
||||||
class="cover-shadow"
|
class="cover-shadow"
|
||||||
:src="$store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
:src="store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
ref="cover"
|
ref="cover"
|
||||||
alt=""
|
alt=""
|
||||||
:src="$store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
:src="store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
<milk-drop
|
<milk-drop
|
||||||
|
@ -212,47 +219,40 @@ if (!isWebGLSupported) {
|
||||||
v-if="!fullscreen || !idle"
|
v-if="!fullscreen || !idle"
|
||||||
class="cover-buttons"
|
class="cover-buttons"
|
||||||
>
|
>
|
||||||
<tooltip :content="!isWebGLSupported && $t('components.Queue.message.webglUnsupported')">
|
<tooltip :content="!isWebGLSupported && t('components.Queue.message.webglUnsupported')">
|
||||||
<button
|
<Button
|
||||||
v-if="coverType === CoverType.COVER_ART"
|
v-if="coverType === CoverType.COVER_ART"
|
||||||
class="ui secondary button"
|
|
||||||
:aria-label="labels.showVisualizer"
|
:aria-label="labels.showVisualizer"
|
||||||
:title="labels.showVisualizer"
|
:title="labels.showVisualizer"
|
||||||
:disabled="!isWebGLSupported"
|
:disabled="!isWebGLSupported"
|
||||||
|
icon="bi-display"
|
||||||
@click="coverType = CoverType.MILK_DROP"
|
@click="coverType = CoverType.MILK_DROP"
|
||||||
>
|
/>
|
||||||
<i class="icon signal" />
|
<Button
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else-if="coverType === CoverType.MILK_DROP"
|
v-else-if="coverType === CoverType.MILK_DROP"
|
||||||
class="ui secondary button"
|
|
||||||
:aria-label="labels.showCoverArt"
|
:aria-label="labels.showCoverArt"
|
||||||
:title="labels.showCoverArt"
|
:title="labels.showCoverArt"
|
||||||
:disabled="!isWebGLSupported"
|
:disabled="!isWebGLSupported"
|
||||||
|
icon="bi-image-fill"
|
||||||
@click="coverType = CoverType.COVER_ART"
|
@click="coverType = CoverType.COVER_ART"
|
||||||
>
|
/>
|
||||||
<i class="icon image outline" />
|
|
||||||
</button>
|
|
||||||
</tooltip>
|
</tooltip>
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
v-if="!fullscreen"
|
v-if="!fullscreen"
|
||||||
class="ui secondary button"
|
|
||||||
:aria-label="labels.fullscreen"
|
:aria-label="labels.fullscreen"
|
||||||
:title="labels.fullscreen"
|
:title="labels.fullscreen"
|
||||||
|
icon="bi-arrows-fullscreen"
|
||||||
@click="enter"
|
@click="enter"
|
||||||
>
|
/>
|
||||||
<i class="icon expand" />
|
<Button
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else
|
v-else
|
||||||
class="ui secondary button"
|
secondary
|
||||||
:aria-label="labels.exitFullscreen"
|
:aria-label="labels.exitFullscreen"
|
||||||
:title="labels.exitFullscreen"
|
:title="labels.exitFullscreen"
|
||||||
|
icon="bi-fullscreen-exit"
|
||||||
@click="exit"
|
@click="exit"
|
||||||
>
|
/>
|
||||||
<i class="icon compress" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
<Transition name="queue">
|
<Transition name="queue">
|
||||||
|
@ -267,65 +267,53 @@ if (!isWebGLSupported) {
|
||||||
v-for="ac in currentTrack.artistCredit"
|
v-for="ac in currentTrack.artistCredit"
|
||||||
:key="ac.artist.id"
|
:key="ac.artist.id"
|
||||||
>
|
>
|
||||||
{{ ac.credit ?? $t('components.Queue.meta.unknownArtist') }}
|
{{ ac.credit ?? t('components.Queue.meta.unknownArtist') }}
|
||||||
<span>{{ ac.joinphrase }}</span>
|
<span>{{ ac.joinphrase }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="symbol hyphen middle" />
|
<span class="symbol hyphen middle" />
|
||||||
{{ currentTrack.albumTitle ?? $t('components.Queue.meta.unknownAlbum') }}
|
{{ currentTrack.albumTitle ?? t('components.Queue.meta.unknownAlbum') }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="ui header">
|
<h1 class="ui header">
|
||||||
<div class="content ellipsis">
|
<Link
|
||||||
<router-link
|
class="track"
|
||||||
class="small header discrete link track"
|
|
||||||
:to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}"
|
:to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}"
|
||||||
>
|
>
|
||||||
{{ currentTrack.title }}
|
{{ currentTrack.title }}
|
||||||
</router-link>
|
</Link>
|
||||||
<div class="sub header ellipsis">
|
</h1>
|
||||||
<span>
|
<h2>
|
||||||
<template
|
|
||||||
v-for="ac in currentTrack.artistCredit"
|
|
||||||
:key="ac.artist.id"
|
|
||||||
>
|
|
||||||
<router-link
|
|
||||||
class="discrete link"
|
|
||||||
:to="{name: 'library.artists.detail', params: {id: ac.artist.id }}"
|
|
||||||
@click.stop.prevent=""
|
|
||||||
>
|
|
||||||
{{ ac.credit ?? $t('components.Queue.meta.unknownArtist') }}
|
|
||||||
</router-link>
|
|
||||||
<span>{{ ac.joinphrase }}</span>
|
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
<template v-if="currentTrack.albumId !== -1">
|
<template v-if="currentTrack.albumId !== -1">
|
||||||
<span class="middle slash symbol" />
|
<Link
|
||||||
<router-link
|
class="album"
|
||||||
class="discrete link album"
|
|
||||||
:to="{name: 'library.albums.detail', params: {id: currentTrack.albumId }}"
|
:to="{name: 'library.albums.detail', params: {id: currentTrack.albumId }}"
|
||||||
>
|
>
|
||||||
{{ currentTrack.albumTitle ?? $t('components.Queue.meta.unknownAlbum') }}
|
{{ currentTrack.albumTitle ?? t('components.Queue.meta.unknownAlbum') }}
|
||||||
</router-link>
|
</Link>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</h2>
|
||||||
</div>
|
<span>
|
||||||
</h1>
|
<ArtistCreditLabel
|
||||||
|
v-if="currentTrack.artistCredit"
|
||||||
|
:artist-credit="currentTrack.artistCredit"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
<div
|
<div
|
||||||
v-if="currentTrack && errored"
|
v-if="currentTrack && errored"
|
||||||
class="ui small warning message"
|
class="ui small warning message"
|
||||||
>
|
>
|
||||||
<h3 class="header">
|
<h3 class="header">
|
||||||
{{ $t('components.Queue.header.failure') }}
|
{{ t('components.Queue.header.failure') }}
|
||||||
</h3>
|
</h3>
|
||||||
<p v-if="hasNext && isPlaying">
|
<p v-if="hasNext && isPlaying">
|
||||||
{{ $t('components.Queue.message.automaticPlay') }}
|
{{ t('components.Queue.message.automaticPlay') }}
|
||||||
<i class="loading spinner icon" />
|
<i class="loading spinner icon" />
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.Queue.warning.connectivity') }}
|
{{ t('components.Queue.warning.connectivity') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -333,32 +321,40 @@ if (!isWebGLSupported) {
|
||||||
class="ui small warning message"
|
class="ui small warning message"
|
||||||
>
|
>
|
||||||
<h3 class="header">
|
<h3 class="header">
|
||||||
{{ $t('components.Queue.header.noSources') }}
|
{{ t('components.Queue.header.noSources') }}
|
||||||
</h3>
|
</h3>
|
||||||
<p v-if="hasNext && isPlaying">
|
<p v-if="hasNext && isPlaying">
|
||||||
{{ $t('components.Queue.message.automaticPlay') }}
|
{{ t('components.Queue.message.automaticPlay') }}
|
||||||
<i class="loading spinner icon" />
|
<i class="loading spinner icon" />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="additional-controls desktop-and-below">
|
<Spacer
|
||||||
|
:size="16"
|
||||||
|
class="desktop-and-below"
|
||||||
|
/>
|
||||||
|
<Layout
|
||||||
|
flex
|
||||||
|
class="additional-controls desktop-and-below"
|
||||||
|
>
|
||||||
<track-favorite-icon
|
<track-favorite-icon
|
||||||
v-if="$store.state.auth.authenticated"
|
v-if="store.state.auth.authenticated"
|
||||||
:track="currentTrack"
|
:track="currentTrack"
|
||||||
|
ghost
|
||||||
/>
|
/>
|
||||||
<track-playlist-icon
|
<track-playlist-icon
|
||||||
v-if="$store.state.auth.authenticated"
|
v-if="store.state.auth.authenticated"
|
||||||
:track="currentTrack"
|
:track="currentTrack"
|
||||||
|
ghost
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
v-if="$store.state.auth.authenticated"
|
v-if="store.state.auth.authenticated"
|
||||||
:class="['ui', 'really', 'basic', 'circular', 'icon', 'button']"
|
ghost
|
||||||
|
icon="bi-eye-slash"
|
||||||
:aria-label="labels.addArtistContentFilter"
|
:aria-label="labels.addArtistContentFilter"
|
||||||
:title="labels.addArtistContentFilter"
|
:title="labels.addArtistContentFilter"
|
||||||
@click="hideArtist"
|
@click="hideArtist"
|
||||||
>
|
/>
|
||||||
<i :class="['eye slash outline', 'basic', 'icon']" />
|
</Layout>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="progress-wrapper">
|
<div class="progress-wrapper">
|
||||||
<div class="progress-area">
|
<div class="progress-area">
|
||||||
<div
|
<div
|
||||||
|
@ -386,28 +382,33 @@ if (!isWebGLSupported) {
|
||||||
<span class="right floated timer total">{{ time.parse(Math.round(duration)) }}</span>
|
<span class="right floated timer total">{{ time.parse(Math.round(duration)) }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="left floated timer">{{ $t('components.Queue.meta.startTime') }}</span>
|
<span class="left floated timer">{{ t('components.Queue.meta.startTime') }}</span>
|
||||||
<span class="right floated timer">{{ $t('components.Queue.meta.startTime') }}</span>
|
<span class="right floated timer">{{ t('components.Queue.meta.startTime') }}</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<player-controls class="desktop-and-below" />
|
<player-controls class="desktop-and-below queue-controls" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div id="queue">
|
<div id="queue">
|
||||||
<div class="ui basic clearing segment">
|
<div class="ui basic clearing segment">
|
||||||
<h2 class="ui header">
|
<h2 class="ui header">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<button
|
<Button
|
||||||
v-t="'components.Queue.button.close'"
|
ghost
|
||||||
class="ui right floated basic button"
|
icon="bi-chevron-down"
|
||||||
@click="$store.commit('ui/queueFocused', null)"
|
style="float: right; margin-right: 24px;"
|
||||||
|
@click="store.commit('ui/queueFocused', null)"
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
v-t="'components.Queue.button.clear'"
|
red
|
||||||
class="ui right floated basic button danger"
|
outline
|
||||||
|
icon="bi-trash-fill"
|
||||||
|
style="float: right; margin-right: 16px;"
|
||||||
@click="clear"
|
@click="clear"
|
||||||
/>
|
>
|
||||||
|
{{ t('components.Queue.button.clear') }}
|
||||||
|
</Button>
|
||||||
{{ labels.queue }}
|
{{ labels.queue }}
|
||||||
<div class="sub header">
|
<div class="sub header">
|
||||||
<div>
|
<div>
|
||||||
|
@ -420,7 +421,10 @@ if (!isWebGLSupported) {
|
||||||
</template>
|
</template>
|
||||||
</i18n-t>
|
</i18n-t>
|
||||||
<span class="middle pipe symbol" />
|
<span class="middle pipe symbol" />
|
||||||
<span v-t="'components.Queue.meta.end'" />
|
<span
|
||||||
|
v-t="'components.Queue.meta.end'"
|
||||||
|
style="margin-right: 8px;"
|
||||||
|
/>
|
||||||
<span :title="labels.duration">
|
<span :title="labels.duration">
|
||||||
{{ endsIn }}
|
{{ endsIn }}
|
||||||
</span>
|
</span>
|
||||||
|
@ -451,30 +455,31 @@ if (!isWebGLSupported) {
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div
|
<div
|
||||||
v-if="$store.state.radios.populating"
|
v-if="store.state.radios.populating"
|
||||||
class="radio-populating"
|
class="radio-populating"
|
||||||
>
|
>
|
||||||
<i class="loading spinner icon" />
|
<i class="loading spinner icon" />
|
||||||
{{ labels.populating }}
|
{{ labels.populating }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="$store.state.radios.running"
|
v-if="store.state.radios.running"
|
||||||
class="ui info message radio-message"
|
class="ui info message radio-message"
|
||||||
>
|
>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<h3 class="header">
|
<h3 class="header">
|
||||||
<i class="feed icon" />
|
<i class="bi bi-boombox-fill" />
|
||||||
{{ $t('components.Queue.header.radio') }}
|
{{ t('components.Queue.header.radio') }}
|
||||||
</h3>
|
</h3>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.Queue.message.radio') }}
|
{{ t('components.Queue.message.radio') }}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button
|
||||||
class="ui basic primary button"
|
primary
|
||||||
@click="$store.dispatch('radios/stop')"
|
icon="bi-stop-fill"
|
||||||
|
@click="store.dispatch('radios/stop')"
|
||||||
>
|
>
|
||||||
{{ $t('components.Queue.button.stopRadio') }}
|
{{ t('components.Queue.button.stopRadio') }}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -3,6 +3,11 @@ import type { QueueItemSource } from '~/types'
|
||||||
|
|
||||||
import time from '~/utils/time'
|
import time from '~/utils/time'
|
||||||
import { generateTrackCreditStringFromQueue } from '~/utils/utils'
|
import { generateTrackCreditStringFromQueue } from '~/utils/utils'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'play', index: number): void
|
(e: 'play', index: number): void
|
||||||
|
@ -24,7 +29,7 @@ defineProps<Props>()
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<div class="handle">
|
<div class="handle">
|
||||||
<i class="grip lines icon" />
|
<i class="bi bi-list" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="image-cell"
|
class="image-cell"
|
||||||
|
@ -37,7 +42,7 @@ defineProps<Props>()
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div @click="$emit('play', index)">
|
<div @click="$emit('play', index)">
|
||||||
<button
|
<div
|
||||||
class="title reset ellipsis"
|
class="title reset ellipsis"
|
||||||
:title="source.title"
|
:title="source.title"
|
||||||
:aria-label="source.labels.selectTrack"
|
:aria-label="source.labels.selectTrack"
|
||||||
|
@ -46,7 +51,7 @@ defineProps<Props>()
|
||||||
<span>
|
<span>
|
||||||
{{ generateTrackCreditStringFromQueue(source) }}
|
{{ generateTrackCreditStringFromQueue(source) }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="duration-cell">
|
<div class="duration-cell">
|
||||||
<template v-if="source.sources.length > 0">
|
<template v-if="source.sources.length > 0">
|
||||||
|
@ -54,26 +59,28 @@ defineProps<Props>()
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<button
|
<Button
|
||||||
v-if="$store.state.auth.authenticated"
|
v-if="store.state.auth.authenticated"
|
||||||
:aria-label="source.labels.favorite"
|
:aria-label="source.labels.favorite"
|
||||||
:title="source.labels.favorite"
|
:title="source.labels.favorite"
|
||||||
class="ui really basic circular icon button"
|
:icon="store.getters['favorites/isFavorite'](source.id) ? 'bi-heart-fill' : 'bi-heart'"
|
||||||
@click.stop="$store.dispatch('favorites/toggle', source.id)"
|
round
|
||||||
>
|
ghost
|
||||||
<i
|
square-small
|
||||||
:class="$store.getters['favorites/isFavorite'](source.id) ? 'pink' : ''"
|
style="align-self: center;"
|
||||||
class="heart icon"
|
:class="store.getters['favorites/isFavorite'](source.id) ? 'pink' : ''"
|
||||||
|
@click.stop="store.dispatch('favorites/toggle', source.id)"
|
||||||
/>
|
/>
|
||||||
</button>
|
<Button
|
||||||
<button
|
|
||||||
:aria-label="source.labels.remove"
|
:aria-label="source.labels.remove"
|
||||||
:title="source.labels.remove"
|
:title="source.labels.remove"
|
||||||
class="ui really tiny basic circular icon button"
|
icon="bi-x"
|
||||||
|
round
|
||||||
|
ghost
|
||||||
|
square-small
|
||||||
|
style="align-self: center;"
|
||||||
@click.stop="$emit('remove', index)"
|
@click.stop="$emit('remove', index)"
|
||||||
>
|
/>
|
||||||
<i class="x icon" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -8,6 +8,11 @@ import { useStore } from '~/store'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import Input from '~/components/ui/Input.vue'
|
||||||
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
|
|
||||||
import updateQueryString from '~/composables/updateQueryString'
|
import updateQueryString from '~/composables/updateQueryString'
|
||||||
import useLogger from '~/composables/useLogger'
|
import useLogger from '~/composables/useLogger'
|
||||||
|
|
||||||
|
@ -165,41 +170,42 @@ watch(() => props.initialId, () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<Layout
|
||||||
v-if="type === 'both'"
|
v-if="type === 'both'"
|
||||||
class="two ui buttons"
|
stack
|
||||||
>
|
>
|
||||||
<button
|
<Button
|
||||||
class="ui left floated labeled icon button"
|
secondary
|
||||||
|
raised
|
||||||
|
split
|
||||||
|
round
|
||||||
|
icon="bi-rss-fill"
|
||||||
|
split-icon="bi-globe"
|
||||||
|
style="align-self: center;"
|
||||||
|
:split-title="t('components.RemoteSearchForm.button.fediverse')"
|
||||||
@click.prevent="type = 'rss'"
|
@click.prevent="type = 'rss'"
|
||||||
|
@split-click.prevent="type = 'artists'"
|
||||||
>
|
>
|
||||||
<i class="feed icon" />
|
{{ t('components.RemoteSearchForm.button.rss') }}
|
||||||
{{ $t('components.RemoteSearchForm.button.rss') }}
|
</Button>
|
||||||
</button>
|
</Layout>
|
||||||
<div class="or" />
|
<Layout
|
||||||
<button
|
v-else
|
||||||
class="ui right floated right labeled icon button"
|
|
||||||
@click.prevent="type = 'artists'"
|
|
||||||
>
|
|
||||||
<i class="globe icon" />
|
|
||||||
{{ $t('components.RemoteSearchForm.button.fediverse') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<form
|
|
||||||
id="remote-search"
|
id="remote-search"
|
||||||
|
form
|
||||||
:class="['ui', {loading: isLoading}, 'form']"
|
:class="['ui', {loading: isLoading}, 'form']"
|
||||||
@submit.stop.prevent="submit"
|
@submit.stop.prevent="submit"
|
||||||
>
|
>
|
||||||
<div
|
<Alert
|
||||||
v-if="errors.length > 0"
|
v-if="errors.length > 0"
|
||||||
|
red
|
||||||
role="alert"
|
role="alert"
|
||||||
class="ui negative message"
|
title="t('components.RemoteSearchForm.header.fetchFailed')"
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
v-if="errors.length > 1"
|
||||||
|
class="list"
|
||||||
>
|
>
|
||||||
<h3 class="header">
|
|
||||||
{{ $t('components.RemoteSearchForm.header.fetchFailed') }}
|
|
||||||
</h3>
|
|
||||||
<ul class="list">
|
|
||||||
<li
|
<li
|
||||||
v-for="(error, key) in errors"
|
v-for="(error, key) in errors"
|
||||||
:key="key"
|
:key="key"
|
||||||
|
@ -207,43 +213,42 @@ watch(() => props.initialId, () => {
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
<p v-else>
|
||||||
<div class="ui required field">
|
{{ errors[0] }}
|
||||||
<label for="object-id">
|
</p>
|
||||||
{{ labels.fieldLabel }}
|
</Alert>
|
||||||
</label>
|
|
||||||
<p v-if="type === 'rss'">
|
<p v-if="type === 'rss'">
|
||||||
{{ $t('components.RemoteSearchForm.description.rss') }}
|
{{ t('components.RemoteSearchForm.description.rss') }}
|
||||||
</p>
|
</p>
|
||||||
<p v-else-if="type === 'artists'">
|
<p v-else-if="type === 'artists'">
|
||||||
{{ $t('components.RemoteSearchForm.description.fediverse') }}
|
{{ t('components.RemoteSearchForm.description.fediverse') }}
|
||||||
</p>
|
</p>
|
||||||
<input
|
|
||||||
|
<Input
|
||||||
id="object-id"
|
id="object-id"
|
||||||
v-model="id"
|
v-model="id"
|
||||||
type="text"
|
type="text"
|
||||||
name="object-id"
|
name="object-id"
|
||||||
|
:label="labels.fieldLabel"
|
||||||
:placeholder="labels.fieldPlaceholder"
|
:placeholder="labels.fieldPlaceholder"
|
||||||
|
style="width: 100%;"
|
||||||
required
|
required
|
||||||
>
|
/>
|
||||||
</div>
|
|
||||||
<button
|
<Button
|
||||||
v-if="showSubmit"
|
v-if="showSubmit"
|
||||||
|
primary
|
||||||
type="submit"
|
type="submit"
|
||||||
:class="['ui', 'primary', {loading: isLoading}, 'button']"
|
:class="{loading: isLoading}"
|
||||||
:disabled="isLoading || !id || id.length === 0"
|
:disabled="isLoading || !id || id.length === 0"
|
||||||
>
|
>
|
||||||
{{ $t('components.RemoteSearchForm.button.search') }}
|
{{ t('components.RemoteSearchForm.button.search') }}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</Layout>
|
||||||
<div
|
<Alert
|
||||||
v-if="!isLoading && obj?.status === 'finished' && !redirectRoute"
|
v-if="!isLoading && obj?.status === 'finished' && !redirectRoute"
|
||||||
role="alert"
|
red
|
||||||
class="ui warning message"
|
|
||||||
>
|
>
|
||||||
<p>
|
{{ t('components.RemoteSearchForm.warning.unsupported') }}
|
||||||
{{ $t('components.RemoteSearchForm.warning.unsupported') }}
|
</Alert>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,7 +1,13 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="ui toast-container">
|
<div class="ui toast-container">
|
||||||
<message
|
<message
|
||||||
v-for="message in $store.state.ui.messages"
|
v-for="message in store.state.ui.messages"
|
||||||
:key="message.key"
|
:key="message.key"
|
||||||
:message="message"
|
:message="message"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -0,0 +1,188 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Modal from '~/components/ui/Modal.vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { uniq } from 'lodash-es'
|
||||||
|
import { useVModel } from '@vueuse/core'
|
||||||
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
// TODO: Delete this file?
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
show: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:show'])
|
||||||
|
const show = useVModel(props, 'show', emit)
|
||||||
|
|
||||||
|
const instanceUrl = ref('')
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
const suggestedInstances = computed(() => {
|
||||||
|
const serverUrl = store.state.instance.frontSettings.defaultServerUrl
|
||||||
|
|
||||||
|
return uniq([
|
||||||
|
store.state.instance.instanceUrl,
|
||||||
|
...store.state.instance.knownInstances,
|
||||||
|
serverUrl.endsWith('/') ? serverUrl : serverUrl + '/',
|
||||||
|
store.getters['instance/defaultInstance']
|
||||||
|
]).slice(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => store.state.instance.instanceUrl, () => store.dispatch('instance/fetchSettings'))
|
||||||
|
|
||||||
|
// TODO: replace translation mechanism { $pgettext } with { t }
|
||||||
|
|
||||||
|
// const { $pgettext } = useGettext()
|
||||||
|
const isError = ref(false)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const checkAndSwitch = async (url: string) => {
|
||||||
|
isError.value = false
|
||||||
|
isLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const instanceUrl = new URL(url.startsWith('https://') || url.startsWith('http://') ? url : `https://${url}`).origin
|
||||||
|
await axios.get(instanceUrl + '/api/v1/instance/nodeinfo/2.0/')
|
||||||
|
|
||||||
|
show.value = false
|
||||||
|
store.commit('ui/addMessage', {
|
||||||
|
content: 'You are now using the Funkwhale instance at %{ url }',
|
||||||
|
// $pgettext('*/Instance/Message', 'You are now using the Funkwhale instance at %{ url }', { url: instanceUrl }),
|
||||||
|
date: new Date()
|
||||||
|
})
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
store.dispatch('instance/setUrl', instanceUrl)
|
||||||
|
} catch (error) {
|
||||||
|
isError.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
|
||||||
|
<Modal
|
||||||
|
v-model="show"
|
||||||
|
:title="t('views.ChooseInstance.header.chooseInstance')"
|
||||||
|
@update="isError = false"
|
||||||
|
>
|
||||||
|
<h3 class="header">
|
||||||
|
<!-- TODO: translate -->
|
||||||
|
|
||||||
|
<!-- <translate translate-context="Popup/Instance/Title">
|
||||||
|
</translate> -->
|
||||||
|
</h3>
|
||||||
|
<div class="scrolling content">
|
||||||
|
<div
|
||||||
|
v-if="isError"
|
||||||
|
role="alert"
|
||||||
|
class="ui negative message"
|
||||||
|
>
|
||||||
|
<h4 class="header">
|
||||||
|
<!-- TODO: translate -->
|
||||||
|
It is not possible to connect to the given URL
|
||||||
|
<!-- <translate translate-context="Popup/Instance/Error message.Title">
|
||||||
|
</translate> -->
|
||||||
|
</h4>
|
||||||
|
<ul class="list">
|
||||||
|
<li>
|
||||||
|
<!-- TODO: translate -->
|
||||||
|
The server might be down
|
||||||
|
<!-- <translate translate-context="Popup/Instance/Error message.List item">
|
||||||
|
</translate> -->
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<!-- TODO: translate -->
|
||||||
|
The given address is not a Funkwhale server
|
||||||
|
<!-- <translate translate-context="Popup/Instance/Error message.List item">
|
||||||
|
</translate> -->
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
class="ui form"
|
||||||
|
@submit.prevent="checkAndSwitch(instanceUrl)"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
v-if="store.state.instance.instanceUrl"
|
||||||
|
v-translate="{url: store.state.instance.instanceUrl, hostname: store.getters['instance/domain'] }"
|
||||||
|
class="description"
|
||||||
|
translate-context="Popup/Login/Paragraph"
|
||||||
|
>
|
||||||
|
You are currently connected to <a
|
||||||
|
href="%{ url }"
|
||||||
|
target="_blank"
|
||||||
|
>%{ hostname } <i class="external icon" /></a>. If you continue, you will be disconnected from your current instance and all your local data will be deleted.
|
||||||
|
</p>
|
||||||
|
<p v-else>
|
||||||
|
<!-- TODO: translate -->
|
||||||
|
To continue, please select the Funkwhale instance you want to connect to. Enter the address directly, or select one of the suggested choices.
|
||||||
|
<!-- <translate translate-context="Popup/Instance/Paragraph">
|
||||||
|
</translate> -->
|
||||||
|
</p>
|
||||||
|
<div class="field">
|
||||||
|
<label for="instance-picker">
|
||||||
|
<!-- TODO: translate -->
|
||||||
|
<!-- <translate translate-context="Popup/Instance/Input.Label/Noun">Instance URL</translate>
|
||||||
|
-->
|
||||||
|
</label>
|
||||||
|
<div class="ui action input">
|
||||||
|
<input
|
||||||
|
id="instance-picker"
|
||||||
|
v-model="instanceUrl"
|
||||||
|
type="text"
|
||||||
|
placeholder="https://funkwhale.server"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:class="['ui', 'icon', {loading: isLoading}, 'button']"
|
||||||
|
>
|
||||||
|
<!-- TODO: translate -->
|
||||||
|
Submit
|
||||||
|
<!-- <translate translate-context="*/*/Button.Label/Verb">
|
||||||
|
</translate> -->
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="ui hidden divider" />
|
||||||
|
<form
|
||||||
|
class="ui form"
|
||||||
|
@submit.prevent=""
|
||||||
|
>
|
||||||
|
<div class="field">
|
||||||
|
<h4>
|
||||||
|
<!-- TODO: translate -->
|
||||||
|
Suggested choices
|
||||||
|
<!-- <translate translate-context="Popup/Instance/List.Label">
|
||||||
|
</translate> -->
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
v-for="(url, key) in suggestedInstances"
|
||||||
|
:key="key"
|
||||||
|
class="ui basic button"
|
||||||
|
@click="checkAndSwitch(url)"
|
||||||
|
>
|
||||||
|
{{ url }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="ui basic cancel button">
|
||||||
|
<!-- TODO: translate -->
|
||||||
|
Cancel
|
||||||
|
<!-- <translate translate-context="*/*/Button.Label/Verb">
|
||||||
|
</translate> -->
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
|
||||||
|
</template>
|
|
@ -1,161 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import SemanticModal from '~/components/semantic/Modal.vue'
|
|
||||||
import { useVModel } from '@vueuse/core'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
interface Events {
|
|
||||||
(e: 'update:show', show: boolean): void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
show: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const emit = defineEmits<Events>()
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
const showRef = useVModel(props, 'show', emit)
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const general = computed(() => [
|
|
||||||
{
|
|
||||||
title: t('components.ShortcutsModal.shortcut.general.label'),
|
|
||||||
shortcuts: [
|
|
||||||
{
|
|
||||||
key: 'h',
|
|
||||||
summary: t('components.ShortcutsModal.shortcut.general.show')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'shift + f',
|
|
||||||
summary: t('components.ShortcutsModal.shortcut.general.focus')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'esc',
|
|
||||||
summary: t('components.ShortcutsModal.shortcut.general.unfocus')
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
const player = computed(() => [
|
|
||||||
{
|
|
||||||
title: t('components.ShortcutsModal.shortcut.audio.label'),
|
|
||||||
shortcuts: [
|
|
||||||
{
|
|
||||||
key: 'p',
|
|
||||||
summary: t('components.ShortcutsModal.shortcut.audio.playPause')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'left',
|
|
||||||
summary: t('components.ShortcutsModal.shortcut.audio.seekBack5')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'right',
|
|
||||||
summary: t('components.ShortcutsModal.shortcut.audio.seekForward5')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'shift + left',
|
|
||||||
summary: t('components.ShortcutsModal.shortcut.audio.seekBack30')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'shift + right',
|
|
||||||
summary: t('components.ShortcutsModal.shortcut.audio.seekForward30')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ctrl + shift + left',
|
|
||||||
summary: t('components.ShortcutsModal.shortcut.audio.playPrevious')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ctrl + shift + right',
|
|
||||||
summary: t('components.ShortcutsModal.shortcut.audio.playNext')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'shift + up',
|
|
||||||
summary: t('components.ShortcutsModal.shortcut.audio.increaseVolume')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'shift + down',
|
|
||||||
summary: t('components.ShortcutsModal.shortcut.audio.decreaseVolume')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'm',
|
|
||||||
summary: t('components.ShortcutsModal.shortcut.audio.toggleMute')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'e',
|
|
||||||
summary: t('components.ShortcutsModal.shortcut.audio.expandQueue')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'l',
|
|
||||||
summary: t('components.ShortcutsModal.shortcut.audio.toggleLoop')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 's',
|
|
||||||
summary: t('components.ShortcutsModal.shortcut.audio.shuffleQueue')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'q',
|
|
||||||
summary: t('components.ShortcutsModal.shortcut.audio.clearQueue')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'f',
|
|
||||||
summary: t('components.ShortcutsModal.shortcut.audio.toggleFavorite')
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<semantic-modal v-model:show="showRef">
|
|
||||||
<header class="header">
|
|
||||||
{{ $t('components.ShortcutsModal.header.modal') }}
|
|
||||||
</header>
|
|
||||||
<section class="scrolling content">
|
|
||||||
<div class="ui stackable two column grid">
|
|
||||||
<div class="column">
|
|
||||||
<table
|
|
||||||
v-for="section in player"
|
|
||||||
:key="section.title"
|
|
||||||
class="ui compact basic table"
|
|
||||||
>
|
|
||||||
<caption>{{ section.title }}</caption>
|
|
||||||
<tbody>
|
|
||||||
<tr
|
|
||||||
v-for="shortcut in section.shortcuts"
|
|
||||||
:key="shortcut.summary"
|
|
||||||
>
|
|
||||||
<td>{{ shortcut.summary }}</td>
|
|
||||||
<td><span class="ui label">{{ shortcut.key }}</span></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="column">
|
|
||||||
<table
|
|
||||||
v-for="section in general"
|
|
||||||
:key="section.title"
|
|
||||||
class="ui compact basic table"
|
|
||||||
>
|
|
||||||
<caption>{{ section.title }}</caption>
|
|
||||||
<tbody>
|
|
||||||
<tr
|
|
||||||
v-for="shortcut in section.shortcuts"
|
|
||||||
:key="shortcut.summary"
|
|
||||||
>
|
|
||||||
<td>{{ shortcut.summary }}</td>
|
|
||||||
<td><span class="ui label">{{ shortcut.key }}</span></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<footer class="actions">
|
|
||||||
<button class="ui basic cancel button">
|
|
||||||
{{ $t('components.ShortcutsModal.button.close') }}
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</semantic-modal>
|
|
||||||
</template>
|
|
|
@ -1,589 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { RouteRecordName } from 'vue-router'
|
|
||||||
|
|
||||||
import { computed, ref, watch, watchEffect, onMounted } from 'vue'
|
|
||||||
import { setI18nLanguage, SUPPORTED_LOCALES } from '~/init/locale'
|
|
||||||
import { useCurrentElement } from '@vueuse/core'
|
|
||||||
import { setupDropdown } from '~/utils/fomantic'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { useStore } from '~/store'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
import SemanticModal from '~/components/semantic/Modal.vue'
|
|
||||||
import UserModal from '~/components/common/UserModal.vue'
|
|
||||||
import SearchBar from '~/components/audio/SearchBar.vue'
|
|
||||||
import UserMenu from '~/components/common/UserMenu.vue'
|
|
||||||
import Logo from '~/components/Logo.vue'
|
|
||||||
|
|
||||||
import useThemeList from '~/composables/useThemeList'
|
|
||||||
import useTheme from '~/composables/useTheme'
|
|
||||||
import { isTauri as checkTauri } from '~/composables/tauri'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
width: number
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps<Props>()
|
|
||||||
|
|
||||||
const store = useStore()
|
|
||||||
const { theme } = useTheme()
|
|
||||||
const themes = useThemeList()
|
|
||||||
const { t, locale: i18nLocale } = useI18n()
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const isCollapsed = ref(true)
|
|
||||||
watch(() => route.path, () => (isCollapsed.value = true))
|
|
||||||
|
|
||||||
const additionalNotifications = computed(() => store.getters['ui/additionalNotifications'])
|
|
||||||
const logoUrl = computed(() => store.state.auth.authenticated ? 'library.index' : 'index')
|
|
||||||
|
|
||||||
const labels = computed(() => ({
|
|
||||||
mainMenu: t('components.Sidebar.label.main'),
|
|
||||||
selectTrack: t('components.Sidebar.label.play'),
|
|
||||||
pendingFollows: t('components.Sidebar.label.follows'),
|
|
||||||
pendingReviewEdits: t('components.Sidebar.label.edits'),
|
|
||||||
pendingReviewReports: t('components.Sidebar.label.reports'),
|
|
||||||
language: t('components.Sidebar.label.language'),
|
|
||||||
theme: t('components.Sidebar.label.theme'),
|
|
||||||
addContent: t('components.Sidebar.label.add'),
|
|
||||||
administration: t('components.Sidebar.label.administration')
|
|
||||||
}))
|
|
||||||
|
|
||||||
type SidebarMenuTabs = 'explore' | 'myLibrary'
|
|
||||||
const expanded = ref<SidebarMenuTabs>('explore')
|
|
||||||
|
|
||||||
const ROUTE_MAPPINGS: Record<SidebarMenuTabs, RouteRecordName[]> = {
|
|
||||||
explore: [
|
|
||||||
'search',
|
|
||||||
'library.index',
|
|
||||||
'library.podcasts.browse',
|
|
||||||
'library.albums.browse',
|
|
||||||
'library.albums.detail',
|
|
||||||
'library.artists.browse',
|
|
||||||
'library.artists.detail',
|
|
||||||
'library.tracks.detail',
|
|
||||||
'library.playlists.browse',
|
|
||||||
'library.playlists.detail',
|
|
||||||
'library.radios.browse',
|
|
||||||
'library.radios.detail'
|
|
||||||
],
|
|
||||||
myLibrary: [
|
|
||||||
'library.me',
|
|
||||||
'library.albums.me',
|
|
||||||
'library.artists.me',
|
|
||||||
'library.playlists.me',
|
|
||||||
'library.radios.me',
|
|
||||||
'favorites'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
watchEffect(() => {
|
|
||||||
if (ROUTE_MAPPINGS.explore.includes(route.name as RouteRecordName)) {
|
|
||||||
expanded.value = 'explore'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ROUTE_MAPPINGS.myLibrary.includes(route.name as RouteRecordName)) {
|
|
||||||
expanded.value = 'myLibrary'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
expanded.value = store.state.auth.authenticated ? 'myLibrary' : 'explore'
|
|
||||||
})
|
|
||||||
|
|
||||||
const moderationNotifications = computed(() =>
|
|
||||||
store.state.ui.notifications.pendingReviewEdits
|
|
||||||
+ store.state.ui.notifications.pendingReviewReports
|
|
||||||
+ store.state.ui.notifications.pendingReviewRequests
|
|
||||||
)
|
|
||||||
|
|
||||||
const showLanguageModal = ref(false)
|
|
||||||
const locale = ref(i18nLocale.value)
|
|
||||||
watch(locale, (locale) => {
|
|
||||||
setI18nLanguage(locale)
|
|
||||||
})
|
|
||||||
|
|
||||||
const isProduction = import.meta.env.PROD
|
|
||||||
const isTauri = checkTauri()
|
|
||||||
|
|
||||||
const showUserModal = ref(false)
|
|
||||||
const showThemeModal = ref(false)
|
|
||||||
|
|
||||||
const el = useCurrentElement()
|
|
||||||
watchEffect(() => {
|
|
||||||
if (store.state.auth.authenticated) {
|
|
||||||
setupDropdown('.admin-dropdown', el.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
setupDropdown('.user-dropdown', el.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.getElementById('fake-sidebar')?.classList.add('loaded')
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<aside :class="['ui', 'vertical', 'left', 'visible', 'wide', {'collapsed': isCollapsed}, 'sidebar', 'component-sidebar']">
|
|
||||||
<header class="ui basic segment header-wrapper">
|
|
||||||
<router-link
|
|
||||||
:title="'Funkwhale'"
|
|
||||||
:to="{name: logoUrl}"
|
|
||||||
>
|
|
||||||
<i class="logo bordered inverted vibrant big icon">
|
|
||||||
<logo class="logo" />
|
|
||||||
<span class="visually-hidden">{{ $t('components.Sidebar.link.home') }}</span>
|
|
||||||
</i>
|
|
||||||
</router-link>
|
|
||||||
<nav class="top ui compact right aligned inverted text menu">
|
|
||||||
<div class="right menu">
|
|
||||||
<div
|
|
||||||
v-if="$store.state.auth.availablePermissions['settings'] || $store.state.auth.availablePermissions['moderation']"
|
|
||||||
class="item"
|
|
||||||
:title="labels.administration"
|
|
||||||
>
|
|
||||||
<div class="item ui inline admin-dropdown dropdown">
|
|
||||||
<i class="wrench icon" />
|
|
||||||
<div
|
|
||||||
v-if="moderationNotifications > 0"
|
|
||||||
:class="['ui', 'accent', 'mini', 'bottom floating', 'circular', 'label']"
|
|
||||||
>
|
|
||||||
{{ moderationNotifications }}
|
|
||||||
</div>
|
|
||||||
<div class="menu">
|
|
||||||
<h3 class="header">
|
|
||||||
{{ $t('components.Sidebar.header.administration') }}
|
|
||||||
</h3>
|
|
||||||
<div class="divider" />
|
|
||||||
<router-link
|
|
||||||
v-if="$store.state.auth.availablePermissions['library']"
|
|
||||||
class="item"
|
|
||||||
:to="{name: 'manage.library.edits', query: {q: 'is_approved:null'}}"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="$store.state.ui.notifications.pendingReviewEdits > 0"
|
|
||||||
:title="labels.pendingReviewEdits"
|
|
||||||
:class="['ui', 'circular', 'mini', 'right floated', 'accent', 'label']"
|
|
||||||
>
|
|
||||||
{{ $store.state.ui.notifications.pendingReviewEdits }}
|
|
||||||
</div>
|
|
||||||
{{ $t('components.Sidebar.link.library') }}
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
v-if="$store.state.auth.availablePermissions['moderation']"
|
|
||||||
class="item"
|
|
||||||
:to="{name: 'manage.moderation.reports.list', query: {q: 'resolved:no'}}"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="$store.state.ui.notifications.pendingReviewReports + $store.state.ui.notifications.pendingReviewRequests > 0"
|
|
||||||
:title="labels.pendingReviewReports"
|
|
||||||
:class="['ui', 'circular', 'mini', 'right floated', 'accent', 'label']"
|
|
||||||
>
|
|
||||||
{{ $store.state.ui.notifications.pendingReviewReports + $store.state.ui.notifications.pendingReviewRequests }}
|
|
||||||
</div>
|
|
||||||
{{ $t('components.Sidebar.link.moderation') }}
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
v-if="$store.state.auth.availablePermissions['settings']"
|
|
||||||
class="item"
|
|
||||||
:to="{name: 'manage.users.users.list'}"
|
|
||||||
>
|
|
||||||
{{ $t('components.Sidebar.link.users') }}
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
v-if="$store.state.auth.availablePermissions['settings']"
|
|
||||||
class="item"
|
|
||||||
:to="{path: '/manage/settings'}"
|
|
||||||
>
|
|
||||||
{{ $t('components.Sidebar.link.settings') }}
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<router-link
|
|
||||||
v-if="$store.state.auth.authenticated"
|
|
||||||
class="item"
|
|
||||||
:to="{name: 'content.index'}"
|
|
||||||
>
|
|
||||||
<i class="upload icon" />
|
|
||||||
<span class="visually-hidden">{{ labels.addContent }}</span>
|
|
||||||
</router-link>
|
|
||||||
<template v-if="width > 768">
|
|
||||||
<div class="item">
|
|
||||||
<div class="ui user-dropdown dropdown">
|
|
||||||
<img
|
|
||||||
v-if="$store.state.auth.authenticated && $store.state.auth.profile?.avatar && $store.state.auth.profile?.avatar.urls.medium_square_crop"
|
|
||||||
class="ui avatar image"
|
|
||||||
alt=""
|
|
||||||
:src="$store.getters['instance/absoluteUrl']($store.state.auth.profile?.avatar.urls.medium_square_crop)"
|
|
||||||
>
|
|
||||||
<actor-avatar
|
|
||||||
v-else-if="$store.state.auth.authenticated"
|
|
||||||
:actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username,}"
|
|
||||||
/>
|
|
||||||
<i
|
|
||||||
v-else
|
|
||||||
class="cog icon"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="$store.state.ui.notifications.inbox + additionalNotifications > 0"
|
|
||||||
:class="['ui', 'accent', 'mini', 'bottom floating', 'circular', 'label']"
|
|
||||||
>
|
|
||||||
{{ $store.state.ui.notifications.inbox + additionalNotifications }}
|
|
||||||
</div>
|
|
||||||
<user-menu
|
|
||||||
v-bind="$attrs"
|
|
||||||
:width="width"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<a
|
|
||||||
href=""
|
|
||||||
class="item"
|
|
||||||
@click.prevent.exact="showUserModal = !showUserModal"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
v-if="$store.state.auth.authenticated && $store.state.auth.profile?.avatar?.urls.medium_square_crop"
|
|
||||||
class="ui avatar image"
|
|
||||||
alt=""
|
|
||||||
:src="$store.getters['instance/absoluteUrl']($store.state.auth.profile?.avatar.urls.medium_square_crop)"
|
|
||||||
>
|
|
||||||
<actor-avatar
|
|
||||||
v-else-if="$store.state.auth.authenticated"
|
|
||||||
:actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username,}"
|
|
||||||
/>
|
|
||||||
<i
|
|
||||||
v-else
|
|
||||||
class="cog icon"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="$store.state.ui.notifications.inbox + additionalNotifications > 0"
|
|
||||||
:class="['ui', 'accent', 'mini', 'bottom floating', 'circular', 'label']"
|
|
||||||
>
|
|
||||||
{{ $store.state.ui.notifications.inbox + additionalNotifications }}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
<user-modal
|
|
||||||
v-model:show="showUserModal"
|
|
||||||
@show-theme-modal-event="showThemeModal=true"
|
|
||||||
@show-language-modal-event="showLanguageModal=true"
|
|
||||||
/>
|
|
||||||
<semantic-modal
|
|
||||||
ref="languageModal"
|
|
||||||
v-model:show="showLanguageModal"
|
|
||||||
:fullscreen="false"
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
role="button"
|
|
||||||
class="left chevron back inside icon"
|
|
||||||
@click.prevent.exact="showUserModal = !showUserModal"
|
|
||||||
/>
|
|
||||||
<div class="header">
|
|
||||||
<h3 class="title">
|
|
||||||
{{ labels.language }}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<fieldset
|
|
||||||
v-for="(language, key) in SUPPORTED_LOCALES"
|
|
||||||
:key="key"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
:id="`${key}`"
|
|
||||||
v-model="locale"
|
|
||||||
type="radio"
|
|
||||||
name="language"
|
|
||||||
:value="key"
|
|
||||||
>
|
|
||||||
<label :for="`${key}`">{{ language }}</label>
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
</semantic-modal>
|
|
||||||
<semantic-modal
|
|
||||||
ref="themeModal"
|
|
||||||
v-model:show="showThemeModal"
|
|
||||||
:fullscreen="false"
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
role="button"
|
|
||||||
class="left chevron back inside icon"
|
|
||||||
@click.prevent.exact="showUserModal = !showUserModal"
|
|
||||||
/>
|
|
||||||
<div class="header">
|
|
||||||
<h3 class="title">
|
|
||||||
{{ labels.theme }}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<fieldset
|
|
||||||
v-for="th in themes"
|
|
||||||
:key="th.key"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
:id="th.key"
|
|
||||||
v-model="theme"
|
|
||||||
type="radio"
|
|
||||||
name="theme"
|
|
||||||
:value="th.key"
|
|
||||||
>
|
|
||||||
<label :for="th.key">{{ th.name }}</label>
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
</semantic-modal>
|
|
||||||
<div class="item collapse-button-wrapper">
|
|
||||||
<button
|
|
||||||
:class="['ui', 'basic', 'big', {'vibrant': !isCollapsed}, 'inverted icon', 'collapse', 'button']"
|
|
||||||
@click="isCollapsed = !isCollapsed"
|
|
||||||
>
|
|
||||||
<i class="sidebar icon" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<div class="ui basic search-wrapper segment">
|
|
||||||
<search-bar @search="isCollapsed = false" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="!$store.state.auth.authenticated"
|
|
||||||
class="ui basic signup segment"
|
|
||||||
>
|
|
||||||
<router-link
|
|
||||||
class="ui fluid tiny primary button"
|
|
||||||
:to="{name: 'login'}"
|
|
||||||
>
|
|
||||||
{{ $t('components.Sidebar.link.login') }}
|
|
||||||
</router-link>
|
|
||||||
<div class="ui small hidden divider" />
|
|
||||||
<router-link
|
|
||||||
class="ui fluid tiny button"
|
|
||||||
:to="{path: '/signup'}"
|
|
||||||
>
|
|
||||||
{{ $t('components.Sidebar.link.createAccount') }}
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
<nav
|
|
||||||
class="secondary"
|
|
||||||
role="navigation"
|
|
||||||
aria-labelledby="navigation-label"
|
|
||||||
>
|
|
||||||
<h1
|
|
||||||
id="navigation-label"
|
|
||||||
class="visually-hidden"
|
|
||||||
>
|
|
||||||
{{ $t('components.Sidebar.header.main') }}
|
|
||||||
</h1>
|
|
||||||
<div class="ui small hidden divider" />
|
|
||||||
<section
|
|
||||||
:aria-label="labels.mainMenu"
|
|
||||||
class="ui bottom attached active tab"
|
|
||||||
>
|
|
||||||
<nav
|
|
||||||
class="ui vertical large fluid inverted menu"
|
|
||||||
role="navigation"
|
|
||||||
:aria-label="labels.mainMenu"
|
|
||||||
>
|
|
||||||
<div :class="[{ collapsed: expanded !== 'explore' }, 'collapsible item']">
|
|
||||||
<h2
|
|
||||||
class="header"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
@click="expanded = 'explore'"
|
|
||||||
@focus="expanded = 'explore'"
|
|
||||||
>
|
|
||||||
{{ $t('components.Sidebar.header.explore') }}
|
|
||||||
<i
|
|
||||||
v-if="expanded !== 'explore'"
|
|
||||||
class="angle right icon"
|
|
||||||
/>
|
|
||||||
</h2>
|
|
||||||
<div class="menu">
|
|
||||||
<router-link
|
|
||||||
class="item"
|
|
||||||
:to="{name: 'search'}"
|
|
||||||
>
|
|
||||||
<i class="search icon" />
|
|
||||||
{{ $t('components.Sidebar.link.search') }}
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
class="item"
|
|
||||||
:to="{name: 'library.index'}"
|
|
||||||
active-class="_active"
|
|
||||||
>
|
|
||||||
<i class="music icon" />
|
|
||||||
{{ $t('components.Sidebar.link.browse') }}
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
class="item"
|
|
||||||
:to="{name: 'library.podcasts.browse'}"
|
|
||||||
>
|
|
||||||
<i class="podcast icon" />
|
|
||||||
{{ $t('components.Sidebar.link.podcasts') }}
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
class="item"
|
|
||||||
:to="{name: 'library.albums.browse'}"
|
|
||||||
>
|
|
||||||
<i class="compact disc icon" />
|
|
||||||
{{ $t('components.Sidebar.link.albums') }}
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
class="item"
|
|
||||||
:to="{name: 'library.artists.browse'}"
|
|
||||||
>
|
|
||||||
<i class="user icon" />
|
|
||||||
{{ $t('components.Sidebar.link.artists') }}
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
class="item"
|
|
||||||
:to="{name: 'library.playlists.browse'}"
|
|
||||||
>
|
|
||||||
<i class="list icon" />
|
|
||||||
{{ $t('components.Sidebar.link.playlists') }}
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
class="item"
|
|
||||||
:to="{name: 'library.radios.browse'}"
|
|
||||||
>
|
|
||||||
<i class="feed icon" />
|
|
||||||
{{ $t('components.Sidebar.link.radios') }}
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="$store.state.auth.authenticated"
|
|
||||||
:class="[{ collapsed: expanded !== 'myLibrary' }, 'collapsible item']"
|
|
||||||
>
|
|
||||||
<h3
|
|
||||||
class="header"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
@click="expanded = 'myLibrary'"
|
|
||||||
@focus="expanded = 'myLibrary'"
|
|
||||||
>
|
|
||||||
{{ $t('components.Sidebar.header.library') }}
|
|
||||||
<i
|
|
||||||
v-if="expanded !== 'myLibrary'"
|
|
||||||
class="angle right icon"
|
|
||||||
/>
|
|
||||||
</h3>
|
|
||||||
<div class="menu">
|
|
||||||
<router-link
|
|
||||||
class="item"
|
|
||||||
:to="{name: 'library.me'}"
|
|
||||||
>
|
|
||||||
<i class="music icon" />
|
|
||||||
{{ $t('components.Sidebar.link.browse') }}
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
class="item"
|
|
||||||
:to="{name: 'library.albums.me'}"
|
|
||||||
>
|
|
||||||
<i class="compact disc icon" />
|
|
||||||
{{ $t('components.Sidebar.link.albums') }}
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
class="item"
|
|
||||||
:to="{name: 'library.artists.me'}"
|
|
||||||
>
|
|
||||||
<i class="user icon" />
|
|
||||||
{{ $t('components.Sidebar.link.artists') }}
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
class="item"
|
|
||||||
:to="{name: 'library.playlists.me'}"
|
|
||||||
>
|
|
||||||
<i class="list icon" />
|
|
||||||
{{ $t('components.Sidebar.link.playlists') }}
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
class="item"
|
|
||||||
:to="{name: 'library.radios.me'}"
|
|
||||||
>
|
|
||||||
<i class="feed icon" />
|
|
||||||
{{ $t('components.Sidebar.link.radios') }}
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
class="item"
|
|
||||||
:to="{name: 'favorites'}"
|
|
||||||
>
|
|
||||||
<i class="heart icon" />
|
|
||||||
{{ $t('components.Sidebar.link.favorites') }}
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<router-link
|
|
||||||
v-if="$store.state.auth.authenticated"
|
|
||||||
class="header item"
|
|
||||||
:to="{name: 'subscriptions'}"
|
|
||||||
>
|
|
||||||
{{ $t('components.Sidebar.link.channels') }}
|
|
||||||
</router-link>
|
|
||||||
<div class="item">
|
|
||||||
<h3 class="header">
|
|
||||||
{{ $t('components.Sidebar.header.more') }}
|
|
||||||
</h3>
|
|
||||||
<div class="menu">
|
|
||||||
<router-link
|
|
||||||
class="item"
|
|
||||||
to="/about"
|
|
||||||
active-class="router-link-exact-active active"
|
|
||||||
>
|
|
||||||
<i class="info icon" />
|
|
||||||
{{ $t('components.Sidebar.link.about') }}
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="!isProduction || isTauri"
|
|
||||||
class="item"
|
|
||||||
>
|
|
||||||
<router-link
|
|
||||||
to="/instance-chooser"
|
|
||||||
class="link item"
|
|
||||||
>
|
|
||||||
{{ $t('components.Sidebar.link.switchInstance') }}
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</section>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
[type="radio"] {
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
height: 0;
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
[type="radio"] + label::after {
|
|
||||||
content: "";
|
|
||||||
font-size: 1.4em;
|
|
||||||
}
|
|
||||||
[type="radio"]:checked + label::after {
|
|
||||||
margin-left: 10px;
|
|
||||||
content: "\2713"; /* Checkmark */
|
|
||||||
font-size: 1.4em;
|
|
||||||
}
|
|
||||||
[type="radio"]:checked + label {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
fieldset {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
.back {
|
|
||||||
font-size: 1.25em !important;
|
|
||||||
position: absolute;
|
|
||||||
top: 0.5rem;
|
|
||||||
left: 0.5rem;
|
|
||||||
width: 2.25rem !important;
|
|
||||||
height: 2.25rem !important;
|
|
||||||
padding: 0.625rem 0 0 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -6,6 +6,17 @@ import useFormData from '~/composables/useFormData'
|
||||||
import { ref, computed, reactive } from 'vue'
|
import { ref, computed, reactive } from 'vue'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
import useLogger from '~/composables/useLogger'
|
import useLogger from '~/composables/useLogger'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import Section from '~/components/ui/Section.vue'
|
||||||
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
|
import Toggle from '~/components/ui/Toggle.vue'
|
||||||
|
import Input from '~/components/ui/Input.vue'
|
||||||
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
group: SettingsGroup
|
group: SettingsGroup
|
||||||
|
@ -96,42 +107,24 @@ const save = async () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- TODO: type the different values in `settings` (use generics) -->
|
||||||
|
<!-- eslint-disable vue/valid-v-model -->
|
||||||
|
<Section
|
||||||
|
align-left
|
||||||
|
:h2="group.label"
|
||||||
|
large-section-heading
|
||||||
|
>
|
||||||
<form
|
<form
|
||||||
:id="group.id"
|
:id="group.id"
|
||||||
class="ui form component-settings-group"
|
class="ui form component-settings-group"
|
||||||
|
style="grid-column: 1 / -1;"
|
||||||
@submit.prevent="save"
|
@submit.prevent="save"
|
||||||
>
|
>
|
||||||
<div class="ui divider" />
|
<Spacer :size="16" />
|
||||||
<h3 class="ui header">
|
|
||||||
{{ group.label }}
|
|
||||||
</h3>
|
|
||||||
<div
|
|
||||||
v-if="errors.length > 0"
|
|
||||||
role="alert"
|
|
||||||
class="ui negative message"
|
|
||||||
>
|
|
||||||
<h4 class="header">
|
|
||||||
{{ $t('components.admin.SettingsGroup.header.error') }}
|
|
||||||
</h4>
|
|
||||||
<ul class="list">
|
|
||||||
<li
|
|
||||||
v-for="(error, key) in errors"
|
|
||||||
:key="key"
|
|
||||||
>
|
|
||||||
{{ error }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="result"
|
|
||||||
class="ui positive message"
|
|
||||||
>
|
|
||||||
{{ $t('components.admin.SettingsGroup.message.success') }}
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-for="(setting, key) in settings"
|
v-for="(setting, key) in settings"
|
||||||
:key="key"
|
:key="key"
|
||||||
class="ui field"
|
:class="[$style.field, 'ui', 'field']"
|
||||||
>
|
>
|
||||||
<template v-if="setting.field.widget.class !== 'CheckboxInput'">
|
<template v-if="setting.field.widget.class !== 'CheckboxInput'">
|
||||||
<label :for="setting.identifier">{{ setting.verbose_name }}</label>
|
<label :for="setting.identifier">{{ setting.verbose_name }}</label>
|
||||||
|
@ -144,60 +137,46 @@ const save = async () => {
|
||||||
v-bind="setting.fieldParams"
|
v-bind="setting.fieldParams"
|
||||||
v-model="values[setting.identifier]"
|
v-model="values[setting.identifier]"
|
||||||
/>
|
/>
|
||||||
<!-- eslint-disable vue/valid-v-model -->
|
|
||||||
<signup-form-builder
|
<signup-form-builder
|
||||||
v-else-if="setting.fieldType === 'formBuilder'"
|
v-else-if="setting.fieldType === 'formBuilder'"
|
||||||
v-model="values[setting.identifier] as Form"
|
v-model="values[setting.identifier] as Form"
|
||||||
:signup-approval-enabled="!!values.moderation__signup_approval_enabled"
|
:signup-approval-enabled="!!values.moderation__signup_approval_enabled"
|
||||||
/>
|
/>
|
||||||
<!-- eslint-enable vue/valid-v-model -->
|
<Input
|
||||||
<input
|
|
||||||
v-else-if="setting.field.widget.class === 'PasswordInput'"
|
v-else-if="setting.field.widget.class === 'PasswordInput'"
|
||||||
:id="setting.identifier"
|
v-model="values[setting.identifier] as string"
|
||||||
v-model="values[setting.identifier]"
|
password
|
||||||
:name="setting.identifier"
|
|
||||||
type="password"
|
type="password"
|
||||||
class="ui input"
|
class="ui input"
|
||||||
>
|
/>
|
||||||
<input
|
<Input
|
||||||
v-else-if="setting.field.widget.class === 'TextInput'"
|
v-else-if="setting.field.widget.class === 'TextInput'"
|
||||||
:id="setting.identifier"
|
v-model="values[setting.identifier] as string"
|
||||||
v-model="values[setting.identifier]"
|
|
||||||
:name="setting.identifier"
|
|
||||||
type="text"
|
type="text"
|
||||||
class="ui input"
|
class="ui input"
|
||||||
>
|
/>
|
||||||
<input
|
<Input
|
||||||
v-else-if="setting.field.class === 'IntegerField'"
|
v-else-if="setting.field.class === 'IntegerField'"
|
||||||
:id="setting.identifier"
|
v-model.number="values[setting.identifier] as number"
|
||||||
v-model.number="values[setting.identifier]"
|
|
||||||
:name="setting.identifier"
|
|
||||||
type="number"
|
type="number"
|
||||||
class="ui input"
|
class="ui input"
|
||||||
>
|
/>
|
||||||
<!-- eslint-disable vue/valid-v-model -->
|
|
||||||
<textarea
|
<textarea
|
||||||
v-else-if="setting.field.widget.class === 'Textarea'"
|
v-else-if="setting.field.widget.class === 'Textarea'"
|
||||||
:id="setting.identifier"
|
|
||||||
v-model="values[setting.identifier] as string"
|
v-model="values[setting.identifier] as string"
|
||||||
:name="setting.identifier"
|
|
||||||
type="text"
|
type="text"
|
||||||
class="ui input"
|
class="ui input"
|
||||||
/>
|
/>
|
||||||
<!-- eslint-enable vue/valid-v-model -->
|
<!-- eslint-enable vue/valid-v-model -->
|
||||||
<div
|
<div
|
||||||
v-else-if="setting.field.widget.class === 'CheckboxInput'"
|
v-else-if="setting.field.widget.class === 'CheckboxInput'"
|
||||||
class="ui toggle checkbox"
|
|
||||||
>
|
>
|
||||||
<!-- eslint-disable vue/valid-v-model -->
|
<Toggle
|
||||||
<input
|
|
||||||
:id="setting.identifier"
|
|
||||||
v-model="values[setting.identifier] as boolean"
|
v-model="values[setting.identifier] as boolean"
|
||||||
:name="setting.identifier"
|
big
|
||||||
type="checkbox"
|
:label="setting.verbose_name"
|
||||||
>
|
/>
|
||||||
<!-- eslint-enable vue/valid-v-model -->
|
<Spacer :size="8" />
|
||||||
<label :for="setting.identifier">{{ setting.verbose_name }}</label>
|
|
||||||
<p v-if="setting.help_text">
|
<p v-if="setting.help_text">
|
||||||
{{ setting.help_text }}
|
{{ setting.help_text }}
|
||||||
</p>
|
</p>
|
||||||
|
@ -208,6 +187,7 @@ const save = async () => {
|
||||||
v-model="values[setting.identifier]"
|
v-model="values[setting.identifier]"
|
||||||
multiple
|
multiple
|
||||||
class="ui search selection dropdown"
|
class="ui search selection dropdown"
|
||||||
|
style="height: 150px;"
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
v-for="v in setting.additional_data?.choices"
|
v-for="v in setting.additional_data?.choices"
|
||||||
|
@ -232,30 +212,75 @@ const save = async () => {
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<div v-else-if="setting.field.widget.class === 'ImageWidget'">
|
<div v-else-if="setting.field.widget.class === 'ImageWidget'">
|
||||||
<input
|
<!-- TODO: Implement image input -->
|
||||||
|
|
||||||
|
<!-- @vue-ignore -->
|
||||||
|
<Input
|
||||||
:id="setting.identifier"
|
:id="setting.identifier"
|
||||||
:ref="setFileRef(setting.identifier)"
|
:ref="setFileRef(setting.identifier)"
|
||||||
type="file"
|
type="file"
|
||||||
>
|
/>
|
||||||
|
|
||||||
<div v-if="values[setting.identifier]">
|
<div v-if="values[setting.identifier]">
|
||||||
<div class="ui hidden divider" />
|
|
||||||
<h3 class="ui header">
|
<h3 class="ui header">
|
||||||
{{ $t('components.admin.SettingsGroup.header.image') }}
|
{{ t('components.admin.SettingsGroup.header.image') }}
|
||||||
</h3>
|
</h3>
|
||||||
<img
|
<img
|
||||||
v-if="values[setting.identifier]"
|
v-if="values[setting.identifier]"
|
||||||
class="ui image"
|
class="ui image"
|
||||||
alt=""
|
alt=""
|
||||||
:src="$store.getters['instance/absoluteUrl'](values[setting.identifier])"
|
:src="store.getters['instance/absoluteUrl'](values[setting.identifier])"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Spacer />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Layout flex>
|
||||||
|
<Spacer grow />
|
||||||
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'success', 'button']"
|
:class="[{'loading': isLoading}]"
|
||||||
|
primary
|
||||||
>
|
>
|
||||||
{{ $t('components.admin.SettingsGroup.button.save') }}
|
{{ t('components.admin.SettingsGroup.button.save') }}
|
||||||
</button>
|
</Button>
|
||||||
|
</Layout>
|
||||||
|
<Spacer />
|
||||||
|
<Alert
|
||||||
|
v-if="errors.length > 0"
|
||||||
|
red
|
||||||
|
>
|
||||||
|
<h4 class="header">
|
||||||
|
{{ t('components.admin.SettingsGroup.header.error', {label: group.label}) }}
|
||||||
|
</h4>
|
||||||
|
<ul class="list">
|
||||||
|
<li
|
||||||
|
v-for="(error, key) in errors"
|
||||||
|
:key="key"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Alert>
|
||||||
|
<Alert
|
||||||
|
v-if="result"
|
||||||
|
green
|
||||||
|
>
|
||||||
|
{{ t('components.admin.SettingsGroup.message.success') }}
|
||||||
|
</Alert>
|
||||||
</form>
|
</form>
|
||||||
|
</Section>
|
||||||
|
<hr :class="$style.separator">
|
||||||
|
<Spacer size-64 />
|
||||||
|
<!-- eslint-enable vue/valid-v-model -->
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style module>
|
||||||
|
.field > div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.separator:last-of-type {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
import type { Form } from '~/types'
|
import type { Form } from '~/types'
|
||||||
|
|
||||||
import SignupForm from '~/components/auth/SignupForm.vue'
|
import SignupForm from '~/components/auth/SignupForm.vue'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
|
||||||
import { useVModel } from '@vueuse/core'
|
import { useVModel } from '@vueuse/core'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
@ -65,18 +67,20 @@ const move = (idx: number, increment: number) => {
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="ui top attached tabular menu">
|
<div class="ui top attached tabular menu">
|
||||||
<button
|
<Button
|
||||||
|
color="primary"
|
||||||
:class="[{active: !isPreviewing}, 'item']"
|
:class="[{active: !isPreviewing}, 'item']"
|
||||||
@click.stop.prevent="isPreviewing = false"
|
@click.stop.prevent="isPreviewing = false"
|
||||||
>
|
>
|
||||||
{{ $t('components.admin.SignupFormBuilder.button.edit') }}
|
{{ t('components.admin.SignupFormBuilder.button.edit') }}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
color="primary"
|
||||||
:class="[{active: isPreviewing}, 'item']"
|
:class="[{active: isPreviewing}, 'item']"
|
||||||
@click.stop.prevent="isPreviewing = true"
|
@click.stop.prevent="isPreviewing = true"
|
||||||
>
|
>
|
||||||
{{ $t('components.admin.SignupFormBuilder.button.preview') }}
|
{{ t('components.admin.SignupFormBuilder.button.preview') }}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="isPreviewing"
|
v-if="isPreviewing"
|
||||||
|
@ -95,10 +99,10 @@ const move = (idx: number, increment: number) => {
|
||||||
>
|
>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="help-text">
|
<label for="help-text">
|
||||||
{{ $t('components.admin.SignupFormBuilder.label.helpText') }}
|
{{ t('components.admin.SignupFormBuilder.label.helpText') }}
|
||||||
</label>
|
</label>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.admin.SignupFormBuilder.help.helpText') }}
|
{{ t('components.admin.SignupFormBuilder.help.helpText') }}
|
||||||
</p>
|
</p>
|
||||||
<content-form
|
<content-form
|
||||||
v-if="value.help_text"
|
v-if="value.help_text"
|
||||||
|
@ -109,24 +113,24 @@ const move = (idx: number, increment: number) => {
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>
|
<label>
|
||||||
{{ $t('components.admin.SignupFormBuilder.label.additionalFields') }}
|
{{ t('components.admin.SignupFormBuilder.label.additionalFields') }}
|
||||||
</label>
|
</label>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.admin.SignupFormBuilder.help.additionalFields') }}
|
{{ t('components.admin.SignupFormBuilder.help.additionalFields') }}
|
||||||
</p>
|
</p>
|
||||||
<table v-if="value.fields?.length > 0">
|
<table v-if="value.fields?.length > 0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th>
|
||||||
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.header.label') }}
|
{{ t('components.admin.SignupFormBuilder.table.additionalFields.header.label') }}
|
||||||
</th>
|
</th>
|
||||||
<th>
|
<th>
|
||||||
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.header.type') }}
|
{{ t('components.admin.SignupFormBuilder.table.additionalFields.header.type') }}
|
||||||
</th>
|
</th>
|
||||||
<th>
|
<th>
|
||||||
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.header.required') }}
|
{{ t('components.admin.SignupFormBuilder.table.additionalFields.header.required') }}
|
||||||
</th>
|
</th>
|
||||||
<th><span class="visually-hidden">{{ $t('components.admin.SignupFormBuilder.table.additionalFields.header.actions') }}</span></th>
|
<th><span class="visually-hidden">{{ t('components.admin.SignupFormBuilder.table.additionalFields.header.actions') }}</span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
@ -144,20 +148,20 @@ const move = (idx: number, increment: number) => {
|
||||||
<td>
|
<td>
|
||||||
<select v-model="field.input_type">
|
<select v-model="field.input_type">
|
||||||
<option value="short_text">
|
<option value="short_text">
|
||||||
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.type.short') }}
|
{{ t('components.admin.SignupFormBuilder.table.additionalFields.type.short') }}
|
||||||
</option>
|
</option>
|
||||||
<option value="long_text">
|
<option value="long_text">
|
||||||
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.type.long') }}
|
{{ t('components.admin.SignupFormBuilder.table.additionalFields.type.long') }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<select v-model="field.required">
|
<select v-model="field.required">
|
||||||
<option :value="true">
|
<option :value="true">
|
||||||
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.required.true') }}
|
{{ t('components.admin.SignupFormBuilder.table.additionalFields.required.true') }}
|
||||||
</option>
|
</option>
|
||||||
<option :value="false">
|
<option :value="false">
|
||||||
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.required.false') }}
|
{{ t('components.admin.SignupFormBuilder.table.additionalFields.required.false') }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
|
@ -187,13 +191,13 @@ const move = (idx: number, increment: number) => {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="ui hidden divider" />
|
<div class="ui hidden divider" />
|
||||||
<button
|
<Button
|
||||||
v-if="value.fields?.length < maxFields"
|
v-if="value.fields?.length < maxFields"
|
||||||
class="ui basic button"
|
color="primary"
|
||||||
@click.stop.prevent="addField"
|
@click.stop.prevent="addField"
|
||||||
>
|
>
|
||||||
{{ $t('components.admin.SignupFormBuilder.button.add') }}
|
{{ t('components.admin.SignupFormBuilder.button.add') }}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui hidden divider" />
|
<div class="ui hidden divider" />
|
||||||
|
|
|
@ -0,0 +1,96 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { momentFormat } from '~/utils/filters'
|
||||||
|
import defaultCover from '~/assets/audio/default-cover.png'
|
||||||
|
|
||||||
|
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||||
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
|
import Card from '~/components/ui/Card.vue'
|
||||||
|
import Link from '~/components/ui/Link.vue'
|
||||||
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
|
|
||||||
|
import { type Album } from '~/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
album: Album;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const { album } = props
|
||||||
|
|
||||||
|
const artistCredit = album.artist_credit || []
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
const imageUrl = computed(() => props.album.cover?.urls.original
|
||||||
|
? store.getters['instance/absoluteUrl'](props.album.cover?.urls.medium_square_crop)
|
||||||
|
: defaultCover
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card
|
||||||
|
:title="album.title"
|
||||||
|
:image="imageUrl"
|
||||||
|
:tags="album.tags"
|
||||||
|
:to="{name: 'library.albums.detail', params: {id: album.id}}"
|
||||||
|
small
|
||||||
|
>
|
||||||
|
<template #topright>
|
||||||
|
<PlayButton
|
||||||
|
icon-only
|
||||||
|
:is-playable="album.is_playable"
|
||||||
|
:album="album"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<Layout
|
||||||
|
flex
|
||||||
|
gap-4
|
||||||
|
style="overflow: hidden;"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-for="ac in artistCredit"
|
||||||
|
:key="ac.artist.id"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
align-text="start"
|
||||||
|
:to="{ name: 'library.artists.detail', params: { id: ac.artist.id }}"
|
||||||
|
>
|
||||||
|
{{ ac.credit ?? t('components.Queue.meta.unknownArtist') }}
|
||||||
|
</Link>
|
||||||
|
<span style="font-weight: 600;">{{ ac.joinphrase }}</span>
|
||||||
|
</template>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<span v-if="album.release_date">
|
||||||
|
{{ momentFormat(new Date(album.release_date), 'Y') }}
|
||||||
|
</span>
|
||||||
|
<i class="bi bi-dot" />
|
||||||
|
<span>
|
||||||
|
{{ t('components.audio.album.Card.meta.tracks', album.tracks_count) }}
|
||||||
|
</span>
|
||||||
|
<Spacer
|
||||||
|
h
|
||||||
|
grow
|
||||||
|
/>
|
||||||
|
<PlayButton
|
||||||
|
:dropdown-only="true"
|
||||||
|
discrete
|
||||||
|
:is-playable="album.is_playable"
|
||||||
|
:album="album"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.play-button {
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -6,21 +6,27 @@ import { useStore } from '~/store'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
import AlbumCard from '~/components/audio/album/Card.vue'
|
import usePage from '~/composables/navigation/usePage'
|
||||||
|
|
||||||
import useErrorHandler from '~/composables/useErrorHandler'
|
import useErrorHandler from '~/composables/useErrorHandler'
|
||||||
|
|
||||||
|
import AlbumCard from '~/components/album/Card.vue'
|
||||||
|
import Section from '~/components/ui/Section.vue'
|
||||||
|
import Loader from '~/components/ui/Loader.vue'
|
||||||
|
import Pagination from '~/components/ui/Pagination.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
filters: Record<string, string | boolean>
|
filters: Record<string, string | boolean>
|
||||||
showCount?: boolean
|
showCount?: boolean
|
||||||
search?: boolean
|
search?: boolean
|
||||||
limit?: number
|
limit?: number
|
||||||
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
showCount: false,
|
showCount: false,
|
||||||
search: false,
|
search: false,
|
||||||
limit: 12
|
limit: 12,
|
||||||
|
title: undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
@ -28,6 +34,7 @@ const store = useStore()
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const albums = reactive([] as Album[])
|
const albums = reactive([] as Album[])
|
||||||
const count = ref(0)
|
const count = ref(0)
|
||||||
|
const page = usePage()
|
||||||
const nextPage = ref()
|
const nextPage = ref()
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
@ -38,13 +45,14 @@ const fetchData = async (url = 'albums/') => {
|
||||||
const params = {
|
const params = {
|
||||||
q: query.value,
|
q: query.value,
|
||||||
...props.filters,
|
...props.filters,
|
||||||
|
page: page.value,
|
||||||
page_size: props.limit
|
page_size: props.limit
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.get(url, { params })
|
const response = await axios.get(url, { params })
|
||||||
nextPage.value = response.data.next
|
nextPage.value = response.data.next
|
||||||
count.value = response.data.count
|
count.value = response.data.count
|
||||||
albums.push(...response.data.results)
|
albums.splice(0, albums.length, ...response.data.results)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
useErrorHandler(error as Error)
|
useErrorHandler(error as Error)
|
||||||
}
|
}
|
||||||
|
@ -52,68 +60,58 @@ const fetchData = async (url = 'albums/') => {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTimeout(fetchData, 1000)
|
||||||
|
|
||||||
const performSearch = () => {
|
const performSearch = () => {
|
||||||
albums.length = 0
|
albums.length = 0
|
||||||
fetchData()
|
fetchData()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => store.state.moderation.lastUpdate,
|
[() => store.state.moderation.lastUpdate, page],
|
||||||
() => fetchData(),
|
() => fetchData(),
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="wrapper">
|
<Section
|
||||||
<h3
|
align-left
|
||||||
v-if="!!$slots.title"
|
:h2="title"
|
||||||
class="ui header"
|
:columns-per-item="1"
|
||||||
>
|
>
|
||||||
<slot name="title" />
|
|
||||||
<span
|
|
||||||
v-if="showCount"
|
|
||||||
class="ui tiny circular label"
|
|
||||||
>{{ count }}</span>
|
|
||||||
</h3>
|
|
||||||
<slot />
|
|
||||||
<inline-search-bar
|
<inline-search-bar
|
||||||
v-if="search"
|
v-if="search"
|
||||||
v-model="query"
|
v-model="query"
|
||||||
|
style="grid-column: 1 / -1;"
|
||||||
@search="performSearch"
|
@search="performSearch"
|
||||||
/>
|
/>
|
||||||
<div class="ui hidden divider" />
|
<Loader
|
||||||
<div class="ui app-cards cards">
|
|
||||||
<div
|
|
||||||
v-if="isLoading"
|
v-if="isLoading"
|
||||||
class="ui inverted active dimmer"
|
style="grid-column: 1 / -1;"
|
||||||
>
|
/>
|
||||||
<div class="ui loader" />
|
<template v-if="!isLoading && albums.length > 0">
|
||||||
</div>
|
|
||||||
<album-card
|
<album-card
|
||||||
v-for="album in albums"
|
v-for="album in albums"
|
||||||
:key="album.id"
|
:key="album.id"
|
||||||
:album="album"
|
:album="album"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
<slot
|
<slot
|
||||||
v-if="!isLoading && albums.length === 0"
|
v-if="!isLoading && albums.length === 0"
|
||||||
name="empty-state"
|
name="empty-state"
|
||||||
>
|
>
|
||||||
<empty-state
|
<empty-state
|
||||||
:refresh="true"
|
:refresh="true"
|
||||||
|
style="grid-column: 1 / -1;"
|
||||||
@refresh="fetchData"
|
@refresh="fetchData"
|
||||||
/>
|
/>
|
||||||
</slot>
|
</slot>
|
||||||
<template v-if="nextPage">
|
<Pagination
|
||||||
<div class="ui hidden divider" />
|
v-if="page && albums && count > props.limit"
|
||||||
<button
|
v-model:page="page"
|
||||||
v-if="nextPage"
|
:pages="Math.ceil((count || 0) / props.limit)"
|
||||||
:class="['ui', 'basic', 'button']"
|
style="grid-column: 1 / -1;"
|
||||||
@click="fetchData(nextPage)"
|
/>
|
||||||
>
|
</Section>
|
||||||
{{ $t('components.audio.album.Widget.button.more') }}
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
|
@ -0,0 +1,103 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import type { components } from '~/generated/types.ts'
|
||||||
|
|
||||||
|
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||||
|
import Card from '~/components/ui/Card.vue'
|
||||||
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
|
|
||||||
|
import type { Artist, Album } from '~/types'
|
||||||
|
|
||||||
|
const albums = ref([] as Album[])
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
artist: Artist | components['schemas']['ArtistWithAlbums'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const { artist } = props
|
||||||
|
|
||||||
|
if ('albums' in artist && Array.isArray(artist.albums)) {
|
||||||
|
albums.value = artist.albums
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card
|
||||||
|
:title="artist.name"
|
||||||
|
class="artist-card"
|
||||||
|
:tags="artist.tags"
|
||||||
|
:to="{name: 'library.artists.detail', params: {id: artist.id}}"
|
||||||
|
small
|
||||||
|
style="align-self: flex-start;"
|
||||||
|
>
|
||||||
|
<template #topright>
|
||||||
|
<PlayButton
|
||||||
|
icon-only
|
||||||
|
:is-playable="true"
|
||||||
|
:artist="artist"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #image>
|
||||||
|
<img
|
||||||
|
v-if="artist.cover"
|
||||||
|
v-lazy="artist.cover.urls.medium_square_crop"
|
||||||
|
:alt="artist.name"
|
||||||
|
:class="[artist.content_category === 'podcast' ? 'podcast-image' : 'channel-image']"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
v-else
|
||||||
|
class="bi bi-person-circle"
|
||||||
|
style="font-size: 167px; margin: 16px;"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<span v-if="artist.content_category === 'music' && 'tracks_count' in artist">
|
||||||
|
{{ t('components.audio.artist.Card.meta.tracks', artist.tracks_count) }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="'tracks_count' in artist">
|
||||||
|
{{ t('components.audio.artist.Card.meta.episodes', artist.tracks_count) }}
|
||||||
|
</span>
|
||||||
|
<i
|
||||||
|
v-if="albums"
|
||||||
|
class="bi bi-dot"
|
||||||
|
/>
|
||||||
|
<span v-if="albums">
|
||||||
|
{{ t('components.audio.artist.Card.meta.albums', albums.length) }}
|
||||||
|
</span>
|
||||||
|
<Spacer style="flex-grow: 1" />
|
||||||
|
<PlayButton
|
||||||
|
:dropdown-only="true"
|
||||||
|
:is-playable="Boolean(albums.find(album => album.is_playable))"
|
||||||
|
:artist="artist"
|
||||||
|
discrete
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.channel-image {
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 168px;
|
||||||
|
height: 168px;
|
||||||
|
margin: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podcast-image {
|
||||||
|
width: 168px;
|
||||||
|
height: 168px;
|
||||||
|
margin: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-button {
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,26 +1,32 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Artist } from '~/types'
|
import type { Artist } from '~/types'
|
||||||
|
|
||||||
import { reactive, ref, watch } from 'vue'
|
import { reactive, ref, watch, onMounted } from 'vue'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
import ArtistCard from '~/components/audio/artist/Card.vue'
|
|
||||||
|
|
||||||
import useErrorHandler from '~/composables/useErrorHandler'
|
import useErrorHandler from '~/composables/useErrorHandler'
|
||||||
|
import usePage from '~/composables/navigation/usePage'
|
||||||
|
|
||||||
|
import ArtistCard from '~/components/artist/Card.vue'
|
||||||
|
import Section from '~/components/ui/Section.vue'
|
||||||
|
import Pagination from '~/components/ui/Pagination.vue'
|
||||||
|
import Loader from '~/components/ui/Loader.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
filters: Record<string, string | boolean>
|
filters: Record<string, string | boolean>
|
||||||
search?: boolean
|
search?: boolean
|
||||||
header?: boolean
|
header?: boolean
|
||||||
limit?: number
|
limit?: number
|
||||||
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
search: false,
|
search: false,
|
||||||
header: true,
|
header: true,
|
||||||
limit: 12
|
limit: 12,
|
||||||
|
title: undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
@ -28,6 +34,7 @@ const store = useStore()
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const artists = reactive([] as Artist[])
|
const artists = reactive([] as Artist[])
|
||||||
const count = ref(0)
|
const count = ref(0)
|
||||||
|
const page = usePage()
|
||||||
const nextPage = ref()
|
const nextPage = ref()
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
@ -38,13 +45,14 @@ const fetchData = async (url = 'artists/') => {
|
||||||
const params = {
|
const params = {
|
||||||
q: query.value,
|
q: query.value,
|
||||||
...props.filters,
|
...props.filters,
|
||||||
|
page: page.value,
|
||||||
page_size: props.limit
|
page_size: props.limit
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.get(url, { params })
|
const response = await axios.get(url, { params })
|
||||||
nextPage.value = response.data.next
|
nextPage.value = response.data.next
|
||||||
count.value = response.data.count
|
count.value = response.data.count
|
||||||
artists.push(...response.data.results)
|
artists.splice(0, artists.length, ...response.data.results)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
useErrorHandler(error as Error)
|
useErrorHandler(error as Error)
|
||||||
}
|
}
|
||||||
|
@ -52,64 +60,58 @@ const fetchData = async (url = 'artists/') => {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeout(fetchData, 1000)
|
||||||
|
})
|
||||||
|
|
||||||
const performSearch = () => {
|
const performSearch = () => {
|
||||||
artists.length = 0
|
artists.length = 0
|
||||||
fetchData()
|
fetchData()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => store.state.moderation.lastUpdate,
|
[() => store.state.moderation.lastUpdate, page],
|
||||||
() => fetchData(),
|
() => fetchData(),
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="wrapper">
|
<Section
|
||||||
<h3
|
align-left
|
||||||
v-if="header"
|
:columns-per-item="1"
|
||||||
class="ui header"
|
:h2="title"
|
||||||
>
|
>
|
||||||
<slot name="title" />
|
<Loader
|
||||||
<span class="ui tiny circular label">{{ count }}</span>
|
|
||||||
</h3>
|
|
||||||
<inline-search-bar
|
|
||||||
v-if="search"
|
|
||||||
v-model="query"
|
|
||||||
@search="performSearch"
|
|
||||||
/>
|
|
||||||
<div class="ui hidden divider" />
|
|
||||||
<div class="ui five app-cards cards">
|
|
||||||
<div
|
|
||||||
v-if="isLoading"
|
v-if="isLoading"
|
||||||
class="ui inverted active dimmer"
|
style="grid-column: 1 / -1;"
|
||||||
>
|
|
||||||
<div class="ui loader" />
|
|
||||||
</div>
|
|
||||||
<artist-card
|
|
||||||
v-for="artist in artists"
|
|
||||||
:key="artist.id"
|
|
||||||
:artist="artist"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<slot
|
<slot
|
||||||
v-if="!isLoading && artists.length === 0"
|
v-if="!isLoading && artists.length === 0"
|
||||||
name="empty-state"
|
name="empty-state"
|
||||||
>
|
>
|
||||||
<empty-state
|
<empty-state
|
||||||
|
style="grid-column: 1 / -1;"
|
||||||
:refresh="true"
|
:refresh="true"
|
||||||
@refresh="fetchData"
|
@refresh="fetchData"
|
||||||
/>
|
/>
|
||||||
</slot>
|
</slot>
|
||||||
<template v-if="nextPage">
|
<inline-search-bar
|
||||||
<div class="ui hidden divider" />
|
v-if="!isLoading && search"
|
||||||
<button
|
v-model="query"
|
||||||
v-if="nextPage"
|
style="grid-column: 1 / -1;"
|
||||||
:class="['ui', 'basic', 'button']"
|
@search="performSearch"
|
||||||
@click="fetchData(nextPage)"
|
/>
|
||||||
>
|
<artist-card
|
||||||
{{ $t('components.audio.artist.Widget.button.more') }}
|
v-for="artist in artists"
|
||||||
</button>
|
:key="artist.id"
|
||||||
</template>
|
:artist="artist"
|
||||||
</div>
|
/>
|
||||||
|
<Pagination
|
||||||
|
v-if="page && artists && count > limit"
|
||||||
|
v-model:page="page"
|
||||||
|
style="grid-column: 1 / -1;"
|
||||||
|
:pages="Math.ceil((count || 0) / limit)"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
</template>
|
</template>
|
|
@ -1,5 +1,11 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ArtistCredit } from '~/types'
|
import type { ArtistCredit } from '~/types'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
|
||||||
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
|
import Pill from '~/components/ui/Pill.vue'
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
artistCredit: ArtistCredit[]
|
artistCredit: ArtistCredit[]
|
||||||
|
@ -7,6 +13,10 @@ interface Props {
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
// TODO: Fix getRoute
|
||||||
|
|
||||||
|
// TODO: check if still needed:
|
||||||
|
/*
|
||||||
const getRoute = (ac: ArtistCredit) => {
|
const getRoute = (ac: ArtistCredit) => {
|
||||||
return {
|
return {
|
||||||
name: ac.artist.channel ? 'channels.detail' : 'library.artists.detail',
|
name: ac.artist.channel ? 'channels.detail' : 'library.artists.detail',
|
||||||
|
@ -15,30 +25,47 @@ const getRoute = (ac: ArtistCredit) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="artist-label ui image label">
|
<Layout
|
||||||
|
flex
|
||||||
|
gap-8
|
||||||
|
>
|
||||||
<template
|
<template
|
||||||
v-for="ac in props.artistCredit"
|
v-for="ac in props.artistCredit"
|
||||||
:key="ac.artist.id"
|
:key="ac.artist.id"
|
||||||
>
|
>
|
||||||
<router-link
|
<router-link
|
||||||
:to="getRoute(ac)"
|
:to="{name: 'library.artists.detail', params: {id: ac.artist.id }}"
|
||||||
|
class="username"
|
||||||
|
@click.stop.prevent=""
|
||||||
>
|
>
|
||||||
|
<Pill>
|
||||||
|
<template #image>
|
||||||
<img
|
<img
|
||||||
v-if="ac.index === 0 && ac.artist.cover && ac.artist.cover.urls.original"
|
v-if="ac.artist.cover && ac.artist.cover.urls.original"
|
||||||
v-lazy="$store.getters['instance/absoluteUrl'](ac.artist.cover.urls.medium_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](ac.artist.cover.urls.small_square_crop)"
|
||||||
alt=""
|
:alt="ac.artist.name"
|
||||||
:class="[{circular: ac.artist.content_category != 'podcast'}]"
|
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
v-else-if="ac.index === 0"
|
v-else
|
||||||
:class="[ac.artist.content_category != 'podcast' ? 'circular' : 'bordered', 'inverted violet users icon']"
|
class="bi bi-person-circle"
|
||||||
|
style="font-size: 24px;"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
{{ ac.credit }}
|
{{ ac.credit }}
|
||||||
|
</Pill>
|
||||||
</router-link>
|
</router-link>
|
||||||
<span>{{ ac.joinphrase }}</span>
|
<span>{{ ac.joinphrase }}</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</Layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
a.username {
|
||||||
|
text-decoration: none;
|
||||||
|
height: 25px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -2,6 +2,9 @@
|
||||||
import type { Artist } from '~/types'
|
import type { Artist } from '~/types'
|
||||||
|
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
artist: Artist
|
artist: Artist
|
||||||
|
@ -10,7 +13,7 @@ interface Props {
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
const route = computed(() => props.artist.channel
|
const route = computed(() => props.artist.channel
|
||||||
? { name: 'channels.detail', params: { id: props.artist.channel.uuid } }
|
? { name: 'channels.detail', params: { id: props.artist.channel } }
|
||||||
: { name: 'library.artists.detail', params: { id: props.artist.id } }
|
: { name: 'library.artists.detail', params: { id: props.artist.id } }
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
@ -22,7 +25,7 @@ const route = computed(() => props.artist.channel
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="artist.cover && artist.cover.urls.original"
|
v-if="artist.cover && artist.cover.urls.original"
|
||||||
v-lazy="$store.getters['instance/absoluteUrl'](artist.cover.urls.medium_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](artist.cover.urls.small_square_crop)"
|
||||||
alt=""
|
alt=""
|
||||||
:class="[{circular: artist.content_category != 'podcast'}]"
|
:class="[{circular: artist.content_category != 'podcast'}]"
|
||||||
>
|
>
|
||||||
|
|
|
@ -9,7 +9,9 @@ import { computed } from 'vue'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
|
|
||||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||||
import TagsList from '~/components/tags/List.vue'
|
import Card from '~/components/ui/Card.vue'
|
||||||
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
|
import ActorLink from '~/components/common/ActorLink.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
object: Channel
|
object: Channel
|
||||||
|
@ -41,64 +43,92 @@ const updatedAgo = computed(() => moment(props.object.artist?.modification_date)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="card app-card">
|
<Card
|
||||||
<div
|
:title="object.artist?.name"
|
||||||
v-lazy:background-image="imageUrl"
|
|
||||||
:class="['ui', 'head-image', {'circular': object.artist?.content_category != 'podcast'}, {'padded': object.artist?.content_category === 'podcast'}, 'image', {'default-cover': !object.artist?.cover}]"
|
|
||||||
@click="$router.push({name: 'channels.detail', params: {id: urlId}})"
|
|
||||||
>
|
|
||||||
<play-button
|
|
||||||
:icon-only="true"
|
|
||||||
:is-playable="true"
|
|
||||||
:button-classes="['ui', 'circular', 'large', 'vibrant', 'icon', 'button']"
|
|
||||||
:artist="object.artist"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<strong>
|
|
||||||
<router-link
|
|
||||||
class="discrete link"
|
|
||||||
:to="{name: 'channels.detail', params: {id: urlId}}"
|
|
||||||
>
|
|
||||||
{{ object.artist?.name }}
|
|
||||||
</router-link>
|
|
||||||
</strong>
|
|
||||||
<div class="description">
|
|
||||||
<span
|
|
||||||
v-if="object.artist?.content_category === 'podcast'"
|
|
||||||
class="meta ellipsis"
|
|
||||||
>
|
|
||||||
{{ $t('components.audio.ChannelCard.meta.episodes', object.artist.tracks_count) }}
|
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
{{ $t('components.audio.ChannelCard.meta.tracks', object.artist?.tracks_count ?? 0) }}
|
|
||||||
</span>
|
|
||||||
<tags-list
|
|
||||||
label-classes="tiny"
|
|
||||||
:truncate-size="20"
|
|
||||||
:limit="2"
|
|
||||||
:show-more="false"
|
|
||||||
:tags="object.artist?.tags ?? []"
|
:tags="object.artist?.tags ?? []"
|
||||||
|
class="artist-card"
|
||||||
|
:to="{name: 'channels.detail', params: {id: urlId}}"
|
||||||
|
solid
|
||||||
|
small
|
||||||
|
>
|
||||||
|
<template #topright>
|
||||||
|
<PlayButton
|
||||||
|
icon-only
|
||||||
|
:artist="object.artist"
|
||||||
|
:is-playable="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
|
||||||
<div class="extra content">
|
<template #image>
|
||||||
|
<img
|
||||||
|
v-if="imageUrl"
|
||||||
|
v-lazy="imageUrl"
|
||||||
|
:alt="object.artist?.name"
|
||||||
|
:class="[object.artist?.content_category === 'podcast' ? 'podcast-image' : 'channel-image']"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
v-else
|
||||||
|
class="bi bi-person-circle"
|
||||||
|
style="font-size: 167px; margin: 16px;"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #default>
|
||||||
|
<Spacer :size="8" />
|
||||||
|
<ActorLink
|
||||||
|
:actor="object.attributed_to"
|
||||||
|
discrete
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
<time
|
<time
|
||||||
class="meta ellipsis"
|
|
||||||
:datetime="object.artist?.modification_date"
|
:datetime="object.artist?.modification_date"
|
||||||
:title="updatedTitle"
|
:title="updatedTitle"
|
||||||
>
|
>
|
||||||
{{ updatedAgo }}
|
{{ updatedAgo }}
|
||||||
</time>
|
</time>
|
||||||
<play-button
|
<i class="bi bi-dot" />
|
||||||
class="right floated basic icon"
|
<span
|
||||||
|
v-if="object.artist?.content_category === 'podcast'"
|
||||||
|
>
|
||||||
|
{{ t('components.audio.ChannelCard.meta.episodes', object.artist.tracks_count) }}
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
{{ t('components.audio.ChannelCard.meta.tracks', object.artist?.tracks_count ?? 0) }}
|
||||||
|
</span>
|
||||||
|
<Spacer
|
||||||
|
h
|
||||||
|
grow
|
||||||
|
/>
|
||||||
|
<PlayButton
|
||||||
:dropdown-only="true"
|
:dropdown-only="true"
|
||||||
:is-playable="true"
|
:is-playable="true"
|
||||||
:dropdown-icon-classes="['ellipsis', 'horizontal', 'large really discrete']"
|
|
||||||
:artist="object.artist"
|
:artist="object.artist"
|
||||||
:channel="object"
|
:channel="object"
|
||||||
:account="object.attributed_to"
|
:account="object.attributed_to"
|
||||||
|
discrete
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.channel-image {
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 168px;
|
||||||
|
height: 168px;
|
||||||
|
margin: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podcast-image {
|
||||||
|
width: 168px;
|
||||||
|
height: 168px;
|
||||||
|
margin: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-button {
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -3,11 +3,14 @@ import type { Cover, Track, BackendResponse, BackendError } from '~/types'
|
||||||
|
|
||||||
import { clone } from 'lodash-es'
|
import { clone } from 'lodash-es'
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import PodcastTable from '~/components/audio/podcast/Table.vue'
|
import PodcastTable from '~/components/audio/podcast/Table.vue'
|
||||||
import TrackTable from '~/components/audio/track/Table.vue'
|
import TrackTable from '~/components/audio/track/Table.vue'
|
||||||
|
|
||||||
|
import Loader from '~/components/ui/Loader.vue'
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'fetched', data: BackendResponse<Track[]>): void
|
(e: 'fetched', data: BackendResponse<Track[]>): void
|
||||||
}
|
}
|
||||||
|
@ -18,6 +21,7 @@ interface Props {
|
||||||
defaultCover: Cover | null
|
defaultCover: Cover | null
|
||||||
isPodcast: boolean
|
isPodcast: boolean
|
||||||
}
|
}
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const emit = defineEmits<Events>()
|
const emit = defineEmits<Events>()
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
@ -61,12 +65,7 @@ watch(page, fetchData, { immediate: true })
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<slot />
|
<slot />
|
||||||
<div class="ui hidden divider" />
|
<Loader v-if="isLoading" />
|
||||||
<div
|
|
||||||
v-if="isLoading"
|
|
||||||
class="ui inverted active dimmer"
|
|
||||||
>
|
|
||||||
<div class="ui loader" />
|
|
||||||
</div>
|
</div>
|
||||||
<podcast-table
|
<podcast-table
|
||||||
v-if="isPodcast"
|
v-if="isPodcast"
|
||||||
|
@ -103,9 +102,8 @@ watch(page, fetchData, { immediate: true })
|
||||||
@refresh="fetchData()"
|
@refresh="fetchData()"
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.audio.ChannelEntries.help.subscribe') }}
|
{{ t('components.audio.ChannelEntries.help.subscribe') }}
|
||||||
</p>
|
</p>
|
||||||
</empty-state>
|
</empty-state>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -5,10 +5,15 @@ import { computed } from 'vue'
|
||||||
|
|
||||||
import { usePlayer } from '~/composables/audio/player'
|
import { usePlayer } from '~/composables/audio/player'
|
||||||
import { useQueue } from '~/composables/audio/queue'
|
import { useQueue } from '~/composables/audio/queue'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
||||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
entry: Track
|
entry: Track
|
||||||
defaultCover: Cover
|
defaultCover: Cover
|
||||||
|
@ -37,30 +42,30 @@ const duration = computed(() => props.entry.uploads.find(upload => upload.durati
|
||||||
</div>
|
</div>
|
||||||
<img
|
<img
|
||||||
v-if="cover && cover.urls.original"
|
v-if="cover && cover.urls.original"
|
||||||
v-lazy="$store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)"
|
||||||
alt=""
|
alt=""
|
||||||
class="channel-image image"
|
class="channel-image image"
|
||||||
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
|
@click="router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="entry.artist_credit?.[0].artist.content_category === 'podcast' && defaultCover != undefined"
|
v-else-if="entry.artist_credit?.[0].artist.content_category === 'podcast' && defaultCover != undefined"
|
||||||
v-lazy="$store.getters['instance/absoluteUrl'](defaultCover.urls.medium_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](defaultCover.urls.medium_square_crop)"
|
||||||
class="channel-image image"
|
class="channel-image image"
|
||||||
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
|
@click="router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="entry.album && entry.album.cover && entry.album.cover.urls.original"
|
v-else-if="entry.album && entry.album.cover && entry.album.cover.urls.original"
|
||||||
v-lazy="$store.getters['instance/absoluteUrl'](entry.album.cover.urls.medium_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](entry.album.cover.urls.medium_square_crop)"
|
||||||
alt=""
|
alt=""
|
||||||
class="channel-image image"
|
class="channel-image image"
|
||||||
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
|
@click="router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else
|
v-else
|
||||||
alt=""
|
alt=""
|
||||||
class="channel-image image"
|
class="channel-image image"
|
||||||
src="../../assets/audio/default-cover.png"
|
src="../../assets/audio/default-cover.png"
|
||||||
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
|
@click="router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
|
||||||
>
|
>
|
||||||
<div class="ellipsis content">
|
<div class="ellipsis content">
|
||||||
<strong>
|
<strong>
|
||||||
|
@ -78,7 +83,7 @@ const duration = computed(() => props.entry.uploads.find(upload => upload.durati
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<template v-if="$store.state.auth.authenticated && $store.getters['favorites/isFavorite'](entry.id)">
|
<template v-if="store.state.auth.authenticated && store.getters['favorites/isFavorite'](entry.id)">
|
||||||
<track-favorite-icon
|
<track-favorite-icon
|
||||||
class="tiny"
|
class="tiny"
|
||||||
:track="entry"
|
:track="entry"
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ContentCategory, Channel, BackendError } from '~/types'
|
import type { ContentCategory, Channel, BackendError } from '~/types'
|
||||||
|
import type { paths } from '~/generated/types'
|
||||||
|
|
||||||
import { slugify } from 'transliteration'
|
import { slugify } from 'transliteration'
|
||||||
import { reactive, computed, ref, watchEffect, watch } from 'vue'
|
import { reactive, computed, ref, watchEffect, watch } from 'vue'
|
||||||
|
@ -7,23 +8,27 @@ import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import AttachmentInput from '~/components/common/AttachmentInput.vue'
|
import AttachmentInput from '~/components/common/AttachmentInput.vue'
|
||||||
import TagsSelector from '~/components/library/TagsSelector.vue'
|
|
||||||
|
|
||||||
interface Events {
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
(e: 'category', contentCategory: ContentCategory): void
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
(e: 'submittable', value: boolean): void
|
import Input from '~/components/ui/Input.vue'
|
||||||
(e: 'loading', value: boolean): void
|
import Textarea from '~/components/ui/Textarea.vue'
|
||||||
(e: 'errored', errors: string[]): void
|
import Pills from '~/components/ui/Pills.vue'
|
||||||
(e: 'created', channel: Channel): void
|
|
||||||
(e: 'updated', channel: Channel): void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
object?: Channel | null
|
object?: Channel | null
|
||||||
step: number
|
step?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<Events>()
|
const emit = defineEmits<{
|
||||||
|
category: [contentCategory: ContentCategory]
|
||||||
|
submittable: [value: boolean]
|
||||||
|
loading: [value: boolean]
|
||||||
|
errored: [errors: string[]]
|
||||||
|
created: [channel: Channel]
|
||||||
|
updated: [channel: Channel]
|
||||||
|
}>()
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
object: null,
|
object: null,
|
||||||
step: 1
|
step: 1
|
||||||
|
@ -38,9 +43,11 @@ const newValues = reactive({
|
||||||
description: props.object?.artist?.description?.text ?? '',
|
description: props.object?.artist?.description?.text ?? '',
|
||||||
cover: props.object?.artist?.cover?.uuid ?? null,
|
cover: props.object?.artist?.cover?.uuid ?? null,
|
||||||
content_category: props.object?.artist?.content_category ?? 'podcast',
|
content_category: props.object?.artist?.content_category ?? 'podcast',
|
||||||
metadata: { ...(props.object?.metadata ?? {}) }
|
metadata: { ...(props.object?.metadata ?? {}) } as Channel['metadata']
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// If props has an object, then this form edits, else it creates
|
||||||
|
// TODO: rename to `process : 'creating' | 'editing'`
|
||||||
const creating = computed(() => props.object === null)
|
const creating = computed(() => props.object === null)
|
||||||
const categoryChoices = computed(() => [
|
const categoryChoices = computed(() => [
|
||||||
{
|
{
|
||||||
|
@ -72,6 +79,8 @@ interface MetadataChoices {
|
||||||
const metadataChoices = ref({ itunes_category: null } as MetadataChoices)
|
const metadataChoices = ref({ itunes_category: null } as MetadataChoices)
|
||||||
const itunesSubcategories = computed(() => {
|
const itunesSubcategories = computed(() => {
|
||||||
for (const element of metadataChoices.value.itunes_category ?? []) {
|
for (const element of metadataChoices.value.itunes_category ?? []) {
|
||||||
|
// TODO: Backend: Define schema for `metadata` field
|
||||||
|
// @ts-expect-error No types defined by backend schema for `metadata` field
|
||||||
if (element.value === newValues.metadata.itunes_category) {
|
if (element.value === newValues.metadata.itunes_category) {
|
||||||
return element.children ?? []
|
return element.children ?? []
|
||||||
}
|
}
|
||||||
|
@ -87,6 +96,7 @@ const labels = computed(() => ({
|
||||||
|
|
||||||
const submittable = computed(() => !!(
|
const submittable = computed(() => !!(
|
||||||
newValues.content_category === 'podcast'
|
newValues.content_category === 'podcast'
|
||||||
|
// @ts-expect-error No types defined by backend schema for `metadata` field
|
||||||
? newValues.name && newValues.username && newValues.metadata.itunes_category && newValues.metadata.language
|
? newValues.name && newValues.username && newValues.metadata.itunes_category && newValues.metadata.language
|
||||||
: newValues.name && newValues.username
|
: newValues.name && newValues.username
|
||||||
))
|
))
|
||||||
|
@ -97,13 +107,16 @@ watch(() => newValues.name, (name) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// @ts-expect-error No types defined by backend schema for `metadata` field
|
||||||
watch(() => newValues.metadata.itunes_category, () => {
|
watch(() => newValues.metadata.itunes_category, () => {
|
||||||
|
// @ts-expect-error No types defined by backend schema for `metadata` field
|
||||||
newValues.metadata.itunes_subcategory = null
|
newValues.metadata.itunes_subcategory = null
|
||||||
})
|
})
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const errors = ref([] as string[])
|
const errors = ref([] as string[])
|
||||||
|
|
||||||
|
// @ts-expect-error Re-check emits
|
||||||
watchEffect(() => emit('category', newValues.content_category))
|
watchEffect(() => emit('category', newValues.content_category))
|
||||||
watchEffect(() => emit('loading', isLoading.value))
|
watchEffect(() => emit('loading', isLoading.value))
|
||||||
watchEffect(() => emit('submittable', submittable.value))
|
watchEffect(() => emit('submittable', submittable.value))
|
||||||
|
@ -111,8 +124,9 @@ watchEffect(() => emit('submittable', submittable.value))
|
||||||
// TODO (wvffle): Add loader / Use Suspense
|
// TODO (wvffle): Add loader / Use Suspense
|
||||||
const fetchMetadataChoices = async () => {
|
const fetchMetadataChoices = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('channels/metadata-choices')
|
const response = await axios.get<paths['/api/v2/channels/metadata-choices/']['get']['responses']['200']['content']['application/json']>('channels/metadata-choices/')
|
||||||
metadataChoices.value = response.data
|
// TODO: Fix schema generation so we don't need to typecast here!
|
||||||
|
metadataChoices.value = response.data as unknown as MetadataChoices
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.value = (error as BackendError).backendErrors
|
errors.value = (error as BackendError).backendErrors
|
||||||
}
|
}
|
||||||
|
@ -155,17 +169,17 @@ defineExpose({
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form
|
<Layout
|
||||||
|
form
|
||||||
class="ui form"
|
class="ui form"
|
||||||
@submit.prevent.stop="submit"
|
@submit.prevent.stop="submit"
|
||||||
>
|
>
|
||||||
<div
|
<Alert
|
||||||
v-if="errors.length > 0"
|
v-if="errors.length > 0"
|
||||||
role="alert"
|
red
|
||||||
class="ui negative message"
|
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
<h4 class="header">
|
||||||
{{ $t('components.audio.ChannelForm.header.error') }}
|
{{ t('components.audio.ChannelForm.header.error') }}
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
<li
|
<li
|
||||||
|
@ -175,14 +189,14 @@ defineExpose({
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</Alert>
|
||||||
<template v-if="metadataChoices">
|
<template v-if="metadataChoices">
|
||||||
<fieldset
|
<fieldset
|
||||||
v-if="creating && step === 1"
|
v-if="creating && step === 1"
|
||||||
class="ui grouped channel-type required field"
|
class="ui grouped channel-type required field"
|
||||||
>
|
>
|
||||||
<legend>
|
<legend>
|
||||||
{{ $t('components.audio.ChannelForm.legend.purpose') }}
|
{{ t('components.audio.ChannelForm.legend.purpose') }}
|
||||||
</legend>
|
</legend>
|
||||||
<div class="ui hidden divider" />
|
<div class="ui hidden divider" />
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
@ -199,7 +213,7 @@ defineExpose({
|
||||||
:value="choice.value"
|
:value="choice.value"
|
||||||
>
|
>
|
||||||
<label :for="`category-${choice.value}`">
|
<label :for="`category-${choice.value}`">
|
||||||
<span :class="['right floated', 'placeholder', 'image', {circular: choice.value === 'music'}]" />
|
<span :class="['right floated', 'placeholder', 'image', 'shifted', {circular: choice.value === 'music'}]" />
|
||||||
<strong>{{ choice.label }}</strong>
|
<strong>{{ choice.label }}</strong>
|
||||||
<div class="ui small hidden divider" />
|
<div class="ui small hidden divider" />
|
||||||
{{ choice.helpText }}
|
{{ choice.helpText }}
|
||||||
|
@ -207,38 +221,30 @@ defineExpose({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<template v-if="!creating || step === 2">
|
<template v-if="!creating || step === 2">
|
||||||
<div class="ui required field">
|
<div class="ui required field">
|
||||||
<label for="channel-name">
|
<Input
|
||||||
{{ $t('components.audio.ChannelForm.label.name') }}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="newValues.name"
|
v-model="newValues.name"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
:placeholder="labels.namePlaceholder"
|
:placeholder="labels.namePlaceholder"
|
||||||
>
|
:label="t('components.audio.ChannelForm.label.name')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui required field">
|
<div class="ui required field">
|
||||||
<label for="channel-username">
|
<Input
|
||||||
{{ $t('components.audio.ChannelForm.label.username') }}
|
|
||||||
</label>
|
|
||||||
<div class="ui left labeled input">
|
|
||||||
<div class="ui basic label">
|
|
||||||
<span class="at symbol" />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
v-model="newValues.username"
|
v-model="newValues.username"
|
||||||
type="text"
|
type="text"
|
||||||
:required="creating"
|
:required="creating"
|
||||||
:disabled="!creating"
|
:disabled="!creating"
|
||||||
:placeholder="labels.usernamePlaceholder"
|
:placeholder="labels.usernamePlaceholder"
|
||||||
>
|
:label="t('components.audio.ChannelForm.label.username')"
|
||||||
</div>
|
/>
|
||||||
<template v-if="creating">
|
<template v-if="creating">
|
||||||
<div class="ui small hidden divider" />
|
<div class="ui small hidden divider" />
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.audio.ChannelForm.help.username') }}
|
{{ t('components.audio.ChannelForm.help.username') }}
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
@ -248,31 +254,25 @@ defineExpose({
|
||||||
:image-class="newValues.content_category === 'podcast' ? '' : 'circular'"
|
:image-class="newValues.content_category === 'podcast' ? '' : 'circular'"
|
||||||
@delete="newValues.cover = null"
|
@delete="newValues.cover = null"
|
||||||
>
|
>
|
||||||
{{ $t('components.audio.ChannelForm.label.image') }}
|
{{ t('components.audio.ChannelForm.label.image') }}
|
||||||
</attachment-input>
|
</attachment-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui small hidden divider" />
|
<Pills
|
||||||
<div class="ui stackable grid row">
|
:get="model => { newValues.tags = model.currents.map(({ label }) => label) }"
|
||||||
<div class="ten wide column">
|
:set="model => ({
|
||||||
<div class="ui field">
|
currents: newValues.tags.map(tag => ({ type: 'custom' as const, label: tag })),
|
||||||
<label for="channel-tags">
|
others: [].map(tag => ({ type: 'custom' as const, label: tag }))
|
||||||
{{ $t('components.audio.ChannelForm.label.tags') }}
|
})"
|
||||||
</label>
|
:label="t('components.audio.ChannelForm.label.tags')"
|
||||||
<tags-selector
|
|
||||||
id="channel-tags"
|
|
||||||
v-model="newValues.tags"
|
|
||||||
:required="false"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-if="newValues.content_category === 'podcast'"
|
v-if="newValues.content_category === 'podcast'"
|
||||||
class="six wide column"
|
|
||||||
>
|
>
|
||||||
<div class="ui required field">
|
|
||||||
<label for="channel-language">
|
<label for="channel-language">
|
||||||
{{ $t('components.audio.ChannelForm.label.language') }}
|
{{ t('components.audio.ChannelForm.label.language') }}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<!-- @vue-ignore -->
|
||||||
<select
|
<select
|
||||||
id="channel-language"
|
id="channel-language"
|
||||||
v-model="newValues.metadata.language"
|
v-model="newValues.metadata.language"
|
||||||
|
@ -289,23 +289,22 @@ defineExpose({
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ui small hidden divider" />
|
|
||||||
<div class="ui field">
|
<div class="ui field">
|
||||||
<label for="channel-name">
|
<Textarea
|
||||||
{{ $t('components.audio.ChannelForm.label.description') }}
|
v-model="newValues.description"
|
||||||
</label>
|
:label="t('components.audio.ChannelForm.label.description')"
|
||||||
<content-form v-model="newValues.description" />
|
initial-lines="3"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<template
|
||||||
v-if="newValues.content_category === 'podcast'"
|
v-if="newValues.content_category === 'podcast'"
|
||||||
class="ui two fields"
|
|
||||||
>
|
>
|
||||||
<div class="ui required field">
|
<div class="ui required field">
|
||||||
<label for="channel-itunes-category">
|
<label for="channel-itunes-category">
|
||||||
{{ $t('components.audio.ChannelForm.label.category') }}
|
{{ t('components.audio.ChannelForm.label.category') }}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<!-- @vue-ignore -->
|
||||||
<select
|
<select
|
||||||
id="itunes-category"
|
id="itunes-category"
|
||||||
v-model="newValues.metadata.itunes_category"
|
v-model="newValues.metadata.itunes_category"
|
||||||
|
@ -324,8 +323,10 @@ defineExpose({
|
||||||
</div>
|
</div>
|
||||||
<div class="ui field">
|
<div class="ui field">
|
||||||
<label for="channel-itunes-category">
|
<label for="channel-itunes-category">
|
||||||
{{ $t('components.audio.ChannelForm.label.subcategory') }}
|
{{ t('components.audio.ChannelForm.label.subcategory') }}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<!-- @vue-ignore -->
|
||||||
<select
|
<select
|
||||||
id="itunes-category"
|
id="itunes-category"
|
||||||
v-model="newValues.metadata.itunes_subcategory"
|
v-model="newValues.metadata.itunes_subcategory"
|
||||||
|
@ -342,37 +343,37 @@ defineExpose({
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
<div
|
<template
|
||||||
v-if="newValues.content_category === 'podcast'"
|
v-if="newValues.content_category === 'podcast'"
|
||||||
class="ui two fields"
|
|
||||||
>
|
>
|
||||||
|
<Alert blue>
|
||||||
|
<span>
|
||||||
|
<i class="bi bi-info-circle-fill" />
|
||||||
|
{{ t('components.audio.ChannelForm.help.podcastFields') }}
|
||||||
|
</span>
|
||||||
|
</Alert>
|
||||||
<div class="ui field">
|
<div class="ui field">
|
||||||
<label for="channel-itunes-email">
|
<!-- @vue-ignore -->
|
||||||
{{ $t('components.audio.ChannelForm.label.email') }}
|
<Input
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="channel-itunes-email"
|
id="channel-itunes-email"
|
||||||
v-model="newValues.metadata.owner_email"
|
v-model="newValues.metadata.owner_email"
|
||||||
name="channel-itunes-email"
|
name="channel-itunes-email"
|
||||||
type="email"
|
type="email"
|
||||||
>
|
:label="t('components.audio.ChannelForm.label.email')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui field">
|
<div class="ui field">
|
||||||
<label for="channel-itunes-name">
|
<!-- @vue-ignore -->
|
||||||
{{ $t('components.audio.ChannelForm.label.owner') }}
|
<Input
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="channel-itunes-name"
|
id="channel-itunes-name"
|
||||||
v-model="newValues.metadata.owner_name"
|
v-model="newValues.metadata.owner_name"
|
||||||
name="channel-itunes-name"
|
name="channel-itunes-name"
|
||||||
maxlength="255"
|
maxlength="255"
|
||||||
>
|
:label="t('components.audio.ChannelForm.label.owner')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
<p>
|
|
||||||
{{ $t('components.audio.ChannelForm.help.podcastFields') }}
|
|
||||||
</p>
|
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<div
|
<div
|
||||||
|
@ -380,8 +381,8 @@ defineExpose({
|
||||||
class="ui active inverted dimmer"
|
class="ui active inverted dimmer"
|
||||||
>
|
>
|
||||||
<div class="ui text loader">
|
<div class="ui text loader">
|
||||||
{{ $t('components.audio.ChannelForm.loader.loading') }}
|
{{ t('components.audio.ChannelForm.loader.loading') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</Layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,72 +1,74 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Album } from '~/types'
|
import { computed } from 'vue'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { momentFormat } from '~/utils/filters'
|
||||||
|
import defaultCover from '~/assets/audio/default-cover.png'
|
||||||
|
|
||||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||||
import { computed } from 'vue'
|
import Card from '~/components/ui/Card.vue'
|
||||||
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
|
|
||||||
|
import { type Album } from '~/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serie: Album
|
serie: Album
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
const cover = computed(() => props.serie?.cover ?? null)
|
const { serie } = props
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
const imageUrl = computed(() => serie?.cover?.urls.original
|
||||||
|
? store.getters['instance/absoluteUrl'](serie.cover?.urls.medium_square_crop)
|
||||||
|
: defaultCover
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="channel-serie-card">
|
<Card
|
||||||
<div class="two-images">
|
:title="serie?.title"
|
||||||
<img
|
:image="imageUrl"
|
||||||
v-if="cover && cover.urls.original"
|
:tags="serie?.tags"
|
||||||
v-lazy="$store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)"
|
:to="{name: 'library.albums.detail', params: {id: serie?.id}}"
|
||||||
alt=""
|
small
|
||||||
class="channel-image"
|
|
||||||
@click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})"
|
|
||||||
>
|
>
|
||||||
<img
|
<template #topright>
|
||||||
v-else
|
<PlayButton
|
||||||
alt=""
|
icon-only
|
||||||
class="channel-image"
|
:is-playable="serie?.is_playable"
|
||||||
src="../../assets/audio/default-cover.png"
|
|
||||||
@click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
v-if="cover && cover.urls.original"
|
|
||||||
v-lazy="$store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)"
|
|
||||||
alt=""
|
|
||||||
class="channel-image"
|
|
||||||
@click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
v-else
|
|
||||||
alt=""
|
|
||||||
class="channel-image"
|
|
||||||
src="../../assets/audio/default-cover.png"
|
|
||||||
@click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="content ellipsis">
|
|
||||||
<strong>
|
|
||||||
<router-link
|
|
||||||
class="discrete link"
|
|
||||||
:to="{name: 'library.albums.detail', params: {id: serie.id}}"
|
|
||||||
>
|
|
||||||
{{ serie.title }}
|
|
||||||
</router-link>
|
|
||||||
</strong>
|
|
||||||
<div class="description">
|
|
||||||
<span>
|
|
||||||
{{ $t('components.audio.ChannelSerieCard.meta.episodes', serie.tracks_count) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="controls">
|
|
||||||
<play-button
|
|
||||||
:icon-only="true"
|
|
||||||
:is-playable="true"
|
|
||||||
:button-classes="['ui', 'circular', 'vibrant', 'icon', 'button']"
|
|
||||||
:album="serie"
|
:album="serie"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
|
||||||
|
<template #footer>
|
||||||
|
<span v-if="serie?.release_date">
|
||||||
|
{{ momentFormat(new Date(serie?.release_date), 'Y') }}
|
||||||
|
</span>
|
||||||
|
<i class="bi bi-dot" />
|
||||||
|
<span>
|
||||||
|
{{ t('components.audio.album.Card.meta.tracks', serie?.tracks_count) }}
|
||||||
|
</span>
|
||||||
|
<Spacer
|
||||||
|
h
|
||||||
|
grow
|
||||||
|
/>
|
||||||
|
<PlayButton
|
||||||
|
:dropdown-only="true"
|
||||||
|
discrete
|
||||||
|
:is-playable="serie?.is_playable"
|
||||||
|
:album="serie"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.play-button {
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -3,10 +3,14 @@ import type { BackendError, Album } from '~/types'
|
||||||
|
|
||||||
import { clone } from 'lodash-es'
|
import { clone } from 'lodash-es'
|
||||||
import { ref, reactive } from 'vue'
|
import { ref, reactive } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import ChannelSerieCard from '~/components/audio/ChannelSerieCard.vue'
|
import ChannelSerieCard from '~/components/audio/ChannelSerieCard.vue'
|
||||||
import AlbumCard from '~/components/audio/album/Card.vue'
|
import AlbumCard from '~/components/audio/album/Card.vue'
|
||||||
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
|
import Loader from '~/components/ui/Loader.vue'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
filters: object
|
filters: object
|
||||||
|
@ -14,6 +18,8 @@ interface Props {
|
||||||
limit?: number
|
limit?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
isPodcast: true,
|
isPodcast: true,
|
||||||
limit: 5
|
limit: 5
|
||||||
|
@ -51,15 +57,9 @@ fetchData()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
|
||||||
<slot />
|
<slot />
|
||||||
<div class="ui hidden divider" />
|
<Layout flex>
|
||||||
<div
|
<Loader v-if="isLoading" />
|
||||||
v-if="isLoading"
|
|
||||||
class="ui inverted active dimmer"
|
|
||||||
>
|
|
||||||
<div class="ui loader" />
|
|
||||||
</div>
|
|
||||||
<template v-if="isPodcast">
|
<template v-if="isPodcast">
|
||||||
<channel-serie-card
|
<channel-serie-card
|
||||||
v-for="serie in albums"
|
v-for="serie in albums"
|
||||||
|
@ -67,35 +67,31 @@ fetchData()
|
||||||
:serie="serie"
|
:serie="serie"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<div
|
<template v-else>
|
||||||
v-else
|
|
||||||
class="ui app-cards cards"
|
|
||||||
>
|
|
||||||
<album-card
|
<album-card
|
||||||
v-for="album in albums"
|
v-for="album in albums"
|
||||||
:key="album.id"
|
:key="album.id"
|
||||||
:album="album"
|
:album="album"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
<template v-if="nextPage">
|
<template v-if="nextPage">
|
||||||
<div class="ui hidden divider" />
|
<Button
|
||||||
<button
|
|
||||||
v-if="nextPage"
|
v-if="nextPage"
|
||||||
:class="['ui', 'basic', 'button']"
|
secondary
|
||||||
@click="fetchData(nextPage)"
|
@click="fetchData(nextPage)"
|
||||||
>
|
>
|
||||||
{{ $t('components.audio.ChannelSeries.button.showMore') }}
|
{{ t('components.audio.ChannelSeries.button.showMore') }}
|
||||||
</button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
</Layout>
|
||||||
<template v-if="!isLoading && albums.length === 0">
|
<template v-if="!isLoading && albums.length === 0">
|
||||||
<empty-state
|
<empty-state
|
||||||
:refresh="true"
|
:refresh="true"
|
||||||
@refresh="fetchData()"
|
@refresh="fetchData()"
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.audio.ChannelSeries.help.subscribe') }}
|
{{ t('components.audio.ChannelSeries.help.subscribe') }}
|
||||||
</p>
|
</p>
|
||||||
</empty-state>
|
</empty-state>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,47 +1,57 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { BackendError, BackendResponse, Channel } from '~/types'
|
import type { BackendError, PaginatedChannelList } from '~/types'
|
||||||
|
import { type operations } from '~/generated/types.ts'
|
||||||
|
|
||||||
import { ref, reactive } from 'vue'
|
import { ref, onMounted, watch } from 'vue'
|
||||||
import { clone } from 'lodash-es'
|
import { clone } from 'lodash-es'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import usePage from '~/composables/navigation/usePage'
|
||||||
|
|
||||||
import ChannelCard from '~/components/audio/ChannelCard.vue'
|
import ChannelCard from '~/components/audio/ChannelCard.vue'
|
||||||
|
import Loader from '~/components/ui/Loader.vue'
|
||||||
|
import Section from '~/components/ui/Section.vue'
|
||||||
|
import Pagination from '~/components/ui/Pagination.vue'
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'fetched', channels: BackendResponse<Channel>): void
|
(e: 'fetched', channels: PaginatedChannelList): void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
filters: object
|
filters: object
|
||||||
limit?: number
|
limit?: number
|
||||||
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<Events>()
|
const emit = defineEmits<Events>()
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
limit: 5
|
limit: 5,
|
||||||
|
title: undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
const channels = reactive([] as Channel[])
|
const result = ref<PaginatedChannelList>()
|
||||||
const errors = ref([] as string[])
|
const errors = ref([] as string[])
|
||||||
const nextPage = ref()
|
const nextPage = ref()
|
||||||
|
const page = usePage()
|
||||||
const count = ref(0)
|
const count = ref(0)
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|
||||||
const fetchData = async (url = 'channels/') => {
|
const fetchData = async (url = 'channels/') => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
|
|
||||||
const params = {
|
const params: operations['get_channels_2']['parameters']['query'] = {
|
||||||
...clone(props.filters),
|
...clone(props.filters),
|
||||||
page_size: props.limit,
|
page: page.value,
|
||||||
include_channels: true
|
page_size: props.limit
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(url, { params })
|
const response = await axios.get<PaginatedChannelList>(url, { params })
|
||||||
nextPage.value = response.data.next
|
nextPage.value = response.data.next
|
||||||
count.value = response.data.count
|
count.value = response.data.count
|
||||||
channels.push(...response.data.results)
|
result.value = response.data
|
||||||
emit('fetched', response.data)
|
emit('fetched', response.data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.value = (error as BackendError).backendErrors
|
errors.value = (error as BackendError).backendErrors
|
||||||
|
@ -50,41 +60,51 @@ const fetchData = async (url = 'channels/') => {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchData()
|
onMounted(() => {
|
||||||
|
fetchData()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([() => props.filters, page],
|
||||||
|
() => fetchData(),
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<Section
|
||||||
<slot />
|
align-left
|
||||||
<div class="ui hidden divider" />
|
:columns-per-item="1"
|
||||||
<div class="ui app-cards cards">
|
:h2="title || undefined"
|
||||||
<div
|
>
|
||||||
|
<Loader
|
||||||
v-if="isLoading"
|
v-if="isLoading"
|
||||||
class="ui inverted active dimmer"
|
style="grid-column: 1 / -1;"
|
||||||
>
|
|
||||||
<div class="ui loader" />
|
|
||||||
</div>
|
|
||||||
<channel-card
|
|
||||||
v-for="object in channels"
|
|
||||||
:key="object.uuid"
|
|
||||||
:object="object"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<template
|
||||||
<template v-if="nextPage">
|
v-if="!isLoading && result?.count === 0"
|
||||||
<div class="ui hidden divider" />
|
|
||||||
<button
|
|
||||||
v-if="nextPage"
|
|
||||||
:class="['ui', 'basic', 'button']"
|
|
||||||
@click="fetchData(nextPage)"
|
|
||||||
>
|
>
|
||||||
{{ $t('components.audio.ChannelsWidget.button.showMore') }}
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
<template v-if="!isLoading && channels.length === 0">
|
|
||||||
<empty-state
|
<empty-state
|
||||||
:refresh="true"
|
:refresh="true"
|
||||||
|
style="grid-column: 1 / -1;"
|
||||||
@refresh="fetchData('channels/')"
|
@refresh="fetchData('channels/')"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
<Pagination
|
||||||
|
v-if="page && result && count > limit && limit > 16"
|
||||||
|
v-model:page="page"
|
||||||
|
:pages="Math.ceil((count || 0) / limit)"
|
||||||
|
style="grid-column: 1 / -1;"
|
||||||
|
/>
|
||||||
|
<channel-card
|
||||||
|
v-for="channel in result?.results"
|
||||||
|
:key="channel.uuid"
|
||||||
|
:object="channel"
|
||||||
|
/>
|
||||||
|
<Pagination
|
||||||
|
v-if="page && result && count > limit"
|
||||||
|
v-model:page="page"
|
||||||
|
:pages="Math.ceil((count || 0) / limit)"
|
||||||
|
style="grid-column: 1 / -1;"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -3,14 +3,25 @@ import { get } from 'lodash-es'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
import { useClipboard } from '@vueuse/core'
|
import { useClipboard } from '@vueuse/core'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
|
import Input from '~/components/ui/Input.vue'
|
||||||
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
type: string
|
type: string
|
||||||
id: number
|
id: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const width = ref(null)
|
|
||||||
|
// TODO: This used to be `null`. Is `0` correct?
|
||||||
|
const width = ref(0)
|
||||||
|
|
||||||
const height = ref(150)
|
const height = ref(150)
|
||||||
const minHeight = ref(100)
|
const minHeight = ref(100)
|
||||||
|
|
||||||
|
@ -51,69 +62,70 @@ const { copy, copied } = useClipboard({ source: embedCode })
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
<strong>
|
<strong>
|
||||||
{{ $t('components.audio.EmbedWizard.warning.anonymous') }}
|
{{ t('components.audio.EmbedWizard.warning.anonymous') }}
|
||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.audio.EmbedWizard.help.anonymous') }}
|
{{ t('components.audio.EmbedWizard.help.anonymous') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui form">
|
<div class="ui form">
|
||||||
<div class="two fields">
|
<div class="two fields">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="embed-width">{{ $t('components.audio.EmbedWizard.label.width') }}</label>
|
<label for="embed-width">{{ t('components.audio.EmbedWizard.label.width') }}</label>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.audio.EmbedWizard.help.width') }}
|
{{ t('components.audio.EmbedWizard.help.width') }}
|
||||||
</p>
|
</p>
|
||||||
<input
|
<Input
|
||||||
id="embed-width"
|
id="embed-width"
|
||||||
v-model.number="width"
|
v-model.number="width"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="10"
|
step="10"
|
||||||
>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="type != 'track'">
|
<template v-if="type != 'track'">
|
||||||
<br>
|
<br>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="embed-height">{{ $t('components.audio.EmbedWizard.label.height') }}</label>
|
<label for="embed-height">{{ t('components.audio.EmbedWizard.label.height') }}</label>
|
||||||
<input
|
<Input
|
||||||
id="embed-height"
|
id="embed-height"
|
||||||
v-model="height"
|
v-model="height"
|
||||||
type="number"
|
type="number"
|
||||||
:min="minHeight"
|
:min="minHeight"
|
||||||
max="1000"
|
max="1000"
|
||||||
step="10"
|
step="10"
|
||||||
>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<Spacer />
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<button
|
<Button
|
||||||
class="ui right accent labeled icon floated button"
|
class="right floated"
|
||||||
|
icon="bi-copy"
|
||||||
|
secondary
|
||||||
@click="copy()"
|
@click="copy()"
|
||||||
>
|
>
|
||||||
<i class="copy icon" />
|
{{ t('components.audio.EmbedWizard.button.copy') }}
|
||||||
{{ $t('components.audio.EmbedWizard.button.copy') }}
|
</Button>
|
||||||
</button>
|
<label for="embed-width">{{ t('components.audio.EmbedWizard.label.embed') }}</label>
|
||||||
<label for="embed-width">{{ $t('components.audio.EmbedWizard.label.embed') }}</label>
|
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.audio.EmbedWizard.help.embed') }}
|
{{ t('components.audio.EmbedWizard.help.embed') }}
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="embedCode"
|
v-model="embedCode"
|
||||||
rows="5"
|
rows="3"
|
||||||
readonly
|
readonly
|
||||||
|
style="width: 100%;"
|
||||||
/>
|
/>
|
||||||
<div class="ui right">
|
<Alert
|
||||||
<p
|
|
||||||
v-if="copied"
|
v-if="copied"
|
||||||
class="message"
|
green
|
||||||
>
|
>
|
||||||
{{ $t('components.audio.EmbedWizard.message.copy') }}
|
{{ t('components.audio.EmbedWizard.message.copy') }}
|
||||||
</p>
|
</Alert>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -123,7 +135,7 @@ const { copy, copied } = useClipboard({ source: embedCode })
|
||||||
:href="iframeSrc"
|
:href="iframeSrc"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
{{ $t('components.audio.EmbedWizard.header.preview') }}
|
{{ t('components.audio.EmbedWizard.header.preview') }}
|
||||||
</a>
|
</a>
|
||||||
</h3>
|
</h3>
|
||||||
<iframe
|
<iframe
|
||||||
|
|
|
@ -3,6 +3,7 @@ import type { Library } from '~/types'
|
||||||
|
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'unfollowed'): void
|
(e: 'unfollowed'): void
|
||||||
|
@ -13,6 +14,7 @@ interface Props {
|
||||||
library: Library
|
library: Library
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const emit = defineEmits<Events>()
|
const emit = defineEmits<Events>()
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
@ -39,13 +41,13 @@ const toggle = () => {
|
||||||
>
|
>
|
||||||
<i class="heart icon" />
|
<i class="heart icon" />
|
||||||
<span v-if="isApproved">
|
<span v-if="isApproved">
|
||||||
{{ $t('components.audio.LibraryFollowButton.button.unfollow') }}
|
{{ t('components.audio.LibraryFollowButton.button.unfollow') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="isPending">
|
<span v-else-if="isPending">
|
||||||
{{ $t('components.audio.LibraryFollowButton.button.cancel') }}
|
{{ t('components.audio.LibraryFollowButton.button.cancel') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ $t('components.audio.LibraryFollowButton.button.follow') }}
|
{{ t('components.audio.LibraryFollowButton.button.follow') }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,15 +1,22 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
|
import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
|
||||||
|
import type { components } from '~/generated/types'
|
||||||
import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
|
import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
|
||||||
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import usePlayOptions from '~/composables/audio/usePlayOptions'
|
import usePlayOptions from '~/composables/audio/usePlayOptions'
|
||||||
import useReport from '~/composables/moderation/useReport'
|
import useReport from '~/composables/moderation/useReport'
|
||||||
import { useCurrentElement } from '@vueuse/core'
|
import { useStore } from '~/store'
|
||||||
import { setupDropdown } from '~/utils/fomantic'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import OptionsButton from '~/components/ui/button/Options.vue'
|
||||||
|
import Popover from '~/components/ui/Popover.vue'
|
||||||
|
import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
|
||||||
|
|
||||||
interface Props extends PlayOptionsProps {
|
interface Props extends PlayOptionsProps {
|
||||||
|
split?: boolean
|
||||||
dropdownIconClasses?: string[]
|
dropdownIconClasses?: string[]
|
||||||
playIconClass?: string
|
playIconClass?: string
|
||||||
buttonClasses?: string[]
|
buttonClasses?: string[]
|
||||||
|
@ -18,20 +25,22 @@ interface Props extends PlayOptionsProps {
|
||||||
iconOnly?: boolean
|
iconOnly?: boolean
|
||||||
playing?: boolean
|
playing?: boolean
|
||||||
paused?: boolean
|
paused?: boolean
|
||||||
|
lowHeight?: boolean
|
||||||
|
|
||||||
// TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged
|
// TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged
|
||||||
isPlayable?: boolean
|
isPlayable?: boolean
|
||||||
tracks?: Track[]
|
tracks?: Track[]
|
||||||
track?: Track | null
|
track?: Track | null
|
||||||
artist?: Artist | null
|
artist?: Artist | components["schemas"]["SimpleChannelArtist"] | components['schemas']['ArtistWithAlbums'] | null
|
||||||
album?: Album | null
|
album?: Album | null
|
||||||
playlist?: Playlist | null
|
playlist?: Playlist | null
|
||||||
library?: Library | null
|
library?: Library | null
|
||||||
channel?: Channel | null
|
channel?: Channel | null
|
||||||
account?: Actor | null
|
account?: Actor | components['schemas']['APIActor'] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
split: false,
|
||||||
tracks: () => [],
|
tracks: () => [],
|
||||||
track: null,
|
track: null,
|
||||||
artist: null,
|
artist: null,
|
||||||
|
@ -40,17 +49,22 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
library: null,
|
library: null,
|
||||||
channel: null,
|
channel: null,
|
||||||
account: null,
|
account: null,
|
||||||
dropdownIconClasses: () => ['dropdown'],
|
dropdownIconClasses: () => ['bi-caret-down-fill'],
|
||||||
playIconClass: () => 'play icon',
|
playIconClass: () => 'bi-play-fill',
|
||||||
buttonClasses: () => ['button'],
|
buttonClasses: () => ['button'],
|
||||||
discrete: () => false,
|
discrete: () => false,
|
||||||
dropdownOnly: () => false,
|
dropdownOnly: () => false,
|
||||||
iconOnly: () => false,
|
iconOnly: () => false,
|
||||||
isPlayable: () => false,
|
isPlayable: () => false,
|
||||||
playing: () => false,
|
playing: () => false,
|
||||||
paused: () => false
|
paused: () => false,
|
||||||
|
lowHeight: () => false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// (1) Create a PlayButton
|
||||||
|
// Some of the props are meant for `usePlayOptions`!
|
||||||
|
// UsePlayOptions accepts the props from this component and returns the following things:
|
||||||
|
|
||||||
const {
|
const {
|
||||||
playable,
|
playable,
|
||||||
filterableArtist,
|
filterableArtist,
|
||||||
|
@ -64,6 +78,10 @@ const {
|
||||||
const { report, getReportableObjects } = useReport()
|
const { report, getReportableObjects } = useReport()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const store = useStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const labels = computed(() => ({
|
const labels = computed(() => ({
|
||||||
playNow: t('components.audio.PlayButton.button.playNow'),
|
playNow: t('components.audio.PlayButton.button.playNow'),
|
||||||
addToQueue: t('components.audio.PlayButton.button.addToQueue'),
|
addToQueue: t('components.audio.PlayButton.button.addToQueue'),
|
||||||
|
@ -83,149 +101,163 @@ const labels = computed(() => ({
|
||||||
: t('components.audio.PlayButton.button.playTracks')
|
: t('components.audio.PlayButton.button.playTracks')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const title = computed(() => {
|
const isOpen = ref(false)
|
||||||
if (playable.value) {
|
|
||||||
return t('components.audio.PlayButton.title.more')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.track) {
|
|
||||||
return t('components.audio.PlayButton.title.unavailable')
|
|
||||||
}
|
|
||||||
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const el = useCurrentElement()
|
|
||||||
const dropdown = ref()
|
|
||||||
onMounted(() => {
|
|
||||||
dropdown.value = setupDropdown('.ui.dropdown', el.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
const openMenu = () => {
|
|
||||||
// little magic to ensure the menu is always visible in the viewport
|
|
||||||
// By default, try to display it on the right if there is enough room
|
|
||||||
const menu = dropdown.value.find('.menu')
|
|
||||||
if (menu.hasClass('visible')) return
|
|
||||||
const viewportOffset = menu.get(0)?.getBoundingClientRect() ?? { right: 0, left: 0 }
|
|
||||||
const viewportWidth = document.documentElement.clientWidth
|
|
||||||
const rightOverflow = viewportOffset.right - viewportWidth
|
|
||||||
const leftOverflow = -viewportOffset.left
|
|
||||||
|
|
||||||
menu.css({
|
|
||||||
cssText: rightOverflow > 0
|
|
||||||
? `left: ${-rightOverflow - 5}px !important;`
|
|
||||||
: `right: ${-leftOverflow + 5}px !important;`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span
|
<Popover
|
||||||
:title="title"
|
v-if="split || (!iconOnly && dropdownOnly)"
|
||||||
:class="['ui', {'tiny': discrete, 'icon': !discrete, 'buttons': !dropdownOnly && !iconOnly}, 'play-button component-play-button']"
|
v-model="isOpen"
|
||||||
>
|
>
|
||||||
<button
|
<OptionsButton
|
||||||
v-if="!dropdownOnly"
|
v-if="dropdownOnly"
|
||||||
:disabled="!playable"
|
v-bind="$attrs"
|
||||||
|
:is-ghost="discrete"
|
||||||
|
@click="isOpen = !isOpen"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-else
|
||||||
|
v-bind="{
|
||||||
|
disabled: !playable && !filterableArtist,
|
||||||
|
primary: playable,
|
||||||
|
split: true,
|
||||||
|
splitIcon: 'bi-caret-down-fill'
|
||||||
|
}"
|
||||||
:aria-label="labels.replacePlay"
|
:aria-label="labels.replacePlay"
|
||||||
:class="[...buttonClasses, 'ui', {loading: isLoading, 'mini': discrete, disabled: !playable}]"
|
:class="[...buttonClasses, 'play-button']"
|
||||||
|
:isloading="isLoading"
|
||||||
|
:dropdown-only="dropdownOnly"
|
||||||
|
:low-height="lowHeight || undefined"
|
||||||
|
style="align-self: start;"
|
||||||
@click.stop.prevent="replacePlay()"
|
@click.stop.prevent="replacePlay()"
|
||||||
|
@split-click="isOpen = !isOpen"
|
||||||
>
|
>
|
||||||
|
<template #main>
|
||||||
<i
|
<i
|
||||||
v-if="playing"
|
v-if="playing"
|
||||||
class="pause icon"
|
class="bi bi-pause-fill"
|
||||||
/>
|
/>
|
||||||
<i
|
<i
|
||||||
v-else
|
v-else
|
||||||
:class="[playIconClass, 'icon']"
|
:class="['bi', playIconClass]"
|
||||||
/>
|
/>
|
||||||
<template v-if="!discrete && !iconOnly"> <slot>{{ $t('components.audio.PlayButton.button.discretePlay') }}</slot></template>
|
<template v-if="!discrete && !iconOnly">
|
||||||
</button>
|
<slot>{{ t('components.audio.PlayButton.button.discretePlay') }}</slot>
|
||||||
<button
|
</template>
|
||||||
v-if="!discrete && !iconOnly"
|
</template>
|
||||||
:class="['ui', {disabled: !playable && !filterableArtist}, 'floating', 'dropdown', {'icon': !dropdownOnly}, {'button': !dropdownOnly}]"
|
</Button>
|
||||||
@click.stop.prevent="openMenu"
|
|
||||||
>
|
<template #items>
|
||||||
<i
|
<PopoverItem
|
||||||
:class="dropdownIconClasses.concat(['icon'])"
|
|
||||||
:title="title"
|
|
||||||
/>
|
|
||||||
<div class="menu">
|
|
||||||
<button
|
|
||||||
class="item basic"
|
|
||||||
:disabled="!playable"
|
:disabled="!playable"
|
||||||
:title="labels.addToQueue"
|
:title="labels.addToQueue"
|
||||||
|
icon="bi-plus"
|
||||||
@click.stop.prevent="enqueue"
|
@click.stop.prevent="enqueue"
|
||||||
>
|
>
|
||||||
<i class="plus icon" />{{ labels.addToQueue }}
|
{{ labels.addToQueue }}
|
||||||
</button>
|
</PopoverItem>
|
||||||
<button
|
|
||||||
class="item basic"
|
<PopoverItem
|
||||||
:disabled="!playable"
|
:disabled="!playable"
|
||||||
:title="labels.playNext"
|
:title="labels.playNext"
|
||||||
|
icon="bi-skip-forward-fill"
|
||||||
@click.stop.prevent="enqueueNext()"
|
@click.stop.prevent="enqueueNext()"
|
||||||
>
|
>
|
||||||
<i class="step forward icon" />{{ labels.playNext }}
|
{{ labels.playNext }}
|
||||||
</button>
|
</PopoverItem>
|
||||||
<button
|
|
||||||
class="item basic"
|
<PopoverItem
|
||||||
:disabled="!playable"
|
:disabled="!playable"
|
||||||
:title="labels.playNow"
|
:title="labels.playNow"
|
||||||
|
icon="bi-play-fill"
|
||||||
@click.stop.prevent="enqueueNext(true)"
|
@click.stop.prevent="enqueueNext(true)"
|
||||||
>
|
>
|
||||||
<i class="play icon" />{{ labels.playNow }}
|
{{ labels.playNow }}
|
||||||
</button>
|
</PopoverItem>
|
||||||
<button
|
|
||||||
|
<PopoverItem
|
||||||
v-if="track"
|
v-if="track"
|
||||||
class="item basic"
|
|
||||||
:disabled="!playable"
|
:disabled="!playable"
|
||||||
:title="labels.startRadio"
|
:title="labels.startRadio"
|
||||||
@click.stop.prevent="$store.dispatch('radios/start', {type: 'similar', objectId: track?.id})"
|
icon="bi-broadcast"
|
||||||
|
@click.stop.prevent="store.dispatch('radios/start', {type: 'similar', objectId: track?.id})"
|
||||||
>
|
>
|
||||||
<i class="feed icon" />{{ labels.startRadio }}
|
{{ labels.startRadio }}
|
||||||
</button>
|
</PopoverItem>
|
||||||
<button
|
|
||||||
|
<PopoverItem
|
||||||
v-if="track"
|
v-if="track"
|
||||||
class="item basic"
|
|
||||||
:disabled="!playable"
|
:disabled="!playable"
|
||||||
@click.stop="$store.commit('playlists/chooseTrack', track)"
|
icon="bi-list"
|
||||||
|
@click.stop="store.commit('playlists/chooseTrack', track)"
|
||||||
>
|
>
|
||||||
<i class="list icon" />
|
|
||||||
{{ labels.addToPlaylist }}
|
{{ labels.addToPlaylist }}
|
||||||
</button>
|
</PopoverItem>
|
||||||
<button
|
<PopoverItem
|
||||||
v-if="track && $route.name !== 'library.tracks.detail'"
|
v-if="track && route.name !== 'library.tracks.detail'"
|
||||||
class="item basic"
|
icon="bi-info-circle"
|
||||||
@click.stop.prevent="$router.push(`/library/tracks/${track?.id}/`)"
|
@click.stop.prevent="router.push(`/library/tracks/${track?.id}/`)"
|
||||||
>
|
>
|
||||||
<i class="info icon" />
|
|
||||||
<span v-if="track.artist_credit?.some(ac => ac.artist.content_category === 'podcast')">
|
<span v-if="track.artist_credit?.some(ac => ac.artist.content_category === 'podcast')">
|
||||||
{{ $t('components.audio.PlayButton.button.episodeDetails') }}
|
{{ t('components.audio.PlayButton.button.episodeDetails') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ $t('components.audio.PlayButton.button.trackDetails') }}
|
{{ t('components.audio.PlayButton.button.trackDetails') }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</PopoverItem>
|
||||||
<div class="divider" />
|
|
||||||
<button
|
<hr v-if="filterableArtist || Object.keys(getReportableObjects({ track, album, artist, playlist, account, channel })).length > 0">
|
||||||
|
|
||||||
|
<PopoverItem
|
||||||
v-if="filterableArtist"
|
v-if="filterableArtist"
|
||||||
class="item basic"
|
|
||||||
:disabled="!filterableArtist"
|
:disabled="!filterableArtist"
|
||||||
:title="labels.hideArtist"
|
:title="labels.hideArtist"
|
||||||
|
icon="bi-eye-slash"
|
||||||
@click.stop.prevent="filterArtist"
|
@click.stop.prevent="filterArtist"
|
||||||
>
|
>
|
||||||
<i class="eye slash outline icon" />
|
|
||||||
{{ labels.hideArtist }}
|
{{ labels.hideArtist }}
|
||||||
</button>
|
</PopoverItem>
|
||||||
<button
|
|
||||||
v-for="obj in getReportableObjects({track, album, artist, playlist, account, channel})"
|
<PopoverItem
|
||||||
|
v-for="obj in getReportableObjects({ track, album, artist, playlist, account, channel })"
|
||||||
:key="obj.target.type + obj.target.id"
|
:key="obj.target.type + obj.target.id"
|
||||||
class="item basic"
|
icon="bi-exclamation-triangle-fill"
|
||||||
@click.stop.prevent="report(obj)"
|
@click.stop.prevent="report(obj)"
|
||||||
>
|
>
|
||||||
<i class="share icon" /> {{ obj.label }}
|
{{ obj.label }}
|
||||||
</button>
|
</PopoverItem>
|
||||||
</div>
|
</template>
|
||||||
</button>
|
</Popover>
|
||||||
|
<Button
|
||||||
|
v-else
|
||||||
|
v-bind="{
|
||||||
|
disabled: !playable,
|
||||||
|
primary: playable,
|
||||||
|
}"
|
||||||
|
:aria-label="labels.replacePlay"
|
||||||
|
:class="[...buttonClasses, 'play-button']"
|
||||||
|
:isloading="isLoading"
|
||||||
|
:square="iconOnly"
|
||||||
|
:icon="!playing ? playIconClass : 'bi-pause-fill'"
|
||||||
|
:round="iconOnly"
|
||||||
|
:primary="iconOnly && !discrete"
|
||||||
|
:ghost="discrete"
|
||||||
|
:low-height="lowHeight || undefined"
|
||||||
|
@click.stop.prevent="replacePlay()"
|
||||||
|
>
|
||||||
|
<template v-if="!discrete && !iconOnly">
|
||||||
|
<span>
|
||||||
|
{{ t('components.audio.PlayButton.button.discretePlay') }}
|
||||||
</span>
|
</span>
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.funkwhale.split-button {
|
||||||
|
&.button {
|
||||||
|
gap: 0px;
|
||||||
|
padding: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { useMouse, useWindowSize } from '@vueuse/core'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
|
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
|
||||||
import time from '~/utils/time'
|
import time from '~/utils/time'
|
||||||
|
@ -14,6 +15,8 @@ import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
||||||
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
|
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
|
||||||
import PlayerControls from './PlayerControls.vue'
|
import PlayerControls from './PlayerControls.vue'
|
||||||
import VolumeControl from './VolumeControl.vue'
|
import VolumeControl from './VolumeControl.vue'
|
||||||
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
LoopingMode,
|
LoopingMode,
|
||||||
|
@ -43,14 +46,26 @@ const {
|
||||||
} = useQueue()
|
} = useQueue()
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
const router = useRouter()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const toggleMobilePlayer = () => {
|
/** Toggle between null and player */
|
||||||
store.commit('ui/queueFocused', ['queue', 'player'].includes(store.state.ui.queueFocused as string) ? null : 'player')
|
const togglePlayer = () => {
|
||||||
|
store.commit('ui/queueFocused',
|
||||||
|
store.state.ui.queueFocused === 'queue'
|
||||||
|
? null
|
||||||
|
: store.state.ui.queueFocused === 'player'
|
||||||
|
? null
|
||||||
|
: 'player'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const switchTab = () => {
|
||||||
|
store.commit('ui/queueFocused', store.state.ui.queueFocused === 'player' ? 'queue' : 'player')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Key binds
|
// Key binds
|
||||||
onKeyboardShortcut('e', toggleMobilePlayer)
|
onKeyboardShortcut('e', togglePlayer)
|
||||||
onKeyboardShortcut('p', () => { isPlaying.value = !isPlaying.value })
|
onKeyboardShortcut('p', () => { isPlaying.value = !isPlaying.value })
|
||||||
onKeyboardShortcut('s', shuffle)
|
onKeyboardShortcut('s', shuffle)
|
||||||
onKeyboardShortcut('q', clear)
|
onKeyboardShortcut('q', clear)
|
||||||
|
@ -84,10 +99,6 @@ const labels = computed(() => ({
|
||||||
addArtistContentFilter: t('components.audio.Player.label.addArtistContentFilter')
|
addArtistContentFilter: t('components.audio.Player.label.addArtistContentFilter')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const switchTab = () => {
|
|
||||||
store.commit('ui/queueFocused', store.state.ui.queueFocused === 'player' ? 'queue' : 'player')
|
|
||||||
}
|
|
||||||
|
|
||||||
const progressBar = ref()
|
const progressBar = ref()
|
||||||
const touchProgress = (event: MouseEvent) => {
|
const touchProgress = (event: MouseEvent) => {
|
||||||
const time = ((event.clientX - ((event.target as Element).closest('.progress')?.getBoundingClientRect().left ?? 0)) / progressBar.value.offsetWidth) * duration.value
|
const time = ((event.clientX - ((event.target as Element).closest('.progress')?.getBoundingClientRect().left ?? 0)) / progressBar.value.offsetWidth) * duration.value
|
||||||
|
@ -108,17 +119,18 @@ const loopingTitle = computed(() => {
|
||||||
: t('components.audio.Player.label.loopingWholeQueue')
|
: t('components.audio.Player.label.loopingWholeQueue')
|
||||||
})
|
})
|
||||||
|
|
||||||
const hideArtist = () => {
|
// TODO: check if still useful for filtering
|
||||||
if (currentTrack.value.artistId !== -1 && currentTrack.value.artistCredit) {
|
// const hideArtist = () => {
|
||||||
return store.dispatch('moderation/hide', {
|
// if (currentTrack.value.artistId !== -1 && currentTrack.value.artistCredit) {
|
||||||
type: 'artist',
|
// return store.dispatch('moderation/hide', {
|
||||||
target: {
|
// type: 'artist',
|
||||||
id: currentTrack.value.artistCredit[0].artist.id,
|
// target: {
|
||||||
name: currentTrack.value.artistCredit[0].artist.name
|
// id: currentTrack.value.artistCredit[0].artist.id,
|
||||||
}
|
// name: currentTrack.value.artistCredit[0].artist.name
|
||||||
})
|
// }
|
||||||
}
|
// })
|
||||||
}
|
// }
|
||||||
|
// }
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -135,7 +147,7 @@ const hideArtist = () => {
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="ui inverted segment fixed-controls"
|
class="ui inverted segment fixed-controls"
|
||||||
@click.prevent.stop="toggleMobilePlayer"
|
@click.prevent.stop="togglePlayer"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref="progressBar"
|
ref="progressBar"
|
||||||
|
@ -156,12 +168,13 @@ const hideArtist = () => {
|
||||||
<div class="controls track-controls queue-not-focused desktop-and-up">
|
<div class="controls track-controls queue-not-focused desktop-and-up">
|
||||||
<div
|
<div
|
||||||
class="ui tiny image"
|
class="ui tiny image"
|
||||||
@click.stop.prevent="$router.push({name: 'library.tracks.detail', params: {id: currentTrack.id }})"
|
@click.stop.prevent="router.push({name: 'library.tracks.detail', params: {id: currentTrack.id }})"
|
||||||
>
|
>
|
||||||
|
<!-- TODO: Use smaller covers -->
|
||||||
<img
|
<img
|
||||||
ref="cover"
|
ref="cover"
|
||||||
|
v-lazy="store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
||||||
alt=""
|
alt=""
|
||||||
:src="$store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -170,7 +183,7 @@ const hideArtist = () => {
|
||||||
>
|
>
|
||||||
<strong>
|
<strong>
|
||||||
<router-link
|
<router-link
|
||||||
class="small header discrete link track"
|
class="header discrete link track"
|
||||||
:to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}"
|
:to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}"
|
||||||
@click.stop.prevent=""
|
@click.stop.prevent=""
|
||||||
>
|
>
|
||||||
|
@ -184,11 +197,11 @@ const hideArtist = () => {
|
||||||
:key="ac.artist.id"
|
:key="ac.artist.id"
|
||||||
>
|
>
|
||||||
<router-link
|
<router-link
|
||||||
class="discrete link"
|
class="small discrete link"
|
||||||
:to="{name: 'library.artists.detail', params: {id: ac.artist.id }}"
|
:to="{name: 'library.artists.detail', params: {id: ac.artist.id }}"
|
||||||
@click.stop.prevent=""
|
@click.stop.prevent=""
|
||||||
>
|
>
|
||||||
{{ ac.credit ?? $t('components.audio.Player.meta.unknownArtist') }}
|
{{ ac.credit ?? t('components.audio.Player.meta.unknownArtist') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<span>{{ ac.joinphrase }}</span>
|
<span>{{ ac.joinphrase }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
@ -196,11 +209,11 @@ const hideArtist = () => {
|
||||||
<template v-if="currentTrack.albumId !== -1">
|
<template v-if="currentTrack.albumId !== -1">
|
||||||
<span class="middle slash symbol" />
|
<span class="middle slash symbol" />
|
||||||
<router-link
|
<router-link
|
||||||
class="discrete link"
|
class="small discrete link"
|
||||||
:to="{name: 'library.albums.detail', params: {id: currentTrack.albumId }}"
|
:to="{name: 'library.albums.detail', params: {id: currentTrack.albumId }}"
|
||||||
@click.stop.prevent=""
|
@click.stop.prevent=""
|
||||||
>
|
>
|
||||||
{{ currentTrack.albumTitle ?? $t('components.audio.Player.meta.unknownAlbum') }}
|
{{ currentTrack.albumTitle ?? t('components.audio.Player.meta.unknownAlbum') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
@ -208,51 +221,57 @@ const hideArtist = () => {
|
||||||
</div>
|
</div>
|
||||||
<div class="controls track-controls queue-not-focused desktop-and-below">
|
<div class="controls track-controls queue-not-focused desktop-and-below">
|
||||||
<div class="ui tiny image">
|
<div class="ui tiny image">
|
||||||
|
<!-- TODO: Use smaller covers -->
|
||||||
<img
|
<img
|
||||||
ref="cover"
|
ref="cover"
|
||||||
|
v-lazy="store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
||||||
alt=""
|
alt=""
|
||||||
:src="$store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="middle aligned content ellipsis">
|
<div class="middle aligned content ellipsis">
|
||||||
<strong>
|
<strong>
|
||||||
{{ currentTrack.title }}
|
{{ currentTrack.title }}
|
||||||
</strong>
|
</strong>
|
||||||
<div class="meta">
|
<Layout
|
||||||
|
flex
|
||||||
|
no-gap
|
||||||
|
class="meta"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
v-for="ac in currentTrack.artistCredit"
|
v-for="ac in currentTrack.artistCredit"
|
||||||
:key="ac.artist.id"
|
:key="ac.artist.id"
|
||||||
>
|
>
|
||||||
{{ ac.credit ?? $t('components.audio.Player.meta.unknownArtist') }}
|
{{ ac.credit ?? t('components.audio.Player.meta.unknownArtist') }}
|
||||||
<span>{{ ac.joinphrase }}</span>
|
<span>{{ ac.joinphrase }}</span>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="currentTrack.albumId !== -1">
|
<template v-if="currentTrack.albumId !== -1">
|
||||||
<span class="middle slash symbol" />
|
<span class="middle slash symbol" />
|
||||||
{{ currentTrack.albumTitle ?? $t('components.audio.Player.meta.unknownAlbum') }}
|
{{ currentTrack.albumTitle ?? t('components.audio.Player.meta.unknownAlbum') }}
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</Layout>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="$store.state.auth.authenticated"
|
v-if="store.state.auth.authenticated"
|
||||||
class="controls desktop-and-up fluid align-right"
|
class="controls desktop-and-up fluid align-right"
|
||||||
>
|
>
|
||||||
<track-favorite-icon
|
<track-favorite-icon
|
||||||
class="control white"
|
ghost
|
||||||
:track="currentTrack"
|
:track="currentTrack"
|
||||||
/>
|
/>
|
||||||
<track-playlist-icon
|
<track-playlist-icon
|
||||||
class="control white"
|
ghost
|
||||||
:track="currentTrack"
|
:track="currentTrack"
|
||||||
/>
|
/>
|
||||||
<button
|
<!-- <Button
|
||||||
:class="['ui', 'really', 'basic', 'circular', 'icon', 'button', 'control']"
|
round
|
||||||
|
ghost
|
||||||
|
icon="bi-eye-slash"
|
||||||
:aria-label="labels.addArtistContentFilter"
|
:aria-label="labels.addArtistContentFilter"
|
||||||
:title="labels.addArtistContentFilter"
|
:title="labels.addArtistContentFilter"
|
||||||
@click="hideArtist"
|
@click="hideArtist"
|
||||||
>
|
>
|
||||||
<i :class="['eye slash outline', 'basic', 'icon']" />
|
</Button> -->
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<player-controls class="controls queue-not-focused" />
|
<player-controls class="controls queue-not-focused" />
|
||||||
<div class="controls progress-controls queue-not-focused tablet-and-up small align-left">
|
<div class="controls progress-controls queue-not-focused tablet-and-up small align-left">
|
||||||
|
@ -272,49 +291,39 @@ const hideArtist = () => {
|
||||||
<div class="controls queue-controls when-queue-focused align-right">
|
<div class="controls queue-controls when-queue-focused align-right">
|
||||||
<div class="group">
|
<div class="group">
|
||||||
<volume-control class="expandable" />
|
<volume-control class="expandable" />
|
||||||
<button
|
<Button
|
||||||
class="circular control button"
|
|
||||||
:class="{ looping: looping !== LoopingMode.None }"
|
:class="{ looping: looping !== LoopingMode.None }"
|
||||||
:title="loopingTitle"
|
:title="loopingTitle"
|
||||||
|
ghost
|
||||||
|
round
|
||||||
:aria-label="loopingTitle"
|
:aria-label="loopingTitle"
|
||||||
:disabled="!currentTrack"
|
:disabled="!currentTrack"
|
||||||
|
:icon="looping === LoopingMode.LoopTrack ? 'bi-repeat-1' : 'bi-repeat'"
|
||||||
@click.prevent.stop="toggleLooping"
|
@click.prevent.stop="toggleLooping"
|
||||||
>
|
|
||||||
<i class="repeat icon">
|
|
||||||
<span
|
|
||||||
v-if="looping !== LoopingMode.None"
|
|
||||||
class="ui circular tiny vibrant label"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-if="looping === LoopingMode.LoopTrack"
|
|
||||||
class="symbol single"
|
|
||||||
/>
|
/>
|
||||||
<span
|
|
||||||
v-else-if="looping === LoopingMode.LoopQueue"
|
|
||||||
class="infinity symbol"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
class="circular control button"
|
round
|
||||||
|
ghost
|
||||||
|
:class="{ shuffling: isShuffled }"
|
||||||
:disabled="queue.length === 0"
|
:disabled="queue.length === 0"
|
||||||
:title="labels.shuffle"
|
:title="labels.shuffle"
|
||||||
:aria-label="labels.shuffle"
|
:aria-label="labels.shuffle"
|
||||||
|
icon="bi-shuffle"
|
||||||
@click.prevent.stop="shuffle()"
|
@click.prevent.stop="shuffle()"
|
||||||
>
|
/>
|
||||||
<i :class="['ui', 'random', { disabled: queue.length === 0, shuffling: isShuffled }, 'icon']" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- TODO: Remove fake responsive elements -->
|
||||||
<div class="group">
|
<div class="group">
|
||||||
<div class="fake-dropdown">
|
<div class="fake-dropdown">
|
||||||
<button
|
<Button
|
||||||
class="position circular control button desktop-and-up"
|
|
||||||
aria-expanded="true"
|
aria-expanded="true"
|
||||||
@click.stop="toggleMobilePlayer"
|
ghost
|
||||||
|
round
|
||||||
|
icon="bi-music-note-list"
|
||||||
|
@click.stop="togglePlayer"
|
||||||
>
|
>
|
||||||
<i class="stream icon" />
|
|
||||||
<i18n-t keypath="components.audio.Player.meta.position">
|
<i18n-t keypath="components.audio.Player.meta.position">
|
||||||
<template #index>
|
<template #index>
|
||||||
{{ currentIndex + 1 }}
|
{{ currentIndex + 1 }}
|
||||||
|
@ -323,12 +332,11 @@ const hideArtist = () => {
|
||||||
{{ queue.length }}
|
{{ queue.length }}
|
||||||
</template>
|
</template>
|
||||||
</i18n-t>
|
</i18n-t>
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
class="position circular control button desktop-and-below"
|
class="position circular control button desktop-and-below"
|
||||||
@click.stop="switchTab"
|
icon="bi-music-note-list"
|
||||||
>
|
>
|
||||||
<i class="stream icon" />
|
|
||||||
<i18n-t keypath="components.audio.Player.meta.position">
|
<i18n-t keypath="components.audio.Player.meta.position">
|
||||||
<template #index>
|
<template #index>
|
||||||
{{ currentIndex + 1 }}
|
{{ currentIndex + 1 }}
|
||||||
|
@ -337,46 +345,35 @@ const hideArtist = () => {
|
||||||
{{ queue.length }}
|
{{ queue.length }}
|
||||||
</template>
|
</template>
|
||||||
</i18n-t>
|
</i18n-t>
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
v-if="$store.state.ui.queueFocused"
|
ghost
|
||||||
class="circular control button close-control desktop-and-up"
|
:class="['desktop-and-up', { 'close-control': store.state.ui.queueFocused }]"
|
||||||
@click.stop="toggleMobilePlayer"
|
:icon="store.state.ui.queueFocused ? 'bi-chevron-down' : 'bi-chevron-up'"
|
||||||
>
|
:aria-pressed="store.state.ui.queueFocused ? true : undefined"
|
||||||
<i class="large down angle icon" />
|
@click.stop="togglePlayer"
|
||||||
</button>
|
/>
|
||||||
<button
|
<Button
|
||||||
v-else
|
ghost
|
||||||
class="circular control button desktop-and-up"
|
:class="['desktop-and-below', { 'close-control': store.state.ui.queueFocused === 'player' }]"
|
||||||
@click.stop="toggleMobilePlayer"
|
:icon="store.state.ui.queueFocused === 'queue' ? 'bi-chevron-down' : 'bi-chevron-up'"
|
||||||
>
|
:aria-pressed="store.state.ui.queueFocused ? true : undefined"
|
||||||
<i class="large up angle icon" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="$store.state.ui.queueFocused === 'player'"
|
|
||||||
class="circular control button close-control desktop-and-below"
|
|
||||||
@click.stop="switchTab"
|
@click.stop="switchTab"
|
||||||
>
|
/>
|
||||||
<i class="large up angle icon" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="$store.state.ui.queueFocused === 'queue'"
|
|
||||||
class="circular control button desktop-and-below"
|
|
||||||
@click.stop="switchTab"
|
|
||||||
>
|
|
||||||
<i class="large down angle icon" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
class="circular control button close-control desktop-and-below"
|
class="close-control desktop-and-below"
|
||||||
@click.stop="$store.commit('ui/queueFocused', null)"
|
icon="bi-x"
|
||||||
>
|
@click.stop="store.commit('ui/queueFocused', null)"
|
||||||
<i class="x icon" />
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
|
@ -5,7 +5,10 @@ import { computed } from 'vue'
|
||||||
import { usePlayer } from '~/composables/audio/player'
|
import { usePlayer } from '~/composables/audio/player'
|
||||||
import { useQueue } from '~/composables/audio/queue'
|
import { useQueue } from '~/composables/audio/queue'
|
||||||
|
|
||||||
const { playPrevious, hasNext, playNext, currentTrack } = useQueue()
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
|
||||||
|
// TODO: Check if we want to use `currentTrack` from useQueue() in order to disable some icon. Or not.
|
||||||
|
const { playPrevious, hasNext, playNext } = useQueue()
|
||||||
const { isPlaying } = usePlayer()
|
const { isPlaying } = usePlayer()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
@ -19,40 +22,33 @@ const labels = computed(() => ({
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="player-controls">
|
<div class="player-controls">
|
||||||
<button
|
<Button
|
||||||
:title="labels.previous"
|
:title="labels.previous"
|
||||||
:aria-label="labels.previous"
|
:aria-label="labels.previous"
|
||||||
class="circular button control tablet-and-up"
|
round
|
||||||
|
ghost
|
||||||
|
class="control tablet-and-up"
|
||||||
|
icon="bi-skip-backward-fill"
|
||||||
@click.prevent.stop="playPrevious()"
|
@click.prevent.stop="playPrevious()"
|
||||||
>
|
/>
|
||||||
<i :class="['ui', 'large', 'backward step', 'icon']" />
|
<Button
|
||||||
</button>
|
:title="isPlaying ? labels.pause : labels.play"
|
||||||
<button
|
round
|
||||||
v-if="!isPlaying"
|
ghost
|
||||||
:title="labels.play"
|
:aria-label="isPlaying ? labels.pause : labels.play"
|
||||||
:aria-label="labels.play"
|
:class="['control', isPlaying ? 'pause' : 'play', 'large']"
|
||||||
class="circular button control"
|
:icon="isPlaying ? 'bi-pause-fill' : 'bi-play-fill'"
|
||||||
@click.prevent.stop="isPlaying = true"
|
@click.prevent.stop="isPlaying = !isPlaying"
|
||||||
>
|
/>
|
||||||
<i :class="['ui', 'big', 'play', {'disabled': !currentTrack}, 'icon']" />
|
<Button
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
:title="labels.pause"
|
|
||||||
:aria-label="labels.pause"
|
|
||||||
class="circular button control"
|
|
||||||
@click.prevent.stop="isPlaying = false"
|
|
||||||
>
|
|
||||||
<i :class="['ui', 'big', 'pause', {'disabled': !currentTrack}, 'icon']" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:title="labels.next"
|
:title="labels.next"
|
||||||
:aria-label="labels.next"
|
:aria-label="labels.next"
|
||||||
|
round
|
||||||
|
ghost
|
||||||
:disabled="!hasNext"
|
:disabled="!hasNext"
|
||||||
class="circular button control"
|
class="control"
|
||||||
|
icon="bi-skip-forward-fill"
|
||||||
@click.prevent.stop="playNext()"
|
@click.prevent.stop="playNext()"
|
||||||
>
|
/>
|
||||||
<i :class="['ui', 'large', {'disabled': !hasNext}, 'forward step', 'icon']" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -6,8 +6,8 @@ import { ref, computed, reactive, watch, onMounted } from 'vue'
|
||||||
import { refDebounced } from '@vueuse/core'
|
import { refDebounced } from '@vueuse/core'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import AlbumCard from '~/components/audio/album/Card.vue'
|
import AlbumCard from '~/components/album/Card.vue'
|
||||||
import ArtistCard from '~/components/audio/artist/Card.vue'
|
import ArtistCard from '~/components/artist/Card.vue'
|
||||||
|
|
||||||
import useErrorHandler from '~/composables/useErrorHandler'
|
import useErrorHandler from '~/composables/useErrorHandler'
|
||||||
import useLogger from '~/composables/useLogger'
|
import useLogger from '~/composables/useLogger'
|
||||||
|
@ -73,7 +73,7 @@ const labels = computed(() => ({
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h2>
|
<h2>
|
||||||
{{ $t('components.audio.Search.header.search') }}
|
{{ t('components.audio.Search.header.search') }}
|
||||||
</h2>
|
</h2>
|
||||||
<div :class="['ui', {'loading': isLoading }, 'search']">
|
<div :class="['ui', {'loading': isLoading }, 'search']">
|
||||||
<div class="ui icon big input">
|
<div class="ui icon big input">
|
||||||
|
@ -89,7 +89,7 @@ const labels = computed(() => ({
|
||||||
</div>
|
</div>
|
||||||
<template v-if="query.length > 0">
|
<template v-if="query.length > 0">
|
||||||
<h3 class="ui title">
|
<h3 class="ui title">
|
||||||
{{ $t('components.audio.Search.header.artists') }}
|
{{ t('components.audio.Search.header.artists') }}
|
||||||
</h3>
|
</h3>
|
||||||
<div v-if="results.artists.length > 0">
|
<div v-if="results.artists.length > 0">
|
||||||
<div class="ui cards">
|
<div class="ui cards">
|
||||||
|
@ -101,12 +101,12 @@ const labels = computed(() => ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-else>
|
<p v-else>
|
||||||
{{ $t('components.audio.Search.empty.noArtists') }}
|
{{ t('components.audio.Search.empty.noArtists') }}
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="query.length > 0">
|
<template v-if="query.length > 0">
|
||||||
<h3 class="ui title">
|
<h3 class="ui title">
|
||||||
{{ $t('components.audio.Search.header.albums') }}
|
{{ t('components.audio.Search.header.albums') }}
|
||||||
</h3>
|
</h3>
|
||||||
<div
|
<div
|
||||||
v-if="results.albums.length > 0"
|
v-if="results.albums.length > 0"
|
||||||
|
@ -124,7 +124,7 @@ const labels = computed(() => ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-else>
|
<p v-else>
|
||||||
{{ $t('components.audio.Search.empty.noAlbums') }}
|
{{ t('components.audio.Search.empty.noAlbums') }}
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,49 +1,12 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Artist, Track, Album, Tag } from '~/types'
|
|
||||||
import type { RouteRecordName, RouteLocationNamedRaw } from 'vue-router'
|
|
||||||
|
|
||||||
import jQuery from 'jquery'
|
import { useFocus } from '@vueuse/core'
|
||||||
import { trim } from 'lodash-es'
|
|
||||||
import { useFocus, useCurrentElement } from '@vueuse/core'
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useStore } from '~/store'
|
|
||||||
import { generateTrackCreditString } from '~/utils/utils'
|
|
||||||
|
|
||||||
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
|
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
|
||||||
|
|
||||||
interface Events {
|
|
||||||
(e: 'search'): void
|
|
||||||
}
|
|
||||||
|
|
||||||
type CategoryCode = 'federation' | 'podcasts' | 'artists' | 'albums' | 'tracks' | 'tags' | 'more'
|
|
||||||
interface Category {
|
|
||||||
code: CategoryCode,
|
|
||||||
name: string,
|
|
||||||
route: RouteRecordName
|
|
||||||
getId: (obj: unknown) => number
|
|
||||||
getTitle: (obj: unknown) => string
|
|
||||||
getDescription: (obj: unknown) => string
|
|
||||||
}
|
|
||||||
|
|
||||||
type SimpleCategory = Partial<Category> & Pick<Category, 'code' | 'name'>
|
|
||||||
const isCategoryGuard = (object: Category | SimpleCategory): object is Category => typeof object.route === 'string'
|
|
||||||
|
|
||||||
interface Results {
|
|
||||||
name: string,
|
|
||||||
results: Result[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Result {
|
|
||||||
title: string
|
|
||||||
id?: number
|
|
||||||
description?: string
|
|
||||||
routerUrl: RouteLocationNamedRaw
|
|
||||||
}
|
|
||||||
|
|
||||||
const emit = defineEmits<Events>()
|
|
||||||
|
|
||||||
const search = ref()
|
const search = ref()
|
||||||
const { focused } = useFocus(search)
|
const { focused } = useFocus(search)
|
||||||
onKeyboardShortcut(['shift', 'f'], () => (focused.value = true), true)
|
onKeyboardShortcut(['shift', 'f'], () => (focused.value = true), true)
|
||||||
|
@ -60,12 +23,11 @@ const labels = computed(() => ({
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useStore()
|
|
||||||
const el = useCurrentElement()
|
|
||||||
const query = ref()
|
const query = ref()
|
||||||
|
|
||||||
const enter = () => {
|
const enter = () => {
|
||||||
jQuery(el.value).search('cancel query')
|
// TODO: Find out what jQuery version supports `search`
|
||||||
|
// jQuery(el.value).search('cancel query')
|
||||||
|
|
||||||
// Cancel any API search request to backend…
|
// Cancel any API search request to backend…
|
||||||
return router.push(`/search?q=${query.value}&type=artists`)
|
return router.push(`/search?q=${query.value}&type=artists`)
|
||||||
|
@ -75,168 +37,109 @@ const blur = () => {
|
||||||
search.value.blur()
|
search.value.blur()
|
||||||
}
|
}
|
||||||
|
|
||||||
const categories = computed(() => [
|
|
||||||
{
|
|
||||||
code: 'federation',
|
|
||||||
name: t('components.audio.SearchBar.label.category.federation')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'podcasts',
|
|
||||||
name: t('components.audio.SearchBar.label.category.podcasts')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'artists',
|
|
||||||
route: 'library.artists.detail',
|
|
||||||
name: labels.value.artist,
|
|
||||||
getId: (obj: Artist) => obj.id,
|
|
||||||
getTitle: (obj: Artist) => obj.name,
|
|
||||||
getDescription: () => ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'albums',
|
|
||||||
route: 'library.albums.detail',
|
|
||||||
name: labels.value.album,
|
|
||||||
getId: (obj: Album) => obj.id,
|
|
||||||
getTitle: (obj: Album) => obj.title,
|
|
||||||
getDescription: (obj: Album) => generateTrackCreditString(obj)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'tracks',
|
|
||||||
route: 'library.tracks.detail',
|
|
||||||
name: labels.value.track,
|
|
||||||
getId: (obj: Track) => obj.id,
|
|
||||||
getTitle: (obj: Track) => obj.title,
|
|
||||||
getDescription: (track: Track) => {
|
|
||||||
const album = track.album ?? null
|
|
||||||
return generateTrackCreditString(album) ?? generateTrackCreditString(track) ?? ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'tags',
|
|
||||||
route: 'library.tags.detail',
|
|
||||||
name: labels.value.tag,
|
|
||||||
getId: (obj: Tag) => obj.name,
|
|
||||||
getTitle: (obj: Tag) => `#${obj.name}`,
|
|
||||||
getDescription: (obj: Tag) => ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: 'more',
|
|
||||||
name: ''
|
|
||||||
}
|
|
||||||
] as (Category | SimpleCategory)[])
|
|
||||||
|
|
||||||
const objectId = computed(() => {
|
|
||||||
const trimmedQuery = trim(trim(query.value), '@')
|
|
||||||
|
|
||||||
if (trimmedQuery.startsWith('http://') || trimmedQuery.startsWith('https://') || trimmedQuery.includes('@')) {
|
|
||||||
return query.value
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
jQuery(el.value).search({
|
// TODO: Find out what jQuery version supports `search`
|
||||||
type: 'category',
|
// jQuery(el.value).search({
|
||||||
minCharacters: 3,
|
// type: 'category',
|
||||||
showNoResults: true,
|
// minCharacters: 3,
|
||||||
error: {
|
// showNoResults: true,
|
||||||
// @ts-expect-error Semantic is broken
|
// error: {
|
||||||
noResultsHeader: t('components.audio.SearchBar.header.noResults'),
|
// // @ts-expect-error Semantic is broken
|
||||||
noResults: t('components.audio.SearchBar.empty.noResults')
|
// noResultsHeader: t('components.audio.SearchBar.header.noResults'),
|
||||||
},
|
// noResults: t('components.audio.SearchBar.empty.noResults')
|
||||||
|
// },
|
||||||
|
|
||||||
onSelect (result, response) {
|
// onSelect (result, response) {
|
||||||
jQuery(el.value).search('set value', query.value)
|
// jQuery(el.value).search('set value', query.value)
|
||||||
router.push(result.routerUrl)
|
// router.push(result.routerUrl)
|
||||||
jQuery(el.value).search('hide results')
|
// jQuery(el.value).search('hide results')
|
||||||
return false
|
// return false
|
||||||
},
|
// },
|
||||||
onSearchQuery (value) {
|
// onSearchQuery (value) {
|
||||||
// query.value = value
|
// // query.value = value
|
||||||
emit('search')
|
// emit('search')
|
||||||
},
|
// },
|
||||||
apiSettings: {
|
// apiSettings: {
|
||||||
url: store.getters['instance/absoluteUrl']('api/v1/search?query={query}'),
|
// url: store.getters['instance/absoluteUrl']('api/v1/search?query={query}'),
|
||||||
beforeXHR: function (xhrObject) {
|
// beforeXHR: function (xhrObject) {
|
||||||
if (!store.state.auth.authenticated) {
|
// if (!store.state.auth.authenticated) {
|
||||||
return xhrObject
|
// return xhrObject
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (store.state.auth.oauth.accessToken) {
|
// if (store.state.auth.oauth.accessToken) {
|
||||||
xhrObject.setRequestHeader('Authorization', store.getters['auth/header'])
|
// xhrObject.setRequestHeader('Authorization', store.getters['auth/header'])
|
||||||
}
|
// }
|
||||||
|
|
||||||
return xhrObject
|
// return xhrObject
|
||||||
},
|
// },
|
||||||
onResponse: function (initialResponse) {
|
// onResponse: function (initialResponse) {
|
||||||
const id = objectId.value
|
// const id = objectId.value
|
||||||
const results: Partial<Record<CategoryCode, Results>> = {}
|
// const results: Partial<Record<CategoryCode, Results>> = {}
|
||||||
|
|
||||||
let resultsEmpty = true
|
// let resultsEmpty = true
|
||||||
for (const category of categories.value) {
|
// for (const category of categories.value) {
|
||||||
results[category.code] = {
|
// results[category.code] = {
|
||||||
name: category.name,
|
// name: category.name,
|
||||||
results: []
|
// results: []
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (category.code === 'federation' && id) {
|
// if (category.code === 'federation' && id) {
|
||||||
resultsEmpty = false
|
// resultsEmpty = false
|
||||||
results[category.code]?.results.push({
|
// results[category.code]?.results.push({
|
||||||
title: t('components.audio.SearchBar.link.fediverse'),
|
// title: t('components.audio.SearchBar.link.fediverse'),
|
||||||
routerUrl: {
|
// routerUrl: {
|
||||||
name: 'search',
|
// name: 'search',
|
||||||
query: { id }
|
// query: { id }
|
||||||
}
|
// }
|
||||||
})
|
// })
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (category.code === 'podcasts' && id) {
|
// if (category.code === 'podcasts' && id) {
|
||||||
resultsEmpty = false
|
// resultsEmpty = false
|
||||||
results[category.code]?.results.push({
|
// results[category.code]?.results.push({
|
||||||
title: t('components.audio.SearchBar.link.rss'),
|
// title: t('components.audio.SearchBar.link.rss'),
|
||||||
routerUrl: {
|
// routerUrl: {
|
||||||
name: 'search',
|
// name: 'search',
|
||||||
query: { id, type: 'rss' }
|
// query: { id, type: 'rss' }
|
||||||
}
|
// }
|
||||||
})
|
// })
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (category.code === 'more') {
|
// if (category.code === 'more') {
|
||||||
results[category.code]?.results.push({
|
// results[category.code]?.results.push({
|
||||||
title: t('components.audio.SearchBar.link.more'),
|
// title: t('components.audio.SearchBar.link.more'),
|
||||||
routerUrl: {
|
// routerUrl: {
|
||||||
name: 'search',
|
// name: 'search',
|
||||||
query: { type: 'artists', q: query.value }
|
// query: { type: 'artists', q: query.value }
|
||||||
}
|
// }
|
||||||
})
|
// })
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (isCategoryGuard(category)) {
|
// if (isCategoryGuard(category)) {
|
||||||
for (const result of initialResponse[category.code]) {
|
// for (const result of initialResponse[category.code]) {
|
||||||
resultsEmpty = false
|
// resultsEmpty = false
|
||||||
const id = category.getId(result)
|
// const id = category.getId(result)
|
||||||
results[category.code]?.results.push({
|
// results[category.code]?.results.push({
|
||||||
title: category.getTitle(result),
|
// title: category.getTitle(result),
|
||||||
id,
|
// id,
|
||||||
routerUrl: {
|
// routerUrl: {
|
||||||
name: category.route,
|
// name: category.route,
|
||||||
params: { id }
|
// params: { id }
|
||||||
},
|
// },
|
||||||
description: category.getDescription(result)
|
// description: category.getDescription(result)
|
||||||
})
|
// })
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
return {
|
// return {
|
||||||
results: resultsEmpty
|
// results: resultsEmpty
|
||||||
? {}
|
// ? {}
|
||||||
: results
|
// : results
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
})
|
// })
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,8 @@ import { useTimeoutFn } from '@vueuse/core'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
|
||||||
const { volume, mute } = usePlayer()
|
const { volume, mute } = usePlayer()
|
||||||
const expanded = ref(false)
|
const expanded = ref(false)
|
||||||
|
|
||||||
|
@ -32,8 +34,10 @@ const scroll = (event: WheelEvent) => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button
|
<Button
|
||||||
class="circular control button"
|
round
|
||||||
|
ghost
|
||||||
|
square
|
||||||
:class="['component-volume-control', {'expanded': expanded}]"
|
:class="['component-volume-control', {'expanded': expanded}]"
|
||||||
@click.prevent.stop=""
|
@click.prevent.stop=""
|
||||||
@mouseover="handleOver"
|
@mouseover="handleOver"
|
||||||
|
@ -47,7 +51,7 @@ const scroll = (event: WheelEvent) => {
|
||||||
:aria-label="labels.unmute"
|
:aria-label="labels.unmute"
|
||||||
@click.prevent.stop="mute"
|
@click.prevent.stop="mute"
|
||||||
>
|
>
|
||||||
<i class="volume off icon" />
|
<i class="bi bi-volume-mute-fill" />
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-else-if="volume < 0.5"
|
v-else-if="volume < 0.5"
|
||||||
|
@ -56,7 +60,7 @@ const scroll = (event: WheelEvent) => {
|
||||||
:aria-label="labels.mute"
|
:aria-label="labels.mute"
|
||||||
@click.prevent.stop="mute"
|
@click.prevent.stop="mute"
|
||||||
>
|
>
|
||||||
<i class="volume down icon" />
|
<i class="bi bi-volume-down-fill" />
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-else
|
v-else
|
||||||
|
@ -65,7 +69,7 @@ const scroll = (event: WheelEvent) => {
|
||||||
:aria-label="labels.mute"
|
:aria-label="labels.mute"
|
||||||
@click.prevent.stop="mute"
|
@click.prevent.stop="mute"
|
||||||
>
|
>
|
||||||
<i class="volume up icon" />
|
<i class="bi bi-volume-up-fill" />
|
||||||
</span>
|
</span>
|
||||||
<div class="popup">
|
<div class="popup">
|
||||||
<label
|
<label
|
||||||
|
@ -81,5 +85,5 @@ const scroll = (event: WheelEvent) => {
|
||||||
max="1"
|
max="1"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,83 +1,16 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Album } from '~/types'
|
import type { Album } from '~/types'
|
||||||
|
|
||||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
import AlbumCard from '~/components/album/Card.vue'
|
||||||
import { momentFormat } from '~/utils/filters'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useStore } from '~/store'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
album: Album
|
album: Album
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
defineProps<Props>()
|
||||||
const store = useStore()
|
|
||||||
|
|
||||||
const imageUrl = computed(() => props.album.cover?.urls.original
|
|
||||||
? store.getters['instance/absoluteUrl'](props.album.cover.urls.medium_square_crop)
|
|
||||||
: null
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="card app-card component-album-card">
|
<AlbumCard :album="album" />
|
||||||
<router-link
|
|
||||||
class="discrete link"
|
|
||||||
:to="{name: 'library.albums.detail', params: {id: album.id}}"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-lazy:background-image="imageUrl"
|
|
||||||
:class="['ui', 'head-image', 'image', {'default-cover': !album.cover || !album.cover.urls.original}]"
|
|
||||||
>
|
|
||||||
<play-button
|
|
||||||
:icon-only="true"
|
|
||||||
:is-playable="album.is_playable"
|
|
||||||
:button-classes="['ui', 'circular', 'large', 'vibrant', 'icon', 'button']"
|
|
||||||
:album="album"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</router-link>
|
|
||||||
<div class="content">
|
|
||||||
<strong>
|
|
||||||
<router-link
|
|
||||||
class="discrete link"
|
|
||||||
:to="{name: 'library.albums.detail', params: {id: album.id}}"
|
|
||||||
>
|
|
||||||
{{ album.title }}
|
|
||||||
</router-link>
|
|
||||||
</strong>
|
|
||||||
<div class="description">
|
|
||||||
<span>
|
|
||||||
<template
|
|
||||||
v-for="ac in album.artist_credit"
|
|
||||||
:key="ac.artist.id"
|
|
||||||
>
|
|
||||||
<router-link
|
|
||||||
class="discrete link"
|
|
||||||
:to="{ name: 'library.artists.detail', params: { id: ac.artist.id }}"
|
|
||||||
>
|
|
||||||
{{ ac.credit }}
|
|
||||||
</router-link>
|
|
||||||
<span>{{ ac.joinphrase }}</span>
|
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="extra content">
|
|
||||||
<span v-if="album.release_date">
|
|
||||||
{{ momentFormat(new Date(album.release_date), 'Y') }}
|
|
||||||
<span class="middle middledot symbol" />
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{{ $t('components.audio.album.Card.meta.tracks', album.tracks_count) }}
|
|
||||||
</span>
|
|
||||||
<play-button
|
|
||||||
class="right floated basic icon"
|
|
||||||
:dropdown-only="true"
|
|
||||||
:is-playable="album.is_playable"
|
|
||||||
:dropdown-icon-classes="['ellipsis', 'horizontal', 'large really discrete']"
|
|
||||||
:album="album"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,20 +1,24 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Artist } from '~/types'
|
import type { Artist } from '~/types'
|
||||||
|
|
||||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
|
||||||
import TagsList from '~/components/tags/List.vue'
|
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
import { truncate } from '~/utils/filters'
|
import { truncate } from '~/utils/filters'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||||
|
import TagsList from '~/components/tags/List.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
artist: Artist
|
artist: Artist
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
const cover = computed(() => !props.artist.cover?.urls.original
|
const cover = computed(() => !props.artist.cover?.urls.original
|
||||||
? props.artist.albums.find(album => !!album.cover?.urls.original)?.cover
|
? undefined // TODO: Also check Albums. Like in props.artist.albums.find(album => !!album.cover?.urls.original)?.cover
|
||||||
: props.artist.cover
|
: props.artist.cover
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -37,7 +41,7 @@ const imageUrl = computed(() => cover.value?.urls.original
|
||||||
>
|
>
|
||||||
<play-button
|
<play-button
|
||||||
:icon-only="true"
|
:icon-only="true"
|
||||||
:is-playable="artist.is_playable"
|
:is-playable="true /* TODO: check if artist.is_playable exists instead */"
|
||||||
:button-classes="['ui', 'circular', 'large', 'vibrant', 'icon', 'button']"
|
:button-classes="['ui', 'circular', 'large', 'vibrant', 'icon', 'button']"
|
||||||
:artist="artist"
|
:artist="artist"
|
||||||
/>
|
/>
|
||||||
|
@ -53,7 +57,7 @@ const imageUrl = computed(() => cover.value?.urls.original
|
||||||
</router-link>
|
</router-link>
|
||||||
</strong>
|
</strong>
|
||||||
|
|
||||||
<tags-list
|
<TagsList
|
||||||
label-classes="tiny"
|
label-classes="tiny"
|
||||||
:truncate-size="20"
|
:truncate-size="20"
|
||||||
:limit="2"
|
:limit="2"
|
||||||
|
@ -63,15 +67,15 @@ const imageUrl = computed(() => cover.value?.urls.original
|
||||||
</div>
|
</div>
|
||||||
<div class="extra content">
|
<div class="extra content">
|
||||||
<span v-if="artist.content_category === 'music'">
|
<span v-if="artist.content_category === 'music'">
|
||||||
{{ $t('components.audio.artist.Card.meta.tracks', artist.tracks_count) }}
|
{{ t('components.audio.artist.Card.meta.tracks', (0 /* TODO: check where artist.tracks_count exists */)) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ $t('components.audio.artist.Card.meta.episodes', artist.tracks_count) }}
|
{{ t('components.audio.artist.Card.meta.episodes', (0 /* TODO: check where artist.tracks_count exists */)) }}
|
||||||
</span>
|
</span>
|
||||||
<play-button
|
<play-button
|
||||||
class="right floated basic icon"
|
class="right floated basic icon"
|
||||||
:dropdown-only="true"
|
:dropdown-only="true"
|
||||||
:is-playable="artist.is_playable"
|
:is-playable="true /* TODO: check if is_playable can be derived from the data */"
|
||||||
:dropdown-icon-classes="['ellipsis', 'horizontal', 'large really discrete']"
|
:dropdown-icon-classes="['ellipsis', 'horizontal', 'large really discrete']"
|
||||||
:artist="artist"
|
:artist="artist"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { usePlayer } from '~/composables/audio/player'
|
import { usePlayer } from '~/composables/audio/player'
|
||||||
import { useQueue } from '~/composables/audio/queue'
|
import { useQueue } from '~/composables/audio/queue'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
|
||||||
import usePlayOptions from '~/composables/audio/usePlayOptions'
|
import usePlayOptions from '~/composables/audio/usePlayOptions'
|
||||||
|
|
||||||
|
@ -54,6 +55,8 @@ const { isPlaying } = usePlayer()
|
||||||
const { activateTrack } = usePlayOptions(props)
|
const { activateTrack } = usePlayOptions(props)
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
const actionsButtonLabel = computed(() => t('components.audio.podcast.MobileRow.button.actions'))
|
const actionsButtonLabel = computed(() => t('components.audio.podcast.MobileRow.button.actions'))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -71,13 +74,13 @@ const actionsButtonLabel = computed(() => t('components.audio.podcast.MobileRow.
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="track.album?.cover?.urls.original"
|
v-if="track.album?.cover?.urls.original"
|
||||||
v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.small_square_crop)"
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="ui artist-track mini image"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="track.cover"
|
v-else-if="track.cover"
|
||||||
v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.small_square_crop)"
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="ui artist-track mini image"
|
||||||
>
|
>
|
||||||
|
@ -136,7 +139,7 @@ const actionsButtonLabel = computed(() => t('components.audio.podcast.MobileRow.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="$store.state.auth.authenticated && track.artist_credit?.[0].artist.content_category !== 'podcast'"
|
v-if="store.state.auth.authenticated && track.artist_credit?.[0].artist.content_category !== 'podcast'"
|
||||||
:class="[
|
:class="[
|
||||||
'meta',
|
'meta',
|
||||||
'right',
|
'right',
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
|
import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
|
||||||
import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
|
import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
|
||||||
// import type { Track } from '~/types'
|
|
||||||
|
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import SemanticModal from '~/components/semantic/Modal.vue'
|
import Modal from '~/components/ui/Modal.vue'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import usePlayOptions from '~/composables/audio/usePlayOptions'
|
import usePlayOptions from '~/composables/audio/usePlayOptions'
|
||||||
import useReport from '~/composables/moderation/useReport'
|
import useReport from '~/composables/moderation/useReport'
|
||||||
|
@ -55,11 +55,13 @@ const show = useVModel(props, 'show', emit)
|
||||||
|
|
||||||
const { report, getReportableObjects } = useReport()
|
const { report, getReportableObjects } = useReport()
|
||||||
const { enqueue, enqueueNext } = usePlayOptions(props)
|
const { enqueue, enqueueNext } = usePlayOptions(props)
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const isFavorite = computed(() => store.getters['favorites/isFavorite'](props.track.id))
|
const isFavorite = computed(() => store.getters['favorites/isFavorite'](props.track.id))
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const favoriteButton = computed(() => isFavorite.value
|
const favoriteButton = computed(() => isFavorite.value
|
||||||
? t('components.audio.podcast.Modal.button.removeFromFavorites')
|
? t('components.audio.podcast.Modal.button.removeFromFavorites')
|
||||||
: t('components.audio.podcast.Modal.button.addToFavorites')
|
: t('components.audio.podcast.Modal.button.addToFavorites')
|
||||||
|
@ -90,18 +92,19 @@ const labels = computed(() => ({
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<semantic-modal
|
<Modal
|
||||||
ref="modal"
|
ref="modal"
|
||||||
v-model:show="show"
|
v-model="show"
|
||||||
|
:title="track.title"
|
||||||
:scrolling="true"
|
:scrolling="true"
|
||||||
:additional-classes="['scrolling-track-options']"
|
class="scrolling-track-options"
|
||||||
>
|
>
|
||||||
<div class="header">
|
<template #topright>
|
||||||
<div class="ui large centered rounded image">
|
<div class="ui large centered rounded image">
|
||||||
<img
|
<img
|
||||||
v-if="track.album && track.album.cover && track.album.cover.urls.original"
|
v-if="track.album && track.album.cover && track.album.cover.urls.original"
|
||||||
v-lazy="
|
v-lazy="
|
||||||
$store.getters['instance/absoluteUrl'](
|
store.getters['instance/absoluteUrl'](
|
||||||
track.album.cover.urls.medium_square_crop
|
track.album.cover.urls.medium_square_crop
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
@ -111,7 +114,7 @@ const labels = computed(() => ({
|
||||||
<img
|
<img
|
||||||
v-else-if="track.cover"
|
v-else-if="track.cover"
|
||||||
v-lazy="
|
v-lazy="
|
||||||
$store.getters['instance/absoluteUrl'](
|
store.getters['instance/absoluteUrl'](
|
||||||
track.cover.urls.medium_square_crop
|
track.cover.urls.medium_square_crop
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
@ -133,18 +136,15 @@ const labels = computed(() => ({
|
||||||
src="../../../assets/audio/default-cover.png"
|
src="../../../assets/audio/default-cover.png"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="track-modal-title">
|
|
||||||
{{ track.title }}
|
|
||||||
</h3>
|
|
||||||
<h4 class="track-modal-subtitle">
|
<h4 class="track-modal-subtitle">
|
||||||
{{ generateTrackCreditString(track) }}
|
{{ generateTrackCreditString(track) }}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</template>
|
||||||
<div class="ui hidden divider" />
|
<div class="ui hidden divider" />
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="ui one column unstackable grid">
|
<div class="ui one column unstackable grid">
|
||||||
<div
|
<div
|
||||||
v-if="$store.state.auth.authenticated && track.artist_credit?.[0].artist?.content_category !== 'podcast'"
|
v-if="store.state.auth.authenticated && track.artist_credit?.[0].artist?.content_category !== 'podcast'"
|
||||||
class="row"
|
class="row"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
@ -152,7 +152,7 @@ const labels = computed(() => ({
|
||||||
class="column"
|
class="column"
|
||||||
role="button"
|
role="button"
|
||||||
:aria-label="favoriteButton"
|
:aria-label="favoriteButton"
|
||||||
@click.stop="$store.dispatch('favorites/toggle', track.id)"
|
@click.stop="store.dispatch('favorites/toggle', track.id)"
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
:class="[
|
:class="[
|
||||||
|
@ -175,7 +175,7 @@ const labels = computed(() => ({
|
||||||
:aria-label="labels.addToQueue"
|
:aria-label="labels.addToQueue"
|
||||||
@click.stop.prevent="
|
@click.stop.prevent="
|
||||||
enqueue();
|
enqueue();
|
||||||
modal.closeModal();
|
show=false
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<i class="plus icon track-modal list-icon" />
|
<i class="plus icon track-modal list-icon" />
|
||||||
|
@ -189,7 +189,7 @@ const labels = computed(() => ({
|
||||||
:aria-label="labels.playNext"
|
:aria-label="labels.playNext"
|
||||||
@click.stop.prevent="
|
@click.stop.prevent="
|
||||||
enqueueNext(true);
|
enqueueNext(true);
|
||||||
modal.closeModal();
|
show=false
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<i class="step forward icon track-modal list-icon" />
|
<i class="step forward icon track-modal list-icon" />
|
||||||
|
@ -202,11 +202,11 @@ const labels = computed(() => ({
|
||||||
role="button"
|
role="button"
|
||||||
:aria-label="labels.startRadio"
|
:aria-label="labels.startRadio"
|
||||||
@click.stop.prevent="
|
@click.stop.prevent="
|
||||||
$store.dispatch('radios/start', {
|
store.dispatch('radios/start', {
|
||||||
type: 'similar',
|
type: 'similar',
|
||||||
objectId: track.id,
|
objectId: track.id,
|
||||||
});
|
});
|
||||||
modal.closeModal();
|
show=false
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<i class="rss icon track-modal list-icon" />
|
<i class="rss icon track-modal list-icon" />
|
||||||
|
@ -218,7 +218,7 @@ const labels = computed(() => ({
|
||||||
class="column"
|
class="column"
|
||||||
role="button"
|
role="button"
|
||||||
:aria-label="labels.addToPlaylist"
|
:aria-label="labels.addToPlaylist"
|
||||||
@click.stop="$store.commit('playlists/chooseTrack', track)"
|
@click.stop="store.commit('playlists/chooseTrack', track)"
|
||||||
>
|
>
|
||||||
<i class="list icon track-modal list-icon" />
|
<i class="list icon track-modal list-icon" />
|
||||||
<span class="track-modal list-item">{{
|
<span class="track-modal list-item">{{
|
||||||
|
@ -236,7 +236,7 @@ const labels = computed(() => ({
|
||||||
role="button"
|
role="button"
|
||||||
:aria-label="albumDetailsButton"
|
:aria-label="albumDetailsButton"
|
||||||
@click.prevent.exact="
|
@click.prevent.exact="
|
||||||
$router.push({
|
router.push({
|
||||||
name: 'library.albums.detail',
|
name: 'library.albums.detail',
|
||||||
params: { id: track.album?.id },
|
params: { id: track.album?.id },
|
||||||
})
|
})
|
||||||
|
@ -258,7 +258,7 @@ const labels = computed(() => ({
|
||||||
class="column"
|
class="column"
|
||||||
role="button"
|
role="button"
|
||||||
:aria-label="artistDetailsButton"
|
:aria-label="artistDetailsButton"
|
||||||
@click.prevent.exact="$router.push({ name: 'library.artists.detail', params: { id: ac.artist.id } })"
|
@click.prevent.exact="router.push({ name: 'library.artists.detail', params: { id: ac.artist.id } })"
|
||||||
>
|
>
|
||||||
<i class="user icon track-modal list-icon" />
|
<i class="user icon track-modal list-icon" />
|
||||||
<span class="track-modal list-item">{{ ac.artist.name }}</span>
|
<span class="track-modal list-item">{{ ac.artist.name }}</span>
|
||||||
|
@ -271,7 +271,7 @@ const labels = computed(() => ({
|
||||||
role="button"
|
role="button"
|
||||||
:aria-label="trackDetailsButton"
|
:aria-label="trackDetailsButton"
|
||||||
@click.prevent.exact="
|
@click.prevent.exact="
|
||||||
$router.push({
|
router.push({
|
||||||
name: 'library.tracks.detail',
|
name: 'library.tracks.detail',
|
||||||
params: { id: track.id },
|
params: { id: track.id },
|
||||||
})
|
})
|
||||||
|
@ -298,5 +298,5 @@ const labels = computed(() => ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</semantic-modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Track, Album, Playlist, Library, Channel, Actor, Cover, ArtistCredit } from '~/types'
|
import type { Track, Album, Playlist, Library, Channel, Actor, Cover, ArtistCredit } from '~/types'
|
||||||
import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
|
import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
|
||||||
|
import { getArtistCoverUrl } from '~/utils/utils'
|
||||||
|
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
import { useQueue } from '~/composables/audio/queue'
|
import { useQueue } from '~/composables/audio/queue'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
|
@ -48,6 +50,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
account: null
|
account: null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
const description = ref('')
|
const description = ref('')
|
||||||
const renderedDescription = useMarkdown(description)
|
const renderedDescription = useMarkdown(description)
|
||||||
|
|
||||||
|
@ -83,13 +87,25 @@ await fetchData()
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="track.cover?.urls.original"
|
v-if="track.cover?.urls.original"
|
||||||
v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.small_square_crop)"
|
||||||
|
alt=""
|
||||||
|
class="ui artist-track mini image"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="track.album?.cover?.urls.original"
|
||||||
|
v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.small_square_crop)"
|
||||||
|
alt=""
|
||||||
|
class="ui artist-track mini image"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-else-if="track.artist_credit.length && track.artist_credit[0].artist.cover"
|
||||||
|
v-lazy="getArtistCoverUrl(track.artist_credit)"
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="ui artist-track mini image"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="defaultCover"
|
v-else-if="defaultCover"
|
||||||
v-lazy="$store.getters['instance/absoluteUrl'](defaultCover.urls.medium_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](defaultCover.urls.small_square_crop)"
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="ui artist-track mini image"
|
||||||
>
|
>
|
||||||
|
@ -120,10 +136,10 @@ await fetchData()
|
||||||
class="meta right floated column"
|
class="meta right floated column"
|
||||||
>
|
>
|
||||||
<play-button
|
<play-button
|
||||||
id="playmenu"
|
|
||||||
class="play-button basic icon"
|
class="play-button basic icon"
|
||||||
:dropdown-only="true"
|
:dropdown-only="true"
|
||||||
:is-playable="track.is_playable"
|
:is-playable="track.is_playable"
|
||||||
|
discrete
|
||||||
:dropdown-icon-classes="[
|
:dropdown-icon-classes="[
|
||||||
'ellipsis',
|
'ellipsis',
|
||||||
'vertical',
|
'vertical',
|
||||||
|
|
|
@ -3,7 +3,7 @@ import type { Track } from '~/types'
|
||||||
|
|
||||||
import PodcastRow from '~/components/audio/podcast/Row.vue'
|
import PodcastRow from '~/components/audio/podcast/Row.vue'
|
||||||
import TrackMobileRow from '~/components/audio/track/MobileRow.vue'
|
import TrackMobileRow from '~/components/audio/track/MobileRow.vue'
|
||||||
import Pagination from '~/components/vui/Pagination.vue'
|
import Pagination from '~/components/ui/Pagination.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tracks: Track[]
|
tracks: Track[]
|
||||||
|
@ -61,11 +61,9 @@ const { page } = defineModels<{ page: number, }>()
|
||||||
v-if="paginateResults"
|
v-if="paginateResults"
|
||||||
class="ui center aligned basic segment desktop-and-up"
|
class="ui center aligned basic segment desktop-and-up"
|
||||||
>
|
>
|
||||||
<pagination
|
<Pagination
|
||||||
v-bind="$attrs"
|
v-model:page="page"
|
||||||
v-model:current="page"
|
:pages="Math.ceil((total || 0)/paginateBy)"
|
||||||
:total="total"
|
|
||||||
:paginate-by="paginateBy"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -90,12 +88,10 @@ const { page } = defineModels<{ page: number, }>()
|
||||||
v-if="paginateResults"
|
v-if="paginateResults"
|
||||||
class="ui center aligned basic segment tablet-and-below"
|
class="ui center aligned basic segment tablet-and-below"
|
||||||
>
|
>
|
||||||
<pagination
|
<Pagination
|
||||||
v-if="paginateResults"
|
v-if="paginateResults"
|
||||||
v-bind="$attrs"
|
v-model:page="page"
|
||||||
v-model:current="page"
|
:pages="Math.ceil((total || 0)/paginateBy)"
|
||||||
:total="total"
|
|
||||||
:compact="true"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -7,12 +7,13 @@ import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { usePlayer } from '~/composables/audio/player'
|
import { usePlayer } from '~/composables/audio/player'
|
||||||
import { useQueue } from '~/composables/audio/queue'
|
import { useQueue } from '~/composables/audio/queue'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
|
||||||
import usePlayOptions from '~/composables/audio/usePlayOptions'
|
import usePlayOptions from '~/composables/audio/usePlayOptions'
|
||||||
|
|
||||||
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
||||||
import TrackModal from '~/components/audio/track/Modal.vue'
|
import TrackModal from '~/components/audio/track/Modal.vue'
|
||||||
import { generateTrackCreditString, getArtistCoverUrl } from '~/utils/utils'
|
import { generateTrackCreditString } from '~/utils/utils'
|
||||||
|
|
||||||
interface Props extends PlayOptionsProps {
|
interface Props extends PlayOptionsProps {
|
||||||
track: Track
|
track: Track
|
||||||
|
@ -54,6 +55,8 @@ const { isPlaying } = usePlayer()
|
||||||
const { activateTrack } = usePlayOptions(props)
|
const { activateTrack } = usePlayOptions(props)
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.button.actions'))
|
const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.button.actions'))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -70,20 +73,14 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
|
||||||
@click.prevent.exact="activateTrack(track, index)"
|
@click.prevent.exact="activateTrack(track, index)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="track.album?.cover?.urls.original"
|
v-if="track.cover"
|
||||||
v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.small_square_crop)"
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="ui artist-track mini image"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="track.cover"
|
v-else-if="track.album?.cover?.urls.original"
|
||||||
v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.small_square_crop)"
|
||||||
alt=""
|
|
||||||
class="ui artist-track mini image"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
v-else-if="track.artist_credit.length && track.artist_credit[0].artist.cover"
|
|
||||||
v-lazy="getArtistCoverUrl(track.artist_credit)"
|
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="ui artist-track mini image"
|
||||||
>
|
>
|
||||||
|
@ -113,13 +110,13 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
|
||||||
{{ generateTrackCreditString(track) }}
|
{{ generateTrackCreditString(track) }}
|
||||||
<span class="middle middledot symbol" />
|
<span class="middle middledot symbol" />
|
||||||
<human-duration
|
<human-duration
|
||||||
v-if="track.uploads[0] && track.uploads[0].duration"
|
v-if="track.uploads?.[0]?.duration"
|
||||||
:duration="track.uploads[0].duration"
|
:duration="track.uploads[0]?.duration"
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="$store.state.auth.authenticated"
|
v-if="store.state.auth.authenticated"
|
||||||
:class="[
|
:class="[
|
||||||
'meta',
|
'meta',
|
||||||
'right',
|
'right',
|
||||||
|
|
|
@ -1,16 +1,18 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
|
import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
|
||||||
import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
|
import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
|
||||||
// import type { Track } from '~/types'
|
|
||||||
|
|
||||||
import { useStore } from '~/store'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import SemanticModal from '~/components/semantic/Modal.vue'
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import usePlayOptions from '~/composables/audio/usePlayOptions'
|
import usePlayOptions from '~/composables/audio/usePlayOptions'
|
||||||
import useReport from '~/composables/moderation/useReport'
|
import useReport from '~/composables/moderation/useReport'
|
||||||
|
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
import { useVModel } from '@vueuse/core'
|
import { useVModel } from '@vueuse/core'
|
||||||
import { generateTrackCreditString, getArtistCoverUrl } from '~/utils/utils'
|
import { generateTrackCreditString, getArtistCoverUrl } from '~/utils/utils'
|
||||||
|
|
||||||
|
import Modal from '~/components/ui/Modal.vue'
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'update:show', value: boolean): void
|
(e: 'update:show', value: boolean): void
|
||||||
}
|
}
|
||||||
|
@ -59,6 +61,8 @@ const store = useStore()
|
||||||
const isFavorite = computed(() => store.getters['favorites/isFavorite'](props.track.id))
|
const isFavorite = computed(() => store.getters['favorites/isFavorite'](props.track.id))
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const favoriteButton = computed(() => isFavorite.value
|
const favoriteButton = computed(() => isFavorite.value
|
||||||
? t('components.audio.track.Modal.button.removeFromFavorites')
|
? t('components.audio.track.Modal.button.removeFromFavorites')
|
||||||
: t('components.audio.track.Modal.button.addToFavorites')
|
: t('components.audio.track.Modal.button.addToFavorites')
|
||||||
|
@ -89,23 +93,24 @@ const labels = computed(() => ({
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<semantic-modal
|
<Modal
|
||||||
ref="modal"
|
ref="modal"
|
||||||
v-model:show="show"
|
v-model="show"
|
||||||
|
:title="track.title"
|
||||||
:scrolling="true"
|
:scrolling="true"
|
||||||
:additional-classes="['scrolling-track-options']"
|
class="scrolling-track-options"
|
||||||
>
|
>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="ui large centered rounded image">
|
<div class="ui large centered rounded image">
|
||||||
<img
|
<img
|
||||||
v-if="track.album?.cover?.urls.original"
|
v-if="track.album?.cover?.urls.original"
|
||||||
v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)"
|
||||||
alt=""
|
alt=""
|
||||||
class="ui centered image"
|
class="ui centered image"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="track.cover"
|
v-else-if="track.cover"
|
||||||
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=""
|
||||||
class="ui centered image"
|
class="ui centered image"
|
||||||
>
|
>
|
||||||
|
@ -122,9 +127,6 @@ const labels = computed(() => ({
|
||||||
src="../../../assets/audio/default-cover.png"
|
src="../../../assets/audio/default-cover.png"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="track-modal-title">
|
|
||||||
{{ track.title }}
|
|
||||||
</h3>
|
|
||||||
<h4 class="track-modal-subtitle">
|
<h4 class="track-modal-subtitle">
|
||||||
{{ generateTrackCreditString(track) }}
|
{{ generateTrackCreditString(track) }}
|
||||||
</h4>
|
</h4>
|
||||||
|
@ -133,7 +135,7 @@ const labels = computed(() => ({
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="ui one column unstackable grid">
|
<div class="ui one column unstackable grid">
|
||||||
<div
|
<div
|
||||||
v-if="$store.state.auth.authenticated && track.artist_credit?.[0].artist.content_category !== 'podcast'"
|
v-if="store.state.auth.authenticated && track.artist_credit?.[0].artist.content_category !== 'podcast'"
|
||||||
class="row"
|
class="row"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
@ -141,7 +143,7 @@ const labels = computed(() => ({
|
||||||
class="column"
|
class="column"
|
||||||
role="button"
|
role="button"
|
||||||
:aria-label="favoriteButton"
|
:aria-label="favoriteButton"
|
||||||
@click.stop="$store.dispatch('favorites/toggle', track.id)"
|
@click.stop="store.dispatch('favorites/toggle', track.id)"
|
||||||
>
|
>
|
||||||
<i :class="[ 'heart', 'favorite-icon', { favorited: isFavorite, pink: isFavorite }, 'icon', 'track-modal', 'list-icon' ]" />
|
<i :class="[ 'heart', 'favorite-icon', { favorited: isFavorite, pink: isFavorite }, 'icon', 'track-modal', 'list-icon' ]" />
|
||||||
<span class="track-modal list-item">{{ favoriteButton }}</span>
|
<span class="track-modal list-item">{{ favoriteButton }}</span>
|
||||||
|
@ -152,7 +154,7 @@ const labels = computed(() => ({
|
||||||
class="column"
|
class="column"
|
||||||
role="button"
|
role="button"
|
||||||
:aria-label="labels.addToQueue"
|
:aria-label="labels.addToQueue"
|
||||||
@click.stop.prevent="enqueue(); modal.closeModal()"
|
@click.stop.prevent="enqueue(); show = false"
|
||||||
>
|
>
|
||||||
<i class="plus icon track-modal list-icon" />
|
<i class="plus icon track-modal list-icon" />
|
||||||
<span class="track-modal list-item">{{ labels.addToQueue }}</span>
|
<span class="track-modal list-item">{{ labels.addToQueue }}</span>
|
||||||
|
@ -163,7 +165,7 @@ const labels = computed(() => ({
|
||||||
class="column"
|
class="column"
|
||||||
role="button"
|
role="button"
|
||||||
:aria-label="labels.playNext"
|
:aria-label="labels.playNext"
|
||||||
@click.stop.prevent="enqueueNext(true);modal.closeModal()"
|
@click.stop.prevent="enqueueNext(true);show = false"
|
||||||
>
|
>
|
||||||
<i class="step forward icon track-modal list-icon" />
|
<i class="step forward icon track-modal list-icon" />
|
||||||
<span class="track-modal list-item">{{ labels.playNext }}</span>
|
<span class="track-modal list-item">{{ labels.playNext }}</span>
|
||||||
|
@ -174,7 +176,7 @@ const labels = computed(() => ({
|
||||||
class="column"
|
class="column"
|
||||||
role="button"
|
role="button"
|
||||||
:aria-label="labels.startRadio"
|
:aria-label="labels.startRadio"
|
||||||
@click.stop.prevent="() => { $store.dispatch('radios/start', { type: 'similar', objectId: track.id }); modal.closeModal() }"
|
@click.stop.prevent="() => { store.dispatch('radios/start', { type: 'similar', objectId: track.id }); show = false }"
|
||||||
>
|
>
|
||||||
<i class="rss icon track-modal list-icon" />
|
<i class="rss icon track-modal list-icon" />
|
||||||
<span class="track-modal list-item">{{ labels.startRadio }}</span>
|
<span class="track-modal list-item">{{ labels.startRadio }}</span>
|
||||||
|
@ -185,7 +187,7 @@ const labels = computed(() => ({
|
||||||
class="column"
|
class="column"
|
||||||
role="button"
|
role="button"
|
||||||
:aria-label="labels.addToPlaylist"
|
:aria-label="labels.addToPlaylist"
|
||||||
@click.stop="$store.commit('playlists/chooseTrack', track)"
|
@click.stop="store.commit('playlists/chooseTrack', track)"
|
||||||
>
|
>
|
||||||
<i class="list icon track-modal list-icon" />
|
<i class="list icon track-modal list-icon" />
|
||||||
<span class="track-modal list-item">
|
<span class="track-modal list-item">
|
||||||
|
@ -202,7 +204,7 @@ const labels = computed(() => ({
|
||||||
class="column"
|
class="column"
|
||||||
role="button"
|
role="button"
|
||||||
:aria-label="albumDetailsButton"
|
:aria-label="albumDetailsButton"
|
||||||
@click.prevent.exact="$router.push({ name: 'library.albums.detail', params: { id: track.album?.id } })"
|
@click.prevent.exact="router.push({ name: 'library.albums.detail', params: { id: track.album?.id } })"
|
||||||
>
|
>
|
||||||
<i class="compact disc icon track-modal list-icon" />
|
<i class="compact disc icon track-modal list-icon" />
|
||||||
<span class="track-modal list-item">{{ albumDetailsButton }}</span>
|
<span class="track-modal list-item">{{ albumDetailsButton }}</span>
|
||||||
|
@ -218,7 +220,7 @@ const labels = computed(() => ({
|
||||||
class="column"
|
class="column"
|
||||||
role="button"
|
role="button"
|
||||||
:aria-label="artistDetailsButton"
|
:aria-label="artistDetailsButton"
|
||||||
@click.prevent.exact="$router.push({ name: 'library.artists.detail', params: { id: ac.artist.id } })"
|
@click.prevent.exact="router.push({ name: 'library.artists.detail', params: { id: ac.artist.id } })"
|
||||||
>
|
>
|
||||||
<i class="user icon track-modal list-icon" />
|
<i class="user icon track-modal list-icon" />
|
||||||
<span class="track-modal list-item">{{ ac.credit }}</span>
|
<span class="track-modal list-item">{{ ac.credit }}</span>
|
||||||
|
@ -230,7 +232,7 @@ const labels = computed(() => ({
|
||||||
class="column"
|
class="column"
|
||||||
role="button"
|
role="button"
|
||||||
:aria-label="trackDetailsButton"
|
:aria-label="trackDetailsButton"
|
||||||
@click.prevent.exact="$router.push({ name: 'library.tracks.detail', params: { id: track.id } })"
|
@click.prevent.exact="router.push({ name: 'library.tracks.detail', params: { id: track.id } })"
|
||||||
>
|
>
|
||||||
<i class="info icon track-modal list-icon" />
|
<i class="info icon track-modal list-icon" />
|
||||||
<span class="track-modal list-item">{{ trackDetailsButton }}</span>
|
<span class="track-modal list-item">{{ trackDetailsButton }}</span>
|
||||||
|
@ -250,5 +252,5 @@ const labels = computed(() => ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</semantic-modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -4,5 +4,9 @@
|
||||||
<div class="audio-bar" />
|
<div class="audio-bar" />
|
||||||
<div class="audio-bar" />
|
<div class="audio-bar" />
|
||||||
<div class="audio-bar" />
|
<div class="audio-bar" />
|
||||||
|
<div class="audio-bar" />
|
||||||
|
<div class="audio-bar" />
|
||||||
|
<div class="audio-bar" />
|
||||||
|
<div class="audio-bar" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -6,11 +6,16 @@ import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import usePlayOptions from '~/composables/audio/usePlayOptions'
|
import usePlayOptions from '~/composables/audio/usePlayOptions'
|
||||||
|
|
||||||
|
import { usePlayer } from '~/composables/audio/player'
|
||||||
|
import { useQueue } from '~/composables/audio/queue'
|
||||||
|
import { useStore } from '~/store'
|
||||||
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
||||||
import PlayIndicator from '~/components/audio/track/PlayIndicator.vue'
|
import PlayIndicator from '~/components/audio/track/PlayIndicator.vue'
|
||||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||||
import { usePlayer } from '~/composables/audio/player'
|
|
||||||
import { useQueue } from '~/composables/audio/queue'
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
interface Props extends PlayOptionsProps {
|
interface Props extends PlayOptionsProps {
|
||||||
track: Track
|
track: Track
|
||||||
|
@ -60,11 +65,14 @@ const hover = ref(false)
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
:class="[{ active }, 'track-row row']"
|
:class="[{ active }, 'track-row row', $style.row]"
|
||||||
|
style="display: contents;"
|
||||||
@dblclick="activateTrack(track, index)"
|
@dblclick="activateTrack(track, index)"
|
||||||
@mousemove="hover = true"
|
@mousemove="hover = true"
|
||||||
@mouseout="hover = false"
|
@mouseout="hover = false"
|
||||||
>
|
>
|
||||||
|
<!-- 1. column: Play button or track position -->
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="actions one wide left floated column"
|
class="actions one wide left floated column"
|
||||||
role="button"
|
role="button"
|
||||||
|
@ -78,32 +86,29 @@ const hover = ref(false)
|
||||||
!hover
|
!hover
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
v-else-if="
|
v-else-if="
|
||||||
!isPlaying &&
|
!isPlaying &&
|
||||||
active &&
|
active &&
|
||||||
!hover
|
!hover
|
||||||
"
|
"
|
||||||
class="ui really tiny basic icon button play-button paused"
|
ghost
|
||||||
>
|
icon="bi-play-fill"
|
||||||
<i class="play icon" />
|
/>
|
||||||
</button>
|
<Button
|
||||||
<button
|
|
||||||
v-else-if="
|
v-else-if="
|
||||||
isPlaying &&
|
isPlaying &&
|
||||||
active &&
|
active &&
|
||||||
hover
|
hover
|
||||||
"
|
"
|
||||||
class="ui really tiny basic icon button play-button"
|
ghost
|
||||||
>
|
icon="bi-pause-fill"
|
||||||
<i class="pause icon" />
|
/>
|
||||||
</button>
|
<Button
|
||||||
<button
|
|
||||||
v-else-if="hover"
|
v-else-if="hover"
|
||||||
class="ui really tiny basic icon button play-button"
|
ghost
|
||||||
>
|
icon="bi-play-fill"
|
||||||
<i class="play icon" />
|
/>
|
||||||
</button>
|
|
||||||
<span
|
<span
|
||||||
v-else-if="showPosition"
|
v-else-if="showPosition"
|
||||||
class="track-position"
|
class="track-position"
|
||||||
|
@ -111,40 +116,35 @@ const hover = ref(false)
|
||||||
{{ `${track.position}`.padStart(2, '0') }}
|
{{ `${track.position}`.padStart(2, '0') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="showArt"
|
|
||||||
class="image left floated column"
|
class="image left floated column"
|
||||||
role="button"
|
role="button"
|
||||||
@click.prevent.exact="activateTrack(track, index)"
|
@click.prevent.exact="activateTrack(track, index)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="track.album?.cover?.urls.original"
|
v-if="showArt && track.cover?.urls.original"
|
||||||
v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.small_square_crop)"
|
||||||
alt=""
|
:alt="track.title"
|
||||||
class="ui artist-track mini image"
|
class="track_image"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="track.cover?.urls.original"
|
v-else-if="showArt && track.album?.cover?.urls.original"
|
||||||
v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.small_square_crop)"
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="track_image"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="track.artist_credit?.length && track.artist_credit[0].artist.cover?.urls.original"
|
v-else-if="showArt"
|
||||||
v-lazy="$store.getters['instance/absoluteUrl'](track.artist_credit[0].artist.cover.urls.medium_square_crop) "
|
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="track_image"
|
||||||
>
|
|
||||||
<img
|
|
||||||
v-else
|
|
||||||
alt=""
|
|
||||||
class="ui artist-track mini image"
|
|
||||||
src="../../../assets/audio/default-cover.png"
|
src="../../../assets/audio/default-cover.png"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="content ellipsis left floated column"
|
class="content ellipsis column left floated column"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
@click="activateTrack(track, index)"
|
@click="activateTrack(track, index)"
|
||||||
|
@ -152,22 +152,23 @@ const hover = ref(false)
|
||||||
{{ track.title }}
|
{{ track.title }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="showAlbum"
|
|
||||||
class="content ellipsis left floated column"
|
class="content ellipsis left floated column"
|
||||||
>
|
>
|
||||||
<router-link
|
<router-link
|
||||||
|
v-if="showAlbum"
|
||||||
:to="{ name: 'library.albums.detail', params: { id: track.album?.id } }"
|
:to="{ name: 'library.albums.detail', params: { id: track.album?.id } }"
|
||||||
>
|
>
|
||||||
{{ track.album?.title }}
|
{{ track.album?.title }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="showArtist"
|
|
||||||
class="content ellipsis left floated column"
|
class="content ellipsis left floated column"
|
||||||
>
|
>
|
||||||
<template
|
<template
|
||||||
v-for="ac in track.artist_credit"
|
v-for="ac in (showArtist ? track.artist_credit : [])"
|
||||||
:key="ac.artist.id"
|
:key="ac.artist.id"
|
||||||
>
|
>
|
||||||
<router-link
|
<router-link
|
||||||
|
@ -182,41 +183,49 @@ const hover = ref(false)
|
||||||
<span>{{ ac.joinphrase }}</span>
|
<span>{{ ac.joinphrase }}</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="$store.state.auth.authenticated"
|
|
||||||
class="meta right floated column"
|
class="meta right floated column"
|
||||||
>
|
>
|
||||||
<track-favorite-icon
|
<track-favorite-icon
|
||||||
class="tiny"
|
v-if="store.state.auth.authenticated"
|
||||||
:border="false"
|
ghost
|
||||||
:track="track"
|
:track="track"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="showDuration"
|
|
||||||
class="meta right floated column"
|
class="meta right floated column"
|
||||||
>
|
>
|
||||||
<human-duration
|
<human-duration
|
||||||
v-if="track.uploads[0] && track.uploads[0].duration"
|
v-if="showDuration && track.uploads && track.uploads.length > 0 && track.uploads[0].duration"
|
||||||
:duration="track.uploads[0].duration"
|
:duration="track.uploads[0].duration"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="displayActions"
|
v-if="displayActions"
|
||||||
class="meta right floated column"
|
class="meta right floated column"
|
||||||
>
|
>
|
||||||
<play-button
|
<PlayButton
|
||||||
id="playmenu"
|
|
||||||
class="play-button basic icon"
|
|
||||||
:dropdown-only="true"
|
:dropdown-only="true"
|
||||||
:is-playable="track.is_playable"
|
:is-playable="track.is_playable"
|
||||||
:dropdown-icon-classes="[
|
|
||||||
'ellipsis',
|
|
||||||
'vertical',
|
|
||||||
'large really discrete',
|
|
||||||
]"
|
|
||||||
:track="track"
|
:track="track"
|
||||||
|
class="ui floating dropdown"
|
||||||
|
ghost
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style module>
|
||||||
|
.row > :has(> :is(a, span)) {
|
||||||
|
line-height: 46px;
|
||||||
|
}
|
||||||
|
.row > div {
|
||||||
|
/* total height 64px, according to designs on penpot */
|
||||||
|
margin-bottom: 8px;
|
||||||
|
margin-right: 8px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -8,8 +8,12 @@ import { ref, computed } from 'vue'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
import TrackMobileRow from '~/components/audio/track/MobileRow.vue'
|
import TrackMobileRow from '~/components/audio/track/MobileRow.vue'
|
||||||
import Pagination from '~/components/vui/Pagination.vue'
|
import Pagination from '~/components/ui/Pagination.vue'
|
||||||
import TrackRow from '~/components/audio/track/Row.vue'
|
import TrackRow from '~/components/audio/track/Row.vue'
|
||||||
|
import Input from '~/components/ui/Input.vue'
|
||||||
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
|
import Loader from '~/components/ui/Loader.vue'
|
||||||
|
import Table from '~/components/ui/Table.vue'
|
||||||
|
|
||||||
import useErrorHandler from '~/composables/useErrorHandler'
|
import useErrorHandler from '~/composables/useErrorHandler'
|
||||||
|
|
||||||
|
@ -62,7 +66,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
filters: () => ({}),
|
filters: () => ({}),
|
||||||
nextUrl: null,
|
nextUrl: null,
|
||||||
|
|
||||||
paginateResults: true,
|
paginateResults: false,
|
||||||
total: 0,
|
total: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
paginateBy: 25,
|
paginateBy: 25,
|
||||||
|
@ -83,11 +87,15 @@ const allTracks = computed(() => {
|
||||||
: tracks
|
: tracks
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const paginateResults = computed(() => props.paginateResults && allTracks.value.length < props.paginateBy)
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const labels = computed(() => ({
|
const labels = computed(() => ({
|
||||||
title: t('components.audio.track.Table.table.header.title'),
|
title: t('components.audio.track.Table.table.header.title'),
|
||||||
album: t('components.audio.track.Table.table.header.album'),
|
album: t('components.audio.track.Table.table.header.album'),
|
||||||
artist: t('components.audio.track.Table.table.header.artist')
|
artist: t('components.audio.track.Table.table.header.artist'),
|
||||||
|
searchPlaceholder: t('views.Search.header.search')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
@ -140,11 +148,16 @@ const updatePage = (page: number) => {
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- Show the search bar if search is true -->
|
<!-- Show the search bar if search is true -->
|
||||||
<inline-search-bar
|
|
||||||
|
<Input
|
||||||
v-if="search"
|
v-if="search"
|
||||||
v-model="query"
|
v-model="query"
|
||||||
|
search
|
||||||
|
autofocus
|
||||||
|
:placeholder="labels.searchPlaceholder"
|
||||||
@search="performSearch"
|
@search="performSearch"
|
||||||
/>
|
/>
|
||||||
|
<Spacer v-if="search" />
|
||||||
|
|
||||||
<!-- Add a header if needed -->
|
<!-- Add a header if needed -->
|
||||||
|
|
||||||
|
@ -161,66 +174,41 @@ const updatePage = (page: number) => {
|
||||||
@refresh="fetchData()"
|
@refresh="fetchData()"
|
||||||
/>
|
/>
|
||||||
</slot>
|
</slot>
|
||||||
<div v-else>
|
|
||||||
<div
|
<!-- Table on screens > 768px wide -->
|
||||||
:class="['track-table', 'ui', 'unstackable', 'grid', 'tablet-and-up']"
|
<!-- TODO: Make responsive to parent container instead of screen -->
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="isLoading"
|
|
||||||
class="ui inverted active dimmer"
|
|
||||||
>
|
|
||||||
<div class="ui loader" />
|
|
||||||
</div>
|
|
||||||
<div class="track-table row">
|
|
||||||
<div
|
|
||||||
v-if="showPosition"
|
|
||||||
class="actions left floated column"
|
|
||||||
>
|
|
||||||
<i class="hashtag icon" />
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="actions left floated column"
|
:class="['track-table', 'ui', 'unstackable', 'grid', 'tablet-and-up']"
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="showArt"
|
|
||||||
class="image left floated column"
|
|
||||||
/>
|
|
||||||
<div class="content ellipsis left floated column">
|
|
||||||
<b>{{ labels.title }}</b>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="showAlbum"
|
|
||||||
class="content ellipsisleft floated column"
|
|
||||||
>
|
>
|
||||||
<b>{{ labels.album }}</b>
|
<Loader v-if="isLoading" />
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="showArtist"
|
|
||||||
class="content ellipsis left floated column"
|
|
||||||
>
|
|
||||||
<b>{{ labels.artist }}</b>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="$store.state.auth.authenticated"
|
|
||||||
class="meta right floated column"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="showDuration"
|
|
||||||
class="meta right floated column"
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
class="clock outline icon"
|
|
||||||
style="padding: 0.5rem"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="displayActions"
|
|
||||||
class="meta right floated column"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- For each item, build a row -->
|
<Table
|
||||||
|
:grid-template-columns="['48px', '56px', 'auto', 'auto', 'auto', '56px', '64px', '48px']"
|
||||||
|
:header-props="{ 'table-header': true }"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<label />
|
||||||
|
<label />
|
||||||
|
<label>
|
||||||
|
<span>{{ labels.title }}</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span v-if="showAlbum">{{ labels.album }}</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span v-if="showArtist">{{ labels.artist }}</span>
|
||||||
|
</label>
|
||||||
|
<label />
|
||||||
|
<label>
|
||||||
|
<i
|
||||||
|
v-if="showDuration"
|
||||||
|
class="bi bi-clock"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label />
|
||||||
|
</template>
|
||||||
|
|
||||||
<track-row
|
<track-row
|
||||||
v-for="(track, index) in allTracks"
|
v-for="(track, index) in allTracks"
|
||||||
|
@ -236,29 +224,25 @@ const updatePage = (page: number) => {
|
||||||
:show-duration="showDuration"
|
:show-duration="showDuration"
|
||||||
:is-podcast="isPodcast"
|
:is-podcast="isPodcast"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Table>
|
||||||
<div
|
|
||||||
v-if="tracks && paginateResults"
|
<!-- Pagination -->
|
||||||
class="ui center aligned basic segment desktop-and-up"
|
|
||||||
>
|
<Pagination
|
||||||
<pagination
|
v-if="paginateResults"
|
||||||
:total="totalTracks"
|
:pages="paginateBy"
|
||||||
:current="tracks !== undefined ? page : currentPage"
|
:page="page"
|
||||||
:paginate-by="paginateBy"
|
|
||||||
@update:current="updatePage"
|
@update:current="updatePage"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- Under 768px screen width -->
|
||||||
|
<!-- TODO: Make responsive to parent container instead of screen -->
|
||||||
|
|
||||||
<div
|
<div
|
||||||
:class="['track-table', 'ui', 'unstackable', 'grid', 'tablet-and-below']"
|
:class="['track-table', 'ui', 'unstackable', 'grid', 'tablet-and-below']"
|
||||||
>
|
>
|
||||||
<div
|
<Loader v-if="isLoading" />
|
||||||
v-if="isLoading"
|
|
||||||
class="ui inverted active dimmer"
|
|
||||||
>
|
|
||||||
<div class="ui loader" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- For each item, build a row -->
|
<!-- For each item, build a row -->
|
||||||
|
|
||||||
|
@ -275,19 +259,19 @@ const updatePage = (page: number) => {
|
||||||
:is-album="isAlbum"
|
:is-album="isAlbum"
|
||||||
:is-podcast="isPodcast"
|
:is-podcast="isPodcast"
|
||||||
/>
|
/>
|
||||||
<div
|
<Pagination
|
||||||
v-if="tracks && paginateResults && totalTracks > paginateBy"
|
v-if="paginateResults"
|
||||||
class="ui center aligned basic segment tablet-and-below"
|
:pages="paginateBy"
|
||||||
>
|
:page="page"
|
||||||
<pagination
|
|
||||||
v-if="paginateResults && totalTracks > paginateBy"
|
|
||||||
:paginate-by="paginateBy"
|
|
||||||
:total="totalTracks"
|
|
||||||
:current="tracks !== undefined ? page : currentPage"
|
|
||||||
:compact="true"
|
|
||||||
@update:current="updatePage"
|
@update:current="updatePage"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
[table-header] > label {
|
||||||
|
padding-left: 4px;
|
||||||
|
align-self: center !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -1,20 +1,27 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Track, Listening } from '~/types'
|
import type { Track, Listening } from '~/types'
|
||||||
|
|
||||||
import { ref, reactive, watch } from 'vue'
|
import { ref, reactive, watch, onMounted } from 'vue'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
import { clone } from 'lodash-es'
|
import { clone } from 'lodash-es'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { getArtistCoverUrl } from '~/utils/utils'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import usePage from '~/composables/navigation/usePage'
|
||||||
import useWebSocketHandler from '~/composables/useWebSocketHandler'
|
import useWebSocketHandler from '~/composables/useWebSocketHandler'
|
||||||
|
|
||||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||||
import TagsList from '~/components/tags/List.vue'
|
import TagsList from '~/components/tags/List.vue'
|
||||||
|
import Section from '~/components/ui/Section.vue'
|
||||||
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
|
import Loader from '~/components/ui/Loader.vue'
|
||||||
|
import Heading from '~/components/ui/Heading.vue'
|
||||||
|
import Pagination from '~/components/ui/Pagination.vue'
|
||||||
|
|
||||||
import useErrorHandler from '~/composables/useErrorHandler'
|
import useErrorHandler from '~/composables/useErrorHandler'
|
||||||
|
|
||||||
import { getArtistCoverUrl } from '~/utils/utils'
|
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'count', count: number): void
|
(e: 'count', count: number): void
|
||||||
}
|
}
|
||||||
|
@ -23,55 +30,61 @@ interface Props {
|
||||||
filters: Record<string, string | boolean>
|
filters: Record<string, string | boolean>
|
||||||
url: string
|
url: string
|
||||||
isActivity?: boolean
|
isActivity?: boolean
|
||||||
showCount?: boolean
|
|
||||||
limit?: number
|
limit?: number
|
||||||
itemClasses?: string
|
itemClasses?: string
|
||||||
websocketHandlers?: string[]
|
websocketHandlers?: string[]
|
||||||
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<Events>()
|
const emit = defineEmits<Events>()
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
isActivity: true,
|
isActivity: true,
|
||||||
showCount: false,
|
limit: 9,
|
||||||
limit: 5,
|
|
||||||
itemClasses: '',
|
itemClasses: '',
|
||||||
websocketHandlers: () => []
|
websocketHandlers: () => [],
|
||||||
|
title: undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const objects = reactive([] as Listening[])
|
const objects = reactive([] as Listening[])
|
||||||
const count = ref(0)
|
const count = ref(0)
|
||||||
const nextPage = ref<string | null>(null)
|
const page = usePage()
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|
||||||
const fetchData = async (url = props.url) => {
|
const fetchData = async (url = props.url) => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
...clone(props.filters),
|
...clone(props.filters),
|
||||||
page_size: props.limit
|
page: page.value,
|
||||||
|
page_size: props.limit ?? 9
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(url, { params })
|
const response = await axios.get(url, { params })
|
||||||
nextPage.value = response.data.next
|
|
||||||
count.value = response.data.count
|
count.value = response.data.count
|
||||||
|
|
||||||
const newObjects = !props.isActivity
|
const newObjects = !props.isActivity
|
||||||
? response.data.results.map((track: Track) => ({ track }))
|
? response.data.results.map((track: Track) => ({ track }))
|
||||||
: response.data.results
|
: response.data.results
|
||||||
|
|
||||||
objects.push(...newObjects)
|
objects.splice(0, objects.length, ...newObjects)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
useErrorHandler(error as Error)
|
useErrorHandler(error as Error)
|
||||||
}
|
} finally {
|
||||||
|
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeout(fetchData, 1000)
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => store.state.moderation.lastUpdate,
|
[() => store.state.moderation.lastUpdate, page],
|
||||||
() => fetchData(),
|
() => fetchData(),
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
@ -79,73 +92,86 @@ watch(
|
||||||
watch(count, (to) => emit('count', to))
|
watch(count, (to) => emit('count', to))
|
||||||
|
|
||||||
watch(() => props.websocketHandlers.includes('Listen'), (to) => {
|
watch(() => props.websocketHandlers.includes('Listen'), (to) => {
|
||||||
useWebSocketHandler('Listen', (event) => {
|
if (to) {
|
||||||
// TODO (wvffle): Add reactivity to recently listened / favorited / added (#1316, #1534)
|
useWebSocketHandler('Listen', (event: unknown) => {
|
||||||
// count.value += 1
|
// Handle WebSocket events for "Listen"
|
||||||
|
|
||||||
// objects.unshift(event as Listening)
|
// Add the event to `objects` reactively
|
||||||
// objects.pop()
|
objects.unshift(event as Listening)
|
||||||
|
|
||||||
|
// Keep the array size within limits (e.g., remove the last item if needed)
|
||||||
|
if (objects.length > props.limit) {
|
||||||
|
objects.pop()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
}
|
||||||
|
}, { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="component-track-widget">
|
<Section
|
||||||
<h3 v-if="!!$slots.title">
|
align-left
|
||||||
<slot name="title" />
|
:h2="title"
|
||||||
<span
|
:columns-per-item="4"
|
||||||
v-if="showCount"
|
|
||||||
class="ui tiny circular label"
|
|
||||||
>{{ count }}</span>
|
|
||||||
</h3>
|
|
||||||
<div
|
|
||||||
v-if="count > 0"
|
|
||||||
class="ui divided unstackable items"
|
|
||||||
>
|
>
|
||||||
|
<Loader
|
||||||
|
v-if="isLoading"
|
||||||
|
style="grid-column: 1 / -1;"
|
||||||
|
/>
|
||||||
|
<Alert
|
||||||
|
v-if="!isLoading && count === 0"
|
||||||
|
style="grid-column: 1 / -1;"
|
||||||
|
blue
|
||||||
|
align-items="center"
|
||||||
|
>
|
||||||
|
<h4>
|
||||||
|
<i class="bi bi-search" />
|
||||||
|
{{ t('components.audio.track.Widget.empty.noResults') }}
|
||||||
|
</h4>
|
||||||
|
</Alert>
|
||||||
|
<!-- TODO: Use activity.vue -->
|
||||||
<div
|
<div
|
||||||
v-for="object in objects"
|
v-for="object in (count > 0 ? objects : [])"
|
||||||
:key="object.id"
|
:key="object.id"
|
||||||
|
class="funkwhale activity"
|
||||||
:class="['item', itemClasses]"
|
:class="['item', itemClasses]"
|
||||||
>
|
>
|
||||||
<div class="ui tiny image">
|
<div class="activity-image">
|
||||||
<img
|
<img
|
||||||
v-if="object.track.album && object.track.album.cover"
|
v-if="object.track.album && object.track.album.cover"
|
||||||
v-lazy="$store.getters['instance/absoluteUrl'](object.track.album.cover.urls.medium_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](object.track.album.cover.urls.small_square_crop)"
|
||||||
alt=""
|
alt=""
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="object.track.cover"
|
v-else-if="object.track.cover"
|
||||||
v-lazy="$store.getters['instance/absoluteUrl'](object.track.cover.urls.medium_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](object.track.cover.urls.small_square_crop)"
|
||||||
alt=""
|
alt=""
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="object.track.artist_credit && object.track.artist_credit.length > 0"
|
v-else-if="object.track.artist_credit && object.track.artist_credit.length > 1"
|
||||||
v-lazy="getArtistCoverUrl(object.track.artist_credit)"
|
v-lazy="getArtistCoverUrl(object.track.artist_credit)"
|
||||||
alt=""
|
alt=""
|
||||||
>
|
>
|
||||||
<img
|
<i
|
||||||
v-else
|
v-else
|
||||||
alt=""
|
class="bi bi-vinyl-fill"
|
||||||
src="../../../assets/audio/default-cover.png"
|
|
||||||
>
|
|
||||||
<play-button
|
|
||||||
class="play-overlay"
|
|
||||||
:icon-only="true"
|
|
||||||
:button-classes="['ui', 'circular', 'tiny', 'vibrant', 'icon', 'button']"
|
|
||||||
:track="object.track"
|
|
||||||
/>
|
/>
|
||||||
|
<!-- TODO: Add Playbutton overlay -->
|
||||||
</div>
|
</div>
|
||||||
<div class="middle aligned content">
|
<div class="activity-content">
|
||||||
<div class="ui unstackable grid">
|
<router-link
|
||||||
<div class="thirteen wide stretched column">
|
class="funkwhale link artist"
|
||||||
<div class="ellipsis">
|
:to="{name: 'library.tracks.detail', params: {id: object.track.id}}"
|
||||||
<router-link :to="{name: 'library.tracks.detail', params: {id: object.track.id}}">
|
>
|
||||||
{{ object.track.title }}
|
<Heading
|
||||||
|
:h3="object.track.title"
|
||||||
|
title
|
||||||
|
/>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
<Spacer :size="2" />
|
||||||
<div
|
<div
|
||||||
v-if="object.track.artist_credit"
|
v-if="object.track.artist_credit"
|
||||||
class="meta ellipsis"
|
class="funkwhale link artist"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-for="ac in object.track.artist_credit"
|
v-for="ac in object.track.artist_credit"
|
||||||
|
@ -160,20 +186,21 @@ watch(() => props.websocketHandlers.includes('Listen'), (to) => {
|
||||||
<span v-if="ac.joinphrase">{{ ac.joinphrase }}</span>
|
<span v-if="ac.joinphrase">{{ ac.joinphrase }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<tags-list
|
<Spacer :size="8" />
|
||||||
|
<TagsList
|
||||||
label-classes="tiny"
|
label-classes="tiny"
|
||||||
:truncate-size="20"
|
:truncate-size="20"
|
||||||
:limit="2"
|
:limit="2"
|
||||||
:show-more="false"
|
:show-more="false"
|
||||||
:tags="object.track.tags"
|
:tags="object.track.tags"
|
||||||
/>
|
/>
|
||||||
|
<Spacer :size="4" />
|
||||||
<div
|
<div
|
||||||
v-if="isActivity"
|
v-if="isActivity"
|
||||||
class="extra"
|
class="extra"
|
||||||
>
|
>
|
||||||
<router-link
|
<router-link
|
||||||
class="left floated"
|
class="funkwhale link user"
|
||||||
:to="{name: 'profile.overview', params: {username: object.actor.name}}"
|
:to="{name: 'profile.overview', params: {username: object.actor.name}}"
|
||||||
>
|
>
|
||||||
<span class="at symbol" />{{ object.actor.name }}
|
<span class="at symbol" />{{ object.actor.name }}
|
||||||
|
@ -181,48 +208,133 @@ watch(() => props.websocketHandlers.includes('Listen'), (to) => {
|
||||||
<span class="right floated"><human-date :date="object.creation_date" /></span>
|
<span class="right floated"><human-date :date="object.creation_date" /></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="one wide stretched column">
|
|
||||||
<play-button
|
<play-button
|
||||||
class="basic icon"
|
|
||||||
:account="object.actor"
|
:account="object.actor"
|
||||||
:dropdown-only="true"
|
:dropdown-only="true"
|
||||||
:dropdown-icon-classes="['ellipsis', 'vertical', 'large really discrete']"
|
|
||||||
:track="object.track"
|
:track="object.track"
|
||||||
|
square-small
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Pagination
|
||||||
</div>
|
v-if="page && count > props.limit"
|
||||||
</div>
|
v-model:page="page"
|
||||||
<div
|
:pages="Math.ceil((count || 0) / props.limit)"
|
||||||
v-if="isLoading"
|
style="grid-column: 1 / -1;"
|
||||||
class="ui inverted active dimmer"
|
/>
|
||||||
>
|
</Section>
|
||||||
<div class="ui loader" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="ui placeholder segment"
|
|
||||||
>
|
|
||||||
<div class="ui icon header">
|
|
||||||
<i class="music icon" />
|
|
||||||
{{ $t('components.audio.track.Widget.empty.noResults') }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="isLoading"
|
|
||||||
class="ui inverted active dimmer"
|
|
||||||
>
|
|
||||||
<div class="ui loader" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<template v-if="nextPage">
|
|
||||||
<div class="ui hidden divider" />
|
|
||||||
<button
|
|
||||||
:class="['ui', 'basic', 'button']"
|
|
||||||
@click="fetchData(nextPage as string)"
|
|
||||||
>
|
|
||||||
{{ $t('components.audio.track.Widget.button.more') }}
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.funkwhale {
|
||||||
|
&.activity {
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid;
|
||||||
|
margin: -11px 0;
|
||||||
|
|
||||||
|
@include light-theme {
|
||||||
|
border-color: var(--fw-gray-300);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
border-color: var(--fw-gray-800);
|
||||||
|
}
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
grid-column: span 4;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .activity-image {
|
||||||
|
|
||||||
|
width: 40px;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--fw-border-radius);
|
||||||
|
|
||||||
|
> img {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
> i {
|
||||||
|
font-size: 40px;
|
||||||
|
line-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .play-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
padding: 0 !important;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
margin: 0;
|
||||||
|
border: 0 !important;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.play-button {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .activity-content {
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .track-title {
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.5em;
|
||||||
|
@include dark-theme {
|
||||||
|
color: var(--fw-gray-300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user, time {
|
||||||
|
line-height: 1.5em;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--fw-gray-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include light-theme {
|
||||||
|
|
||||||
|
.play-button {
|
||||||
|
background: rgba(255, 255, 255, .5);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
--fw-text-color: var(--fw-gray-800) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include dark-theme {
|
||||||
|
|
||||||
|
.play-button {
|
||||||
|
background: rgba(0, 0, 0, .2);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, .8);
|
||||||
|
--fw-text-color: var(--fw-gray-200) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -26,6 +26,19 @@ onScopeDispose(() => {
|
||||||
defineExpose({
|
defineExpose({
|
||||||
loadRandomPreset
|
loadRandomPreset
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let autoPresetInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function toggleAutoPreset() {
|
||||||
|
if (autoPresetInterval) {
|
||||||
|
clearInterval(autoPresetInterval)
|
||||||
|
autoPresetInterval = null
|
||||||
|
} else {
|
||||||
|
autoPresetInterval = setInterval(() => {
|
||||||
|
loadRandomPreset()
|
||||||
|
}, 23000)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -36,6 +49,7 @@ defineExpose({
|
||||||
<canvas
|
<canvas
|
||||||
ref="canvas"
|
ref="canvas"
|
||||||
@click="loadRandomPreset()"
|
@click="loadRandomPreset()"
|
||||||
|
@dblclick="toggleAutoPreset()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -60,7 +60,7 @@ store.state.auth.applicationSecret = undefined
|
||||||
<template>
|
<template>
|
||||||
<main
|
<main
|
||||||
v-title="labels.title"
|
v-title="labels.title"
|
||||||
class="main pusher"
|
class="main"
|
||||||
>
|
>
|
||||||
<div class="ui vertical stripe segment">
|
<div class="ui vertical stripe segment">
|
||||||
<section class="ui text container">
|
<section class="ui text container">
|
||||||
|
@ -72,17 +72,17 @@ store.state.auth.applicationSecret = undefined
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<router-link :to="{name: 'settings'}">
|
<router-link :to="{name: 'settings'}">
|
||||||
{{ $t('components.auth.ApplicationEdit.link.settings') }}
|
{{ t('components.auth.ApplicationEdit.link.settings') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<h2 class="ui header">
|
<h2 class="ui header">
|
||||||
{{ $t('components.auth.ApplicationEdit.header.appDetails') }}
|
{{ t('components.auth.ApplicationEdit.header.appDetails') }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="ui form">
|
<div class="ui form">
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.auth.ApplicationEdit.help.appDetails') }}
|
{{ t('components.auth.ApplicationEdit.help.appDetails') }}
|
||||||
</p>
|
</p>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="copy-id">{{ $t('components.auth.ApplicationEdit.label.appId') }}</label>
|
<label for="copy-id">{{ t('components.auth.ApplicationEdit.label.appId') }}</label>
|
||||||
<copy-input
|
<copy-input
|
||||||
id="copy-id"
|
id="copy-id"
|
||||||
:value="application.client_id"
|
:value="application.client_id"
|
||||||
|
@ -94,14 +94,14 @@ store.state.auth.applicationSecret = undefined
|
||||||
>
|
>
|
||||||
<div class="ui small warning message">
|
<div class="ui small warning message">
|
||||||
<h3 class="header">
|
<h3 class="header">
|
||||||
{{ $t('components.auth.ApplicationEdit.header.appSecretWarning') }}
|
{{ t('components.auth.ApplicationEdit.header.appSecretWarning') }}
|
||||||
</h3>
|
</h3>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.auth.ApplicationEdit.message.appSecretWarning') }}
|
{{ t('components.auth.ApplicationEdit.message.appSecretWarning') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label for="copy-secret">{{ $t('components.auth.ApplicationEdit.label.appSecret') }}</label>
|
<label for="copy-secret">{{ t('components.auth.ApplicationEdit.label.appSecret') }}</label>
|
||||||
<copy-input
|
<copy-input
|
||||||
id="copy-secret"
|
id="copy-secret"
|
||||||
:value="secret"
|
:value="secret"
|
||||||
|
@ -111,7 +111,7 @@ store.state.auth.applicationSecret = undefined
|
||||||
v-if="application.token != undefined"
|
v-if="application.token != undefined"
|
||||||
class="field"
|
class="field"
|
||||||
>
|
>
|
||||||
<label for="copy-secret">{{ $t('components.auth.ApplicationEdit.label.accessToken') }}</label>
|
<label for="copy-secret">{{ t('components.auth.ApplicationEdit.label.accessToken') }}</label>
|
||||||
<copy-input
|
<copy-input
|
||||||
id="copy-secret"
|
id="copy-secret"
|
||||||
:value="application.token"
|
:value="application.token"
|
||||||
|
@ -121,12 +121,12 @@ store.state.auth.applicationSecret = undefined
|
||||||
@click.prevent="refreshToken"
|
@click.prevent="refreshToken"
|
||||||
>
|
>
|
||||||
<i class="refresh icon" />
|
<i class="refresh icon" />
|
||||||
{{ $t('components.auth.ApplicationEdit.button.regenerateToken') }}
|
{{ t('components.auth.ApplicationEdit.button.regenerateToken') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="ui header">
|
<h2 class="ui header">
|
||||||
{{ $t('components.auth.ApplicationEdit.header.editApp') }}
|
{{ t('components.auth.ApplicationEdit.header.editApp') }}
|
||||||
</h2>
|
</h2>
|
||||||
<application-form
|
<application-form
|
||||||
:app="application"
|
:app="application"
|
||||||
|
|
|
@ -7,6 +7,8 @@ import { computedEager } from '@vueuse/core'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { uniq } from 'lodash-es'
|
import { uniq } from 'lodash-es'
|
||||||
|
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
|
||||||
import useScopes from '~/composables/auth/useScopes'
|
import useScopes from '~/composables/auth/useScopes'
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
|
@ -120,7 +122,7 @@ const allScopes = computed(() => {
|
||||||
class="ui negative message"
|
class="ui negative message"
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
<h4 class="header">
|
||||||
{{ $t('components.auth.ApplicationForm.header.failure') }}
|
{{ t('components.auth.ApplicationForm.header.failure') }}
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
<li
|
<li
|
||||||
|
@ -132,7 +134,7 @@ const allScopes = computed(() => {
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui field">
|
<div class="ui field">
|
||||||
<label for="application-name">{{ $t('components.auth.ApplicationForm.label.name') }}</label>
|
<label for="application-name">{{ t('components.auth.ApplicationForm.label.name') }}</label>
|
||||||
<input
|
<input
|
||||||
id="application-name"
|
id="application-name"
|
||||||
v-model="fields.name"
|
v-model="fields.name"
|
||||||
|
@ -142,7 +144,7 @@ const allScopes = computed(() => {
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui field">
|
<div class="ui field">
|
||||||
<label for="redirect-uris">{{ $t('components.auth.ApplicationForm.label.redirectUri') }}</label>
|
<label for="redirect-uris">{{ t('components.auth.ApplicationForm.label.redirectUri') }}</label>
|
||||||
<input
|
<input
|
||||||
id="redirect-uris"
|
id="redirect-uris"
|
||||||
v-model="fields.redirect_uris"
|
v-model="fields.redirect_uris"
|
||||||
|
@ -150,13 +152,13 @@ const allScopes = computed(() => {
|
||||||
type="text"
|
type="text"
|
||||||
>
|
>
|
||||||
<p class="help">
|
<p class="help">
|
||||||
{{ $t('components.auth.ApplicationForm.help.redirectUri') }}
|
{{ t('components.auth.ApplicationForm.help.redirectUri') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui field">
|
<div class="ui field">
|
||||||
<label>{{ $t('components.auth.ApplicationForm.label.scopes.label') }}</label>
|
<label>{{ t('components.auth.ApplicationForm.label.scopes.label') }}</label>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.auth.ApplicationForm.label.scopes.description') }}
|
{{ t('components.auth.ApplicationForm.label.scopes.description') }}
|
||||||
</p>
|
</p>
|
||||||
<div class="ui stackable two column grid">
|
<div class="ui stackable two column grid">
|
||||||
<div
|
<div
|
||||||
|
@ -198,16 +200,16 @@ const allScopes = computed(() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
:class="['ui', {'loading': isLoading}, 'success', 'button']"
|
:is-loading="isLoading"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
<span v-if="app !== null">
|
<span v-if="app !== null">
|
||||||
{{ $t('components.auth.ApplicationForm.button.update') }}
|
{{ t('components.auth.ApplicationForm.button.update') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ $t('components.auth.ApplicationForm.button.create') }}
|
{{ t('components.auth.ApplicationForm.button.create') }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -53,12 +53,12 @@ const created = (application: Application) => {
|
||||||
<template>
|
<template>
|
||||||
<main
|
<main
|
||||||
v-title="labels.title"
|
v-title="labels.title"
|
||||||
class="main pusher"
|
class="main"
|
||||||
>
|
>
|
||||||
<div class="ui vertical stripe segment">
|
<div class="ui vertical stripe segment">
|
||||||
<section class="ui text container">
|
<section class="ui text container">
|
||||||
<router-link :to="{name: 'settings'}">
|
<router-link :to="{name: 'settings'}">
|
||||||
{{ $t('components.auth.ApplicationNew.link.settings') }}
|
{{ t('components.auth.ApplicationNew.link.settings') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<h2 class="ui header">
|
<h2 class="ui header">
|
||||||
{{ labels.title }}
|
{{ labels.title }}
|
||||||
|
|
|
@ -9,6 +9,8 @@ import { ref, computed } from 'vue'
|
||||||
import useSharedLabels from '~/composables/locale/useSharedLabels'
|
import useSharedLabels from '~/composables/locale/useSharedLabels'
|
||||||
import useScopes from '~/composables/auth/useScopes'
|
import useScopes from '~/composables/auth/useScopes'
|
||||||
import useFormData from '~/composables/useFormData'
|
import useFormData from '~/composables/useFormData'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
clientId: string
|
clientId: string
|
||||||
|
@ -112,31 +114,30 @@ whenever(() => props.clientId, fetchApplication, { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main
|
<layout
|
||||||
v-title="labels.title"
|
v-title="labels.title"
|
||||||
class="main pusher"
|
main
|
||||||
|
class="main"
|
||||||
>
|
>
|
||||||
<section class="ui vertical stripe segment">
|
<section class="ui vertical stripe segment">
|
||||||
<div class="ui small text container">
|
<div class="ui small text container">
|
||||||
<h2>
|
<h2>
|
||||||
<i class="lock open icon" />{{ $t('components.auth.Authorize.header.authorize') }}
|
<i class="bi bi-unlock-fill" />{{ t('components.auth.Authorize.header.authorize') }}
|
||||||
</h2>
|
</h2>
|
||||||
<div
|
<Alert
|
||||||
v-if="errors.length > 0"
|
v-if="errors.length > 0"
|
||||||
|
red
|
||||||
role="alert"
|
role="alert"
|
||||||
class="ui negative message"
|
|
||||||
>
|
>
|
||||||
<h4
|
<h4
|
||||||
v-if="application"
|
v-if="application"
|
||||||
class="header"
|
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.Authorize.header.authorizeFailure') }}
|
{{ t('components.auth.Authorize.header.authorizeFailure') }}
|
||||||
</h4>
|
</h4>
|
||||||
<h4
|
<h4
|
||||||
v-else
|
v-else
|
||||||
class="header"
|
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.Authorize.header.fetchFailure') }}
|
{{ t('components.auth.Authorize.header.fetchFailure') }}
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
<li
|
<li
|
||||||
|
@ -146,20 +147,18 @@ whenever(() => props.clientId, fetchApplication, { immediate: true })
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</Alert>
|
||||||
<div
|
<Loader
|
||||||
v-if="isLoading"
|
v-if="isLoading"
|
||||||
class="ui inverted active dimmer"
|
class="ui inverted active dimmer"
|
||||||
>
|
/>
|
||||||
<div class="ui loader" />
|
|
||||||
</div>
|
|
||||||
<form
|
<form
|
||||||
v-else-if="application && !code"
|
v-else-if="application && !code"
|
||||||
:class="['ui', {loading: isLoading}, 'form']"
|
:class="['ui', {loading: isLoading}, 'form']"
|
||||||
@submit.prevent="submit"
|
@submit.prevent="submit"
|
||||||
>
|
>
|
||||||
<h3>
|
<h3>
|
||||||
{{ $t('components.auth.Authorize.header.access', {app_name: application.name}) }}
|
{{ t('components.auth.Authorize.header.access', {app_name: application.name}) }}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<h4
|
<h4
|
||||||
|
@ -171,23 +170,23 @@ whenever(() => props.clientId, fetchApplication, { immediate: true })
|
||||||
v-if="topic.write && !topic.read"
|
v-if="topic.write && !topic.read"
|
||||||
:class="['ui', 'basic', 'right floated', 'tiny', 'vertically-spaced component-label label']"
|
:class="['ui', 'basic', 'right floated', 'tiny', 'vertically-spaced component-label label']"
|
||||||
>
|
>
|
||||||
<i class="pencil icon" />
|
<i class="bi bi-pencil" />
|
||||||
{{ $t('components.auth.Authorize.header.writeOnly') }}
|
{{ t('components.auth.Authorize.header.writeOnly') }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-else-if="!topic.write && topic.read"
|
v-else-if="!topic.write && topic.read"
|
||||||
:class="['ui', 'basic', 'right floated', 'tiny', 'vertically-spaced component-label label']"
|
:class="['ui', 'basic', 'right floated', 'tiny', 'vertically-spaced component-label label']"
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.Authorize.header.readOnly') }}
|
{{ t('components.auth.Authorize.header.readOnly') }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-else-if="topic.write && topic.read"
|
v-else-if="topic.write && topic.read"
|
||||||
:class="['ui', 'basic', 'right floated', 'tiny', 'vertically-spaced component-label label']"
|
:class="['ui', 'basic', 'right floated', 'tiny', 'vertically-spaced component-label label']"
|
||||||
>
|
>
|
||||||
<i class="pencil icon" />
|
<i class="bi bi-pencil" />
|
||||||
{{ $t('components.auth.Authorize.header.allScopes') }}
|
{{ t('components.auth.Authorize.header.allScopes') }}
|
||||||
</span>
|
</span>
|
||||||
<i :class="[topic.icon, 'icon']" />
|
<i :class="[topic.icon, 'bi']" />
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{{ topic.label }}
|
{{ topic.label }}
|
||||||
<div class="sub header">
|
<div class="sub header">
|
||||||
|
@ -196,7 +195,7 @@ whenever(() => props.clientId, fetchApplication, { immediate: true })
|
||||||
</div>
|
</div>
|
||||||
</h4>
|
</h4>
|
||||||
<div v-if="unknownRequestedScopes.length > 0">
|
<div v-if="unknownRequestedScopes.length > 0">
|
||||||
<p><strong>{{ $t('components.auth.Authorize.message.unknownPermissions') }}</strong></p>
|
<p><strong>{{ t('components.auth.Authorize.message.unknownPermissions') }}</strong></p>
|
||||||
<ul
|
<ul
|
||||||
v-for="(unknownscope, key) in unknownRequestedScopes"
|
v-for="(unknownscope, key) in unknownRequestedScopes"
|
||||||
:key="key"
|
:key="key"
|
||||||
|
@ -204,17 +203,16 @@ whenever(() => props.clientId, fetchApplication, { immediate: true })
|
||||||
<li>{{ unknownscope }}</li>
|
<li>{{ unknownscope }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
class="ui success labeled icon button"
|
icon="bi-unlock"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
<i class="lock open icon" />
|
{{ t('components.auth.Authorize.button.authorize', { app: application.name }) }}
|
||||||
{{ $t('components.auth.Authorize.button.authorize', { app: application.name }) }}
|
</Button>
|
||||||
</button>
|
|
||||||
<p
|
<p
|
||||||
v-if="redirectUri === 'urn:ietf:wg:oauth:2.0:oob'"
|
v-if="redirectUri === 'urn:ietf:wg:oauth:2.0:oob'"
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.Authorize.help.copyCode') }}
|
{{ t('components.auth.Authorize.help.copyCode') }}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
v-else
|
v-else
|
||||||
|
@ -225,10 +223,10 @@ whenever(() => props.clientId, fetchApplication, { immediate: true })
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
<div v-else-if="code">
|
<div v-else-if="code">
|
||||||
<p><strong>{{ $t('components.auth.Authorize.help.pasteCode') }}</strong></p>
|
<p><strong>{{ t('components.auth.Authorize.help.pasteCode') }}</strong></p>
|
||||||
<copy-input :value="code" />
|
<copy-input :value="code" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -2,12 +2,16 @@
|
||||||
import type { BackendError } from '~/types'
|
import type { BackendError } from '~/types'
|
||||||
import { onBeforeRouteLeave, type RouteLocationRaw, useRouter } from 'vue-router'
|
import { onBeforeRouteLeave, type RouteLocationRaw, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
import { ref, reactive, computed } from 'vue'
|
||||||
import { useEventListener } from '@vueuse/core'
|
import { useEventListener } from '@vueuse/core'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
|
||||||
import PasswordInput from '~/components/forms/PasswordInput.vue'
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
|
import Input from '~/components/ui/Input.vue'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
next?: RouteLocationRaw
|
next?: RouteLocationRaw
|
||||||
|
@ -44,12 +48,6 @@ const labels = computed(() => ({
|
||||||
usernamePlaceholder: t('components.auth.LoginForm.placeholder.username')
|
usernamePlaceholder: t('components.auth.LoginForm.placeholder.username')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const username = ref()
|
|
||||||
onMounted(async () => {
|
|
||||||
await nextTick()
|
|
||||||
username.value?.focus()
|
|
||||||
})
|
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const errors = ref([] as string[])
|
const errors = ref([] as string[])
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
|
@ -77,82 +75,97 @@ const submit = async () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form
|
<Layout
|
||||||
class="ui form"
|
form
|
||||||
|
stack
|
||||||
|
style="max-width: 600px"
|
||||||
@submit.prevent="submit()"
|
@submit.prevent="submit()"
|
||||||
>
|
>
|
||||||
<div
|
<Alert
|
||||||
v-if="errors.length > 0"
|
v-if="errors.length > 0"
|
||||||
role="alert"
|
red
|
||||||
class="ui negative message"
|
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
<h4 class="header">
|
||||||
{{ $t('components.auth.LoginForm.header.loginFailure') }}
|
{{ t('components.auth.LoginForm.header.loginFailure') }}
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="list">
|
<component
|
||||||
<li
|
:is="errors.length>1 ? 'ul' : 'div'"
|
||||||
v-if="errors[0] == 'invalid_credentials' && $store.state.instance.settings.moderation.signup_approval_enabled.value"
|
class="list"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="errors.length>1 ? 'li' : 'div'"
|
||||||
|
v-if="errors[0] == 'invalid_credentials' && store.state.instance.settings.moderation.signup_approval_enabled.value"
|
||||||
|
>
|
||||||
|
{{ t('components.auth.LoginForm.help.approvalRequired') }}
|
||||||
|
</component>
|
||||||
|
<component
|
||||||
|
:is="errors.length>1 ? 'li' : 'div'"
|
||||||
|
v-else-if="errors[0] == 'invalid_credentials'"
|
||||||
|
>
|
||||||
|
{{ t('components.auth.LoginForm.help.invalidCredentials') }}
|
||||||
|
</component>
|
||||||
|
<component
|
||||||
|
:is="errors.length>1 ? 'li' : 'div'"
|
||||||
|
v-else
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.LoginForm.help.approvalRequired') }}
|
|
||||||
</li>
|
|
||||||
<li v-else-if="errors[0] == 'invalid_credentials'">
|
|
||||||
{{ $t('components.auth.LoginForm.help.invalidCredentials') }}
|
|
||||||
</li>
|
|
||||||
<li v-else>
|
|
||||||
{{ errors[0] }}
|
{{ errors[0] }}
|
||||||
</li>
|
</component>
|
||||||
</ul>
|
</component>
|
||||||
</div>
|
</Alert>
|
||||||
<template v-if="domain === $store.getters['instance/domain']">
|
<Spacer />
|
||||||
<div class="field">
|
<template v-if="domain === store.getters['instance/domain']">
|
||||||
<label for="username-field">
|
<Input
|
||||||
{{ $t('components.auth.LoginForm.label.username') }}
|
|
||||||
<template v-if="showSignup">
|
|
||||||
<span class="middle pipe symbol" />
|
|
||||||
<router-link :to="{ path: '/signup' }">
|
|
||||||
{{ $t('components.auth.LoginForm.link.createAccount') }}
|
|
||||||
</router-link>
|
|
||||||
</template>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="username-field"
|
id="username-field"
|
||||||
ref="username"
|
ref="username"
|
||||||
v-model="credentials.username"
|
v-model="credentials.username"
|
||||||
|
autocomplete="username"
|
||||||
required
|
required
|
||||||
name="username"
|
name="username"
|
||||||
type="text"
|
type="text"
|
||||||
autofocus
|
autofocus
|
||||||
:placeholder="labels.usernamePlaceholder"
|
:placeholder="labels.usernamePlaceholder"
|
||||||
>
|
>
|
||||||
</div>
|
<template #label>
|
||||||
<div class="field">
|
{{ t('components.auth.LoginForm.label.username') }}
|
||||||
<label for="password-field">
|
<template v-if="showSignup">
|
||||||
{{ $t('components.auth.LoginForm.label.password') }}
|
<span class="middle pipe symbol" />
|
||||||
|
<router-link :to="{ path: '/signup' }">
|
||||||
|
{{ t('components.auth.LoginForm.link.createAccount') }}
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</Input>
|
||||||
|
<Input
|
||||||
|
v-model="credentials.password"
|
||||||
|
password
|
||||||
|
name="password-field"
|
||||||
|
autocomplete="current-password"
|
||||||
|
field-id="password-field"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<template #label>
|
||||||
|
{{ t('components.auth.LoginForm.label.password') }}
|
||||||
<span class="middle pipe symbol" />
|
<span class="middle pipe symbol" />
|
||||||
<router-link
|
<router-link
|
||||||
tabindex="1"
|
tabindex="1"
|
||||||
:to="{ name: 'auth.password-reset', query: { email: credentials.username } }"
|
:to="{ name: 'auth.password-reset', query: { email: credentials.username } }"
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.LoginForm.link.resetPassword') }}
|
{{ t('components.auth.LoginForm.link.resetPassword') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</label>
|
</template>
|
||||||
<password-input
|
</Input>
|
||||||
v-model="credentials.password"
|
|
||||||
field-id="password-field"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.auth.LoginForm.message.redirect', { domain: $store.getters['instance/domain'] }) }}
|
{{ t('components.auth.LoginForm.message.redirect', { domain: store.getters['instance/domain'] }) }}
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
<button
|
<Button
|
||||||
:class="['ui', { 'loading': isLoading }, 'right', 'floated', buttonClasses, 'button']"
|
solid
|
||||||
|
primary
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.LoginForm.button.login') }}
|
{{ t('components.auth.LoginForm.button.login') }}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</Layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,8 +1,16 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
|
import Link from '~/components/ui/Link.vue'
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const labels = computed(() => ({
|
const labels = computed(() => ({
|
||||||
title: t('components.auth.Logout.title')
|
title: t('components.auth.Logout.title')
|
||||||
}))
|
}))
|
||||||
|
@ -11,40 +19,43 @@ const labels = computed(() => ({
|
||||||
<template>
|
<template>
|
||||||
<main
|
<main
|
||||||
v-title="labels.title"
|
v-title="labels.title"
|
||||||
class="main pusher"
|
class="main"
|
||||||
>
|
>
|
||||||
<section class="ui vertical stripe segment">
|
|
||||||
<div
|
<div
|
||||||
v-if="$store.state.auth.authenticated"
|
v-if="store.state.auth.authenticated"
|
||||||
class="ui small text container"
|
class="ui small text container"
|
||||||
>
|
>
|
||||||
<h2>
|
<h2>
|
||||||
{{ $t('components.auth.Logout.header.confirm') }}
|
{{ t('components.auth.Logout.header.confirm') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.auth.Logout.message.loggedIn', { username: $store.state.auth.username }) }}
|
{{ t('components.auth.Logout.message.loggedIn', { username: store.state.auth.username }) }}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Spacer />
|
||||||
class="ui button"
|
<Button
|
||||||
@click="$store.dispatch('auth/logout')"
|
solid
|
||||||
|
primary
|
||||||
|
@click="store.dispatch('auth/logout')"
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.Logout.button.logout') }}
|
{{ t('components.auth.Logout.button.logout') }}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<Alert
|
||||||
v-else
|
v-else
|
||||||
class="ui small text container"
|
yellow
|
||||||
>
|
>
|
||||||
<h2>
|
<h2>
|
||||||
{{ $t('components.auth.Logout.header.unauthenticated') }}
|
{{ t('components.auth.Logout.header.unauthenticated') }}
|
||||||
</h2>
|
</h2>
|
||||||
<router-link
|
<template #actions>
|
||||||
|
<Link
|
||||||
|
solid
|
||||||
|
primary
|
||||||
to="/login"
|
to="/login"
|
||||||
class="ui button"
|
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.Logout.link.login') }}
|
{{ t('components.auth.Logout.link.login') }}
|
||||||
</router-link>
|
</Link>
|
||||||
</div>
|
</template>
|
||||||
</section>
|
</Alert>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -5,12 +5,21 @@ import axios from 'axios'
|
||||||
import { clone } from 'lodash-es'
|
import { clone } from 'lodash-es'
|
||||||
import useMarkdown, { useMarkdownRaw } from '~/composables/useMarkdown'
|
import useMarkdown, { useMarkdownRaw } from '~/composables/useMarkdown'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
|
import Input from '~/components/ui/Input.vue'
|
||||||
|
import Toggle from '~/components/ui/Toggle.vue'
|
||||||
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
plugin: Plugin
|
plugin: Plugin
|
||||||
libraries: Library[]
|
libraries: Library[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
const description = useMarkdown(() => props.plugin.description ?? '')
|
const description = useMarkdown(() => props.plugin.description ?? '')
|
||||||
|
@ -53,8 +62,9 @@ const submitAndScan = async () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form
|
<Layout
|
||||||
:class="['ui segment form', {loading: isLoading}]"
|
form
|
||||||
|
:class="['ui form', {loading: isLoading}]"
|
||||||
@submit.prevent="submit"
|
@submit.prevent="submit"
|
||||||
>
|
>
|
||||||
<h3>{{ plugin.label }}</h3>
|
<h3>{{ plugin.label }}</h3>
|
||||||
|
@ -63,23 +73,20 @@ const submitAndScan = async () => {
|
||||||
:html="description"
|
:html="description"
|
||||||
/>
|
/>
|
||||||
<template v-if="plugin.homepage">
|
<template v-if="plugin.homepage">
|
||||||
<div class="ui small hidden divider" />
|
|
||||||
<a
|
<a
|
||||||
:href="plugin.homepage"
|
:href="plugin.homepage"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<i class="external icon" />
|
<i class="external icon" />
|
||||||
{{ $t('components.auth.Plugin.link.documentation') }}
|
{{ t('components.auth.Plugin.link.documentation') }}
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
<div class="ui clearing hidden divider" />
|
<Alert
|
||||||
<div
|
|
||||||
v-if="errors.length > 0"
|
v-if="errors.length > 0"
|
||||||
role="alert"
|
red
|
||||||
class="ui negative message"
|
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
<h4 class="header">
|
||||||
{{ $t('components.auth.Plugin.header.failure') }}
|
{{ t('components.auth.Plugin.header.failure') }}
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
<li
|
<li
|
||||||
|
@ -89,23 +96,19 @@ const submitAndScan = async () => {
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</Alert>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="ui toggle checkbox">
|
<Toggle
|
||||||
<input
|
|
||||||
:id="`${plugin.name}-enabled`"
|
|
||||||
v-model="enabled"
|
v-model="enabled"
|
||||||
type="checkbox"
|
big
|
||||||
>
|
:label="t('components.auth.Plugin.label.pluginEnabled')"
|
||||||
<label :for="`${plugin.name}-enabled`">{{ $t('components.auth.Plugin.label.pluginEnabled') }}</label>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="ui clearing hidden divider" />
|
|
||||||
<div
|
<div
|
||||||
v-if="plugin.source"
|
v-if="plugin.source"
|
||||||
class="field"
|
class="field"
|
||||||
>
|
>
|
||||||
<label for="plugin-library">{{ $t('components.auth.Plugin.label.library') }}</label>
|
<label for="plugin-library">{{ t('components.auth.Plugin.label.library') }}</label>
|
||||||
<select
|
<select
|
||||||
id="plugin-library"
|
id="plugin-library"
|
||||||
v-model="values['library']"
|
v-model="values['library']"
|
||||||
|
@ -119,7 +122,7 @@ const submitAndScan = async () => {
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<div>
|
<div>
|
||||||
{{ $t('components.auth.Plugin.description.library') }}
|
{{ t('components.auth.Plugin.description.library') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="(plugin.conf?.length ?? 0) > 0">
|
<template v-if="(plugin.conf?.length ?? 0) > 0">
|
||||||
|
@ -131,12 +134,12 @@ const submitAndScan = async () => {
|
||||||
v-if="field.type === 'text'"
|
v-if="field.type === 'text'"
|
||||||
class="field"
|
class="field"
|
||||||
>
|
>
|
||||||
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
|
<Input
|
||||||
<input
|
|
||||||
:id="`plugin-${field.name}`"
|
:id="`plugin-${field.name}`"
|
||||||
v-model="values[field.name]"
|
v-model="values[field.name]"
|
||||||
|
:label="field.label || field.name"
|
||||||
type="text"
|
type="text"
|
||||||
>
|
/>
|
||||||
<sanitized-html
|
<sanitized-html
|
||||||
v-if="field.help"
|
v-if="field.help"
|
||||||
:html="useMarkdownRaw(field.help)"
|
:html="useMarkdownRaw(field.help)"
|
||||||
|
@ -146,10 +149,10 @@ const submitAndScan = async () => {
|
||||||
v-if="field.type === 'long_text'"
|
v-if="field.type === 'long_text'"
|
||||||
class="field"
|
class="field"
|
||||||
>
|
>
|
||||||
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
:id="`plugin-${field.name}`"
|
:id="`plugin-${field.name}`"
|
||||||
v-model="values[field.name]"
|
v-model="values[field.name]"
|
||||||
|
:label="field.label || field.name"
|
||||||
type="text"
|
type="text"
|
||||||
rows="5"
|
rows="5"
|
||||||
/>
|
/>
|
||||||
|
@ -162,12 +165,12 @@ const submitAndScan = async () => {
|
||||||
v-if="field.type === 'url'"
|
v-if="field.type === 'url'"
|
||||||
class="field"
|
class="field"
|
||||||
>
|
>
|
||||||
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
|
<Input
|
||||||
<input
|
|
||||||
:id="`plugin-${field.name}`"
|
:id="`plugin-${field.name}`"
|
||||||
v-model="values[field.name]"
|
v-model="values[field.name]"
|
||||||
|
:label="field.label || field.name"
|
||||||
type="url"
|
type="url"
|
||||||
>
|
/>
|
||||||
<sanitized-html
|
<sanitized-html
|
||||||
v-if="field.help"
|
v-if="field.help"
|
||||||
:html="useMarkdownRaw(field.help)"
|
:html="useMarkdownRaw(field.help)"
|
||||||
|
@ -177,12 +180,12 @@ const submitAndScan = async () => {
|
||||||
v-if="field.type === 'password'"
|
v-if="field.type === 'password'"
|
||||||
class="field"
|
class="field"
|
||||||
>
|
>
|
||||||
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
|
<Input
|
||||||
<input
|
|
||||||
:id="`plugin-${field.name}`"
|
:id="`plugin-${field.name}`"
|
||||||
v-model="values[field.name]"
|
v-model="values[field.name]"
|
||||||
type="password"
|
:label="field.label || field.name"
|
||||||
>
|
password
|
||||||
|
/>
|
||||||
<sanitized-html
|
<sanitized-html
|
||||||
v-if="field.help"
|
v-if="field.help"
|
||||||
:html="useMarkdownRaw(field.help)"
|
:html="useMarkdownRaw(field.help)"
|
||||||
|
@ -192,30 +195,27 @@ const submitAndScan = async () => {
|
||||||
v-if="field.type === 'boolean'"
|
v-if="field.type === 'boolean'"
|
||||||
class="field"
|
class="field"
|
||||||
>
|
>
|
||||||
<div class="ui toggle checkbox">
|
<Toggle
|
||||||
<input
|
|
||||||
:id="`plugin-${field.name}`"
|
|
||||||
v-model="values[field.name]"
|
v-model="values[field.name]"
|
||||||
type="checkbox"
|
:label="field.label || field.name"
|
||||||
>
|
/>
|
||||||
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']"
|
primary
|
||||||
|
:class="[{'loading': isLoading}]"
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.Plugin.button.save') }}
|
{{ t('components.auth.Plugin.button.save') }}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
v-if="plugin.source"
|
v-if="plugin.source"
|
||||||
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']"
|
primary
|
||||||
|
:class="[{'loading': isLoading}]"
|
||||||
@click.prevent="submitAndScan"
|
@click.prevent="submitAndScan"
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.Plugin.button.scan') }}
|
{{ t('components.auth.Plugin.button.scan') }}
|
||||||
</button>
|
</Button>
|
||||||
<div class="ui clearing hidden divider" />
|
</Layout>
|
||||||
</form>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -3,9 +3,8 @@ import type { BackendError, Application, PrivacyLevel } from '~/types'
|
||||||
import type { $ElementType } from 'utility-types'
|
import type { $ElementType } from 'utility-types'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import $ from 'jquery'
|
|
||||||
|
|
||||||
import { computed, reactive, ref, onMounted } from 'vue'
|
import { computed, reactive, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
@ -13,10 +12,20 @@ import { useStore } from '~/store'
|
||||||
import useSharedLabels from '~/composables/locale/useSharedLabels'
|
import useSharedLabels from '~/composables/locale/useSharedLabels'
|
||||||
import useLogger from '~/composables/useLogger'
|
import useLogger from '~/composables/useLogger'
|
||||||
|
|
||||||
|
import DangerousButton from '~/components/common/DangerousButton.vue'
|
||||||
import SubsonicTokenForm from '~/components/auth/SubsonicTokenForm.vue'
|
import SubsonicTokenForm from '~/components/auth/SubsonicTokenForm.vue'
|
||||||
import AttachmentInput from '~/components/common/AttachmentInput.vue'
|
import AttachmentInput from '~/components/common/AttachmentInput.vue'
|
||||||
import PasswordInput from '~/components/forms/PasswordInput.vue'
|
import PasswordInput from '~/components/forms/PasswordInput.vue'
|
||||||
|
|
||||||
|
import Input from '~/components/ui/Input.vue'
|
||||||
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
|
import Slider from '~/components/ui/Slider.vue'
|
||||||
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
|
import Heading from '~/components/ui/Heading.vue'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import Link from '~/components/ui/Link.vue'
|
||||||
|
import Textarea from '~/components/ui/Textarea.vue'
|
||||||
|
|
||||||
const SETTINGS_ORDER: FieldId[] = ['summary', 'privacy_level']
|
const SETTINGS_ORDER: FieldId[] = ['summary', 'privacy_level']
|
||||||
|
|
||||||
type Field = { id: 'summary', type: 'content', value: { text: string, content_type: 'text/markdown' } }
|
type Field = { id: 'summary', type: 'content', value: { text: string, content_type: 'text/markdown' } }
|
||||||
|
@ -260,43 +269,39 @@ const changeEmail = async () => {
|
||||||
isChangingEmail.value = false
|
isChangingEmail.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
$('select.dropdown').dropdown()
|
|
||||||
})
|
|
||||||
|
|
||||||
fetchApps()
|
fetchApps()
|
||||||
fetchOwnedApps()
|
fetchOwnedApps()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main
|
<Layout
|
||||||
v-title="labels.title"
|
v-title="labels.title"
|
||||||
class="main pusher"
|
main
|
||||||
|
stack
|
||||||
>
|
>
|
||||||
<div class="ui vertical stripe segment">
|
<Heading
|
||||||
<section class="ui text container">
|
:h1="t('components.auth.Settings.header.accountSettings')"
|
||||||
<h2 class="ui header">
|
page-heading
|
||||||
{{ $t('components.auth.Settings.header.accountSettings') }}
|
/>
|
||||||
</h2>
|
<Layout
|
||||||
<form
|
form
|
||||||
class="ui form"
|
|
||||||
@submit.prevent="submitSettings()"
|
@submit.prevent="submitSettings()"
|
||||||
>
|
>
|
||||||
<div
|
<Alert
|
||||||
v-if="settings.success"
|
v-if="settings.success"
|
||||||
class="ui positive message"
|
green
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
<h4 class="header">
|
||||||
{{ $t('components.auth.Settings.header.settingsUpdated') }}
|
{{ t('components.auth.Settings.header.settingsUpdated') }}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</Alert>
|
||||||
<div
|
<Alert
|
||||||
v-if="settings.errors.length > 0"
|
v-if="settings.errors.length > 0"
|
||||||
|
red
|
||||||
role="alert"
|
role="alert"
|
||||||
class="ui negative message"
|
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
<h4 class="header">
|
||||||
{{ $t('components.auth.Settings.header.updateFailure') }}
|
{{ t('components.auth.Settings.header.updateFailure') }}
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
<li
|
<li
|
||||||
|
@ -306,57 +311,50 @@ fetchOwnedApps()
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</Alert>
|
||||||
<div
|
<div
|
||||||
v-for="f in orderedSettingsFields"
|
v-for="f in orderedSettingsFields"
|
||||||
:key="f.id"
|
:key="f.id + sharedLabels.fields[f.id].help"
|
||||||
class="field"
|
class="field"
|
||||||
>
|
>
|
||||||
|
<Textarea
|
||||||
|
v-if="f.type === 'content'"
|
||||||
|
v-model="f.value.text"
|
||||||
|
:label="sharedLabels.fields[f.id].label"
|
||||||
|
:placeholder="sharedLabels.fields[f.id].help"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
<label :for="f.id">{{ sharedLabels.fields[f.id].label }}</label>
|
<label :for="f.id">{{ sharedLabels.fields[f.id].label }}</label>
|
||||||
<p v-if="sharedLabels.fields[f.id].help">
|
<p v-if="sharedLabels.fields[f.id].help">
|
||||||
{{ sharedLabels.fields[f.id].help }}
|
{{ sharedLabels.fields[f.id].help }}
|
||||||
</p>
|
</p>
|
||||||
<select
|
<Slider
|
||||||
v-if="f.type === 'dropdown'"
|
v-if="f.type === 'dropdown'"
|
||||||
:id="f.id"
|
|
||||||
v-model="f.value"
|
v-model="f.value"
|
||||||
class="ui dropdown"
|
:options="Object.fromEntries(f.choices.map(c => [c, sharedLabels.fields[f.id].choices?.[c] || c]))"
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="(c, key) in f.choices"
|
|
||||||
:key="key"
|
|
||||||
:value="c"
|
|
||||||
>
|
|
||||||
{{ sharedLabels.fields[f.id].choices?.[c] }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<content-form
|
|
||||||
v-if="f.type === 'content'"
|
|
||||||
v-model="f.value.text"
|
|
||||||
:field-id="f.id"
|
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
:class="['ui', { loading: isLoading }, 'button']"
|
primary
|
||||||
|
:is-loading="isLoading"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.Settings.button.updateSettings') }}
|
{{ t('components.auth.Settings.button.updateSettings') }}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</Layout>
|
||||||
</section>
|
|
||||||
<section class="ui text container">
|
<section class="ui text container">
|
||||||
<div class="ui hidden divider" />
|
|
||||||
<h2 class="ui header">
|
<h2 class="ui header">
|
||||||
{{ $t('components.auth.Settings.header.avatar') }}
|
{{ t('components.auth.Settings.header.avatar') }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="ui form">
|
<Layout form>
|
||||||
<div
|
<Alert
|
||||||
v-if="avatarErrors.length > 0"
|
v-if="avatarErrors.length > 0"
|
||||||
|
red
|
||||||
role="alert"
|
role="alert"
|
||||||
class="ui negative message"
|
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
<h4 class="header">
|
||||||
{{ $t('components.auth.Settings.header.avatarFailure') }}
|
{{ t('components.auth.Settings.header.avatarFailure') }}
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
<li
|
<li
|
||||||
|
@ -366,46 +364,44 @@ fetchOwnedApps()
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</Alert>
|
||||||
<attachment-input
|
<attachment-input
|
||||||
v-model="avatar.uuid"
|
v-model="avatar.uuid"
|
||||||
:initial-value="initialAvatar"
|
:initial-value="initialAvatar"
|
||||||
@update:model-value="submitAvatar($event)"
|
@update:model-value="submitAvatar($event)"
|
||||||
@delete="avatar = {uuid: null}"
|
@delete="avatar = {uuid: null}"
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.Settings.label.avatar') }}
|
{{ t('components.auth.Settings.label.avatar') }}
|
||||||
</attachment-input>
|
</attachment-input>
|
||||||
</div>
|
</Layout>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="ui text container">
|
<section class="ui text container">
|
||||||
<div class="ui hidden divider" />
|
|
||||||
<h2 class="ui header">
|
<h2 class="ui header">
|
||||||
{{ $t('components.auth.Settings.header.changePassword') }}
|
{{ t('components.auth.Settings.header.changePassword') }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="ui message">
|
<div class="ui message">
|
||||||
{{ $t('components.auth.Settings.description.changePassword.paragraph1') }} {{ $t('components.auth.Settings.description.changePassword.paragraph2') }}
|
{{ t('components.auth.Settings.description.changePassword.paragraph1') }} {{ t('components.auth.Settings.description.changePassword.paragraph2') }}
|
||||||
</div>
|
</div>
|
||||||
<form
|
<Layout
|
||||||
class="ui form"
|
form
|
||||||
@submit.prevent="submitPassword()"
|
@submit.prevent="submitPassword()"
|
||||||
>
|
>
|
||||||
<div
|
<Alert
|
||||||
v-if="passwordError"
|
v-if="passwordError"
|
||||||
role="alert"
|
role="alert"
|
||||||
class="ui negative message"
|
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
<h4 class="header">
|
||||||
{{ $t('components.auth.Settings.header.passwordFailure') }}
|
{{ t('components.auth.Settings.header.passwordFailure') }}
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
<li v-if="passwordError == 'invalid_credentials'">
|
<li v-if="passwordError == 'invalid_credentials'">
|
||||||
{{ $t('components.auth.Settings.help.changePassword') }}
|
{{ t('components.auth.Settings.help.changePassword') }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</Alert>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="old-password-field">{{ $t('components.auth.Settings.label.currentPassword') }}</label>
|
<label for="old-password-field">{{ t('components.auth.Settings.label.currentPassword') }}</label>
|
||||||
<password-input
|
<password-input
|
||||||
v-model="credentials.oldPassword"
|
v-model="credentials.oldPassword"
|
||||||
field-id="old-password-field"
|
field-id="old-password-field"
|
||||||
|
@ -413,7 +409,7 @@ fetchOwnedApps()
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="new-password-field">{{ $t('components.auth.Settings.label.newPassword') }}</label>
|
<label for="new-password-field">{{ t('components.auth.Settings.label.newPassword') }}</label>
|
||||||
<password-input
|
<password-input
|
||||||
v-model="credentials.newPassword"
|
v-model="credentials.newPassword"
|
||||||
field-id="new-password-field"
|
field-id="new-password-field"
|
||||||
|
@ -423,35 +419,31 @@ fetchOwnedApps()
|
||||||
<dangerous-button
|
<dangerous-button
|
||||||
:class="['ui', {'loading': isLoadingPassword}, {disabled: !credentials.newPassword || !credentials.oldPassword}, 'warning', 'button']"
|
:class="['ui', {'loading': isLoadingPassword}, {disabled: !credentials.newPassword || !credentials.oldPassword}, 'warning', 'button']"
|
||||||
:action="submitPassword"
|
:action="submitPassword"
|
||||||
|
:title="t('components.auth.Settings.modal.changePassword.header')"
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.Settings.button.password') }}
|
{{ t('components.auth.Settings.button.password') }}
|
||||||
<template #modal-header>
|
|
||||||
<p>
|
|
||||||
{{ $t('components.auth.Settings.modal.changePassword.header') }}
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<template #modal-content>
|
<template #modal-content>
|
||||||
<div>
|
<div>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.auth.Settings.modal.changePassword.content.warning') }}
|
{{ t('components.auth.Settings.modal.changePassword.content.warning') }}
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
{{ $t('components.auth.Settings.modal.changePassword.content.logout') }}
|
{{ t('components.auth.Settings.modal.changePassword.content.logout') }}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
{{ $t('components.auth.Settings.modal.changePassword.content.subsonic') }}
|
{{ t('components.auth.Settings.modal.changePassword.content.subsonic') }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #modal-confirm>
|
<template #modal-confirm>
|
||||||
<div>
|
<div>
|
||||||
{{ $t('components.auth.Settings.button.disableSubsonic') }}
|
{{ t('components.auth.Settings.button.disableSubsonic') }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</dangerous-button>
|
</dangerous-button>
|
||||||
</form>
|
</Layout>
|
||||||
<div class="ui hidden divider" />
|
<div class="ui hidden divider" />
|
||||||
<subsonic-token-form />
|
<subsonic-token-form />
|
||||||
</section>
|
</section>
|
||||||
|
@ -460,42 +452,41 @@ fetchOwnedApps()
|
||||||
id="content-filters"
|
id="content-filters"
|
||||||
class="ui text container"
|
class="ui text container"
|
||||||
>
|
>
|
||||||
<div class="ui hidden divider" />
|
|
||||||
<h2 class="ui header">
|
<h2 class="ui header">
|
||||||
<i class="eye slash outline icon" />
|
<i class="bi bi-eye-slash" />
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{{ $t('components.auth.Settings.header.contentFilters') }}
|
{{ t('components.auth.Settings.header.contentFilters') }}
|
||||||
</div>
|
</div>
|
||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.auth.Settings.description.contentFilters') }}
|
{{ t('components.auth.Settings.description.contentFilters') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
class="ui icon button"
|
primary
|
||||||
@click="$store.dispatch('moderation/fetchContentFilters')"
|
icon="bi-arrow-clockwise"
|
||||||
|
@click="store.dispatch('moderation/fetchContentFilters')"
|
||||||
>
|
>
|
||||||
<i class="refresh icon" />
|
{{ t('components.auth.Settings.button.refresh') }}
|
||||||
{{ $t('components.auth.Settings.button.refresh') }}
|
</Button>
|
||||||
</button>
|
|
||||||
<h3 class="ui header">
|
<h3 class="ui header">
|
||||||
{{ $t('components.auth.Settings.header.hiddenArtists') }}
|
{{ t('components.auth.Settings.header.hiddenArtists') }}
|
||||||
</h3>
|
</h3>
|
||||||
<table class="ui compact very basic unstackable table">
|
<table class="ui compact very basic unstackable table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th>
|
||||||
{{ $t('components.auth.Settings.table.artists.header.name') }}
|
{{ t('components.auth.Settings.table.artists.header.name') }}
|
||||||
</th>
|
</th>
|
||||||
<th>
|
<th>
|
||||||
{{ $t('components.auth.Settings.table.artists.header.creationDate') }}
|
{{ t('components.auth.Settings.table.artists.header.creationDate') }}
|
||||||
</th>
|
</th>
|
||||||
<th />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="filter in $store.getters['moderation/artistFilters']()"
|
v-for="filter in store.getters['moderation/artistFilters']()"
|
||||||
:key="filter.uuid"
|
:key="filter.uuid"
|
||||||
>
|
>
|
||||||
<td>
|
<td>
|
||||||
|
@ -507,12 +498,12 @@ fetchOwnedApps()
|
||||||
<human-date :date="filter.creation_date" />
|
<human-date :date="filter.creation_date" />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<Button
|
||||||
class="ui basic tiny button"
|
secondary
|
||||||
@click="$store.dispatch('moderation/deleteContentFilter', filter.uuid)"
|
@click="store.dispatch('moderation/deleteContentFilter', filter.uuid)"
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.Settings.button.delete') }}
|
{{ t('components.auth.Settings.button.delete') }}
|
||||||
</button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -524,21 +515,22 @@ fetchOwnedApps()
|
||||||
>
|
>
|
||||||
<div class="ui hidden divider" />
|
<div class="ui hidden divider" />
|
||||||
<h2 class="ui header">
|
<h2 class="ui header">
|
||||||
<i class="open lock icon" />
|
<i class="bi bi-unlock-fill" />
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{{ $t('components.auth.Settings.header.authorizedApps') }}
|
{{ t('components.auth.Settings.header.authorizedApps') }}
|
||||||
</div>
|
</div>
|
||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.auth.Settings.description.authorizedApps') }}
|
{{ t('components.auth.Settings.description.authorizedApps') }}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button
|
||||||
:class="['ui', 'icon', { loading: isLoadingApps }, 'button']"
|
primary
|
||||||
|
icon="bi-arrow-clockwise"
|
||||||
|
:is-loading="isLoadingApps"
|
||||||
@click="fetchApps()"
|
@click="fetchApps()"
|
||||||
>
|
>
|
||||||
<i class="refresh icon" />
|
{{ t('components.auth.Settings.button.refresh') }}
|
||||||
{{ $t('components.auth.Settings.button.refresh') }}
|
</Button>
|
||||||
</button>
|
|
||||||
<table
|
<table
|
||||||
v-if="apps.length > 0"
|
v-if="apps.length > 0"
|
||||||
class="ui compact very basic unstackable table"
|
class="ui compact very basic unstackable table"
|
||||||
|
@ -546,10 +538,10 @@ fetchOwnedApps()
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th>
|
||||||
{{ $t('components.auth.Settings.table.authorizedApps.header.application') }}
|
{{ t('components.auth.Settings.table.authorizedApps.header.application') }}
|
||||||
</th>
|
</th>
|
||||||
<th>
|
<th>
|
||||||
{{ $t('components.auth.Settings.table.authorizedApps.header.permissions') }}
|
{{ t('components.auth.Settings.table.authorizedApps.header.permissions') }}
|
||||||
</th>
|
</th>
|
||||||
<th />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -568,23 +560,15 @@ fetchOwnedApps()
|
||||||
<td>
|
<td>
|
||||||
<dangerous-button
|
<dangerous-button
|
||||||
:class="['ui', 'tiny', 'danger', { loading: isRevoking.has(app.client_id) }, 'button']"
|
:class="['ui', 'tiny', 'danger', { loading: isRevoking.has(app.client_id) }, 'button']"
|
||||||
|
:title="t('components.auth.Settings.modal.revokeApp.header', {app: app.name})"
|
||||||
@confirm="revokeApp(app.client_id)"
|
@confirm="revokeApp(app.client_id)"
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.Settings.button.revoke') }}
|
{{ t('components.auth.Settings.button.revoke') }}
|
||||||
<template #modal-header>
|
|
||||||
<p>
|
|
||||||
{{ $t('components.auth.Settings.modal.revokeApp.header', {app: app.name}) }}
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<template #modal-content>
|
<template #modal-content>
|
||||||
<p>
|
{{ t('components.auth.Settings.modal.revokeApp.content.warning') }}
|
||||||
{{ $t('components.auth.Settings.modal.revokeApp.content.warning') }}
|
|
||||||
</p>
|
|
||||||
</template>
|
</template>
|
||||||
<template #modal-confirm>
|
<template #modal-confirm>
|
||||||
<div>
|
{{ t('components.auth.Settings.button.revokeAccess') }}
|
||||||
{{ $t('components.auth.Settings.button.revokeAccess') }}
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</dangerous-button>
|
</dangerous-button>
|
||||||
</td>
|
</td>
|
||||||
|
@ -593,9 +577,9 @@ fetchOwnedApps()
|
||||||
</table>
|
</table>
|
||||||
<empty-state v-else>
|
<empty-state v-else>
|
||||||
<template #title>
|
<template #title>
|
||||||
{{ $t('components.auth.Settings.header.noApps') }}
|
{{ t('components.auth.Settings.header.noApps') }}
|
||||||
</template>
|
</template>
|
||||||
{{ $t('components.auth.Settings.help.noApps') }}
|
{{ t('components.auth.Settings.help.noApps') }}
|
||||||
</empty-state>
|
</empty-state>
|
||||||
</section>
|
</section>
|
||||||
<section
|
<section
|
||||||
|
@ -604,20 +588,20 @@ fetchOwnedApps()
|
||||||
>
|
>
|
||||||
<div class="ui hidden divider" />
|
<div class="ui hidden divider" />
|
||||||
<h2 class="ui header">
|
<h2 class="ui header">
|
||||||
<i class="code icon" />
|
<i class="bi bi-code-slash" />
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{{ $t('components.auth.Settings.header.yourApps') }}
|
{{ t('components.auth.Settings.header.yourApps') }}
|
||||||
</div>
|
</div>
|
||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.auth.Settings.description.yourApps') }}
|
{{ t('components.auth.Settings.description.yourApps') }}
|
||||||
</p>
|
</p>
|
||||||
<router-link
|
<Link
|
||||||
class="ui success button"
|
class="ui success button"
|
||||||
:to="{name: 'settings.applications.new'}"
|
:to="{name: 'settings.applications.new'}"
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.Settings.link.newApp') }}
|
{{ t('components.auth.Settings.link.newApp') }}
|
||||||
</router-link>
|
</Link>
|
||||||
<table
|
<table
|
||||||
v-if="ownedApps.length > 0"
|
v-if="ownedApps.length > 0"
|
||||||
class="ui compact very basic unstackable table"
|
class="ui compact very basic unstackable table"
|
||||||
|
@ -625,13 +609,13 @@ fetchOwnedApps()
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th>
|
||||||
{{ $t('components.auth.Settings.table.yourApps.header.application') }}
|
{{ t('components.auth.Settings.table.yourApps.header.application') }}
|
||||||
</th>
|
</th>
|
||||||
<th>
|
<th>
|
||||||
{{ $t('components.auth.Settings.table.yourApps.header.scopes') }}
|
{{ t('components.auth.Settings.table.yourApps.header.scopes') }}
|
||||||
</th>
|
</th>
|
||||||
<th>
|
<th>
|
||||||
{{ $t('components.auth.Settings.table.yourApps.header.creationDate') }}
|
{{ t('components.auth.Settings.table.yourApps.header.creationDate') }}
|
||||||
</th>
|
</th>
|
||||||
<th />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -653,42 +637,35 @@ fetchOwnedApps()
|
||||||
<human-date :date="app.created" />
|
<human-date :date="app.created" />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<router-link
|
<Link
|
||||||
class="ui tiny success button"
|
class="ui tiny success button"
|
||||||
:to="{name: 'settings.applications.edit', params: {id: app.client_id}}"
|
:to="{name: 'settings.applications.edit', params: {id: app.client_id}}"
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.Settings.button.edit') }}
|
{{ t('components.auth.Settings.button.edit') }}
|
||||||
</router-link>
|
</Link>
|
||||||
<dangerous-button
|
<DangerousButton
|
||||||
:class="['ui', 'tiny', 'danger', { loading: isDeleting.has(app.client_id) }, 'button']"
|
:is-loading="isDeleting.has(app.client_id)"
|
||||||
|
class="tiny"
|
||||||
|
:title="t('components.auth.Settings.modal.deleteApp.header', {app: app.name})"
|
||||||
@confirm="deleteApp(app.client_id)"
|
@confirm="deleteApp(app.client_id)"
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.Settings.button.remove') }}
|
{{ t('components.auth.Settings.button.remove') }}
|
||||||
<template #modal-header>
|
|
||||||
<p>
|
|
||||||
{{ $t('components.auth.Settings.modal.deleteApp.header', {app: app.name}) }}
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<template #modal-content>
|
<template #modal-content>
|
||||||
<p>
|
{{ t('components.auth.Settings.modal.deleteApp.content.warning') }}
|
||||||
{{ $t('components.auth.Settings.modal.deleteApp.content.warning') }}
|
|
||||||
</p>
|
|
||||||
</template>
|
</template>
|
||||||
<template #modal-confirm>
|
<template #modal-confirm>
|
||||||
<div>
|
{{ t('components.auth.Settings.button.removeApp') }}
|
||||||
{{ $t('components.auth.Settings.button.removeApp') }}
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</dangerous-button>
|
</DangerousButton>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<empty-state v-else>
|
<empty-state v-else>
|
||||||
<template #title>
|
<template #title>
|
||||||
{{ $t('components.auth.Settings.header.noPersonalApps') }}
|
{{ t('components.auth.Settings.header.noPersonalApps') }}
|
||||||
</template>
|
</template>
|
||||||
{{ $t('components.auth.Settings.help.noPersonalApps') }}
|
{{ t('components.auth.Settings.help.noPersonalApps') }}
|
||||||
</empty-state>
|
</empty-state>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@ -698,46 +675,48 @@ fetchOwnedApps()
|
||||||
>
|
>
|
||||||
<div class="ui hidden divider" />
|
<div class="ui hidden divider" />
|
||||||
<h2 class="ui header">
|
<h2 class="ui header">
|
||||||
<i class="code icon" />
|
<i class="bi bi-code" />
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{{ $t('components.auth.Settings.header.plugins') }}
|
{{ t('components.auth.Settings.header.plugins') }}
|
||||||
</div>
|
</div>
|
||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.auth.Settings.description.plugins') }}
|
{{ t('components.auth.Settings.description.plugins') }}
|
||||||
</p>
|
</p>
|
||||||
<router-link
|
<Link
|
||||||
class="ui success button"
|
primary
|
||||||
|
solid
|
||||||
:to="{name: 'settings.plugins'}"
|
:to="{name: 'settings.plugins'}"
|
||||||
|
icon="bi-puzzle-fill"
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.Settings.link.managePlugins') }}
|
{{ t('components.auth.Settings.link.managePlugins') }}
|
||||||
</router-link>
|
</Link>
|
||||||
</section>
|
</section>
|
||||||
<section class="ui text container">
|
<section class="ui text container">
|
||||||
<div class="ui hidden divider" />
|
<div class="ui hidden divider" />
|
||||||
<h2 class="ui header">
|
<h2 class="ui header">
|
||||||
<i class="comment icon" />
|
<i class="bi bi-envelope-at" />
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{{ $t('components.auth.Settings.header.changeEmail') }}
|
{{ t('components.auth.Settings.header.changeEmail') }}
|
||||||
</div>
|
</div>
|
||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.auth.Settings.description.changeEmail') }}
|
{{ t('components.auth.Settings.description.changeEmail') }}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.auth.Settings.message.currentEmail', { email: $store.state.auth.profile?.email }) }}
|
{{ t('components.auth.Settings.message.currentEmail', { email: store.state.auth.profile?.email }) }}
|
||||||
</p>
|
</p>
|
||||||
<form
|
<Layout
|
||||||
class="ui form"
|
form
|
||||||
@submit.prevent="changeEmail"
|
@submit.prevent="changeEmail"
|
||||||
>
|
>
|
||||||
<div
|
<Alert
|
||||||
v-if="changeEmailErrors.length > 0"
|
v-if="changeEmailErrors.length > 0"
|
||||||
|
red
|
||||||
role="alert"
|
role="alert"
|
||||||
class="ui negative message"
|
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
<h4 class="header">
|
||||||
{{ $t('components.auth.Settings.header.emailFailure') }}
|
{{ t('components.auth.Settings.header.emailFailure') }}
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
<li
|
<li
|
||||||
|
@ -747,57 +726,57 @@ fetchOwnedApps()
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</Alert>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="new-email">{{ $t('components.auth.Settings.label.newEmail') }}</label>
|
<label for="new-email">{{ t('components.auth.Settings.label.newEmail') }}</label>
|
||||||
<input
|
<Input
|
||||||
id="new-email"
|
id="new-email"
|
||||||
v-model="newEmail"
|
v-model="newEmail"
|
||||||
required
|
required
|
||||||
type="email"
|
type="email"
|
||||||
>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="current-password-field-email">{{ $t('components.auth.Settings.label.password') }}</label>
|
<label for="current-password-field-email">{{ t('components.auth.Settings.label.password') }}</label>
|
||||||
<password-input
|
<password-input
|
||||||
v-model="emailPassword"
|
v-model="emailPassword"
|
||||||
field-id="current-password-field-email"
|
field-id="current-password-field-email"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
|
primary
|
||||||
type="submit"
|
type="submit"
|
||||||
class="ui button"
|
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.Settings.button.update') }}
|
{{ t('components.auth.Settings.button.update') }}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</Layout>
|
||||||
</section>
|
</section>
|
||||||
<section class="ui text container">
|
<section class="ui text container">
|
||||||
<div class="ui hidden divider" />
|
<div class="ui hidden divider" />
|
||||||
<h2 class="ui header">
|
<h2 class="ui header">
|
||||||
<i class="trash icon" />
|
<i class="bi bi-trash" />
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{{ $t('components.auth.Settings.header.deleteAccount') }}
|
{{ t('components.auth.Settings.header.deleteAccount') }}
|
||||||
</div>
|
</div>
|
||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.auth.Settings.description.deleteAccount') }}
|
{{ t('components.auth.Settings.description.deleteAccount') }}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<Alert
|
||||||
|
yellow
|
||||||
role="alert"
|
role="alert"
|
||||||
class="ui warning message"
|
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.Settings.warning.deleteAccount') }}
|
{{ t('components.auth.Settings.warning.deleteAccount') }}
|
||||||
</div>
|
</Alert>
|
||||||
<div class="ui form">
|
<Layout form>
|
||||||
<div
|
<Alert
|
||||||
v-if="accountDeleteErrors.length > 0"
|
v-if="accountDeleteErrors.length > 0"
|
||||||
|
red
|
||||||
role="alert"
|
role="alert"
|
||||||
class="ui negative message"
|
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
<h4 class="header">
|
||||||
{{ $t('components.auth.Settings.header.accountFailure') }}
|
{{ t('components.auth.Settings.header.accountFailure') }}
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
<li
|
<li
|
||||||
|
@ -807,9 +786,9 @@ fetchOwnedApps()
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</Alert>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="current-password-field">{{ $t('components.auth.Settings.label.currentPassword') }}</label>
|
<label for="current-password-field">{{ t('components.auth.Settings.label.currentPassword') }}</label>
|
||||||
<password-input
|
<password-input
|
||||||
v-model="deleteAccountPassword"
|
v-model="deleteAccountPassword"
|
||||||
field-id="current-password-field"
|
field-id="current-password-field"
|
||||||
|
@ -817,30 +796,21 @@ fetchOwnedApps()
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<dangerous-button
|
<dangerous-button
|
||||||
:class="['ui', {'loading': isDeletingAccount}, {disabled: !deleteAccountPassword}, {danger: deleteAccountPassword}, 'button']"
|
:is-loading="isDeletingAccount"
|
||||||
|
:disabled="!deleteAccountPassword || undefined"
|
||||||
|
:class="{danger: deleteAccountPassword}"
|
||||||
:action="deleteAccount"
|
:action="deleteAccount"
|
||||||
|
:title="t('components.auth.Settings.modal.deleteAccount.header')"
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.Settings.button.deleteAccount') }}
|
{{ t('components.auth.Settings.button.deleteAccount') }}
|
||||||
<template #modal-header>
|
|
||||||
<p>
|
|
||||||
{{ $t('components.auth.Settings.modal.deleteAccount.header') }}
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<template #modal-content>
|
<template #modal-content>
|
||||||
<div>
|
{{ t('components.auth.Settings.modal.deleteAccount.content.warning') }}
|
||||||
<p>
|
|
||||||
{{ $t('components.auth.Settings.modal.deleteAccount.content.warning') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template #modal-confirm>
|
<template #modal-confirm>
|
||||||
<div>
|
{{ t('components.auth.Settings.button.deleteAccountConfirm') }}
|
||||||
{{ $t('components.auth.Settings.button.deleteAccountConfirm') }}
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</dangerous-button>
|
</dangerous-button>
|
||||||
</div>
|
</Layout>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</Layout>
|
||||||
</main>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -9,7 +9,12 @@ import { useStore } from '~/store'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
import LoginForm from '~/components/auth/LoginForm.vue'
|
import LoginForm from '~/components/auth/LoginForm.vue'
|
||||||
import PasswordInput from '~/components/forms/PasswordInput.vue'
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
|
import Input from '~/components/ui/Input.vue'
|
||||||
|
import Textarea from '~/components/ui/Textarea.vue'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
|
|
||||||
import useLogger from '~/composables/useLogger'
|
import useLogger from '~/composables/useLogger'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -86,54 +91,59 @@ fetchInstanceSettings()
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="submitted">
|
<div v-if="submitted">
|
||||||
<div class="ui success message">
|
<Alert
|
||||||
<p v-if="signupRequiresApproval">
|
v-if="signupRequiresApproval"
|
||||||
{{ $t('components.auth.SignupForm.message.awaitingReview') }}
|
yellow
|
||||||
</p>
|
>
|
||||||
<p v-else>
|
{{ t('components.auth.SignupForm.message.awaitingReview') }}
|
||||||
{{ $t('components.auth.SignupForm.message.accountCreated') }}
|
</Alert>
|
||||||
</p>
|
<Alert
|
||||||
</div>
|
v-else
|
||||||
|
green
|
||||||
|
>
|
||||||
|
{{ t('components.auth.SignupForm.message.accountCreated') }}
|
||||||
|
</Alert>
|
||||||
<h2>
|
<h2>
|
||||||
{{ $t('components.auth.SignupForm.header.login') }}
|
{{ t('components.auth.SignupForm.header.login') }}
|
||||||
</h2>
|
</h2>
|
||||||
<login-form
|
<login-form
|
||||||
|
style="max-width: 600px"
|
||||||
button-classes="basic success"
|
button-classes="basic success"
|
||||||
:show-signup="false"
|
:show-signup="false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<Layout
|
||||||
v-else
|
v-else
|
||||||
:class="['ui', {'loading': isLoadingInstanceSetting}, 'form']"
|
form
|
||||||
|
stack
|
||||||
|
style="max-width: 600px"
|
||||||
@submit.prevent="submit()"
|
@submit.prevent="submit()"
|
||||||
>
|
>
|
||||||
<p
|
<Alert
|
||||||
v-if="!$store.state.instance.settings.users.registration_enabled.value"
|
v-if="!store.state.instance.settings.users.registration_enabled.value"
|
||||||
class="ui message"
|
red
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.SignupForm.message.registrationClosed') }}
|
{{ t('components.auth.SignupForm.message.registrationClosed') }}
|
||||||
</p>
|
</Alert>
|
||||||
<p
|
<Alert
|
||||||
v-else-if="signupRequiresApproval"
|
v-else-if="signupRequiresApproval"
|
||||||
class="ui message"
|
yellow
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.SignupForm.message.requiresReview') }}
|
{{ t('components.auth.SignupForm.message.requiresReview') }}
|
||||||
</p>
|
</Alert>
|
||||||
<template v-if="formCustomization?.help_text">
|
<template v-if="formCustomization?.help_text">
|
||||||
<rendered-description
|
<rendered-description
|
||||||
:content="formCustomization.help_text"
|
:content="formCustomization.help_text"
|
||||||
:fetch-html="fetchDescriptionHtml"
|
:fetch-html="fetchDescriptionHtml"
|
||||||
:permissive="true"
|
:permissive="true"
|
||||||
/>
|
/>
|
||||||
<div class="ui hidden divider" />
|
|
||||||
</template>
|
</template>
|
||||||
<div
|
<Alert
|
||||||
v-if="errors.length > 0"
|
v-if="errors.length > 0"
|
||||||
role="alert"
|
red
|
||||||
class="ui negative message"
|
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
<h4 class="header">
|
||||||
{{ $t('components.auth.SignupForm.header.signupFailure') }}
|
{{ t('components.auth.SignupForm.header.signupFailure') }}
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
<li
|
<li
|
||||||
|
@ -143,81 +153,79 @@ fetchInstanceSettings()
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</Alert>
|
||||||
<div class="required field">
|
<Input
|
||||||
<label for="username-field">{{ $t('components.auth.SignupForm.label.username') }}</label>
|
|
||||||
<input
|
|
||||||
id="username-field"
|
id="username-field"
|
||||||
ref="username"
|
ref="username"
|
||||||
v-model="payload.username"
|
v-model="payload.username"
|
||||||
|
:label="t('components.auth.SignupForm.label.username')"
|
||||||
name="username"
|
name="username"
|
||||||
required
|
required
|
||||||
type="text"
|
type="text"
|
||||||
autofocus
|
autofocus
|
||||||
:placeholder="labels.usernamePlaceholder"
|
:placeholder="labels.usernamePlaceholder"
|
||||||
>
|
/>
|
||||||
</div>
|
<Input
|
||||||
<div class="required field">
|
|
||||||
<label for="email-field">{{ $t('components.auth.SignupForm.label.email') }}</label>
|
|
||||||
<input
|
|
||||||
id="email-field"
|
id="email-field"
|
||||||
ref="email"
|
ref="email"
|
||||||
v-model="payload.email"
|
v-model="payload.email"
|
||||||
|
:label="t('components.auth.SignupForm.label.email')"
|
||||||
|
autocomplete="email"
|
||||||
name="email"
|
name="email"
|
||||||
required
|
required
|
||||||
type="email"
|
type="email"
|
||||||
:placeholder="labels.emailPlaceholder"
|
:placeholder="labels.emailPlaceholder"
|
||||||
>
|
/>
|
||||||
</div>
|
<Input
|
||||||
<div class="required field">
|
|
||||||
<label for="password-field">{{ $t('components.auth.SignupForm.label.password') }}</label>
|
|
||||||
<password-input
|
|
||||||
v-model="payload.password1"
|
v-model="payload.password1"
|
||||||
|
password
|
||||||
|
autocomplete="new-password"
|
||||||
|
:label="t('components.auth.SignupForm.label.password')"
|
||||||
field-id="password-field"
|
field-id="password-field"
|
||||||
/>
|
/>
|
||||||
</div>
|
<Input
|
||||||
<div
|
v-if="!store.state.instance.settings.users.registration_enabled.value && payload.invitation"
|
||||||
v-if="!$store.state.instance.settings.users.registration_enabled.value"
|
|
||||||
class="required field"
|
|
||||||
>
|
|
||||||
<label for="invitation-code">{{ $t('components.auth.SignupForm.label.invitation') }}</label>
|
|
||||||
<input
|
|
||||||
id="invitation-code"
|
id="invitation-code"
|
||||||
v-model="payload.invitation"
|
v-model="payload.invitation"
|
||||||
|
:label="t('components.auth.SignupForm.label.invitation')"
|
||||||
required
|
required
|
||||||
type="text"
|
type="text"
|
||||||
name="invitation"
|
name="invitation"
|
||||||
:placeholder="labels.placeholder"
|
:placeholder="labels.placeholder"
|
||||||
>
|
/>
|
||||||
</div>
|
|
||||||
<template v-if="signupRequiresApproval && (formCustomization?.fields.length ?? 0) > 0">
|
|
||||||
<div
|
<div
|
||||||
v-for="(field, idx) in formCustomization?.fields"
|
v-for="(field, idx) in
|
||||||
|
( signupRequiresApproval && formCustomization && (formCustomization.fields.length ?? 0) > 0
|
||||||
|
? formCustomization.fields
|
||||||
|
: []
|
||||||
|
)"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
:class="[{required: field.required}, 'field']"
|
:class="[{required: field.required}, 'field']"
|
||||||
>
|
>
|
||||||
<label :for="`custom-field-${idx}`">{{ field.label }}</label>
|
<!-- TODO: as string is probably leading to issues with editform. -->
|
||||||
<textarea
|
<Textarea
|
||||||
v-if="field.input_type === 'long_text'"
|
v-if="field.input_type === 'long_text'"
|
||||||
:id="`custom-field-${idx}`"
|
:id="`custom-field-${idx}`"
|
||||||
v-model="payload.request_fields[field.label]"
|
v-model="payload.request_fields[field.label] as string"
|
||||||
:required="field.required"
|
:label="field.label"
|
||||||
|
:required="field.required || undefined"
|
||||||
rows="5"
|
rows="5"
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
v-else
|
v-else
|
||||||
:id="`custom-field-${idx}`"
|
:id="`custom-field-${idx}`"
|
||||||
v-model="payload.request_fields[field.label]"
|
v-model="payload.request_fields[field.label] as string"
|
||||||
|
:label="field.label"
|
||||||
type="text"
|
type="text"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<Button
|
||||||
<button
|
primary
|
||||||
:class="['ui', buttonClasses, {'loading': isLoading}, ' right floated button']"
|
auto
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.SignupForm.button.create') }}
|
{{ t('components.auth.SignupForm.button.create') }}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</Layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -6,7 +6,11 @@ import { computed, ref } from 'vue'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import DangerousButton from '~/components/common/DangerousButton.vue'
|
||||||
|
|
||||||
import PasswordInput from '~/components/forms/PasswordInput.vue'
|
import PasswordInput from '~/components/forms/PasswordInput.vue'
|
||||||
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
@ -82,43 +86,43 @@ fetchToken()
|
||||||
@submit.prevent="requestNewToken()"
|
@submit.prevent="requestNewToken()"
|
||||||
>
|
>
|
||||||
<h2>
|
<h2>
|
||||||
{{ $t('components.auth.SubsonicTokenForm.header.subsonic') }}
|
{{ t('components.auth.SubsonicTokenForm.header.subsonic') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p
|
<p
|
||||||
v-if="!subsonicEnabled"
|
v-if="!subsonicEnabled"
|
||||||
class="ui message"
|
class="ui message"
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.SubsonicTokenForm.message.unavailable') }}
|
{{ t('components.auth.SubsonicTokenForm.message.unavailable') }}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.auth.SubsonicTokenForm.description.subsonic.paragraph1') }} {{ $t('components.auth.SubsonicTokenForm.description.subsonic.paragraph2') }}
|
{{ t('components.auth.SubsonicTokenForm.description.subsonic.paragraph1') }} {{ t('components.auth.SubsonicTokenForm.description.subsonic.paragraph2') }}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.auth.SubsonicTokenForm.description.subsonic.paragraph3') }}
|
{{ t('components.auth.SubsonicTokenForm.description.subsonic.paragraph3') }}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a
|
<a
|
||||||
href="https://docs.funkwhale.audio/users/apps.html#subsonic-compatible-clients"
|
href="https://docs.funkwhale.audio/users/apps.html#subsonic-compatible-clients"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.SubsonicTokenForm.link.apps') }}
|
{{ t('components.auth.SubsonicTokenForm.link.apps') }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<div
|
<Alert
|
||||||
v-if="success"
|
v-if="success"
|
||||||
class="ui positive message"
|
green
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
<h4 class="header">
|
||||||
{{ successMessage }}
|
{{ successMessage }}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</Alert>
|
||||||
<div
|
<Alert
|
||||||
v-if="subsonicEnabled && errors.length > 0"
|
v-if="subsonicEnabled && errors.length > 0"
|
||||||
|
red
|
||||||
role="alert"
|
role="alert"
|
||||||
class="ui negative message"
|
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
<h4 class="header">
|
||||||
{{ $t('components.auth.SubsonicTokenForm.header.error') }}
|
{{ t('components.auth.SubsonicTokenForm.header.error') }}
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
<li
|
<li
|
||||||
|
@ -128,7 +132,7 @@ fetchToken()
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</Alert>
|
||||||
<template v-if="subsonicEnabled">
|
<template v-if="subsonicEnabled">
|
||||||
<div
|
<div
|
||||||
v-if="token"
|
v-if="token"
|
||||||
|
@ -147,58 +151,42 @@ fetchToken()
|
||||||
:default-show="showToken"
|
:default-show="showToken"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<dangerous-button
|
<DangerousButton
|
||||||
v-if="token"
|
v-if="token"
|
||||||
:class="['ui', {'loading': isLoading}, 'button']"
|
:is-loading="isLoading"
|
||||||
:action="requestNewToken"
|
:action="requestNewToken"
|
||||||
|
:title="t('components.auth.SubsonicTokenForm.modal.newPassword.header')"
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.SubsonicTokenForm.button.newPassword') }}
|
{{ t('components.auth.SubsonicTokenForm.button.newPassword') }}
|
||||||
<template #modal-header>
|
|
||||||
<p>
|
|
||||||
{{ $t('components.auth.SubsonicTokenForm.modal.newPassword.header') }}
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<template #modal-content>
|
<template #modal-content>
|
||||||
<p>
|
{{ t('components.auth.SubsonicTokenForm.modal.newPassword.content.warning') }}
|
||||||
{{ $t('components.auth.SubsonicTokenForm.modal.newPassword.content.warning') }}
|
|
||||||
</p>
|
|
||||||
</template>
|
</template>
|
||||||
<template #modal-confirm>
|
<template #modal-confirm>
|
||||||
<div>
|
{{ t('components.auth.SubsonicTokenForm.button.confirmNewPassword') }}
|
||||||
{{ $t('components.auth.SubsonicTokenForm.button.confirmNewPassword') }}
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</dangerous-button>
|
</DangerousButton>
|
||||||
<button
|
<Button
|
||||||
v-else
|
v-else
|
||||||
color=""
|
primary
|
||||||
:class="['ui', {'loading': isLoading}, 'button']"
|
:is-loading="isLoading"
|
||||||
@click="requestNewToken"
|
@click="requestNewToken"
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.SubsonicTokenForm.button.confirmNewPassword') }}
|
{{ t('components.auth.SubsonicTokenForm.button.confirmNewPassword') }}
|
||||||
</button>
|
</Button>
|
||||||
<dangerous-button
|
<DangerousButton
|
||||||
v-if="token"
|
v-if="token"
|
||||||
:class="['ui', {'loading': isLoading}, 'warning', 'button']"
|
:is-loading="isLoading"
|
||||||
:action="disable"
|
:action="disable"
|
||||||
|
:title="t('components.auth.SubsonicTokenForm.modal.disableSubsonic.header')"
|
||||||
>
|
>
|
||||||
{{ $t('components.auth.SubsonicTokenForm.button.disable') }}
|
{{ t('components.auth.SubsonicTokenForm.button.disable') }}
|
||||||
<template #modal-header>
|
|
||||||
<p>
|
|
||||||
{{ $t('components.auth.SubsonicTokenForm.modal.disableSubsonic.header') }}
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<template #modal-content>
|
<template #modal-content>
|
||||||
<p>
|
{{ t('components.auth.SubsonicTokenForm.modal.disableSubsonic.content.warning') }}
|
||||||
{{ $t('components.auth.SubsonicTokenForm.modal.disableSubsonic.content.warning') }}
|
|
||||||
</p>
|
|
||||||
</template>
|
</template>
|
||||||
<template #modal-confirm>
|
<template #modal-confirm>
|
||||||
<div>
|
{{ t('components.auth.SubsonicTokenForm.button.confirmDisable') }}
|
||||||
{{ $t('components.auth.SubsonicTokenForm.button.confirmDisable') }}
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</dangerous-button>
|
</DangerousButton>
|
||||||
</template>
|
</template>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -2,20 +2,24 @@
|
||||||
import type { BackendError, Channel } from '~/types'
|
import type { BackendError, Channel } from '~/types'
|
||||||
|
|
||||||
import { computed, watch, ref } from 'vue'
|
import { computed, watch, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
|
import Input from '~/components/ui/Input.vue'
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'submittable', value: boolean): void
|
(e: 'submittable', value: boolean): void
|
||||||
(e: 'loading', value: boolean): void
|
(e: 'loading', value: boolean): void
|
||||||
(e: 'created'): void
|
(e: 'created'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
const channel = defineModel<Channel>({ required: true })
|
||||||
channel: Channel
|
|
||||||
}
|
const { t } = useI18n()
|
||||||
|
|
||||||
const emit = defineEmits<Events>()
|
const emit = defineEmits<Events>()
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
const title = ref('')
|
const title = ref('')
|
||||||
|
|
||||||
|
@ -28,7 +32,7 @@ const submit = async () => {
|
||||||
try {
|
try {
|
||||||
await axios.post('albums/', {
|
await axios.post('albums/', {
|
||||||
title: title.value,
|
title: title.value,
|
||||||
artist: props.channel.artist?.id
|
artist: channel.value.artist?.id
|
||||||
})
|
})
|
||||||
|
|
||||||
emit('created')
|
emit('created')
|
||||||
|
@ -49,17 +53,17 @@ defineExpose({
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form
|
<Layout
|
||||||
|
form
|
||||||
:class="['ui', {loading: isLoading}, 'form']"
|
:class="['ui', {loading: isLoading}, 'form']"
|
||||||
@submit.stop.prevent
|
@submit.stop.prevent
|
||||||
>
|
>
|
||||||
<div
|
<Alert
|
||||||
v-if="errors.length > 0"
|
v-if="errors.length > 0"
|
||||||
role="alert"
|
red
|
||||||
class="ui negative message"
|
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
<h4 class="header">
|
||||||
{{ $t('components.channels.AlbumForm.header.error') }}
|
{{ t('components.channels.AlbumForm.header.error') }}
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
<li
|
<li
|
||||||
|
@ -69,15 +73,13 @@ defineExpose({
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</Alert>
|
||||||
<div class="ui required field">
|
<div class="ui required field">
|
||||||
<label for="album-title">
|
<Input
|
||||||
{{ $t('components.channels.AlbumForm.label.albumTitle') }}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="title"
|
v-model="title"
|
||||||
type="text"
|
type="text"
|
||||||
>
|
:label="t('components.channels.AlbumForm.label.albumTitle')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</Layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,68 +1,105 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Channel } from '~/types'
|
import type { Channel, BackendError } from '~/types'
|
||||||
import SemanticModal from '~/components/semantic/Modal.vue'
|
|
||||||
import ChannelAlbumForm from '~/components/channels/AlbumForm.vue'
|
|
||||||
import { watch, ref } from 'vue'
|
|
||||||
|
|
||||||
interface Events {
|
import axios from 'axios'
|
||||||
(e: 'created'): void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
import { watch, computed, ref } from 'vue'
|
||||||
channel: Channel
|
import { useI18n } from 'vue-i18n'
|
||||||
}
|
import { useModal } from '~/ui/composables/useModal.ts'
|
||||||
|
|
||||||
const emit = defineEmits<Events>()
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
defineProps<Props>()
|
import Modal from '~/components/ui/Modal.vue'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import Input from '~/components/ui/Input.vue'
|
||||||
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const channel = defineModel<Channel>({ required: true })
|
||||||
|
const emit = defineEmits(['created'])
|
||||||
|
const newAlbumTitle = ref<string>('')
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const submittable = ref(false)
|
const submittable = computed(() => newAlbumTitle.value.length > 0)
|
||||||
const show = ref(false)
|
const errors = ref<string[]>([])
|
||||||
|
|
||||||
watch(show, () => {
|
const isOpen = useModal('album').isOpen
|
||||||
|
|
||||||
|
watch(isOpen, () => {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
submittable.value = false
|
newAlbumTitle.value = '' // Reset the title to ensure submittable becomes false
|
||||||
})
|
})
|
||||||
|
|
||||||
const albumForm = ref()
|
const submit = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
errors.value = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post('albums/', {
|
||||||
|
title: newAlbumTitle.value,
|
||||||
|
artist: channel.value.artist?.id
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
errors.value = (error as BackendError).backendErrors
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
emit('created')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
show
|
submit
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<semantic-modal
|
<Modal
|
||||||
v-model:show="show"
|
v-model="isOpen"
|
||||||
|
:title="channel?.artist?.content_category === 'podcast' ? t('components.channels.AlbumModal.header.newSeries') : t('components.channels.AlbumModal.header.newAlbum')"
|
||||||
class="small"
|
class="small"
|
||||||
|
:cancel="t('components.channels.AlbumModal.button.cancel')"
|
||||||
|
>
|
||||||
|
<template #alert>
|
||||||
|
<Alert
|
||||||
|
v-if="errors?.length > 0"
|
||||||
|
red
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
<h4 class="header">
|
||||||
<span v-if="channel.content_category === 'podcast'">
|
{{ t('components.channels.AlbumForm.header.error') }}
|
||||||
{{ $t('components.channels.AlbumModal.header.newSeries') }}
|
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
{{ $t('components.channels.AlbumModal.header.newAlbum') }}
|
|
||||||
</span>
|
|
||||||
</h4>
|
</h4>
|
||||||
<div class="scrolling content">
|
<ul class="list">
|
||||||
<channel-album-form
|
<li
|
||||||
ref="albumForm"
|
v-for="(error, key) in errors"
|
||||||
:channel="channel"
|
:key="key"
|
||||||
@loading="isLoading = $event"
|
|
||||||
@submittable="submittable = $event"
|
|
||||||
@created="emit('created')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button class="ui basic cancel button">
|
|
||||||
{{ $t('components.channels.AlbumModal.button.cancel') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="['ui', 'primary', {loading: isLoading}, 'button']"
|
|
||||||
:disabled="!submittable"
|
|
||||||
@click.stop.prevent="albumForm.submit()"
|
|
||||||
>
|
>
|
||||||
{{ $t('components.channels.AlbumModal.button.create') }}
|
{{ error }}
|
||||||
</button>
|
</li>
|
||||||
</div>
|
</ul>
|
||||||
</semantic-modal>
|
</Alert>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<Layout
|
||||||
|
form
|
||||||
|
:class="['ui', {loading: isLoading}, 'form']"
|
||||||
|
@submit.stop.prevent
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
v-model="newAlbumTitle"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
:label="t('components.channels.AlbumForm.label.albumTitle')"
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<Button
|
||||||
|
:is-loading="isLoading"
|
||||||
|
:disabled="!submittable"
|
||||||
|
primary
|
||||||
|
@click.stop.prevent="submit()"
|
||||||
|
>
|
||||||
|
{{ t('components.channels.AlbumModal.button.create') }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -3,7 +3,16 @@ import type { Album, Channel } from '~/types'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { useVModel } from '@vueuse/core'
|
import { useVModel } from '@vueuse/core'
|
||||||
import { reactive, ref, watch } from 'vue'
|
import { ref, watch, reactive } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useModal } from '~/ui/composables/useModal.ts'
|
||||||
|
|
||||||
|
import AlbumModal from '~/components/channels/AlbumModal.vue'
|
||||||
|
import Link from '~/components/ui/Link.vue'
|
||||||
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'update:modelValue', value: string): void
|
(e: 'update:modelValue', value: string): void
|
||||||
|
@ -21,6 +30,11 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
})
|
})
|
||||||
|
|
||||||
const value = useVModel(props, 'modelValue', emit)
|
const value = useVModel(props, 'modelValue', emit)
|
||||||
|
const localChannel = ref(props.channel ?? { artist: {} } as Channel)
|
||||||
|
|
||||||
|
watch(() => props.channel, (newChannel) => {
|
||||||
|
localChannel.value = newChannel ?? { artist: {} } as Channel
|
||||||
|
})
|
||||||
|
|
||||||
const albums = reactive<Album[]>([])
|
const albums = reactive<Album[]>([])
|
||||||
|
|
||||||
|
@ -45,13 +59,12 @@ watch(() => props.channel, fetchData, { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
|
||||||
<label for="album-dropdown">
|
<label for="album-dropdown">
|
||||||
<span v-if="channel && channel.artist && channel.artist.content_category === 'podcast'">
|
<span v-if="channel && channel.artist && channel.artist.content_category === 'podcast'">
|
||||||
{{ $t('components.channels.AlbumSelect.label.series') }}
|
{{ t('components.channels.AlbumSelect.label.series') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ $t('components.channels.AlbumSelect.label.album') }}
|
{{ t('components.channels.AlbumSelect.label.album') }}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
|
@ -60,7 +73,7 @@ watch(() => props.channel, fetchData, { immediate: true })
|
||||||
class="ui search normal dropdown"
|
class="ui search normal dropdown"
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
{{ $t('components.channels.AlbumSelect.option.none') }}
|
{{ t('components.channels.AlbumSelect.option.none') }}
|
||||||
</option>
|
</option>
|
||||||
<option
|
<option
|
||||||
v-for="album in albums"
|
v-for="album in albums"
|
||||||
|
@ -68,10 +81,21 @@ watch(() => props.channel, fetchData, { immediate: true })
|
||||||
:value="album.id"
|
:value="album.id"
|
||||||
>
|
>
|
||||||
{{ album.title }}
|
{{ album.title }}
|
||||||
<span>
|
{{ t('components.channels.AlbumSelect.meta.tracks', album.tracks_count) }}
|
||||||
{{ $t('components.channels.AlbumSelect.meta.tracks', album.tracks_count) }}
|
|
||||||
</span>
|
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
<Spacer :size="4" />
|
||||||
|
<Link
|
||||||
|
solid
|
||||||
|
primary
|
||||||
|
icon="bi-plus"
|
||||||
|
:to="useModal('album').to"
|
||||||
|
>
|
||||||
|
{{ t('components.channels.AlbumSelect.add') }}
|
||||||
|
<AlbumModal
|
||||||
|
v-if="channel"
|
||||||
|
v-model="localChannel"
|
||||||
|
@created="fetchData"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -4,6 +4,7 @@ import type { License } from '~/types'
|
||||||
import { computed, reactive, ref } from 'vue'
|
import { computed, reactive, ref } from 'vue'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { useVModel } from '@vueuse/core'
|
import { useVModel } from '@vueuse/core'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'update:modelValue', value: string): void
|
(e: 'update:modelValue', value: string): void
|
||||||
|
@ -13,6 +14,8 @@ interface Props {
|
||||||
modelValue: string | null
|
modelValue: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const emit = defineEmits<Events>()
|
const emit = defineEmits<Events>()
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
modelValue: null
|
modelValue: null
|
||||||
|
@ -55,7 +58,7 @@ fetchLicenses()
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<label for="license-dropdown">
|
<label for="license-dropdown">
|
||||||
{{ $t('components.channels.LicenseSelect.label.license') }}
|
{{ t('components.channels.LicenseSelect.label.license') }}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="license-dropdown"
|
id="license-dropdown"
|
||||||
|
@ -63,7 +66,7 @@ fetchLicenses()
|
||||||
class="ui search normal dropdown"
|
class="ui search normal dropdown"
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
{{ $t('components.channels.LicenseSelect.option.none') }}
|
{{ t('components.channels.LicenseSelect.option.none') }}
|
||||||
</option>
|
</option>
|
||||||
<option
|
<option
|
||||||
v-for="l in featuredLicenses"
|
v-for="l in featuredLicenses"
|
||||||
|
@ -84,7 +87,7 @@ fetchLicenses()
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
>
|
>
|
||||||
{{ $t('components.channels.LicenseSelect.link.license') }}
|
{{ t('components.channels.LicenseSelect.link.license') }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,8 +4,12 @@ import type { Channel } from '~/types'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
import LoginModal from '~/components/common/LoginModal.vue'
|
import LoginModal from '~/components/common/LoginModal.vue'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'unsubscribed'): void
|
(e: 'unsubscribed'): void
|
||||||
|
@ -43,28 +47,29 @@ const loginModal = ref()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button
|
<Button
|
||||||
v-if="$store.state.auth.authenticated"
|
v-if="store.state.auth.authenticated"
|
||||||
:class="['ui', 'pink', {'inverted': isSubscribed}, {'favorited': isSubscribed}, 'icon', 'labeled', 'button']"
|
:class="['pink', {'favorited': isSubscribed}]"
|
||||||
|
outline
|
||||||
|
:aria-pressed="isSubscribed || undefined"
|
||||||
@click.stop="toggle"
|
@click.stop="toggle"
|
||||||
>
|
>
|
||||||
<i class="heart icon" />
|
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
v-else
|
v-else
|
||||||
:class="['ui', 'pink', 'icon', 'labeled', 'button']"
|
outline
|
||||||
|
icon="bi-heart"
|
||||||
@click="loginModal.show = true"
|
@click="loginModal.show = true"
|
||||||
>
|
>
|
||||||
<i class="heart icon" />
|
|
||||||
{{ title }}
|
{{ title }}
|
||||||
<login-modal
|
<login-modal
|
||||||
ref="loginModal"
|
ref="loginModal"
|
||||||
class="small"
|
class="small"
|
||||||
:next-route="$route.fullPath"
|
:next-route="route.fullPath"
|
||||||
:message="message.authMessage"
|
:message="message.authMessage"
|
||||||
:cover="channel.artist?.cover!"
|
:cover="channel.artist?.cover!"
|
||||||
@created="loginModal.show = false"
|
@created="loginModal.show = false"
|
||||||
/>
|
/>
|
||||||
</button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -3,13 +3,13 @@ import type { BackendError, Channel, Upload, Track } from '~/types'
|
||||||
import type { VueUploadItem } from 'vue-upload-component'
|
import type { VueUploadItem } from 'vue-upload-component'
|
||||||
|
|
||||||
import { computed, ref, reactive, watchEffect, watch } from 'vue'
|
import { computed, ref, reactive, watchEffect, watch } from 'vue'
|
||||||
import { whenever, useCurrentElement } from '@vueuse/core'
|
import { whenever } from '@vueuse/core'
|
||||||
import { humanSize } from '~/utils/filters'
|
import { humanSize } from '~/utils/filters'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import $ from 'jquery'
|
import { type paths, type operations, type components } from '~/generated/types.ts'
|
||||||
|
|
||||||
import UploadMetadataForm from '~/components/channels/UploadMetadataForm.vue'
|
import UploadMetadataForm from '~/components/channels/UploadMetadataForm.vue'
|
||||||
import FileUploadWidget from '~/components/library/FileUploadWidget.vue'
|
import FileUploadWidget from '~/components/library/FileUploadWidget.vue'
|
||||||
|
@ -18,13 +18,19 @@ import AlbumSelect from '~/components/channels/AlbumSelect.vue'
|
||||||
|
|
||||||
import useErrorHandler from '~/composables/useErrorHandler'
|
import useErrorHandler from '~/composables/useErrorHandler'
|
||||||
|
|
||||||
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import Loader from '~/components/ui/Loader.vue'
|
||||||
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'status', status: UploadStatus): void
|
(e: 'status', status: UploadStatus): void
|
||||||
(e: 'step', step: 1 | 2 | 3): void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
channel?: Channel | null
|
channel: Channel | null,
|
||||||
|
filter: 'podcast' | 'music' | undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QuotaStatus {
|
interface QuotaStatus {
|
||||||
|
@ -57,8 +63,12 @@ const store = useStore()
|
||||||
|
|
||||||
const errors = ref([] as string[])
|
const errors = ref([] as string[])
|
||||||
|
|
||||||
const values = reactive({
|
const values = reactive<{
|
||||||
channel: props.channel?.uuid ?? null,
|
channelUuid: string | null; // Channel UUID
|
||||||
|
license: string | null;
|
||||||
|
album: string | null;
|
||||||
|
}>({
|
||||||
|
channelUuid: props.channel?.uuid ?? null,
|
||||||
license: null,
|
license: null,
|
||||||
album: null
|
album: null
|
||||||
})
|
})
|
||||||
|
@ -68,29 +78,91 @@ const files = ref([] as VueUploadItem[])
|
||||||
//
|
//
|
||||||
// Channels
|
// Channels
|
||||||
//
|
//
|
||||||
const availableChannels = reactive({
|
const availableChannels = ref<Channel[] | null>(null)
|
||||||
channels: [] as Channel[],
|
|
||||||
count: 0,
|
/*
|
||||||
loading: false
|
availableChannels>1? :=1 :=0
|
||||||
})
|
| | |
|
||||||
|
v v v
|
||||||
|
props select a channel | create empty channel
|
||||||
|
| | null
|
||||||
|
v v |
|
||||||
|
channelDropdownId v
|
||||||
|
|
|
||||||
|
v
|
||||||
|
selectedChannel
|
||||||
|
|
|
||||||
|
v
|
||||||
|
as a model to Album
|
||||||
|
|
|
||||||
|
v
|
||||||
|
albums
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
// In the channel dropdown, we can select a value
|
||||||
|
//
|
||||||
|
|
||||||
|
const channelDropdownId = ref<Channel['artist']['id'] | null>(null)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
|
||||||
|
const selectedChannel = computed(() =>
|
||||||
|
// Deeplink / Preset channel
|
||||||
|
props.channel
|
||||||
|
? props.channel
|
||||||
|
// Not yet loaded the available channels
|
||||||
|
: availableChannels.value === null
|
||||||
|
? null
|
||||||
|
// No channels available
|
||||||
|
: availableChannels.value.length === 0
|
||||||
|
? (createEmptyChannel(), null)
|
||||||
|
// Exactly one available channel
|
||||||
|
: availableChannels.value.length === 1
|
||||||
|
? availableChannels.value[0]
|
||||||
|
// Multiple available channels
|
||||||
|
: availableChannels.value.find(({ artist }) => artist.id === channelDropdownId.value) || null
|
||||||
|
)
|
||||||
|
|
||||||
|
const emptyChannelCreateRequest:components['schemas']['ChannelCreateRequest'] = {
|
||||||
|
name: store.state.auth.fullUsername,
|
||||||
|
username: store.state.auth.username,
|
||||||
|
description: null,
|
||||||
|
tags: [],
|
||||||
|
content_category: 'music'
|
||||||
|
}
|
||||||
|
|
||||||
|
const createEmptyChannel = async () => {
|
||||||
|
try {
|
||||||
|
await axios.post(
|
||||||
|
'channels/',
|
||||||
|
(emptyChannelCreateRequest satisfies operations['create_channel_2']['requestBody']['content']['application/json'])
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
errors.value = (error as BackendError).backendErrors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fetchChannels = async () => {
|
const fetchChannels = async () => {
|
||||||
availableChannels.loading = true
|
isLoading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('channels/', { params: { scope: 'me' } })
|
const response = await axios.get<paths['/api/v2/channels/']['get']['responses']['200']['content']['application/json']>(
|
||||||
availableChannels.channels = response.data.results
|
'channels/',
|
||||||
availableChannels.count = response.data.count
|
{ params: { scope: 'me' } }
|
||||||
|
)
|
||||||
|
|
||||||
|
availableChannels.value = response.data.results.filter(channel =>
|
||||||
|
props.filter === undefined
|
||||||
|
? true
|
||||||
|
: channel.artist?.content_category === props.filter
|
||||||
|
)
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.value = (error as BackendError).backendErrors
|
errors.value = (error as BackendError).backendErrors
|
||||||
}
|
}
|
||||||
|
|
||||||
availableChannels.loading = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedChannel = computed(() => availableChannels.channels.find((channel) => channel.uuid === values.channel) ?? null)
|
|
||||||
|
|
||||||
//
|
|
||||||
// Quota and space
|
// Quota and space
|
||||||
//
|
//
|
||||||
const quotaStatus = ref()
|
const quotaStatus = ref()
|
||||||
|
@ -125,13 +197,13 @@ const remainingSpace = computed(() => Math.max(
|
||||||
//
|
//
|
||||||
const includeDraftUploads = ref()
|
const includeDraftUploads = ref()
|
||||||
const draftUploads = ref([] as Upload[])
|
const draftUploads = ref([] as Upload[])
|
||||||
whenever(() => values.channel !== null, async () => {
|
whenever(() => values.channelUuid !== null, async () => {
|
||||||
files.value = []
|
files.value = []
|
||||||
draftUploads.value = []
|
draftUploads.value = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('uploads', {
|
const response = await axios.get('uploads', {
|
||||||
params: { import_status: 'draft', channel: values.channel }
|
params: { import_status: 'draft', channel: values.channelUuid }
|
||||||
})
|
})
|
||||||
|
|
||||||
draftUploads.value = response.data.results as Upload[]
|
draftUploads.value = response.data.results as Upload[]
|
||||||
|
@ -157,12 +229,6 @@ const beforeFileUpload = (newFile: VueUploadItem) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseImportMetadata = computed(() => ({
|
|
||||||
channel: values.channel,
|
|
||||||
import_status: 'draft',
|
|
||||||
import_metadata: { license: values.license, album: values.album }
|
|
||||||
}))
|
|
||||||
|
|
||||||
//
|
//
|
||||||
// Uploaded files
|
// Uploaded files
|
||||||
//
|
//
|
||||||
|
@ -213,6 +279,13 @@ const uploadedFilesById = computed(() => uploadedFiles.value.reduce((acc: Record
|
||||||
//
|
//
|
||||||
// Metadata
|
// Metadata
|
||||||
//
|
//
|
||||||
|
|
||||||
|
const baseImportMetadata = computed(() => ({
|
||||||
|
channel: selectedChannel,
|
||||||
|
import_status: 'draft',
|
||||||
|
import_metadata: { license: values.license, album: values.album }
|
||||||
|
}))
|
||||||
|
|
||||||
type Metadata = Pick<Track, 'title' | 'position' | 'tags'> & { cover: string | null, description: string }
|
type Metadata = Pick<Track, 'title' | 'position' | 'tags'> & { cover: string | null, description: string }
|
||||||
const uploadImportData = reactive({} as Record<string, Metadata>)
|
const uploadImportData = reactive({} as Record<string, Metadata>)
|
||||||
const audioMetadata = reactive({} as Record<string, Record<string, string>>)
|
const audioMetadata = reactive({} as Record<string, Record<string, string>>)
|
||||||
|
@ -237,7 +310,7 @@ const fetchAudioMetadata = async (uuid: string) => {
|
||||||
|
|
||||||
for (const key of ['title', 'position', 'tags'] as const) {
|
for (const key of ['title', 'position', 'tags'] as const) {
|
||||||
if (uploadImportData[uuid][key] === undefined) {
|
if (uploadImportData[uuid][key] === undefined) {
|
||||||
uploadImportData[uuid][key] = response.data[key] as never
|
// uploadImportData[uuid][key] = response.data[key] as never
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -301,70 +374,7 @@ const retry = async (file: VueUploadItem) => {
|
||||||
fetchChannels()
|
fetchChannels()
|
||||||
fetchQuota()
|
fetchQuota()
|
||||||
|
|
||||||
//
|
watch(selectedUploadId, async (_, from) => {
|
||||||
// Dropdown
|
|
||||||
//
|
|
||||||
const el = useCurrentElement()
|
|
||||||
watch(() => availableChannels.channels, () => {
|
|
||||||
$(el.value).find('#channel-dropdown').dropdown({
|
|
||||||
onChange (value) {
|
|
||||||
values.channel = value
|
|
||||||
},
|
|
||||||
values: availableChannels.channels.map((channel) => {
|
|
||||||
const value = {
|
|
||||||
name: channel.artist?.name ?? '',
|
|
||||||
value: channel.uuid,
|
|
||||||
selected: props.channel?.uuid === channel.uuid
|
|
||||||
} as {
|
|
||||||
name: string
|
|
||||||
value: string
|
|
||||||
selected: boolean
|
|
||||||
image?: string
|
|
||||||
imageClass?: string
|
|
||||||
icon?: string
|
|
||||||
iconClass?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
if (channel.artist?.cover?.urls.medium_square_crop) {
|
|
||||||
value.image = store.getters['instance/absoluteUrl'](channel.artist.cover.urls.medium_square_crop)
|
|
||||||
value.imageClass = channel.artist.content_category !== 'podcast'
|
|
||||||
? 'ui image avatar'
|
|
||||||
: 'ui image'
|
|
||||||
} else {
|
|
||||||
value.icon = 'user'
|
|
||||||
value.iconClass = channel.artist?.content_category !== 'podcast'
|
|
||||||
? 'circular icon'
|
|
||||||
: 'bordered icon'
|
|
||||||
}
|
|
||||||
|
|
||||||
return value
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
$(el.value).find('#channel-dropdown').dropdown('hide')
|
|
||||||
})
|
|
||||||
|
|
||||||
//
|
|
||||||
// Step
|
|
||||||
//
|
|
||||||
const step = ref<1 | 2 | 3>(1)
|
|
||||||
watchEffect(() => {
|
|
||||||
emit('step', step.value)
|
|
||||||
|
|
||||||
if (step.value === 2) {
|
|
||||||
selectedUploadId.value = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(selectedUploadId, async (to, from) => {
|
|
||||||
if (to) {
|
|
||||||
step.value = 3
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!to && step.value !== 2) {
|
|
||||||
step.value = 2
|
|
||||||
}
|
|
||||||
|
|
||||||
if (from) {
|
if (from) {
|
||||||
await patchUpload(from, { import_metadata: uploadImportData[from] })
|
await patchUpload(from, { import_metadata: uploadImportData[from] })
|
||||||
}
|
}
|
||||||
|
@ -400,23 +410,28 @@ const labels = computed(() => ({
|
||||||
editTitle: t('components.channels.UploadForm.button.edit')
|
editTitle: t('components.channels.UploadForm.button.edit')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const publish = async () => {
|
const publish = async () => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
|
|
||||||
errors.value = []
|
errors.value = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post('uploads/action/', {
|
// Post list of uuids of uploadedFiles to axios action:publish
|
||||||
|
|
||||||
|
/* { import_status: components["schemas"]["ImportStatusEnum"];
|
||||||
|
audio_file: string;} */
|
||||||
|
|
||||||
|
await axios.post<paths['/api/v2/uploads/action/']['post']['responses']['200']['content']['application/json']>('uploads/action/', {
|
||||||
action: 'publish',
|
action: 'publish',
|
||||||
objects: uploadedFiles.value.map((file) => file.response?.uuid)
|
objects: uploadedFiles.value.map((file) => file.response?.uuid)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Tell the store that the uploaded files are pending import
|
||||||
store.commit('channels/publish', {
|
store.commit('channels/publish', {
|
||||||
uploads: uploadedFiles.value.map((file) => ({ ...file.response, import_status: 'pending' })),
|
uploads: uploadedFiles.value.map((file) => ({ ...file.response, import_status: 'pending' })),
|
||||||
channel: selectedChannel.value
|
channel: selectedChannel.value
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// TODO: Use inferred error type instead of typecasting
|
||||||
errors.value = (error as BackendError).backendErrors
|
errors.value = (error as BackendError).backendErrors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -424,23 +439,24 @@ const publish = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
step,
|
|
||||||
publish
|
publish
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form
|
<Layout
|
||||||
:class="['ui', { loading: availableChannels.loading }, 'form component-file-upload']"
|
form
|
||||||
|
gap-8
|
||||||
|
:class="['ui', { loading: isLoading }, 'form component-file-upload']"
|
||||||
@submit.stop.prevent
|
@submit.stop.prevent
|
||||||
>
|
>
|
||||||
<div
|
<!-- Error message -->
|
||||||
|
<Alert
|
||||||
v-if="errors.length > 0"
|
v-if="errors.length > 0"
|
||||||
role="alert"
|
red
|
||||||
class="ui negative message"
|
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
<h4 class="header">
|
||||||
{{ $t('components.channels.UploadForm.header.error') }}
|
{{ t('components.channels.UploadForm.header.error') }}
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
<li
|
<li
|
||||||
|
@ -450,74 +466,88 @@ defineExpose({
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</Alert>
|
||||||
<div :class="['ui', 'required', {hidden: step > 1}, 'field']">
|
|
||||||
<label for="channel-dropdown">
|
<!-- Select Album and License -->
|
||||||
{{ $t('components.channels.UploadForm.label.channel') }}
|
|
||||||
</label>
|
<div :class="['ui', 'required', 'field']">
|
||||||
<div
|
<label
|
||||||
id="channel-dropdown"
|
v-if="availableChannels !== null && availableChannels.length === 1"
|
||||||
class="ui search normal selection dropdown"
|
|
||||||
>
|
>
|
||||||
<div class="text" />
|
{{ `${t('components.channels.UploadForm.label.channel')}: ${selectedChannel?.artist.name}` }}
|
||||||
<i class="dropdown icon" />
|
</label>
|
||||||
</div>
|
<label
|
||||||
|
v-else
|
||||||
|
for="channel-dropdown"
|
||||||
|
>
|
||||||
|
{{ t('components.channels.UploadForm.label.channel') }}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-if="availableChannels !== null && availableChannels.length > 1"
|
||||||
|
id="channel-dropdown"
|
||||||
|
v-model="channelDropdownId"
|
||||||
|
class="dropdown"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="availableChannel in availableChannels"
|
||||||
|
:key="availableChannel.artist.id"
|
||||||
|
:value="availableChannel.artist.id"
|
||||||
|
>
|
||||||
|
{{ availableChannel.artist.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<album-select
|
<album-select
|
||||||
|
v-if="selectedChannel !== null"
|
||||||
v-model.number="values.album"
|
v-model.number="values.album"
|
||||||
:channel="selectedChannel"
|
:channel="selectedChannel"
|
||||||
:class="['ui', {hidden: step > 1}, 'field']"
|
|
||||||
/>
|
/>
|
||||||
|
<div>
|
||||||
<license-select
|
<license-select
|
||||||
|
v-if="values.license !== null"
|
||||||
v-model="values.license"
|
v-model="values.license"
|
||||||
:class="['ui', {hidden: step > 1}, 'field']"
|
:class="['ui', 'field']"
|
||||||
/>
|
/>
|
||||||
<div :class="['ui', {hidden: step > 1}, 'message']">
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<p>
|
<p>
|
||||||
<i class="copyright icon" />
|
<i class="copyright icon" />
|
||||||
{{ $t('components.channels.UploadForm.help.license') }}
|
{{ t('components.channels.UploadForm.help.license') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="step === 2 || step === 3">
|
|
||||||
<div
|
<!-- Files to upload -->
|
||||||
v-if="remainingSpace === 0"
|
<template v-if="remainingSpace === 0">
|
||||||
role="alert"
|
<Alert
|
||||||
class="ui warning message"
|
red
|
||||||
>
|
>
|
||||||
<div class="content">
|
<i class="bi bi-exclamation-triangle" />
|
||||||
<p>
|
{{ t('components.channels.UploadForm.warning.quota') }}
|
||||||
<i class="warning icon" />
|
</Alert>
|
||||||
{{ $t('components.channels.UploadForm.warning.quota') }}
|
</template>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div
|
<Alert
|
||||||
v-if="step === 2 && draftUploads?.length > 0 && includeDraftUploads === undefined"
|
v-if="draftUploads?.length > 0 && includeDraftUploads === undefined"
|
||||||
class="ui visible info message"
|
blue
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
<i class="redo icon" />
|
<i class="bi bi-circle-clockwise" />
|
||||||
{{ $t('components.channels.UploadForm.message.pending') }}
|
{{ t('components.channels.UploadForm.message.pending') }}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button
|
||||||
class="ui basic button"
|
|
||||||
@click.stop.prevent="includeDraftUploads = false"
|
@click.stop.prevent="includeDraftUploads = false"
|
||||||
>
|
>
|
||||||
{{ $t('components.channels.UploadForm.button.ignore') }}
|
{{ t('components.channels.UploadForm.button.ignore') }}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
class="ui basic button"
|
|
||||||
@click.stop.prevent="includeDraftUploads = true"
|
@click.stop.prevent="includeDraftUploads = true"
|
||||||
>
|
>
|
||||||
{{ $t('components.channels.UploadForm.button.resume') }}
|
{{ t('components.channels.UploadForm.button.resume') }}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Alert>
|
||||||
<div
|
<Alert
|
||||||
v-if="uploadedFiles.length > 0"
|
v-if="uploadedFiles.length > 0"
|
||||||
:class="[{hidden: step === 3}]"
|
v-bind="{[ uploadedFiles.some(file=>file.error) ? 'red' : 'green' ]:true}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="file in uploadedFiles"
|
v-for="file in uploadedFiles"
|
||||||
|
@ -525,27 +555,22 @@ defineExpose({
|
||||||
class="channel-file"
|
class="channel-file"
|
||||||
>
|
>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div
|
<Button
|
||||||
v-if="file.response?.uuid"
|
v-if="file.response?.uuid"
|
||||||
role="button"
|
icon="bi-pencil-fill"
|
||||||
class="ui basic icon button"
|
class="ui basic icon button"
|
||||||
:title="labels.editTitle"
|
:title="labels.editTitle"
|
||||||
@click.stop.prevent="selectedUploadId = file.response?.uuid"
|
@click.stop.prevent="selectedUploadId = file.response?.uuid"
|
||||||
>
|
/>
|
||||||
<i class="pencil icon" />
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-if="file.error"
|
v-if="file.error"
|
||||||
class="ui basic danger icon label"
|
class="ui basic danger icon label"
|
||||||
:title="file.error.toString()"
|
:title="file.error.toString()"
|
||||||
@click.stop.prevent="selectedUploadId = file.response?.uuid"
|
@click.stop.prevent="selectedUploadId = file.response?.uuid"
|
||||||
>
|
>
|
||||||
<i class="warning sign icon" />
|
<i class="bi bi-exclamation-triangle-fill" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<Loader v-else-if="file.active && !file.response" />
|
||||||
v-else-if="file.active && !file.response"
|
|
||||||
class="ui active slow inline loader"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<h4 class="ui header">
|
<h4 class="ui header">
|
||||||
<template v-if="file.metadata.title">
|
<template v-if="file.metadata.title">
|
||||||
|
@ -564,13 +589,13 @@ defineExpose({
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span v-if="file.active">
|
<span v-if="file.active">
|
||||||
{{ $t('components.channels.UploadForm.status.uploading') }}
|
{{ t('components.channels.UploadForm.status.uploading') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="file.error">
|
<span v-else-if="file.error">
|
||||||
{{ $t('components.channels.UploadForm.status.errored') }}
|
{{ t('components.channels.UploadForm.status.errored') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ $t('components.channels.UploadForm.status.pending') }}
|
{{ t('components.channels.UploadForm.status.pending') }}
|
||||||
</span>
|
</span>
|
||||||
<span class="middle middledot symbol" />
|
<span class="middle middledot symbol" />
|
||||||
{{ humanSize(file.size ?? 0) }}
|
{{ humanSize(file.size ?? 0) }}
|
||||||
|
@ -580,52 +605,60 @@ defineExpose({
|
||||||
</template>
|
</template>
|
||||||
<span class="middle middledot symbol" />
|
<span class="middle middledot symbol" />
|
||||||
<a @click.stop.prevent="remove(file)">
|
<a @click.stop.prevent="remove(file)">
|
||||||
{{ $t('components.channels.UploadForm.button.remove') }}
|
{{ t('components.channels.UploadForm.button.remove') }}
|
||||||
</a>
|
</a>
|
||||||
<template v-if="file.error">
|
<template v-if="file.error">
|
||||||
<span class="middle middledot symbol" />
|
<span class="middle middledot symbol" />
|
||||||
<a @click.stop.prevent="retry(file)">
|
<a @click.stop.prevent="retry(file)">
|
||||||
{{ $t('components.channels.UploadForm.button.retry') }}
|
{{ t('components.channels.UploadForm.button.retry') }}
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Alert>
|
||||||
|
</template>
|
||||||
<upload-metadata-form
|
<upload-metadata-form
|
||||||
v-if="selectedUpload"
|
v-if="selectedUpload"
|
||||||
v-model:values="uploadImportData[selectedUploadId]"
|
v-model:values="uploadImportData[selectedUploadId]"
|
||||||
:upload="selectedUpload"
|
:upload="selectedUpload"
|
||||||
/>
|
/>
|
||||||
<div
|
<Alert
|
||||||
v-if="step === 2"
|
blue
|
||||||
class="ui message"
|
class="ui message"
|
||||||
>
|
>
|
||||||
<div class="content">
|
<Layout
|
||||||
<p>
|
flex
|
||||||
<i class="info icon" />
|
gap-8
|
||||||
{{ $t('components.channels.UploadForm.description.extensions', {extensions: $store.state.ui.supportedExtensions.join(', ')}) }}
|
>
|
||||||
</p>
|
<i class="bi bi-info-circle-fill" />
|
||||||
</div>
|
{{ t('components.channels.UploadForm.description.extensions', {extensions: store.state.ui.supportedExtensions.join(', ')}) }}
|
||||||
</div>
|
</Layout>
|
||||||
<file-upload-widget
|
</Alert>
|
||||||
|
<FileUploadWidget
|
||||||
|
v-if="selectedChannel && selectedChannel.uuid"
|
||||||
ref="upload"
|
ref="upload"
|
||||||
v-model="files"
|
v-model="files"
|
||||||
:class="['ui', 'icon', 'basic', 'button', 'channels', {hidden: step === 3}]"
|
:class="['ui', 'button', 'channels']"
|
||||||
|
:channel="selectedChannel.uuid"
|
||||||
:data="baseImportMetadata"
|
:data="baseImportMetadata"
|
||||||
@input-file="beforeFileUpload"
|
@input-file="beforeFileUpload"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<i class="upload icon" />
|
<i class="bi bi-upload" />
|
||||||
{{ $t('components.channels.UploadForm.message.dragAndDrop') }}
|
{{ t('components.channels.UploadForm.message.dragAndDrop') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="ui very small divider" />
|
<div class="ui very small divider" />
|
||||||
<div>
|
<Button
|
||||||
{{ $t('components.channels.UploadForm.label.openBrowser') }}
|
primary
|
||||||
</div>
|
icon="bi-folder2-open"
|
||||||
</file-upload-widget>
|
>
|
||||||
<div class="ui hidden divider" />
|
{{ t('components.channels.UploadForm.label.openBrowser') }}
|
||||||
</template>
|
</Button>
|
||||||
</template>
|
<Spacer
|
||||||
</form>
|
class="divider"
|
||||||
|
:size="32"
|
||||||
|
/>
|
||||||
|
</FileUploadWidget>
|
||||||
|
</Layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Upload, Track } from '~/types'
|
import type { Upload, Track } from '~/types'
|
||||||
|
|
||||||
import { reactive, computed, watch } from 'vue'
|
import { reactive, computed, watch, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import TagsSelector from '~/components/library/TagsSelector.vue'
|
|
||||||
import AttachmentInput from '~/components/common/AttachmentInput.vue'
|
import AttachmentInput from '~/components/common/AttachmentInput.vue'
|
||||||
|
|
||||||
|
import Input from '~/components/ui/Input.vue'
|
||||||
|
import Pills from '~/components/ui/Pills.vue'
|
||||||
|
|
||||||
type Values = Pick<Track, 'title' | 'position' | 'tags'> & { cover: string | null, description: string }
|
type Values = Pick<Track, 'title' | 'position' | 'tags'> & { cover: string | null, description: string }
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'update:values', values: Values): void
|
(e: 'update:values', values: Values): void
|
||||||
|
@ -16,15 +19,31 @@ interface Props {
|
||||||
values: Partial<Values> | null
|
values: Partial<Values> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const emit = defineEmits<Events>()
|
const emit = defineEmits<Events>()
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
values: null
|
values: null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// TODO: Make Tags work
|
||||||
|
type Item = { type: 'custom' | 'preset', label: string }
|
||||||
|
type tagsModel = {
|
||||||
|
currents: Item[],
|
||||||
|
others?: Item[],
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = ref<tagsModel>({
|
||||||
|
currents: [],
|
||||||
|
others: []
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO: check if `position: 0` is a good default
|
||||||
const newValues = reactive<Values>({
|
const newValues = reactive<Values>({
|
||||||
|
position: 0,
|
||||||
description: '',
|
description: '',
|
||||||
title: '',
|
title: '',
|
||||||
tags: [],
|
tags: tags.value.currents.map(tag => tag.label),
|
||||||
cover: null,
|
cover: null,
|
||||||
...(props.values ?? props.upload.import_metadata ?? {})
|
...(props.values ?? props.upload.import_metadata ?? {})
|
||||||
})
|
})
|
||||||
|
@ -34,54 +53,45 @@ watch(newValues, (values) => emit('update:values', values), { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="['ui', {loading: isLoading}, 'form']">
|
<Layout
|
||||||
<div class="ui required field">
|
form
|
||||||
<label for="upload-title">
|
:class="[{loading: isLoading}]"
|
||||||
{{ $t('components.channels.UploadMetadataForm.label.title') }}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="newValues.title"
|
|
||||||
type="text"
|
|
||||||
>
|
>
|
||||||
</div>
|
<Input
|
||||||
|
v-model="newValues.title"
|
||||||
|
:label="t('components.channels.UploadMetadataForm.label.title')"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<attachment-input
|
<attachment-input
|
||||||
v-model="newValues.cover"
|
v-model="newValues.cover"
|
||||||
@delete="newValues.cover = ''"
|
@delete="newValues.cover = ''"
|
||||||
>
|
>
|
||||||
{{ $t('components.channels.UploadMetadataForm.label.image') }}
|
{{ t('components.channels.UploadMetadataForm.label.image') }}
|
||||||
</attachment-input>
|
</attachment-input>
|
||||||
<div class="ui small hidden divider" />
|
|
||||||
<div class="ui two fields">
|
|
||||||
<div class="ui field">
|
|
||||||
<label for="upload-tags">
|
<label for="upload-tags">
|
||||||
{{ $t('components.channels.UploadMetadataForm.label.tags') }}
|
{{ t('components.channels.UploadMetadataForm.label.tags') }}
|
||||||
</label>
|
</label>
|
||||||
<tags-selector
|
<!-- TODO: Make Tags work -->
|
||||||
id="upload-tags"
|
<Pills
|
||||||
v-model="newValues.tags"
|
:get="(v) => { tags = v }"
|
||||||
:required="false"
|
:set="() => tags"
|
||||||
|
label="Custom Tags"
|
||||||
|
cancel="Cancel"
|
||||||
/>
|
/>
|
||||||
</div>
|
<Spacer />
|
||||||
<div class="ui field">
|
<Input
|
||||||
<label for="upload-position">
|
|
||||||
{{ $t('components.channels.UploadMetadataForm.label.position') }}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="newValues.position"
|
v-model="newValues.position"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
step="1"
|
step="1"
|
||||||
>
|
:label="t('components.channels.UploadMetadataForm.label.position')"
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
<div class="ui field">
|
|
||||||
<label for="upload-description">
|
<label for="upload-description">
|
||||||
{{ $t('components.channels.UploadMetadataForm.label.description') }}
|
{{ t('components.channels.UploadMetadataForm.label.description') }}
|
||||||
</label>
|
</label>
|
||||||
<content-form
|
<content-form
|
||||||
v-model="newValues.description"
|
v-model="newValues.description"
|
||||||
field-id="upload-description"
|
field-id="upload-description"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Layout>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import SemanticModal from '~/components/semantic/Modal.vue'
|
import Modal from '~/components/ui/Modal.vue'
|
||||||
import ChannelUploadForm from '~/components/channels/UploadForm.vue'
|
import ChannelUploadForm from '~/components/channels/UploadForm.vue'
|
||||||
|
import Popover from '~/components/ui/Popover.vue'
|
||||||
|
import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
import { humanSize } from '~/utils/filters'
|
import { humanSize } from '~/utils/filters'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
@ -15,6 +18,8 @@ const update = (value: boolean) => store.commit('channels/showUploadModal', { sh
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const { filter } = defineProps<{ filter: 'podcast' | 'music' }>()
|
||||||
|
|
||||||
const uploadForm = ref()
|
const uploadForm = ref()
|
||||||
|
|
||||||
const statusData = ref()
|
const statusData = ref()
|
||||||
|
@ -45,31 +50,30 @@ const statusInfo = computed(() => {
|
||||||
|
|
||||||
const step = ref(1)
|
const step = ref(1)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
|
||||||
|
const title = computed(() =>
|
||||||
|
[t('components.channels.UploadModal.header'),
|
||||||
|
t('components.channels.UploadModal.header.publish'),
|
||||||
|
t('components.channels.UploadModal.header.uploadFiles'),
|
||||||
|
t('components.channels.UploadModal.header.uploadDetails'),
|
||||||
|
t('components.channels.UploadModal.header.processing')
|
||||||
|
][step.value]
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<semantic-modal
|
<Modal
|
||||||
v-model:show="$store.state.channels.showUploadModal"
|
v-model="store.state.channels.showUploadModal"
|
||||||
|
:title="title"
|
||||||
class="small"
|
class="small"
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
|
||||||
<span v-if="step === 1">
|
|
||||||
{{ $t('components.channels.UploadModal.header.publish') }}
|
|
||||||
</span>
|
|
||||||
<span v-else-if="step === 2">
|
|
||||||
{{ $t('components.channels.UploadModal.header.uploadFiles') }}
|
|
||||||
</span>
|
|
||||||
<span v-else-if="step === 3">
|
|
||||||
{{ $t('components.channels.UploadModal.header.uploadDetails') }}
|
|
||||||
</span>
|
|
||||||
<span v-else-if="step === 4">
|
|
||||||
{{ $t('components.channels.UploadModal.header.processing') }}
|
|
||||||
</span>
|
|
||||||
</h4>
|
|
||||||
<div class="scrolling content">
|
<div class="scrolling content">
|
||||||
<channel-upload-form
|
<channel-upload-form
|
||||||
ref="uploadForm"
|
ref="uploadForm"
|
||||||
:channel="$store.state.channels.uploadModalConfig.channel ?? null"
|
:filter="filter"
|
||||||
|
:channel="store.state.channels.uploadModalConfig.channel ?? null"
|
||||||
@step="step = $event"
|
@step="step = $event"
|
||||||
@loading="isLoading = $event"
|
@loading="isLoading = $event"
|
||||||
@status="statusData = $event"
|
@status="statusData = $event"
|
||||||
|
@ -82,74 +86,74 @@ const isLoading = ref(false)
|
||||||
</template>
|
</template>
|
||||||
<div class="ui very small hidden divider" />
|
<div class="ui very small hidden divider" />
|
||||||
<template v-if="statusData && statusData.quotaStatus">
|
<template v-if="statusData && statusData.quotaStatus">
|
||||||
{{ $t('components.channels.UploadModal.meta.quota', humanSize((statusData.quotaStatus.remaining - statusData.uploadedSize) * 1000 * 1000)) }}
|
{{ t('components.channels.UploadModal.meta.quota', humanSize((statusData.quotaStatus.remaining - statusData.uploadedSize) * 1000 * 1000)) }}
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui hidden clearing divider mobile-only" />
|
<div class="ui hidden clearing divider mobile-only" />
|
||||||
<button
|
<Button
|
||||||
v-if="step === 1"
|
v-if="step === 1"
|
||||||
class="ui basic cancel button"
|
color="secondary"
|
||||||
|
variant="outline"
|
||||||
|
@click="update(false)"
|
||||||
>
|
>
|
||||||
{{ $t('components.channels.UploadModal.button.cancel') }}
|
{{ t('components.channels.UploadModal.button.cancel') }}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
v-else-if="step < 3"
|
v-else-if="step < 3"
|
||||||
class="ui basic button"
|
color="secondary"
|
||||||
|
variant="outline"
|
||||||
@click.stop.prevent="uploadForm.step -= 1"
|
@click.stop.prevent="uploadForm.step -= 1"
|
||||||
>
|
>
|
||||||
{{ $t('components.channels.UploadModal.button.previous') }}
|
{{ t('components.channels.UploadModal.button.previous') }}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
v-else-if="step === 3"
|
v-else-if="step === 3"
|
||||||
class="ui basic button"
|
color="secondary"
|
||||||
@click.stop.prevent="uploadForm.step -= 1"
|
@click.stop.prevent="uploadForm.step -= 1"
|
||||||
>
|
>
|
||||||
{{ $t('components.channels.UploadModal.button.update') }}
|
{{ t('components.channels.UploadModal.button.update') }}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
v-if="step === 1"
|
v-if="step === 1"
|
||||||
class="ui primary button"
|
color="secondary"
|
||||||
@click.stop.prevent="uploadForm.step += 1"
|
@click.stop.prevent="uploadForm.step += 1"
|
||||||
>
|
>
|
||||||
{{ $t('components.channels.UploadModal.button.next') }}
|
{{ t('components.channels.UploadModal.button.next') }}
|
||||||
</button>
|
</Button>
|
||||||
<div
|
<div class="ui primary buttons">
|
||||||
v-if="step === 2"
|
<Button
|
||||||
class="ui primary buttons"
|
:is-loading="isLoading"
|
||||||
>
|
|
||||||
<button
|
|
||||||
:class="['ui', 'primary button', {loading: isLoading}]"
|
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="!statusData?.canSubmit || undefined"
|
:disabled="!statusData?.canSubmit"
|
||||||
@click.prevent.stop="uploadForm.publish"
|
@click.prevent.stop="uploadForm.publish"
|
||||||
>
|
>
|
||||||
{{ $t('components.channels.UploadModal.button.publish') }}
|
{{ t('components.channels.UploadModal.button.publish') }}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
|
||||||
ref="dropdown"
|
<Popover v-model="open">
|
||||||
v-dropdown
|
<template #default="{ toggleOpen }">
|
||||||
class="ui floating dropdown icon button"
|
<Button
|
||||||
:disabled="!statusData?.canSubmit || undefined"
|
color="primary"
|
||||||
>
|
icon="bi-chevron-down"
|
||||||
<i class="dropdown icon" />
|
:disabled="!statusData?.canSubmit"
|
||||||
<div class="menu">
|
@click.stop="toggleOpen()"
|
||||||
<div
|
/>
|
||||||
role="button"
|
</template>
|
||||||
class="basic item"
|
<template #items>
|
||||||
@click="update(false)"
|
<PopoverItem @click="update(false)">
|
||||||
>
|
{{ t('components.channels.UploadModal.button.finishLater') }}
|
||||||
{{ $t('components.channels.UploadModal.button.finishLater') }}
|
</PopoverItem>
|
||||||
|
</template>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</button>
|
<Button
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
v-if="step === 4"
|
v-if="step === 4"
|
||||||
class="ui basic cancel button"
|
color="secondary"
|
||||||
@click="update(false)"
|
@click="update(false)"
|
||||||
>
|
>
|
||||||
{{ $t('components.channels.UploadModal.button.close') }}
|
{{ t('components.channels.UploadModal.button.close') }}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</semantic-modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,9 +1,16 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { BackendError } from '~/types'
|
import type { BackendError } from '~/types'
|
||||||
|
|
||||||
import { ref, computed, reactive, watch } from 'vue'
|
import { ref, computed, reactive, watch, useSlots } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import DangerousButton from '~/components/common/DangerousButton.vue'
|
||||||
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import Table from '~/components/ui/Table.vue'
|
||||||
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
interface Action {
|
interface Action {
|
||||||
|
@ -11,7 +18,7 @@ interface Action {
|
||||||
label: string
|
label: string
|
||||||
isDangerous?: boolean
|
isDangerous?: boolean
|
||||||
allowAll?: boolean
|
allowAll?: boolean
|
||||||
confirmColor?: string
|
confirmColor?: 'success' | 'danger'
|
||||||
confirmationMessage?: string
|
confirmationMessage?: string
|
||||||
filterChackable?: (item: any) => boolean
|
filterChackable?: (item: any) => boolean
|
||||||
}
|
}
|
||||||
|
@ -54,6 +61,13 @@ const checkable = computed(() => {
|
||||||
.map(item => item[props.idField] as string)
|
.map(item => item[props.idField] as string)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// objects is `any`.
|
||||||
|
// Can we narrow down this type?
|
||||||
|
// TODO: Search `action-table` globally and narrow all
|
||||||
|
// `objectsData` props
|
||||||
|
|
||||||
|
// Type Custom = A | B | C
|
||||||
|
|
||||||
const objects = computed(() => props.objectsData.results.map(object => {
|
const objects = computed(() => props.objectsData.results.map(object => {
|
||||||
return props.customObjects.find(custom => custom[props.idField] === object[props.idField])
|
return props.customObjects.find(custom => custom[props.idField] === object[props.idField])
|
||||||
?? object
|
?? object
|
||||||
|
@ -154,160 +168,36 @@ const launchAction = async () => {
|
||||||
|
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const slots = useSlots()
|
||||||
|
|
||||||
|
// Count the number of elements inside the "header-cells" slot
|
||||||
|
const columnCount = computed(() => {
|
||||||
|
return slots['header-cells'] ? slots['header-cells']({}).length : 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Compute the correct number of columns
|
||||||
|
const gridColumns = computed(() => {
|
||||||
|
let columns = columnCount.value
|
||||||
|
|
||||||
|
// Add 1 column if for the checkbox if there are more than one action
|
||||||
|
if (props.actions.length > 0) {
|
||||||
|
columns += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from({ length: columns }, () => 'auto' as const)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="table-wrapper component-action-table">
|
<div class="table-wrapper component-action-table">
|
||||||
<table class="ui compact very basic unstackable table">
|
<Table
|
||||||
<thead>
|
v-if="objectsData.count > 0"
|
||||||
<tr>
|
:grid-template-columns="gridColumns"
|
||||||
<th colspan="1000">
|
class="ui compact very basic unstackable table"
|
||||||
<div
|
|
||||||
v-if="refreshable"
|
|
||||||
class="right floated"
|
|
||||||
>
|
>
|
||||||
<span v-if="needsRefresh">
|
<template #header>
|
||||||
{{ $t('components.common.ActionTable.message.needsRefresh') }}
|
<label v-if="actions.length > 0">
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
class="ui basic icon button"
|
|
||||||
:title="labels.refresh"
|
|
||||||
:aria-label="labels.refresh"
|
|
||||||
@click="$emit('refresh')"
|
|
||||||
>
|
|
||||||
<i class="refresh icon" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="actionUrl && actions.length > 0"
|
|
||||||
class="ui small left floated form"
|
|
||||||
>
|
|
||||||
<div class="ui inline fields">
|
|
||||||
<div class="field">
|
|
||||||
<label for="actions-select">{{ $t('components.common.ActionTable.label.actions') }}</label>
|
|
||||||
<select
|
|
||||||
id="actions-select"
|
|
||||||
v-model="currentActionName"
|
|
||||||
class="ui dropdown"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="action in actions"
|
|
||||||
:key="action.name"
|
|
||||||
:value="action.name"
|
|
||||||
>
|
|
||||||
{{ action.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<dangerous-button
|
|
||||||
v-if="selectAll || currentAction?.isDangerous"
|
|
||||||
:class="['ui', {disabled: checked.length === 0}, {'loading': isLoading}, 'button']"
|
|
||||||
:confirm-color="currentAction?.confirmColor ?? 'success'"
|
|
||||||
:aria-label="labels.performAction"
|
|
||||||
@confirm="launchAction"
|
|
||||||
>
|
|
||||||
{{ $t('components.common.ActionTable.button.go') }}
|
|
||||||
<template #modal-header>
|
|
||||||
<p>
|
|
||||||
<span key="1">
|
|
||||||
{{ $t('components.common.ActionTable.modal.performAction.header', { action: currentActionName }, affectedObjectsCount) }}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<template #modal-content>
|
|
||||||
<p>
|
|
||||||
<template v-if="currentAction?.confirmationMessage">
|
|
||||||
{{ currentAction?.confirmationMessage }}
|
|
||||||
</template>
|
|
||||||
<span v-else>
|
|
||||||
{{ $t('components.common.ActionTable.modal.performAction.content.warning') }}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<template #modal-confirm>
|
|
||||||
<div :aria-label="labels.performAction">
|
|
||||||
{{ $t('components.common.ActionTable.button.launch') }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</dangerous-button>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
:disabled="checked.length === 0"
|
|
||||||
:aria-label="labels.performAction"
|
|
||||||
:class="['ui', {disabled: checked.length === 0}, {'loading': isLoading}, 'button']"
|
|
||||||
@click="launchAction"
|
|
||||||
>
|
|
||||||
{{ $t('components.common.ActionTable.button.go') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="count field">
|
|
||||||
<span v-if="selectAll">
|
|
||||||
{{ $t('components.common.ActionTable.button.allSelected', objectsData.count) }}
|
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
{{ $t('components.common.ActionTable.button.selected', { total: objectsData.count }, checked.length) }}
|
|
||||||
</span>
|
|
||||||
<template v-if="currentAction?.allowAll && checkable.length > 0 && checkable.length === checked.length">
|
|
||||||
<a
|
|
||||||
v-if="!selectAll"
|
|
||||||
href=""
|
|
||||||
@click.prevent="selectAll = true"
|
|
||||||
>
|
|
||||||
<span key="3">
|
|
||||||
{{ $t('components.common.ActionTable.button.selectElement', objectsData.count) }}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
v-else
|
|
||||||
href=""
|
|
||||||
@click.prevent="selectAll = false"
|
|
||||||
>
|
|
||||||
<span key="4">
|
|
||||||
{{ $t('components.common.ActionTable.button.selectCurrentPage') }}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="errors.length > 0"
|
|
||||||
role="alert"
|
|
||||||
class="ui negative message"
|
|
||||||
>
|
|
||||||
<h4 class="header">
|
|
||||||
{{ $t('components.common.ActionTable.header.error') }}
|
|
||||||
</h4>
|
|
||||||
<ul class="list">
|
|
||||||
<li
|
|
||||||
v-for="(error, key) in errors"
|
|
||||||
:key="key"
|
|
||||||
>
|
|
||||||
{{ error }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="result"
|
|
||||||
class="ui positive message"
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
<span>
|
|
||||||
{{ $t('components.common.ActionTable.message.success', { action: result.action }, result.updated) }}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<slot
|
|
||||||
name="action-success-footer"
|
|
||||||
:result="result"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th v-if="actions.length > 0">
|
|
||||||
<div class="ui checkbox">
|
<div class="ui checkbox">
|
||||||
<!-- TODO (wvffle): Check if we don't have to migrate to v-model -->
|
<!-- TODO (wvffle): Check if we don't have to migrate to v-model -->
|
||||||
<input
|
<input
|
||||||
|
@ -318,16 +208,163 @@ const launchAction = async () => {
|
||||||
@change="toggleCheckAll"
|
@change="toggleCheckAll"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</label>
|
||||||
<slot name="header-cells" />
|
<slot name="header-cells" />
|
||||||
</tr>
|
</template>
|
||||||
</thead>
|
|
||||||
<tbody v-if="objectsData.count > 0">
|
<div
|
||||||
<tr
|
v-if="actionUrl && actions.length > 0 || refreshable"
|
||||||
|
:style="{ gridColumn: `span ${gridColumns.length}`, height: '128px' }"
|
||||||
|
>
|
||||||
|
<Layout
|
||||||
|
v-if="actionUrl && actions.length > 0"
|
||||||
|
stack
|
||||||
|
no-gap
|
||||||
|
>
|
||||||
|
<label for="actions-select">{{ t('components.common.ActionTable.label.actions') }}</label>
|
||||||
|
<Layout
|
||||||
|
flex
|
||||||
|
class="ui form"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="actions-select"
|
||||||
|
v-model="currentActionName"
|
||||||
|
class="dropdown"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="action in actions"
|
||||||
|
:key="action.name"
|
||||||
|
:value="action.name"
|
||||||
|
>
|
||||||
|
{{ action.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<dangerous-button
|
||||||
|
v-if="selectAll || currentAction?.isDangerous"
|
||||||
|
:disabled="checked.length === 0 || undefined"
|
||||||
|
:is-loading="isLoading"
|
||||||
|
:confirm-color="currentAction?.confirmColor ?? 'success'"
|
||||||
|
style="margin-top: 7px;"
|
||||||
|
:aria-label="labels.performAction"
|
||||||
|
:title="t('components.common.ActionTable.modal.performAction.header', { action: currentActionName }, affectedObjectsCount)"
|
||||||
|
@confirm="launchAction"
|
||||||
|
>
|
||||||
|
{{ t('components.common.ActionTable.button.go') }}
|
||||||
|
<template #modal-content>
|
||||||
|
<template v-if="currentAction?.confirmationMessage">
|
||||||
|
{{ currentAction?.confirmationMessage }}
|
||||||
|
</template>
|
||||||
|
<span v-else>
|
||||||
|
{{ t('components.common.ActionTable.modal.performAction.content.warning') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #modal-confirm>
|
||||||
|
<span :aria-label="labels.performAction">
|
||||||
|
{{ t('components.common.ActionTable.button.launch') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</dangerous-button>
|
||||||
|
<Button
|
||||||
|
v-else
|
||||||
|
primary
|
||||||
|
:disabled="checked.length === 0"
|
||||||
|
:aria-label="labels.performAction"
|
||||||
|
:class="[{disabled: checked.length === 0}, {'loading': isLoading}]"
|
||||||
|
style="margin-top: 7px;"
|
||||||
|
@click="launchAction"
|
||||||
|
>
|
||||||
|
{{ t('components.common.ActionTable.button.go') }}
|
||||||
|
</Button>
|
||||||
|
<div class="count field">
|
||||||
|
<span v-if="selectAll">
|
||||||
|
{{ t('components.common.ActionTable.button.allSelected', objectsData.count) }}
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
{{ t('components.common.ActionTable.button.selected', { total: objectsData.count }, checked.length) }}
|
||||||
|
</span>
|
||||||
|
<template v-if="currentAction?.allowAll && checkable.length > 0 && checkable.length === checked.length">
|
||||||
|
<a
|
||||||
|
v-if="!selectAll"
|
||||||
|
href=""
|
||||||
|
@click.prevent="selectAll = true"
|
||||||
|
>
|
||||||
|
<span key="3">
|
||||||
|
{{ t('components.common.ActionTable.button.selectElement', objectsData.count) }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
v-else
|
||||||
|
href=""
|
||||||
|
@click.prevent="selectAll = false"
|
||||||
|
>
|
||||||
|
<span key="4">
|
||||||
|
{{ t('components.common.ActionTable.button.selectCurrentPage') }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<Alert
|
||||||
|
v-if="errors.length > 0"
|
||||||
|
red
|
||||||
|
>
|
||||||
|
<h4 class="header">
|
||||||
|
{{ t('components.common.ActionTable.header.error') }}
|
||||||
|
</h4>
|
||||||
|
<ul class="list">
|
||||||
|
<li
|
||||||
|
v-for="(error, key) in errors"
|
||||||
|
:key="key"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Alert>
|
||||||
|
<Alert
|
||||||
|
v-if="result"
|
||||||
|
green
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
<span>
|
||||||
|
{{ t('components.common.ActionTable.message.success', { action: result.action }, result.updated) }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<slot
|
||||||
|
name="action-success-footer"
|
||||||
|
:result="result"
|
||||||
|
/>
|
||||||
|
</Alert>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<Spacer grow />
|
||||||
|
|
||||||
|
<Layout
|
||||||
|
v-if="refreshable"
|
||||||
|
label
|
||||||
|
class="right floated"
|
||||||
|
>
|
||||||
|
<span v-if="needsRefresh">
|
||||||
|
{{ t('components.common.ActionTable.message.needsRefresh') }}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
primary
|
||||||
|
icon="bi-arrow-clockwise"
|
||||||
|
:title="labels.refresh"
|
||||||
|
:aria-label="labels.refresh"
|
||||||
|
style="align-self: end;"
|
||||||
|
@click="$emit('refresh')"
|
||||||
|
>
|
||||||
|
{{ labels.refresh }}
|
||||||
|
</Button>
|
||||||
|
</Layout>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template
|
||||||
v-for="(obj, index) in objects"
|
v-for="(obj, index) in objects"
|
||||||
:key="index"
|
:key="index"
|
||||||
>
|
>
|
||||||
<td
|
<label
|
||||||
v-if="actions.length > 0"
|
v-if="actions.length > 0"
|
||||||
class="collapsing"
|
class="collapsing"
|
||||||
>
|
>
|
||||||
|
@ -339,13 +376,12 @@ const launchAction = async () => {
|
||||||
:checked="checked.indexOf(obj[idField]) > -1"
|
:checked="checked.indexOf(obj[idField]) > -1"
|
||||||
@click="toggleCheck($event, obj[idField], index)"
|
@click="toggleCheck($event, obj[idField], index)"
|
||||||
>
|
>
|
||||||
</td>
|
</label>
|
||||||
<slot
|
<slot
|
||||||
name="row-cells"
|
name="row-cells"
|
||||||
:obj="obj"
|
:obj="obj"
|
||||||
/>
|
/>
|
||||||
</tr>
|
</template>
|
||||||
</tbody>
|
</Table>
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { hashCode, intToRGB } from '~/utils/color'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
actor: Actor
|
actor: { full_username : string; preferred_username?:string; icon?:Actor['icon'] }
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
@ -18,12 +18,29 @@ const defaultAvatarStyle = computed(() => ({ backgroundColor: `#${actorColor.val
|
||||||
<img
|
<img
|
||||||
v-if="actor.icon && actor.icon.urls.original"
|
v-if="actor.icon && actor.icon.urls.original"
|
||||||
alt=""
|
alt=""
|
||||||
:src="actor.icon.urls.medium_square_crop"
|
:src="actor.icon.urls.small_square_crop"
|
||||||
class="ui avatar circular image"
|
class="ui tiny avatar circular image"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-else
|
v-else
|
||||||
:style="defaultAvatarStyle"
|
:style="defaultAvatarStyle"
|
||||||
class="ui avatar circular label"
|
class="ui tiny avatar circular label"
|
||||||
>{{ actor.preferred_username?.[0] || "" }}</span>
|
>
|
||||||
|
{{ actor.preferred_username?.[0] || "" }}
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.ui.circular.avatar {
|
||||||
|
float: left;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
&.label {
|
||||||
|
align-content: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -1,23 +1,28 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Actor } from '~/types'
|
import type { Actor } from '~/types'
|
||||||
|
import type { components } from '~/generated/types'
|
||||||
|
|
||||||
import { toRefs } from '@vueuse/core'
|
import { toRefs } from '@vueuse/core'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { truncate } from '~/utils/filters'
|
import { truncate } from '~/utils/filters'
|
||||||
|
|
||||||
|
import Pill from '~/components/ui/Pill.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
actor: Actor
|
actor: Actor | components['schemas']['APIActor']
|
||||||
avatar?: boolean
|
avatar?: boolean
|
||||||
admin?: boolean
|
admin?: boolean
|
||||||
displayName?: boolean
|
displayName?: boolean
|
||||||
truncateLength?: number
|
truncateLength?: number
|
||||||
|
discrete?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
avatar: true,
|
avatar: true,
|
||||||
admin: false,
|
admin: false,
|
||||||
displayName: false,
|
displayName: false,
|
||||||
truncateLength: 30
|
truncateLength: 30,
|
||||||
|
discrete: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const { displayName, actor, truncateLength, admin, avatar } = toRefs(props)
|
const { displayName, actor, truncateLength, admin, avatar } = toRefs(props)
|
||||||
|
@ -27,7 +32,7 @@ const repr = computed(() => {
|
||||||
? actor.value.preferred_username
|
? actor.value.preferred_username
|
||||||
: actor.value.full_username
|
: actor.value.full_username
|
||||||
|
|
||||||
return truncate(name, truncateLength.value)
|
return truncate(name || '', truncateLength.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
const url = computed(() => {
|
const url = computed(() => {
|
||||||
|
@ -35,15 +40,15 @@ const url = computed(() => {
|
||||||
return { name: 'manage.moderation.accounts.detail', params: { id: actor.value.full_username } }
|
return { name: 'manage.moderation.accounts.detail', params: { id: actor.value.full_username } }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actor.value.is_local) {
|
if (actor.value?.is_local) {
|
||||||
return { name: 'profile.overview', params: { username: actor.value.preferred_username } }
|
return { name: 'profile.overview', params: { username: actor.value?.preferred_username } }
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: 'profile.full.overview',
|
name: 'profile.full.overview',
|
||||||
params: {
|
params: {
|
||||||
username: actor.value.preferred_username,
|
username: actor.value?.preferred_username,
|
||||||
domain: actor.value.domain
|
domain: actor.value?.domain
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -52,13 +57,28 @@ const url = computed(() => {
|
||||||
<template>
|
<template>
|
||||||
<router-link
|
<router-link
|
||||||
:to="url"
|
:to="url"
|
||||||
:title="actor.full_username"
|
class="username"
|
||||||
|
@click.stop.prevent=""
|
||||||
>
|
>
|
||||||
|
<Pill>
|
||||||
|
<template #image>
|
||||||
<actor-avatar
|
<actor-avatar
|
||||||
v-if="avatar"
|
v-if="avatar"
|
||||||
:actor="actor"
|
:actor="actor"
|
||||||
/>
|
/>
|
||||||
<span> </span>
|
<i
|
||||||
<slot>{{ repr }}</slot>
|
v-else
|
||||||
|
class="bi bi-person-circle"
|
||||||
|
style="font-size: 24px;"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
{{ repr }}
|
||||||
|
</Pill>
|
||||||
</router-link>
|
</router-link>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
a.username {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -5,8 +5,12 @@ import axios from 'axios'
|
||||||
import { useVModel } from '@vueuse/core'
|
import { useVModel } from '@vueuse/core'
|
||||||
import { reactive, ref, watch } from 'vue'
|
import { reactive, ref, watch } from 'vue'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import useFormData from '~/composables/useFormData'
|
import useFormData from '~/composables/useFormData'
|
||||||
|
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'update:modelValue', value: string | null): void
|
(e: 'update:modelValue', value: string | null): void
|
||||||
(e: 'delete'): void
|
(e: 'delete'): void
|
||||||
|
@ -20,6 +24,8 @@ interface Props {
|
||||||
initialValue?: string | undefined
|
initialValue?: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const emit = defineEmits<Events>()
|
const emit = defineEmits<Events>()
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
imageClass: '',
|
imageClass: '',
|
||||||
|
@ -101,13 +107,13 @@ const getAttachmentUrl = (uuid: string) => {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="ui form">
|
<div class="ui form">
|
||||||
<div
|
<Alert
|
||||||
v-if="errors.length > 0"
|
v-if="errors.length > 0"
|
||||||
|
red
|
||||||
role="alert"
|
role="alert"
|
||||||
class="ui negative message"
|
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
<h4 class="header">
|
||||||
{{ $t('components.common.AttachmentInput.header.failure') }}
|
{{ t('components.common.AttachmentInput.header.failure') }}
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
<li
|
<li
|
||||||
|
@ -117,7 +123,7 @@ const getAttachmentUrl = (uuid: string) => {
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</Alert>
|
||||||
<div class="ui field">
|
<div class="ui field">
|
||||||
<span id="avatarLabel">
|
<span id="avatarLabel">
|
||||||
<slot />
|
<slot />
|
||||||
|
@ -144,7 +150,7 @@ const getAttachmentUrl = (uuid: string) => {
|
||||||
<div class="eleven wide column">
|
<div class="eleven wide column">
|
||||||
<div class="file-input">
|
<div class="file-input">
|
||||||
<label :for="attachmentId">
|
<label :for="attachmentId">
|
||||||
{{ $t('components.common.AttachmentInput.label.upload') }}
|
{{ t('components.common.AttachmentInput.label.upload') }}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
:id="attachmentId"
|
:id="attachmentId"
|
||||||
|
@ -159,21 +165,22 @@ const getAttachmentUrl = (uuid: string) => {
|
||||||
</div>
|
</div>
|
||||||
<div class="ui very small hidden divider" />
|
<div class="ui very small hidden divider" />
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.common.AttachmentInput.help.upload') }}
|
{{ t('components.common.AttachmentInput.help.upload') }}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button
|
||||||
v-if="value"
|
v-if="value"
|
||||||
class="ui basic tiny button"
|
destructive
|
||||||
|
icon="bi-trash"
|
||||||
@click.stop.prevent="remove(value as string)"
|
@click.stop.prevent="remove(value as string)"
|
||||||
>
|
>
|
||||||
{{ $t('components.common.AttachmentInput.button.remove') }}
|
{{ t('components.common.AttachmentInput.button.remove') }}
|
||||||
</button>
|
</Button>
|
||||||
<div
|
<div
|
||||||
v-if="isLoading"
|
v-if="isLoading"
|
||||||
class="ui active inverted dimmer"
|
class="ui active inverted dimmer"
|
||||||
>
|
>
|
||||||
<div class="ui indeterminate text loader">
|
<div class="ui indeterminate text loader">
|
||||||
{{ $t('components.common.AttachmentInput.loader.uploading') }}
|
{{ t('components.common.AttachmentInput.loader.uploading') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useVModel } from '@vueuse/core'
|
import { useVModel } from '@vueuse/core'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'update:modelValue', value: boolean): void
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
@ -9,6 +10,8 @@ interface Props {
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const emit = defineEmits<Events>()
|
const emit = defineEmits<Events>()
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const value = useVModel(props, 'modelValue', emit)
|
const value = useVModel(props, 'modelValue', emit)
|
||||||
|
@ -21,10 +24,10 @@ const value = useVModel(props, 'modelValue', emit)
|
||||||
@click.prevent="value = !value"
|
@click.prevent="value = !value"
|
||||||
>
|
>
|
||||||
<span v-if="value">
|
<span v-if="value">
|
||||||
{{ $t('components.common.CollapseLink.button.expand') }}
|
{{ t('components.common.CollapseLink.button.expand') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ $t('components.common.CollapseLink.button.collapse') }}
|
{{ t('components.common.CollapseLink.button.collapse') }}
|
||||||
</span>
|
</span>
|
||||||
<i :class="[{ down: !value, right: value }, 'angle', 'icon']" />
|
<i :class="[{ down: !value, right: value }, 'angle', 'icon']" />
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import axios from 'axios'
|
import { useVModel, useTextareaAutosize, syncRef } from '@vueuse/core'
|
||||||
import { useVModel, watchDebounced, useTextareaAutosize, syncRef } from '@vueuse/core'
|
import { computed } from 'vue'
|
||||||
import { ref, computed, watchEffect, onMounted, nextTick, watch } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import useLogger from '~/composables/useLogger'
|
import Textarea from '~/components/ui/Textarea.vue'
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'update:modelValue', value: string): void
|
(e: 'update:modelValue', value: string): void
|
||||||
|
@ -28,110 +27,27 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
required: false
|
required: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const logger = useLogger()
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { textarea, input } = useTextareaAutosize()
|
const { textarea, input } = useTextareaAutosize()
|
||||||
const value = useVModel(props, 'modelValue', emit)
|
const value = useVModel(props, 'modelValue', emit)
|
||||||
syncRef(value, input)
|
syncRef(value, input)
|
||||||
|
|
||||||
const isPreviewing = ref(false)
|
|
||||||
const preview = ref()
|
|
||||||
const isLoadingPreview = ref(false)
|
|
||||||
|
|
||||||
const labels = computed(() => ({
|
const labels = computed(() => ({
|
||||||
placeholder: props.placeholder ?? t('components.common.ContentForm.placeholder.input')
|
placeholder: props.placeholder ?? t('components.common.ContentForm.placeholder.input')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const remainingChars = computed(() => props.charLimit - props.modelValue.length)
|
const remainingChars = computed(() => props.charLimit - props.modelValue.length)
|
||||||
|
|
||||||
const loadPreview = async () => {
|
|
||||||
isLoadingPreview.value = true
|
|
||||||
try {
|
|
||||||
const response = await axios.post('text-preview/', { text: value.value, permissive: props.permissive })
|
|
||||||
preview.value = response.data.rendered
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error)
|
|
||||||
}
|
|
||||||
isLoadingPreview.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
watchDebounced(value, async () => {
|
|
||||||
await loadPreview()
|
|
||||||
}, { immediate: true, debounce: 500 })
|
|
||||||
|
|
||||||
watchEffect(async () => {
|
|
||||||
if (isPreviewing.value) {
|
|
||||||
if (value.value && !preview.value && !isLoadingPreview.value) {
|
|
||||||
await loadPreview()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(isPreviewing, (to, from) => {
|
|
||||||
if (from === true) {
|
|
||||||
textarea.value.focus()
|
|
||||||
}
|
|
||||||
}, { flush: 'post' })
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (props.autofocus) {
|
|
||||||
await nextTick()
|
|
||||||
textarea.value.focus()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="content-form ui segments">
|
<Textarea
|
||||||
<div class="ui segment">
|
|
||||||
<div class="ui tiny secondary pointing menu">
|
|
||||||
<button
|
|
||||||
:class="[{active: !isPreviewing}, 'item']"
|
|
||||||
@click.prevent="isPreviewing = false"
|
|
||||||
>
|
|
||||||
{{ $t('components.common.ContentForm.button.write') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="[{active: isPreviewing}, 'item']"
|
|
||||||
@click.prevent="isPreviewing = true"
|
|
||||||
>
|
|
||||||
{{ $t('components.common.ContentForm.button.preview') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<template v-if="isPreviewing">
|
|
||||||
<div
|
|
||||||
v-if="isLoadingPreview"
|
|
||||||
class="ui placeholder"
|
|
||||||
>
|
|
||||||
<div class="paragraph">
|
|
||||||
<div class="line" />
|
|
||||||
<div class="line" />
|
|
||||||
<div class="line" />
|
|
||||||
<div class="line" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p v-else-if="!preview">
|
|
||||||
{{ $t('components.common.ContentForm.empty.noContent') }}
|
|
||||||
</p>
|
|
||||||
<sanitized-html
|
|
||||||
v-else
|
|
||||||
:html="preview"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="ui transparent input">
|
|
||||||
<textarea
|
|
||||||
ref="textarea"
|
ref="textarea"
|
||||||
v-model="value"
|
v-model="value"
|
||||||
:required="required"
|
:required="required || undefined"
|
||||||
:placeholder="labels.placeholder"
|
:placeholder="labels.placeholder"
|
||||||
|
:autofocus="autofocus || undefined"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div class="ui very small hidden divider" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<div class="ui bottom attached segment">
|
|
||||||
<span
|
<span
|
||||||
v-if="charLimit"
|
v-if="charLimit"
|
||||||
:class="['right', 'floated', {'ui danger text': remainingChars < 0}]"
|
:class="['right', 'floated', {'ui danger text': remainingChars < 0}]"
|
||||||
|
@ -139,8 +55,6 @@ onMounted(async () => {
|
||||||
{{ remainingChars }}
|
{{ remainingChars }}
|
||||||
</span>
|
</span>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.common.ContentForm.help.markdown') }}
|
{{ t('components.common.ContentForm.help.markdown') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,43 +1,79 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { toRefs, useClipboard } from '@vueuse/core'
|
import { toRefs, useClipboard } from '@vueuse/core'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import Input from '~/components/ui/Input.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: string
|
value: string
|
||||||
buttonClasses?: string
|
buttonClasses?: string
|
||||||
id?: string
|
id?: string
|
||||||
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
buttonClasses: 'accent',
|
buttonClasses: 'accent',
|
||||||
id: 'copy-input'
|
id: 'copy-input',
|
||||||
|
label: 'label'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const { value } = toRefs(props)
|
const { value } = toRefs(props)
|
||||||
const { copy, isSupported: canCopy, copied } = useClipboard({ source: value, copiedDuring: 5000 })
|
const { copy, isSupported: canCopy, copied } = useClipboard({ source: value, copiedDuring: 5000 })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="ui fluid action input component-copy-input">
|
|
||||||
<p
|
<p
|
||||||
v-if="copied"
|
v-if="copied"
|
||||||
class="message"
|
class="message"
|
||||||
>
|
>
|
||||||
{{ $t('components.common.CopyInput.message.success') }}
|
{{ t('components.common.CopyInput.message.success') }}
|
||||||
</p>
|
</p>
|
||||||
<input
|
<Input
|
||||||
:id="id"
|
:id="id"
|
||||||
:value="value"
|
v-model="value"
|
||||||
|
readonly
|
||||||
:name="id"
|
:name="id"
|
||||||
type="text"
|
type="text"
|
||||||
readonly
|
:label="label"
|
||||||
>
|
>
|
||||||
<button
|
<template #input-right>
|
||||||
:class="['ui', buttonClasses, 'right', 'labeled', 'icon', 'button']"
|
<Button
|
||||||
|
:class="['ui', buttonClasses, 'input-right']"
|
||||||
|
min-content
|
||||||
|
secondary
|
||||||
:disabled="!canCopy || undefined"
|
:disabled="!canCopy || undefined"
|
||||||
@click="copy()"
|
@click="copy()"
|
||||||
>
|
>
|
||||||
<i class="copy icon" />
|
<i class="bi bi-copy" />
|
||||||
{{ $t('components.common.CopyInput.button.copy') }}
|
{{ t('components.common.CopyInput.button.copy') }}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</template>
|
||||||
|
</Input>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.input-right {
|
||||||
|
position: absolute;
|
||||||
|
right: 0px;
|
||||||
|
bottom: 0px;
|
||||||
|
height: 48px;
|
||||||
|
min-width: 48px;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.button {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
margin-right: 0px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.message {
|
||||||
|
background-color: var(--hover-background-color);
|
||||||
|
padding: 8px;
|
||||||
|
position: absolute;
|
||||||
|
bottom: -32px;
|
||||||
|
right: 0px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -1,23 +1,26 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import SemanticModal from '~/components/semantic/Modal.vue'
|
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import Modal from '~/components/ui/Modal.vue'
|
||||||
|
import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'confirm'): void
|
(e: 'confirm'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
// Note that properties such as [disabled] and 'destructive' | 'primary' are inherited.
|
||||||
action?: () => void
|
const props = defineProps<{
|
||||||
disabled?: boolean
|
title?: string
|
||||||
confirmColor?: 'danger' | 'success'
|
action?:() => void,
|
||||||
}
|
confirmColor?:'success' | 'danger',
|
||||||
|
popoverItem?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const emit = defineEmits<Events>()
|
const emit = defineEmits<Events>()
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
action: () => undefined,
|
|
||||||
disabled: false,
|
|
||||||
confirmColor: 'danger'
|
|
||||||
})
|
|
||||||
|
|
||||||
const showModal = ref(false)
|
const showModal = ref(false)
|
||||||
|
|
||||||
|
@ -29,40 +32,35 @@ const confirm = () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button
|
<component
|
||||||
:class="[{disabled: disabled}]"
|
:is="props.popoverItem ? PopoverItem : Button"
|
||||||
:disabled="disabled"
|
destructive
|
||||||
|
v-bind="$attrs"
|
||||||
@click.prevent.stop="showModal = true"
|
@click.prevent.stop="showModal = true"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|
||||||
<semantic-modal
|
<Modal
|
||||||
v-model:show="showModal"
|
v-model="showModal"
|
||||||
class="small"
|
destructive
|
||||||
|
:title="title || t('components.common.DangerousButton.header.confirm')"
|
||||||
|
:cancel="t('components.common.DangerousButton.button.cancel')"
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
|
||||||
<slot name="modal-header">
|
|
||||||
{{ $t('components.common.DangerousButton.header.confirm') }}
|
|
||||||
</slot>
|
|
||||||
</h4>
|
|
||||||
<div class="scrolling content">
|
<div class="scrolling content">
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<slot name="modal-content" />
|
<slot name="modal-content" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<template #actions>
|
||||||
<button class="ui basic cancel button">
|
<Button
|
||||||
{{ $t('components.common.DangerousButton.button.cancel') }}
|
v-bind="{[{success: 'primary', danger: 'destructive'}[confirmColor || 'danger']]: true}"
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="['ui', 'confirm', confirmColor, 'button']"
|
|
||||||
@click="confirm"
|
@click="confirm"
|
||||||
>
|
>
|
||||||
<slot name="modal-confirm">
|
<slot name="modal-confirm">
|
||||||
{{ $t('components.common.DangerousButton.button.confirm') }}
|
{{ t('components.common.DangerousButton.button.confirm') }}
|
||||||
</slot>
|
</slot>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</template>
|
||||||
</semantic-modal>
|
</Modal>
|
||||||
</button>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
seconds?: number
|
seconds?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
seconds: 0
|
seconds: 0
|
||||||
})
|
})
|
||||||
|
@ -19,10 +22,10 @@ const duration = computed(() => {
|
||||||
<template>
|
<template>
|
||||||
<span>
|
<span>
|
||||||
<span v-if="duration.hours > 0">
|
<span v-if="duration.hours > 0">
|
||||||
{{ $t('components.common.Duration.meta.hours', duration) }}
|
{{ t('components.common.Duration.meta.hours', duration) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ $t('components.common.Duration.meta.minutes', duration) }}
|
{{ t('components.common.Duration.meta.minutes', duration) }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,4 +1,10 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'refresh'): void
|
(e: 'refresh'): void
|
||||||
}
|
}
|
||||||
|
@ -7,6 +13,8 @@ interface Props {
|
||||||
refresh?: boolean
|
refresh?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const emit = defineEmits<Events>()
|
const emit = defineEmits<Events>()
|
||||||
withDefaults(defineProps<Props>(), {
|
withDefaults(defineProps<Props>(), {
|
||||||
refresh: false
|
refresh: false
|
||||||
|
@ -14,24 +22,28 @@ withDefaults(defineProps<Props>(), {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="ui small placeholder segment component-placeholder component-empty-state">
|
<Alert
|
||||||
|
blue
|
||||||
|
align-items="center"
|
||||||
|
>
|
||||||
<h4 class="ui header">
|
<h4 class="ui header">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<slot name="title">
|
<slot name="title">
|
||||||
<i class="search icon" />
|
<i class="bi bi-search" />
|
||||||
{{ $t('components.common.EmptyState.header.noResults') }}
|
{{ t('components.common.EmptyState.header.noResults') }}
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</h4>
|
</h4>
|
||||||
<div class="inline center aligned text">
|
<div class="inline center aligned text">
|
||||||
<slot />
|
<slot />
|
||||||
<button
|
<Spacer :size="16" />
|
||||||
|
<Button
|
||||||
v-if="refresh"
|
v-if="refresh"
|
||||||
class="ui button"
|
primary
|
||||||
@click="emit('refresh')"
|
@click="emit('refresh')"
|
||||||
>
|
>
|
||||||
{{ $t('components.common.EmptyState.button.refresh') }}
|
{{ t('components.common.EmptyState.button.refresh') }}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Alert>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useToggle } from '@vueuse/core'
|
import { useToggle } from '@vueuse/core'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
content: string
|
content: string
|
||||||
|
@ -11,6 +12,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
length: 150
|
length: 150
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const [expanded, toggleExpanded] = useToggle(false)
|
const [expanded, toggleExpanded] = useToggle(false)
|
||||||
const truncated = computed(() => props.content.slice(0, props.length))
|
const truncated = computed(() => props.content.slice(0, props.length))
|
||||||
</script>
|
</script>
|
||||||
|
@ -27,10 +30,10 @@ const truncated = computed(() => props.content.slice(0, props.length))
|
||||||
>
|
>
|
||||||
<br>
|
<br>
|
||||||
<span v-if="expanded">
|
<span v-if="expanded">
|
||||||
{{ $t('components.common.ExpandableDiv.button.less') }}
|
{{ t('components.common.ExpandableDiv.button.less') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ $t('components.common.ExpandableDiv.button.more') }}
|
{{ t('components.common.ExpandableDiv.button.more') }}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -25,8 +25,15 @@ const realDate = useTimeAgo(date)
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
v-if="props.icon"
|
v-if="props.icon"
|
||||||
class="outline clock icon"
|
class="bi bi-clock"
|
||||||
/>
|
/>
|
||||||
{{ realDate }}
|
{{ realDate }}
|
||||||
</time>
|
</time>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
i {
|
||||||
|
margin-right: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -3,6 +3,9 @@ import { useVModel } from '@vueuse/core'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import Input from '~/components/ui/Input.vue'
|
||||||
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'update:modelValue', value: string): void
|
(e: 'update:modelValue', value: string): void
|
||||||
(e: 'search', query: string): void
|
(e: 'search', query: string): void
|
||||||
|
@ -25,16 +28,11 @@ const labels = computed(() => ({
|
||||||
searchPlaceholder: t('components.common.InlineSearchBar.placeholder.search'),
|
searchPlaceholder: t('components.common.InlineSearchBar.placeholder.search'),
|
||||||
clear: t('components.common.InlineSearchBar.button.clear')
|
clear: t('components.common.InlineSearchBar.button.clear')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const search = () => {
|
|
||||||
value.value = ''
|
|
||||||
emit('search', value.value)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form
|
<Layout
|
||||||
class="ui inline form"
|
form
|
||||||
@submit.stop.prevent="emit('search', value)"
|
@submit.stop.prevent="emit('search', value)"
|
||||||
>
|
>
|
||||||
<div :class="['ui', 'action', {icon: value}, 'input']">
|
<div :class="['ui', 'action', {icon: value}, 'input']">
|
||||||
|
@ -42,27 +40,16 @@ const search = () => {
|
||||||
for="search-query"
|
for="search-query"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
>
|
>
|
||||||
{{ $t('components.common.InlineSearchBar.label.search') }}
|
{{ t('components.common.InlineSearchBar.label.search') }}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<Input
|
||||||
id="search-query"
|
id="search-query"
|
||||||
v-model="value"
|
v-model="value"
|
||||||
|
search
|
||||||
name="search-query"
|
name="search-query"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="placeholder || labels.searchPlaceholder"
|
:placeholder="placeholder || labels.searchPlaceholder"
|
||||||
>
|
|
||||||
<i
|
|
||||||
v-if="value"
|
|
||||||
class="x link icon"
|
|
||||||
:title="labels.clear"
|
|
||||||
@click.stop.prevent="search"
|
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="ui icon basic button"
|
|
||||||
>
|
|
||||||
<i class="search icon" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</Layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -2,9 +2,13 @@
|
||||||
import type { RouteLocationRaw } from 'vue-router'
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
import type { Cover } from '~/types'
|
import type { Cover } from '~/types'
|
||||||
|
|
||||||
import SemanticModal from '~/components/semantic/Modal.vue'
|
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
|
||||||
|
import Modal from '~/components/ui/Modal.vue'
|
||||||
|
import Link from '~/components/ui/Link.vue'
|
||||||
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
nextRoute: RouteLocationRaw
|
nextRoute: RouteLocationRaw
|
||||||
|
@ -14,6 +18,8 @@ interface Props {
|
||||||
|
|
||||||
defineProps<Props>()
|
defineProps<Props>()
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
const show = ref(false)
|
const show = ref(false)
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
@ -26,10 +32,10 @@ const labels = computed(() => ({
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<semantic-modal v-model:show="show">
|
<Modal
|
||||||
<h4 class="header">
|
v-model="show"
|
||||||
{{ labels.header }}
|
:title="labels.header"
|
||||||
</h4>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="cover"
|
v-if="cover"
|
||||||
class="image content"
|
class="image content"
|
||||||
|
@ -57,22 +63,21 @@ const labels = computed(() => ({
|
||||||
{{ message }}
|
{{ message }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<template #actions>
|
||||||
<router-link
|
<Spacer grow />
|
||||||
|
<Link
|
||||||
:to="{path: '/login', query: { next: nextRoute as string }}"
|
:to="{path: '/login', query: { next: nextRoute as string }}"
|
||||||
class="ui labeled icon button"
|
icon="bi-key-fill"
|
||||||
>
|
>
|
||||||
<i class="key icon" />
|
|
||||||
{{ labels.login }}
|
{{ labels.login }}
|
||||||
</router-link>
|
</Link>
|
||||||
<router-link
|
<Link
|
||||||
v-if="$store.state.instance.settings.users.registration_enabled.value"
|
v-if="store.state.instance.settings.users.registration_enabled.value"
|
||||||
:to="{path: '/signup'}"
|
:to="{path: '/signup'}"
|
||||||
class="ui labeled icon button"
|
icon="bi-person-fill"
|
||||||
>
|
>
|
||||||
<i class="user icon" />
|
|
||||||
{{ labels.signup }}
|
{{ labels.signup }}
|
||||||
</router-link>
|
</Link>
|
||||||
</div>
|
</template>
|
||||||
</semantic-modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,35 +1,49 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import $ from 'jquery'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { onMounted } from 'vue'
|
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
content: string
|
content: string
|
||||||
key: string
|
key: string
|
||||||
|
color?: 'blue' | 'red' | 'purple' | 'green' | 'yellow'
|
||||||
|
error?: boolean | string
|
||||||
|
date?: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{ message: Message }>()
|
const props = defineProps<{ message: Message }>()
|
||||||
|
const isVisible = ref(true)
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
onMounted(() => {
|
|
||||||
const params = {
|
const messageColor = computed(() => {
|
||||||
context: '#app',
|
if (props.message.color) {
|
||||||
message: props.message.content,
|
return props.message.color
|
||||||
showProgress: 'top',
|
|
||||||
position: 'bottom right',
|
|
||||||
progressUp: true,
|
|
||||||
onRemove () {
|
|
||||||
store.commit('ui/removeMessage', props.message.key)
|
|
||||||
},
|
|
||||||
...props.message
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-expect-error fomantic ui
|
if (props.message.error || props.message.content?.toLowerCase().includes('error')) {
|
||||||
$('body').toast(params)
|
return 'red'
|
||||||
$('.ui.toast.visible').last().attr('role', 'alert')
|
}
|
||||||
|
|
||||||
|
return 'blue'
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
isVisible.value = false
|
||||||
|
store.commit('ui/removeMessage', props.message.key)
|
||||||
|
}, 5000)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div />
|
<Transition name="fade">
|
||||||
|
<Alert
|
||||||
|
v-if="isVisible"
|
||||||
|
role="alert"
|
||||||
|
:[messageColor]="true"
|
||||||
|
class="is-notification"
|
||||||
|
>
|
||||||
|
{{ message.content }}
|
||||||
|
</Alert>
|
||||||
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -3,10 +3,15 @@ import type { BackendError } from '~/types'
|
||||||
|
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { whenever } from '@vueuse/core'
|
import { whenever } from '@vueuse/core'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import clip from 'text-clipper'
|
import clip from 'text-clipper'
|
||||||
|
|
||||||
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'updated', data: unknown): void
|
(e: 'updated', data: unknown): void
|
||||||
}
|
}
|
||||||
|
@ -19,8 +24,11 @@ interface Props {
|
||||||
fetchHtml?: boolean
|
fetchHtml?: boolean
|
||||||
permissive?: boolean
|
permissive?: boolean
|
||||||
truncateLength?: number
|
truncateLength?: number
|
||||||
|
moreLink?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const emit = defineEmits<Events>()
|
const emit = defineEmits<Events>()
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
content: null,
|
content: null,
|
||||||
|
@ -29,7 +37,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
canUpdate: true,
|
canUpdate: true,
|
||||||
fetchHtml: false,
|
fetchHtml: false,
|
||||||
permissive: false,
|
permissive: false,
|
||||||
truncateLength: 500
|
truncateLength: 200,
|
||||||
|
moreLink: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const preview = ref('')
|
const preview = ref('')
|
||||||
|
@ -46,6 +55,8 @@ const truncatedHtml = computed(() => clip(props.content?.html ?? '', props.trunc
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const showMore = ref(false)
|
const showMore = ref(false)
|
||||||
|
|
||||||
|
// Truncated or full Html or an empty string
|
||||||
const html = computed(() => props.fetchHtml
|
const html = computed(() => props.fetchHtml
|
||||||
? preview.value
|
? preview.value
|
||||||
: props.truncateLength > 0 && !showMore.value
|
: props.truncateLength > 0 && !showMore.value
|
||||||
|
@ -81,53 +92,57 @@ const submit = async () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<Layout
|
||||||
<template v-if="content && !isUpdating">
|
v-if="content && !isUpdating"
|
||||||
<sanitized-html :html="html" />
|
flex
|
||||||
|
gap-4
|
||||||
|
>
|
||||||
|
<!-- Render the truncated or full description -->
|
||||||
|
<sanitized-html
|
||||||
|
:html="html"
|
||||||
|
:class="['description', isTruncated ? 'truncated' : '']"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Display the `show more` / `show less` button -->
|
||||||
|
|
||||||
<template v-if="isTruncated">
|
<template v-if="isTruncated">
|
||||||
<div class="ui small hidden divider" />
|
|
||||||
<a
|
<a
|
||||||
v-if="showMore === false"
|
v-if="showMore === false && props.moreLink !== false"
|
||||||
|
class="more"
|
||||||
|
style="align-self: flex-end; color: var(--fw-primary);"
|
||||||
href=""
|
href=""
|
||||||
@click.stop.prevent="showMore = true"
|
@click.stop.prevent="showMore = true"
|
||||||
>
|
>
|
||||||
{{ $t('components.common.RenderedDescription.button.more') }}
|
{{ t('components.common.RenderedDescription.button.more') }}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
v-else
|
v-else-if="props.moreLink !== false"
|
||||||
|
class="more"
|
||||||
|
style="align-self: center; color: var(--fw-primary);"
|
||||||
href=""
|
href=""
|
||||||
@click.stop.prevent="showMore = false"
|
@click.stop.prevent="showMore = false"
|
||||||
>
|
>
|
||||||
{{ $t('components.common.RenderedDescription.button.less') }}
|
{{ t('components.common.RenderedDescription.button.less') }}
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</Layout>
|
||||||
<p v-else-if="!isUpdating">
|
<span v-else-if="!isUpdating">
|
||||||
{{ $t('components.common.RenderedDescription.empty.noDescription') }}
|
{{ t('components.common.RenderedDescription.empty.noDescription') }}
|
||||||
</p>
|
|
||||||
<template v-if="!isUpdating && canUpdate && updateUrl">
|
|
||||||
<div class="ui hidden divider" />
|
|
||||||
<span
|
|
||||||
role="button"
|
|
||||||
@click="isUpdating = true"
|
|
||||||
>
|
|
||||||
<i class="pencil icon" />
|
|
||||||
{{ $t('components.common.RenderedDescription.button.edit') }}
|
|
||||||
</span>
|
</span>
|
||||||
</template>
|
|
||||||
|
<!-- [DISABLED] Display an edit form -->
|
||||||
|
<!-- TODO: Check if we want to revive in-situ editing here -->
|
||||||
|
|
||||||
<form
|
<form
|
||||||
v-if="isUpdating"
|
v-if="isUpdating"
|
||||||
class="ui form"
|
|
||||||
@submit.prevent="submit()"
|
@submit.prevent="submit()"
|
||||||
>
|
>
|
||||||
<div
|
<Alert
|
||||||
v-if="errors.length > 0"
|
v-if="errors.length > 0"
|
||||||
|
red
|
||||||
|
title="{{ t('components.common.RenderedDescription.header.failure') }}"
|
||||||
role="alert"
|
role="alert"
|
||||||
class="ui negative message"
|
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
|
||||||
{{ $t('components.common.RenderedDescription.header.failure') }}
|
|
||||||
</h4>
|
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
<li
|
<li
|
||||||
v-for="(error, key) in errors"
|
v-for="(error, key) in errors"
|
||||||
|
@ -136,25 +151,43 @@ const submit = async () => {
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</Alert>
|
||||||
<content-form
|
<content-form
|
||||||
v-model="text"
|
v-model="text"
|
||||||
:autofocus="true"
|
:autofocus="true"
|
||||||
/>
|
/>
|
||||||
<a
|
<Button
|
||||||
class="left floated"
|
class="left floated"
|
||||||
|
solid
|
||||||
|
secondary
|
||||||
@click.prevent="isUpdating = false"
|
@click.prevent="isUpdating = false"
|
||||||
>
|
>
|
||||||
{{ $t('components.common.RenderedDescription.button.cancel') }}
|
{{ t('components.common.RenderedDescription.button.cancel') }}
|
||||||
</a>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']"
|
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']"
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
|
solid
|
||||||
|
primary
|
||||||
>
|
>
|
||||||
{{ $t('components.common.RenderedDescription.button.update') }}
|
{{ t('components.common.RenderedDescription.button.update') }}
|
||||||
</button>
|
</Button>
|
||||||
<div class="ui clearing hidden divider" />
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.description {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
white-space: normal;
|
||||||
|
&.truncated {
|
||||||
|
-webkit-line-clamp: 1; /* Number of lines to show */
|
||||||
|
line-clamp: 1;
|
||||||
|
max-height: 72px;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -18,7 +18,7 @@ withDefaults(defineProps<Props>(), {
|
||||||
class="tooltip"
|
class="tooltip"
|
||||||
>
|
>
|
||||||
<slot>
|
<slot>
|
||||||
<i class="question circle icon" />
|
<i class="bi bi-question-circle" />
|
||||||
</slot>
|
</slot>
|
||||||
</component>
|
</component>
|
||||||
<slot v-else />
|
<slot v-else />
|
||||||
|
|
|
@ -3,14 +3,23 @@ import type { User } from '~/types'
|
||||||
|
|
||||||
import { hashCode, intToRGB } from '~/utils/color'
|
import { hashCode, intToRGB } from '~/utils/color'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
|
||||||
|
import Link from '~/components/ui/Link.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User
|
user: User
|
||||||
avatar?: boolean
|
avatar?: boolean
|
||||||
|
discrete?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
avatar: true
|
avatar: true,
|
||||||
|
discrete: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const userColor = computed(() => intToRGB(hashCode(props.user.username + props.user.id)))
|
const userColor = computed(() => intToRGB(hashCode(props.user.username + props.user.id)))
|
||||||
|
@ -18,21 +27,29 @@ const defaultAvatarStyle = computed(() => ({ backgroundColor: `#${userColor.valu
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span class="component-user-link">
|
<Link
|
||||||
|
:to="user"
|
||||||
|
:title="user.full_username"
|
||||||
|
class="username"
|
||||||
|
:solid="!discrete"
|
||||||
|
:round="!discrete"
|
||||||
|
>
|
||||||
<template v-if="avatar">
|
<template v-if="avatar">
|
||||||
<img
|
<img
|
||||||
v-if="user.avatar && user.avatar.urls.medium_square_crop"
|
v-if="user.avatar && user.avatar.urls.small_square_crop"
|
||||||
v-lazy="$store.getters['instance/absoluteUrl'](user.avatar.urls.medium_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](user.avatar.urls.small_square_crop)"
|
||||||
class="ui tiny circular avatar"
|
class="ui avatar tiny circular image"
|
||||||
alt=""
|
alt=""
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-else
|
v-else
|
||||||
:style="defaultAvatarStyle"
|
:style="defaultAvatarStyle"
|
||||||
class="ui circular label"
|
class="ui tiny avatar circular label"
|
||||||
>{{ user.username[0] }}</span>
|
>
|
||||||
|
{{ user.username[0] }}
|
||||||
|
</span>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
{{ $t('components.common.UserLink.link.username', {username: user.username}) }}
|
{{ t('components.common.UserLink.link.username', {username: user.username}) }}
|
||||||
</span>
|
</Link>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
import { SUPPORTED_LOCALES, setI18nLanguage } from '~/init/locale'
|
import { SUPPORTED_LOCALES, setI18nLanguage } from '~/init/locale'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
import useThemeList from '~/composables/useThemeList'
|
import useThemeList from '~/composables/useThemeList'
|
||||||
import useTheme from '~/composables/useTheme'
|
import useTheme from '~/composables/useTheme'
|
||||||
|
@ -10,9 +12,13 @@ interface Events {
|
||||||
(e: 'show:shortcuts-modal'): void
|
(e: 'show:shortcuts-modal'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
const emit = defineEmits<Events>()
|
const emit = defineEmits<Events>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
|
|
||||||
const themes = useThemeList()
|
const themes = useThemeList()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
|
||||||
|
@ -48,7 +54,7 @@ const labels = computed(() => ({
|
||||||
<a
|
<a
|
||||||
v-for="(language, key) in SUPPORTED_LOCALES"
|
v-for="(language, key) in SUPPORTED_LOCALES"
|
||||||
:key="key"
|
:key="key"
|
||||||
:class="[{'active': $i18n.locale === key},'item']"
|
:class="[{'active': locale === key},'item']"
|
||||||
:value="key"
|
:value="key"
|
||||||
@click="setI18nLanguage(key)"
|
@click="setI18nLanguage(key)"
|
||||||
>{{ language }}</a>
|
>{{ language }}</a>
|
||||||
|
@ -74,27 +80,27 @@ const labels = computed(() => ({
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="$store.state.auth.authenticated">
|
<template v-if="store.state.auth.authenticated">
|
||||||
<div class="divider" />
|
<div class="divider" />
|
||||||
<router-link
|
<router-link
|
||||||
class="item"
|
class="item"
|
||||||
:to="{name: 'profile.overview', params: { username: $store.state.auth.username },}"
|
:to="{name: 'profile.overview', params: { username: store.state.auth.username },}"
|
||||||
>
|
>
|
||||||
<i class="user icon" />
|
<i class="user icon" />
|
||||||
{{ labels.profile }}
|
{{ labels.profile }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="$store.state.auth.authenticated"
|
v-if="store.state.auth.authenticated"
|
||||||
class="item"
|
class="item"
|
||||||
:to="{name: 'notifications'}"
|
:to="{name: 'notifications'}"
|
||||||
>
|
>
|
||||||
<i class="bell icon" />
|
<i class="bell icon" />
|
||||||
<div
|
<div
|
||||||
v-if="$store.state.ui.notifications.inbox > 0"
|
v-if="store.state.ui.notifications.inbox > 0"
|
||||||
:title="labels.notifications"
|
:title="labels.notifications"
|
||||||
:class="['ui', 'circular', 'mini', 'right floated', 'accent', 'label']"
|
:class="['ui', 'circular', 'mini', 'right floated', 'accent', 'label']"
|
||||||
>
|
>
|
||||||
{{ $store.state.ui.notifications.inbox }}
|
{{ store.state.ui.notifications.inbox }}
|
||||||
</div>
|
</div>
|
||||||
{{ labels.notifications }}
|
{{ labels.notifications }}
|
||||||
</router-link>
|
</router-link>
|
||||||
|
@ -155,14 +161,14 @@ const labels = computed(() => ({
|
||||||
{{ labels.shortcuts }}
|
{{ labels.shortcuts }}
|
||||||
</a>
|
</a>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="$route.path != '/about'"
|
v-if="route.path != '/about'"
|
||||||
class="item"
|
class="item"
|
||||||
:to="{ name: 'about' }"
|
:to="{ name: 'about' }"
|
||||||
>
|
>
|
||||||
<i class="question circle outline icon" />
|
<i class="question circle outline icon" />
|
||||||
{{ labels.about }}
|
{{ labels.about }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<template v-if="$store.state.auth.authenticated && $route.path != '/logout'">
|
<template v-if="store.state.auth.authenticated && route.path != '/logout'">
|
||||||
<div class="divider" />
|
<div class="divider" />
|
||||||
<router-link
|
<router-link
|
||||||
class="item"
|
class="item"
|
||||||
|
@ -173,7 +179,7 @@ const labels = computed(() => ({
|
||||||
{{ labels.logout }}
|
{{ labels.logout }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="!$store.state.auth.authenticated">
|
<template v-if="!store.state.auth.authenticated">
|
||||||
<div class="divider" />
|
<div class="divider" />
|
||||||
<router-link
|
<router-link
|
||||||
class="item"
|
class="item"
|
||||||
|
@ -183,7 +189,7 @@ const labels = computed(() => ({
|
||||||
{{ labels.login }}
|
{{ labels.login }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="!$store.state.auth.authenticated && $store.state.instance.settings.users.registration_enabled.value">
|
<template v-if="!store.state.auth.authenticated && store.state.instance.settings.users.registration_enabled.value">
|
||||||
<router-link
|
<router-link
|
||||||
class="item"
|
class="item"
|
||||||
:to="{ name: 'signup' }"
|
:to="{ name: 'signup' }"
|
||||||
|
|
|
@ -8,6 +8,8 @@ import useTheme from '~/composables/useTheme'
|
||||||
import { useVModel } from '@vueuse/core'
|
import { useVModel } from '@vueuse/core'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { SUPPORTED_LOCALES } from '~/init/locale'
|
import { SUPPORTED_LOCALES } from '~/init/locale'
|
||||||
|
|
||||||
|
@ -21,6 +23,9 @@ interface Props {
|
||||||
show: boolean
|
show: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
const emit = defineEmits<Events>()
|
const emit = defineEmits<Events>()
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
@ -62,18 +67,18 @@ const locale = computed(() => SUPPORTED_LOCALES[i18nLocale.value as SupportedLan
|
||||||
:fullscreen="false"
|
:fullscreen="false"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="$store.state.auth.authenticated"
|
v-if="store.state.auth.authenticated"
|
||||||
class="header"
|
class="header"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="$store.state.auth.profile?.avatar && $store.state.auth.profile?.avatar.urls.medium_square_crop"
|
v-if="store.state.auth.profile?.avatar && store.state.auth.profile?.avatar.urls.medium_square_crop"
|
||||||
v-lazy="$store.getters['instance/absoluteUrl']($store.state.auth.profile?.avatar.urls.medium_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](store.state.auth.profile?.avatar.urls.medium_square_crop)"
|
||||||
alt=""
|
alt=""
|
||||||
class="ui centered small circular image"
|
class="ui centered small circular image"
|
||||||
>
|
>
|
||||||
<actor-avatar
|
<actor-avatar
|
||||||
v-else
|
v-else
|
||||||
:actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username,}"
|
:actor="{preferred_username: store.state.auth.username, full_username: store.state.auth.username,}"
|
||||||
/>
|
/>
|
||||||
<h3 class="user-modal title">
|
<h3 class="user-modal title">
|
||||||
{{ labels.header }}
|
{{ labels.header }}
|
||||||
|
@ -124,12 +129,12 @@ const locale = computed(() => SUPPORTED_LOCALES[i18nLocale.value as SupportedLan
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui divider" />
|
<div class="ui divider" />
|
||||||
<template v-if="$store.state.auth.authenticated">
|
<template v-if="store.state.auth.authenticated">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div
|
<div
|
||||||
class="column"
|
class="column"
|
||||||
role="button"
|
role="button"
|
||||||
@click.prevent.exact="$router.push({name: 'profile.overview', params: { username: $store.state.auth.username }})"
|
@click.prevent.exact="router.push({name: 'profile.overview', params: { username: store.state.auth.username }})"
|
||||||
>
|
>
|
||||||
<i class="user icon user-modal list-icon" />
|
<i class="user icon user-modal list-icon" />
|
||||||
<span class="user-modal list-item">{{ labels.profile }}</span>
|
<span class="user-modal list-item">{{ labels.profile }}</span>
|
||||||
|
@ -137,7 +142,7 @@ const locale = computed(() => SUPPORTED_LOCALES[i18nLocale.value as SupportedLan
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<router-link
|
<router-link
|
||||||
v-if="$store.state.auth.authenticated"
|
v-if="store.state.auth.authenticated"
|
||||||
v-slot="{ navigate }"
|
v-slot="{ navigate }"
|
||||||
custom
|
custom
|
||||||
:to="{ name: 'notifications' }"
|
:to="{ name: 'notifications' }"
|
||||||
|
@ -212,7 +217,7 @@ const locale = computed(() => SUPPORTED_LOCALES[i18nLocale.value as SupportedLan
|
||||||
<div class="ui divider" />
|
<div class="ui divider" />
|
||||||
|
|
||||||
<router-link
|
<router-link
|
||||||
v-if="$store.state.auth.authenticated"
|
v-if="store.state.auth.authenticated"
|
||||||
v-slot="{ navigate }"
|
v-slot="{ navigate }"
|
||||||
custom
|
custom
|
||||||
:to="{ name: 'logout' }"
|
:to="{ name: 'logout' }"
|
||||||
|
@ -244,7 +249,7 @@ const locale = computed(() => SUPPORTED_LOCALES[i18nLocale.value as SupportedLan
|
||||||
</div>
|
</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="!$store.state.auth.authenticated && $store.state.instance.settings.users.registration_enabled.value"
|
v-if="!store.state.auth.authenticated && store.state.instance.settings.users.registration_enabled.value"
|
||||||
v-slot="{ navigate }"
|
v-slot="{ navigate }"
|
||||||
custom
|
custom
|
||||||
:to="{ name: 'signup' }"
|
:to="{ name: 'signup' }"
|
||||||
|
|
|
@ -10,11 +10,16 @@ import { sortedUniq } from 'lodash-es'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import $ from 'jquery'
|
|
||||||
|
|
||||||
import TrackTable from '~/components/audio/track/Table.vue'
|
import TrackTable from '~/components/audio/track/Table.vue'
|
||||||
import RadioButton from '~/components/radios/Button.vue'
|
import RadioButton from '~/components/radios/Button.vue'
|
||||||
import Pagination from '~/components/vui/Pagination.vue'
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
|
import Header from '~/components/ui/Header.vue'
|
||||||
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
|
import Link from '~/components/ui/Link.vue'
|
||||||
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
|
import Pagination from '~/components/ui/Pagination.vue'
|
||||||
|
import Loader from '~/components/ui/Loader.vue'
|
||||||
|
|
||||||
import useSharedLabels from '~/composables/locale/useSharedLabels'
|
import useSharedLabels from '~/composables/locale/useSharedLabels'
|
||||||
import useOrdering from '~/composables/navigation/useOrdering'
|
import useOrdering from '~/composables/navigation/useOrdering'
|
||||||
|
@ -58,15 +63,15 @@ const fetchFavorites = async () => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
favorites: 'true',
|
|
||||||
page: page.value,
|
page: page.value,
|
||||||
page_size: paginateBy.value,
|
page_size: paginateBy.value,
|
||||||
ordering: orderingString.value
|
ordering: orderingString.value,
|
||||||
|
scope: store.state.auth.fullUsername
|
||||||
}
|
}
|
||||||
|
|
||||||
const measureLoading = logger.time('Loading user favorites')
|
const measureLoading = logger.time('Loading user favorites')
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('tracks/', { params })
|
const response = await axios.get('favorites/tracks/', { params })
|
||||||
|
|
||||||
results.length = 0
|
results.length = 0
|
||||||
results.push(...response.data.results)
|
results.push(...response.data.results)
|
||||||
|
@ -86,16 +91,20 @@ const fetchFavorites = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(page, fetchFavorites)
|
onMounted(() => {
|
||||||
fetchFavorites()
|
fetchFavorites()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([() => paginateBy, page],
|
||||||
|
() => fetchFavorites(),
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
onOrderingUpdate(() => {
|
onOrderingUpdate(() => {
|
||||||
page.value = 1
|
page.value = 1
|
||||||
fetchFavorites()
|
fetchFavorites()
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => $('.ui.dropdown').dropdown())
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const labels = computed(() => ({
|
const labels = computed(() => ({
|
||||||
title: t('components.favorites.List.title')
|
title: t('components.favorites.List.title')
|
||||||
|
@ -105,42 +114,50 @@ const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main
|
<Layout
|
||||||
v-title="labels.title"
|
v-title="labels.title"
|
||||||
class="main pusher"
|
main
|
||||||
|
stack
|
||||||
|
no-gap
|
||||||
|
align-left
|
||||||
>
|
>
|
||||||
<section class="ui vertical center aligned stripe segment">
|
<Header
|
||||||
<div :class="['ui', { 'active': isLoading }, 'inverted', 'dimmer']">
|
page-heading
|
||||||
<div class="ui text loader">
|
:h1="labels.title"
|
||||||
{{ $t('components.favorites.List.loader.loading') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h2
|
|
||||||
v-if="results"
|
|
||||||
class="ui center aligned icon header"
|
|
||||||
>
|
>
|
||||||
<i class="circular inverted heart pink icon" />
|
<template #action>
|
||||||
{{ $t('components.favorites.List.header.favorites', $store.state.favorites.count) }}
|
<RadioButton
|
||||||
</h2>
|
v-if="store.state.favorites.count > 0"
|
||||||
<radio-button
|
|
||||||
v-if="$store.state.favorites.count > 0"
|
|
||||||
type="favorites"
|
type="favorites"
|
||||||
/>
|
/>
|
||||||
</section>
|
</template>
|
||||||
<section
|
</Header>
|
||||||
v-if="$store.state.favorites.count > 0"
|
|
||||||
class="ui vertical stripe segment"
|
<Loader v-if="isLoading" />
|
||||||
|
<Layout
|
||||||
|
v-if="store.state.favorites.count > 0"
|
||||||
|
form
|
||||||
|
stack
|
||||||
|
:class="['ui', { 'loading': isLoading }, 'form']"
|
||||||
>
|
>
|
||||||
<div :class="['ui', { 'loading': isLoading }, 'form']">
|
<Spacer :size="16" />
|
||||||
<div class="fields">
|
<Layout
|
||||||
<div class="field">
|
flex
|
||||||
<label for="favorites-ordering">
|
style="justify-content: flex-end;"
|
||||||
{{ $t('components.favorites.List.ordering.label') }}
|
>
|
||||||
</label>
|
<Layout
|
||||||
|
stack
|
||||||
|
no-gap
|
||||||
|
label
|
||||||
|
for="favorites-ordering"
|
||||||
|
>
|
||||||
|
<span class="label">
|
||||||
|
{{ t('components.favorites.List.ordering.label') }}
|
||||||
|
</span>
|
||||||
<select
|
<select
|
||||||
id="favorites-ordering"
|
id="favorites-ordering"
|
||||||
v-model="ordering"
|
v-model="ordering"
|
||||||
class="ui dropdown"
|
class="dropdown"
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
v-for="option in orderingOptions"
|
v-for="option in orderingOptions"
|
||||||
|
@ -150,32 +167,42 @@ const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value]
|
||||||
{{ sharedLabels.filters[option[1]] }}
|
{{ sharedLabels.filters[option[1]] }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</Layout>
|
||||||
<div class="field">
|
<Layout
|
||||||
<label for="favorites-ordering-direction">
|
stack
|
||||||
{{ $t('components.favorites.List.ordering.direction.label') }}
|
no-gap
|
||||||
</label>
|
label
|
||||||
|
for="favorites-ordering-direction"
|
||||||
|
>
|
||||||
|
<span class="label">
|
||||||
|
{{ t('components.favorites.List.ordering.direction.label') }}
|
||||||
|
</span>
|
||||||
<select
|
<select
|
||||||
id="favorites-ordering-direction"
|
id="favorites-ordering-direction"
|
||||||
v-model="orderingDirection"
|
v-model="orderingDirection"
|
||||||
class="ui dropdown"
|
class="dropdown"
|
||||||
>
|
>
|
||||||
<option value="+">
|
<option value="+">
|
||||||
{{ $t('components.favorites.List.ordering.direction.ascending') }}
|
{{ t('components.favorites.List.ordering.direction.ascending') }}
|
||||||
</option>
|
</option>
|
||||||
<option value="-">
|
<option value="-">
|
||||||
{{ $t('components.favorites.List.ordering.direction.descending') }}
|
{{ t('components.favorites.List.ordering.direction.descending') }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</Layout>
|
||||||
<div class="field">
|
<Layout
|
||||||
<label for="favorites-results">
|
stack
|
||||||
{{ $t('components.favorites.List.pagination.results') }}
|
no-gap
|
||||||
</label>
|
label
|
||||||
|
for="favorites-results"
|
||||||
|
>
|
||||||
|
<span class="label">
|
||||||
|
{{ t('components.favorites.List.pagination.results') }}
|
||||||
|
</span>
|
||||||
<select
|
<select
|
||||||
id="favorites-results"
|
id="favorites-results"
|
||||||
v-model="paginateBy"
|
v-model="paginateBy"
|
||||||
class="ui dropdown"
|
class="dropdown"
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
v-for="opt in paginateOptions"
|
v-for="opt in paginateOptions"
|
||||||
|
@ -185,39 +212,42 @@ const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value]
|
||||||
{{ opt }}
|
{{ opt }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</Layout>
|
||||||
</div>
|
</Layout>
|
||||||
</div>
|
<Pagination
|
||||||
<track-table
|
v-if="page && results && count > paginateBy"
|
||||||
|
v-model:page="page"
|
||||||
|
:pages="Math.ceil((count || 0) / paginateBy)"
|
||||||
|
style="grid-column: 1 / -1;"
|
||||||
|
/>
|
||||||
|
<TrackTable
|
||||||
v-if="results"
|
v-if="results"
|
||||||
|
:search="true"
|
||||||
:show-artist="true"
|
:show-artist="true"
|
||||||
:show-album="true"
|
:show-album="true"
|
||||||
:tracks="results"
|
:tracks="results"
|
||||||
/>
|
/>
|
||||||
<div class="ui center aligned basic segment">
|
</Layout>
|
||||||
<pagination
|
<Alert
|
||||||
v-if="results && count > paginateBy"
|
v-else-if="!isLoading"
|
||||||
v-model:current="page"
|
blue
|
||||||
:paginate-by="paginateBy"
|
align-items="center"
|
||||||
:total="count"
|
>
|
||||||
|
<i
|
||||||
|
class="bi bi-heartbreak-fill"
|
||||||
|
style="font-size: 100px;"
|
||||||
/>
|
/>
|
||||||
</div>
|
<Spacer />
|
||||||
</section>
|
{{ t('components.favorites.List.empty.noFavorites') }}
|
||||||
<div
|
<Spacer :size="32" />
|
||||||
v-else
|
<Link
|
||||||
class="ui placeholder segment"
|
to="/library"
|
||||||
|
solid
|
||||||
|
primary
|
||||||
|
icon="bi-headphones"
|
||||||
>
|
>
|
||||||
<div class="ui icon header">
|
{{ t('components.favorites.List.link.library') }}
|
||||||
<i class="broken heart icon" />
|
</Link>
|
||||||
{{ $t('components.favorites.List.empty.noFavorites') }}
|
</Alert>
|
||||||
</div>
|
</Layout>
|
||||||
<router-link
|
|
||||||
:to="'/library'"
|
|
||||||
class="ui success labeled icon button"
|
|
||||||
>
|
|
||||||
<i class="headphones icon" />
|
|
||||||
{{ $t('components.favorites.List.link.library') }}
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -6,16 +6,20 @@ import { useI18n } from 'vue-i18n'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
track?: QueueTrack | Track
|
track?: QueueTrack | Track
|
||||||
button?: boolean
|
button?: boolean
|
||||||
border?: boolean
|
border?: boolean
|
||||||
|
ghost?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
track: () => ({} as Track),
|
track: () => ({} as Track),
|
||||||
button: false,
|
button: false,
|
||||||
border: false
|
border: false,
|
||||||
|
ghost: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
@ -29,26 +33,27 @@ const title = computed(() => isFavorite.value
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button
|
<Button
|
||||||
v-if="button"
|
v-if="button"
|
||||||
|
:icon="isFavorite ? 'bi-heart-fill' : 'bi-heart'"
|
||||||
:class="['ui', 'pink', {'inverted': isFavorite}, {'favorited': isFavorite}, 'icon', 'labeled', 'button']"
|
:class="['ui', 'pink', {'inverted': isFavorite}, {'favorited': isFavorite}, 'icon', 'labeled', 'button']"
|
||||||
@click.stop="$store.dispatch('favorites/toggle', track.id)"
|
@click.stop="store.dispatch('favorites/toggle', track.id)"
|
||||||
>
|
>
|
||||||
<i class="heart icon" />
|
|
||||||
<span v-if="isFavorite">
|
<span v-if="isFavorite">
|
||||||
{{ $t('components.favorites.TrackFavoriteIcon.label.inFavorites') }}
|
{{ t('components.favorites.TrackFavoriteIcon.label.inFavorites') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ $t('components.favorites.TrackFavoriteIcon.button.add') }}
|
{{ t('components.favorites.TrackFavoriteIcon.button.add') }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
v-else
|
v-else
|
||||||
|
:ghost="ghost"
|
||||||
|
:secondary="!ghost"
|
||||||
|
:icon="isFavorite ? 'bi-heart-fill' : 'bi-heart'"
|
||||||
:class="['ui', 'favorite-icon', {'pink': isFavorite}, {'favorited': isFavorite}, 'basic', 'circular', 'icon', {'really': !border}, 'button']"
|
:class="['ui', 'favorite-icon', {'pink': isFavorite}, {'favorited': isFavorite}, 'basic', 'circular', 'icon', {'really': !border}, 'button']"
|
||||||
:aria-label="title"
|
:aria-label="title"
|
||||||
:title="title"
|
:title="title"
|
||||||
@click.stop="$store.dispatch('favorites/toggle', track.id)"
|
@click.stop="store.dispatch('favorites/toggle', track.id)"
|
||||||
>
|
/>
|
||||||
<i :class="['heart', {'pink': isFavorite}, 'basic', 'icon']" />
|
|
||||||
</button>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -2,10 +2,17 @@
|
||||||
import type { BackendError } from '~/types'
|
import type { BackendError } from '~/types'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import SemanticModal from '~/components/semantic/Modal.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 Modal from '~/components/ui/Modal.vue'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'refresh'): void
|
(e: 'refresh'): void
|
||||||
}
|
}
|
||||||
|
@ -68,20 +75,22 @@ 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>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
<semantic-modal
|
<Modal
|
||||||
v-model:show="showModal"
|
v-model="showModal"
|
||||||
|
:title="t('components.federation.FetchButton.header.refresh')"
|
||||||
class="small"
|
class="small"
|
||||||
|
:cancel="t('components.federation.FetchButton.button.close')"
|
||||||
>
|
>
|
||||||
<h3 class="header">
|
|
||||||
{{ $t('components.federation.FetchButton.header.refresh') }}
|
|
||||||
</h3>
|
|
||||||
<div class="scrolling content">
|
<div class="scrolling content">
|
||||||
<template v-if="data && data.status != 'pending'">
|
<template v-if="data && data.status != 'pending'">
|
||||||
<div
|
<div
|
||||||
|
@ -89,10 +98,10 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
|
||||||
class="ui message"
|
class="ui message"
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
<h4 class="header">
|
||||||
{{ $t('components.federation.FetchButton.header.skipped') }}
|
{{ t('components.federation.FetchButton.header.skipped') }}
|
||||||
</h4>
|
</h4>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.federation.FetchButton.description.skipped') }}
|
{{ t('components.federation.FetchButton.description.skipped') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -100,10 +109,10 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
|
||||||
class="ui success message"
|
class="ui success message"
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
<h4 class="header">
|
||||||
{{ $t('components.federation.FetchButton.header.success') }}
|
{{ t('components.federation.FetchButton.header.success') }}
|
||||||
</h4>
|
</h4>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.federation.FetchButton.description.success') }}
|
{{ t('components.federation.FetchButton.description.success') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -111,16 +120,16 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
|
||||||
class="ui error message"
|
class="ui error message"
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
<h4 class="header">
|
||||||
{{ $t('components.federation.FetchButton.header.failure') }}
|
{{ t('components.federation.FetchButton.header.failure') }}
|
||||||
</h4>
|
</h4>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.federation.FetchButton.description.failure') }}
|
{{ t('components.federation.FetchButton.description.failure') }}
|
||||||
</p>
|
</p>
|
||||||
<table class="ui very basic collapsing celled table">
|
<table class="ui very basic collapsing celled table">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
{{ $t('components.federation.FetchButton.table.error.label.type') }}
|
{{ t('components.federation.FetchButton.table.error.label.type') }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ data.detail.error_code }}
|
{{ data.detail.error_code }}
|
||||||
|
@ -128,32 +137,32 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
{{ $t('components.federation.FetchButton.table.error.label.detail') }}
|
{{ t('components.federation.FetchButton.table.error.label.detail') }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span v-if="data.detail.error_code === 'http' && data.detail.status_code">
|
<span v-if="data.detail.error_code === 'http' && data.detail.status_code">
|
||||||
{{ $t('components.federation.FetchButton.table.error.value.httpStatus', {status: data.detail.status_code}) }}
|
{{ t('components.federation.FetchButton.table.error.value.httpStatus', {status: data.detail.status_code}) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="['http', 'request'].includes(data.detail.error_code)">
|
<span v-else-if="['http', 'request'].includes(data.detail.error_code)">
|
||||||
{{ $t('components.federation.FetchButton.table.error.value.httpError') }}
|
{{ t('components.federation.FetchButton.table.error.value.httpError') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="data.detail.error_code === 'timeout'">
|
<span v-else-if="data.detail.error_code === 'timeout'">
|
||||||
{{ $t('components.federation.FetchButton.table.error.value.timeoutError') }}
|
{{ t('components.federation.FetchButton.table.error.value.timeoutError') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="data.detail.error_code === 'connection'">
|
<span v-else-if="data.detail.error_code === 'connection'">
|
||||||
{{ $t('components.federation.FetchButton.table.error.value.connectionError') }}
|
{{ t('components.federation.FetchButton.table.error.value.connectionError') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="['invalid_json', 'invalid_jsonld', 'missing_jsonld_type'].includes(data.detail.error_code)">
|
<span v-else-if="['invalid_json', 'invalid_jsonld', 'missing_jsonld_type'].includes(data.detail.error_code)">
|
||||||
{{ $t('components.federation.FetchButton.table.error.value.invalidJsonError') }}
|
{{ t('components.federation.FetchButton.table.error.value.invalidJsonError') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="data.detail.error_code === 'validation'">
|
<span v-else-if="data.detail.error_code === 'validation'">
|
||||||
{{ $t('components.federation.FetchButton.table.error.value.invalidAttributesError') }}
|
{{ t('components.federation.FetchButton.table.error.value.invalidAttributesError') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="data.detail.error_code === 'unhandled'">
|
<span v-else-if="data.detail.error_code === 'unhandled'">
|
||||||
{{ $t('components.federation.FetchButton.table.error.value.unknownError') }}
|
{{ t('components.federation.FetchButton.table.error.value.unknownError') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ $t('components.federation.FetchButton.table.error.value.unknownError') }}
|
{{ t('components.federation.FetchButton.table.error.value.unknownError') }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -166,7 +175,7 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
|
||||||
class="ui active inverted dimmer"
|
class="ui active inverted dimmer"
|
||||||
>
|
>
|
||||||
<div class="ui text loader">
|
<div class="ui text loader">
|
||||||
{{ $t('components.federation.FetchButton.loader.fetchRequest') }}
|
{{ t('components.federation.FetchButton.loader.fetchRequest') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -174,7 +183,7 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
|
||||||
class="ui active inverted dimmer"
|
class="ui active inverted dimmer"
|
||||||
>
|
>
|
||||||
<div class="ui text loader">
|
<div class="ui text loader">
|
||||||
{{ $t('components.federation.FetchButton.loader.awaitingResult') }}
|
{{ t('components.federation.FetchButton.loader.awaitingResult') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -183,7 +192,7 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
|
||||||
class="ui negative message"
|
class="ui negative message"
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
<h4 class="header">
|
||||||
{{ $t('components.federation.FetchButton.header.saveFailure') }}
|
{{ t('components.federation.FetchButton.header.saveFailure') }}
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
<li
|
<li
|
||||||
|
@ -200,25 +209,21 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
|
||||||
class="ui warning message"
|
class="ui warning message"
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
<h4 class="header">
|
||||||
{{ $t('components.federation.FetchButton.header.pending') }}
|
{{ t('components.federation.FetchButton.header.pending') }}
|
||||||
</h4>
|
</h4>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.federation.FetchButton.description.pending') }}
|
{{ t('components.federation.FetchButton.description.pending') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="ui basic cancel button">
|
<Button
|
||||||
{{ $t('components.federation.FetchButton.button.close') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="data && data.status === 'finished'"
|
v-if="data && data.status === 'finished'"
|
||||||
class="ui confirm success button"
|
|
||||||
@click.prevent="showModal = false; emit('refresh')"
|
@click.prevent="showModal = false; emit('refresh')"
|
||||||
>
|
>
|
||||||
{{ $t('components.federation.FetchButton.button.reload') }}
|
{{ t('components.federation.FetchButton.button.reload') }}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
|
||||||
</semantic-modal>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,11 +1,18 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Library } from '~/types'
|
import type { Library } from '~/types'
|
||||||
|
|
||||||
import { ref, reactive } from 'vue'
|
import { ref, reactive, onMounted, watch } from 'vue'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
import LibraryCard from '~/views/content/remote/Card.vue'
|
import LibraryCard from '~/views/content/remote/Card.vue'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import Section from '~/components/ui/Section.vue'
|
||||||
|
import Loader from '~/components/ui/Loader.vue'
|
||||||
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
|
|
||||||
import useErrorHandler from '~/composables/useErrorHandler'
|
import useErrorHandler from '~/composables/useErrorHandler'
|
||||||
|
|
||||||
|
@ -15,8 +22,12 @@ interface Events {
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
url: string
|
url: string
|
||||||
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
const emit = defineEmits<Events>()
|
const emit = defineEmits<Events>()
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
@ -29,12 +40,12 @@ const fetchData = async (url = props.url) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(url, {
|
const response = await axios.get(url, {
|
||||||
params: {
|
params: {
|
||||||
page_size: 6
|
page_size: 3
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
nextPage.value = response.data.next
|
nextPage.value = response.data.next
|
||||||
libraries.push(...response.data.results)
|
libraries.splice(0, libraries.length, ...response.data.results)
|
||||||
emit('loaded', libraries)
|
emit('loaded', libraries)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
useErrorHandler(error as Error)
|
useErrorHandler(error as Error)
|
||||||
|
@ -43,55 +54,49 @@ const fetchData = async (url = props.url) => {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchData()
|
onMounted(() => {
|
||||||
|
setTimeout(fetchData, 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.url, () => {
|
||||||
|
fetchData()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="wrapper">
|
<Section
|
||||||
<h3
|
align-left
|
||||||
v-if="!!$slots.title"
|
:h2="title"
|
||||||
class="ui header"
|
:columns-per-item="3"
|
||||||
>
|
>
|
||||||
<slot name="title" />
|
<Loader
|
||||||
</h3>
|
|
||||||
<p
|
|
||||||
v-if="!isLoading && libraries.length > 0"
|
|
||||||
class="ui subtitle"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-if="!isLoading && libraries.length === 0"
|
|
||||||
class="ui subtitle"
|
|
||||||
>
|
|
||||||
{{ $t('components.federation.LibraryWidget.empty.noMatch') }}
|
|
||||||
</p>
|
|
||||||
<div class="ui hidden divider" />
|
|
||||||
<div class="ui cards">
|
|
||||||
<div
|
|
||||||
v-if="isLoading"
|
v-if="isLoading"
|
||||||
class="ui inverted active dimmer"
|
style="grid-column: 1 / -1;"
|
||||||
|
/>
|
||||||
|
<Alert
|
||||||
|
v-if="!isLoading && libraries.length === 0"
|
||||||
|
blue
|
||||||
|
style="grid-column: 1 / -1;"
|
||||||
>
|
>
|
||||||
<div class="ui loader" />
|
{{ t('components.federation.LibraryWidget.empty.noMatch') }}
|
||||||
</div>
|
</Alert>
|
||||||
<library-card
|
<library-card
|
||||||
v-for="library in libraries"
|
v-for="library in libraries"
|
||||||
:key="library.uuid"
|
:key="library.uuid"
|
||||||
:display-scan="false"
|
:display-scan="false"
|
||||||
:display-follow="$store.state.auth.authenticated && library.actor.full_username != $store.state.auth.fullUsername"
|
:display-follow="store.state.auth.authenticated && library.actor.full_username != store.state.auth.fullUsername"
|
||||||
:initial-library="library"
|
:initial-library="library"
|
||||||
:display-copy-fid="true"
|
:display-copy-fid="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<template v-if="nextPage">
|
<template v-if="nextPage">
|
||||||
<div class="ui hidden divider" />
|
<Spacer />
|
||||||
<button
|
<Button
|
||||||
v-if="nextPage"
|
v-if="nextPage"
|
||||||
:class="['ui', 'basic', 'button']"
|
primary
|
||||||
@click="fetchData(nextPage)"
|
@click="fetchData(nextPage)"
|
||||||
>
|
>
|
||||||
{{ $t('components.federation.LibraryWidget.button.showMore') }}
|
{{ t('components.federation.LibraryWidget.button.showMore') }}
|
||||||
</button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</Section>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,16 +1,21 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Actor } from '~/types'
|
import type { components } from '~/generated/types'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'unfollowed'): void
|
(e: 'unfollowed'): void
|
||||||
(e: 'followed'): void
|
(e: 'followed'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
actor: Actor
|
actor: components['schemas']['FullActor']
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<Events>()
|
const emit = defineEmits<Events>()
|
||||||
|
@ -33,19 +38,20 @@ const toggle = () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button
|
<Button
|
||||||
|
secondary
|
||||||
:class="['ui', 'pink', {'inverted': isApproved || isPending}, {'favorited': isApproved}, 'icon', 'labeled', 'button']"
|
:class="['ui', 'pink', {'inverted': isApproved || isPending}, {'favorited': isApproved}, 'icon', 'labeled', 'button']"
|
||||||
|
:icon="isPending ? 'bi-heart' : 'bi-heart-fill'"
|
||||||
@click.stop="toggle"
|
@click.stop="toggle"
|
||||||
>
|
>
|
||||||
<i class="heart icon" />
|
|
||||||
<span v-if="isApproved">
|
<span v-if="isApproved">
|
||||||
{{ $t('components.audio.LibraryFollowButton.button.unfollow') }}
|
{{ t('components.audio.LibraryFollowButton.button.unfollow') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="isPending">
|
<span v-else-if="isPending">
|
||||||
{{ $t('components.audio.LibraryFollowButton.button.cancel') }}
|
{{ t('components.audio.LibraryFollowButton.button.cancel') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ $t('components.audio.LibraryFollowButton.button.follow') }}
|
{{ t('components.audio.LibraryFollowButton.button.follow') }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useClipboard, useVModel } from '@vueuse/core'
|
import { useClipboard, useVModel } from '@vueuse/core'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
|
||||||
|
import Input from '~/components/ui/Input.vue'
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'update:modelValue', value: string): void
|
(e: 'update:modelValue', value: string): void
|
||||||
}
|
}
|
||||||
|
@ -23,16 +25,12 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
|
|
||||||
const value = useVModel(props, 'modelValue', emit)
|
const value = useVModel(props, 'modelValue', emit)
|
||||||
|
|
||||||
const showPassword = ref(props.defaultShow)
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const labels = computed(() => ({
|
const labels = computed(() => ({
|
||||||
title: t('components.forms.PasswordInput.title'),
|
title: t('components.forms.PasswordInput.title'),
|
||||||
copy: t('components.forms.PasswordInput.button.copy')
|
copy: t('components.forms.PasswordInput.button.copy')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const passwordInputType = computed(() => showPassword.value ? 'text' : 'password')
|
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
const { isSupported: canCopy, copy } = useClipboard({ source: value })
|
const { isSupported: canCopy, copy } = useClipboard({ source: value })
|
||||||
const copyPassword = () => {
|
const copyPassword = () => {
|
||||||
|
@ -45,30 +43,38 @@ const copyPassword = () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="ui fluid action input">
|
<div>
|
||||||
<input
|
<Input
|
||||||
:id="fieldId"
|
:id="fieldId"
|
||||||
v-model="value"
|
v-model="value"
|
||||||
|
password
|
||||||
required
|
required
|
||||||
name="password"
|
|
||||||
:type="passwordInputType"
|
|
||||||
>
|
>
|
||||||
<button
|
<template #input-right>
|
||||||
type="button"
|
|
||||||
:title="labels.title"
|
|
||||||
class="ui icon button"
|
|
||||||
@click.prevent="showPassword = !showPassword"
|
|
||||||
>
|
|
||||||
<i class="eye icon" />
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
v-if="copyButton && canCopy"
|
v-if="copyButton && canCopy"
|
||||||
|
role="switch"
|
||||||
type="button"
|
type="button"
|
||||||
class="ui icon button"
|
class="input-right copy"
|
||||||
:title="labels.copy"
|
:title="labels.copy"
|
||||||
@click.prevent="copyPassword"
|
@click.prevent="copyPassword"
|
||||||
>
|
>
|
||||||
<i class="copy icon" />
|
<i class="bi bi-copy" />
|
||||||
</button>
|
</button>
|
||||||
|
</template>
|
||||||
|
</Input>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.funkwhale.input .input-right.copy {
|
||||||
|
position: absolute;
|
||||||
|
background:transparent;
|
||||||
|
border:none;
|
||||||
|
appearance:none;
|
||||||
|
right: 40px;
|
||||||
|
bottom: 12px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--fw-placeholder-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -4,15 +4,24 @@ import type { Track, Album, Artist, Library, ArtistCredit } from '~/types'
|
||||||
import { momentFormat } from '~/utils/filters'
|
import { momentFormat } from '~/utils/filters'
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { sum } from 'lodash-es'
|
import { sum } from 'lodash-es'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
import { useQueue } from '~/composables/audio/queue'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
import ArtistCreditLabel from '~/components/audio/ArtistCreditLabel.vue'
|
import ArtistCreditLabel from '~/components/audio/ArtistCreditLabel.vue'
|
||||||
|
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
||||||
|
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
|
||||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||||
import TagsList from '~/components/tags/List.vue'
|
|
||||||
import AlbumDropdown from './AlbumDropdown.vue'
|
import AlbumDropdown from './AlbumDropdown.vue'
|
||||||
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
|
import Header from '~/components/ui/Header.vue'
|
||||||
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
|
import Loader from '~/components/ui/Loader.vue'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import HumanDuration from '~/components/common/HumanDuration.vue'
|
||||||
|
|
||||||
import useErrorHandler from '~/composables/useErrorHandler'
|
import useErrorHandler from '~/composables/useErrorHandler'
|
||||||
import useLogger from '~/composables/useLogger'
|
import useLogger from '~/composables/useLogger'
|
||||||
|
@ -22,9 +31,11 @@ interface Events {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: number
|
id: number | string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
const emit = defineEmits<Events>()
|
const emit = defineEmits<Events>()
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
@ -51,10 +62,16 @@ const publicLibraries = computed(() => libraries.value?.filter(library => librar
|
||||||
const logger = useLogger()
|
const logger = useLogger()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const labels = computed(() => ({
|
const labels = computed(() => ({
|
||||||
title: t('components.library.AlbumBase.title')
|
title: t('components.library.AlbumBase.title'),
|
||||||
|
shuffle: t('components.audio.Player.label.shuffleQueue')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const {
|
||||||
|
shuffle
|
||||||
|
} = useQueue()
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
|
@ -63,12 +80,10 @@ const fetchData = async () => {
|
||||||
|
|
||||||
artistCredit.value = albumResponse.data.artist_credit
|
artistCredit.value = albumResponse.data.artist_credit
|
||||||
|
|
||||||
|
// fetch the first artist of the album
|
||||||
const artistResponse = await axios.get(`artists/${albumResponse.data.artist_credit[0].artist.id}/`)
|
const artistResponse = await axios.get(`artists/${albumResponse.data.artist_credit[0].artist.id}/`)
|
||||||
|
|
||||||
artist.value = artistResponse.data
|
artist.value = artistResponse.data
|
||||||
if (artist.value?.channel) {
|
|
||||||
artist.value.channel.artist = artist.value
|
|
||||||
}
|
|
||||||
|
|
||||||
object.value = albumResponse.data
|
object.value = albumResponse.data
|
||||||
if (object.value) {
|
if (object.value) {
|
||||||
|
@ -116,6 +131,8 @@ const fetchTracks = async () => {
|
||||||
watch(() => props.id, fetchData, { immediate: true })
|
watch(() => props.id, fetchData, { immediate: true })
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const remove = async () => {
|
const remove = async () => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
|
@ -131,32 +148,20 @@ const remove = async () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<Loader
|
||||||
<div
|
|
||||||
v-if="isLoading"
|
v-if="isLoading"
|
||||||
v-title="labels.title"
|
v-title="labels.title"
|
||||||
class="ui vertical segment"
|
/>
|
||||||
|
<Header
|
||||||
|
v-if="object"
|
||||||
|
:h1="object.title"
|
||||||
|
page-heading
|
||||||
>
|
>
|
||||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" />
|
<template #image>
|
||||||
</div>
|
|
||||||
<template v-if="object">
|
|
||||||
<section class="ui vertical stripe segment channel-serie">
|
|
||||||
<div class="ui stackable grid container">
|
|
||||||
<div class="ui seven wide column">
|
|
||||||
<div
|
|
||||||
v-if="isSerie"
|
|
||||||
class="padded basic segment"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="isSerie"
|
|
||||||
class="ui two column grid"
|
|
||||||
>
|
|
||||||
<div class="column">
|
|
||||||
<div class="large two-images">
|
|
||||||
<img
|
<img
|
||||||
v-if="object.cover && object.cover.urls.original"
|
v-if="object.cover && 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.large_square_crop)"
|
||||||
alt=""
|
:alt="object.title"
|
||||||
class="channel-image"
|
class="channel-image"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
|
@ -165,129 +170,95 @@ const remove = async () => {
|
||||||
class="channel-image"
|
class="channel-image"
|
||||||
src="../../assets/audio/default-cover.png"
|
src="../../assets/audio/default-cover.png"
|
||||||
>
|
>
|
||||||
<img
|
|
||||||
v-if="object.cover && object.cover.urls.original"
|
|
||||||
v-lazy="$store.getters['instance/absoluteUrl'](object.cover.urls.medium_square_crop)"
|
|
||||||
alt=""
|
|
||||||
class="channel-image"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
v-else
|
|
||||||
alt=""
|
|
||||||
class="channel-image"
|
|
||||||
src="../../assets/audio/default-cover.png"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ui column right aligned">
|
|
||||||
<tags-list
|
|
||||||
v-if="object.tags && object.tags.length > 0"
|
|
||||||
:tags="object.tags"
|
|
||||||
/>
|
|
||||||
<div class="ui small hidden divider" />
|
|
||||||
<human-duration
|
|
||||||
v-if="totalDuration > 0"
|
|
||||||
:duration="totalDuration"
|
|
||||||
/>
|
|
||||||
<template v-if="totalTracks > 0">
|
|
||||||
<div class="ui hidden very small divider" />
|
|
||||||
<span v-if="isSerie">
|
|
||||||
{{ $t('components.library.AlbumBase.meta.episodes', totalTracks) }}
|
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
{{ $t('components.library.AlbumBase.meta.tracks', totalTracks) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
</template>
|
||||||
<div class="ui small hidden divider" />
|
|
||||||
<play-button
|
|
||||||
class="vibrant"
|
|
||||||
:tracks="object.tracks"
|
|
||||||
:is-playable="object.is_playable"
|
|
||||||
/>
|
|
||||||
<div class="ui hidden horizontal divider" />
|
|
||||||
<album-dropdown
|
|
||||||
:object="object"
|
|
||||||
:public-libraries="publicLibraries"
|
|
||||||
:is-loading="isLoading"
|
|
||||||
:is-album="isAlbum"
|
|
||||||
:is-serie="isSerie"
|
|
||||||
:is-channel="isChannel"
|
|
||||||
:artist-credit="artistCredit"
|
|
||||||
@remove="remove"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ui small hidden divider" />
|
|
||||||
<header>
|
|
||||||
<h2
|
|
||||||
class="ui header"
|
|
||||||
:title="object.title"
|
|
||||||
>
|
|
||||||
{{ object.title }}
|
|
||||||
</h2>
|
|
||||||
<artist-credit-label
|
<artist-credit-label
|
||||||
v-if="artistCredit"
|
v-if="artistCredit"
|
||||||
:artist-credit="artistCredit"
|
:artist-credit="artistCredit"
|
||||||
/>
|
/>
|
||||||
</header>
|
<!-- Metadata: -->
|
||||||
</div>
|
<Layout
|
||||||
<div
|
gap-4
|
||||||
v-else
|
class="meta"
|
||||||
class="ui center aligned text padded basic segment"
|
|
||||||
>
|
>
|
||||||
<img
|
<Layout
|
||||||
v-if="object.cover && object.cover.urls.original"
|
flex
|
||||||
v-lazy="$store.getters['instance/absoluteUrl'](object.cover.urls.medium_square_crop)"
|
gap-4
|
||||||
alt=""
|
|
||||||
class="channel-image"
|
|
||||||
>
|
>
|
||||||
<img
|
|
||||||
v-else
|
|
||||||
alt=""
|
|
||||||
class="channel-image"
|
|
||||||
src="../../assets/audio/default-cover.png"
|
|
||||||
>
|
|
||||||
<div class="ui hidden divider" />
|
|
||||||
<header>
|
|
||||||
<h2
|
|
||||||
class="ui header"
|
|
||||||
:title="object.title"
|
|
||||||
>
|
|
||||||
{{ object.title }}
|
|
||||||
</h2>
|
|
||||||
<artist-credit-label
|
|
||||||
v-if="artistCredit"
|
|
||||||
:artist-credit="artistCredit"
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
<div
|
|
||||||
v-if="object.release_date || (totalTracks > 0)"
|
|
||||||
class="ui small hidden divider"
|
|
||||||
/>
|
|
||||||
<template v-if="object.release_date">
|
<template v-if="object.release_date">
|
||||||
{{ momentFormat(new Date(object.release_date ?? '1970-01-01'), 'Y') }}
|
{{ momentFormat(new Date(object.release_date ?? '1970-01-01'), 'Y') }}
|
||||||
<span class="middle middledot symbol" />
|
<i class="bi bi-dot" />
|
||||||
</template>
|
</template>
|
||||||
<template v-if="totalTracks > 0">
|
<template v-if="totalTracks > 0">
|
||||||
<span v-if="isSerie">
|
<span v-if="isSerie">
|
||||||
{{ $t('components.library.AlbumBase.meta.episodes', totalTracks) }}
|
{{ t('components.library.AlbumBase.meta.episodes', totalTracks) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ $t('components.library.AlbumBase.meta.tracks', totalTracks) }}
|
{{ t('components.library.AlbumBase.meta.tracks', totalTracks) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="middle middledot symbol" />
|
|
||||||
</template>
|
</template>
|
||||||
|
<i
|
||||||
|
v-if="totalDuration > 0"
|
||||||
|
class="bi bi-dot"
|
||||||
|
/>
|
||||||
<human-duration
|
<human-duration
|
||||||
v-if="totalDuration > 0"
|
v-if="totalDuration > 0"
|
||||||
:duration="totalDuration"
|
:duration="totalDuration"
|
||||||
/>
|
/>
|
||||||
<div class="ui small hidden divider" />
|
<!--TODO: License -->
|
||||||
<play-button
|
</Layout>
|
||||||
class="vibrant"
|
</Layout>
|
||||||
:album="object"
|
<RenderedDescription
|
||||||
|
v-if="object.description"
|
||||||
|
:content="{ html: object.description.html }"
|
||||||
|
:truncate-length="50"
|
||||||
|
/>
|
||||||
|
<Layout flex>
|
||||||
|
<PlayButton
|
||||||
|
v-if="object.tracks"
|
||||||
|
split
|
||||||
|
:tracks="object.tracks"
|
||||||
|
low-height
|
||||||
:is-playable="object.is_playable"
|
:is-playable="object.is_playable"
|
||||||
/>
|
/>
|
||||||
<div class="ui horizontal hidden divider" />
|
<Button
|
||||||
|
v-if="object?.tracks?.length && object?.tracks?.length > 2"
|
||||||
|
primary
|
||||||
|
icon="bi-shuffle"
|
||||||
|
low-height
|
||||||
|
:aria-label="labels.shuffle"
|
||||||
|
@click.prevent.stop="shuffle()"
|
||||||
|
>
|
||||||
|
{{ labels.shuffle }}
|
||||||
|
</Button>
|
||||||
|
<DangerousButton
|
||||||
|
v-if="artistCredit[0] &&
|
||||||
|
store.state.auth.authenticated &&
|
||||||
|
artistCredit[0].artist.channel
|
||||||
|
/* TODO: Re-implement once attributed_to is not only a number
|
||||||
|
&& artistCredit[0].artist.attributed_to?.full_username === store.state.auth.fullUsername
|
||||||
|
*/"
|
||||||
|
:is-loading="isLoading"
|
||||||
|
low-height
|
||||||
|
icon="bi-trash"
|
||||||
|
@confirm="remove()"
|
||||||
|
>
|
||||||
|
{{ t('components.library.AlbumDropdown.button.delete') }}
|
||||||
|
</DangerousButton>
|
||||||
|
<Spacer
|
||||||
|
h
|
||||||
|
grow
|
||||||
|
/>
|
||||||
|
<TrackFavoriteIcon
|
||||||
|
v-if="store.state.auth.authenticated"
|
||||||
|
square-small
|
||||||
|
:album="object"
|
||||||
|
/>
|
||||||
|
<TrackPlaylistIcon
|
||||||
|
v-if="store.state.auth.authenticated"
|
||||||
|
square-small
|
||||||
|
:album="object"
|
||||||
|
/>
|
||||||
|
<!-- TODO: Share Button -->
|
||||||
<album-dropdown
|
<album-dropdown
|
||||||
:object="object"
|
:object="object"
|
||||||
:public-libraries="publicLibraries"
|
:public-libraries="publicLibraries"
|
||||||
|
@ -298,48 +269,13 @@ const remove = async () => {
|
||||||
:artist-credit="artistCredit"
|
:artist-credit="artistCredit"
|
||||||
@remove="remove"
|
@remove="remove"
|
||||||
/>
|
/>
|
||||||
<div v-if="(object.tags && object.tags.length > 0) || object.description || $store.state.auth.authenticated && object.is_local">
|
</Layout>
|
||||||
<div class="ui small hidden divider" />
|
</Header>
|
||||||
<div class="ui divider" />
|
|
||||||
<div class="ui small hidden divider" />
|
<div style="flex 1;">
|
||||||
<template v-if="object.tags && object.tags.length > 0">
|
|
||||||
<tags-list :tags="object.tags" />
|
|
||||||
<div class="ui small hidden divider" />
|
|
||||||
</template>
|
|
||||||
<rendered-description
|
|
||||||
v-if="object.description"
|
|
||||||
:content="object.description"
|
|
||||||
:can-update="false"
|
|
||||||
/>
|
|
||||||
<router-link
|
|
||||||
v-else-if="$store.state.auth.authenticated && object.is_local"
|
|
||||||
:to="{name: 'library.albums.edit', params: {id: object.id }}"
|
|
||||||
>
|
|
||||||
<i class="pencil icon" />
|
|
||||||
{{ $t('components.library.AlbumBase.link.addDescription') }}
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<template v-if="isSerie">
|
|
||||||
<div class="ui hidden divider" />
|
|
||||||
<rendered-description
|
|
||||||
v-if="object.description"
|
|
||||||
:content="object.description"
|
|
||||||
:can-update="false"
|
|
||||||
/>
|
|
||||||
<router-link
|
|
||||||
v-else-if="$store.state.auth.authenticated && object.is_local"
|
|
||||||
:to="{name: 'library.albums.edit', params: {id: object.id }}"
|
|
||||||
>
|
|
||||||
<i class="pencil icon" />
|
|
||||||
{{ $t('components.library.AlbumBase.link.addDescription') }}
|
|
||||||
</router-link>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<div class="nine wide column">
|
|
||||||
<router-view
|
<router-view
|
||||||
v-if="object"
|
v-if="object"
|
||||||
:key="$route.fullPath"
|
:key="route.fullPath"
|
||||||
:paginate-by="paginateBy"
|
:paginate-by="paginateBy"
|
||||||
:total-tracks="totalTracks"
|
:total-tracks="totalTracks"
|
||||||
:is-serie="isSerie"
|
:is-serie="isSerie"
|
||||||
|
@ -350,8 +286,16 @@ const remove = async () => {
|
||||||
@libraries-loaded="libraries = $event"
|
@libraries-loaded="libraries = $event"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
</main>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.meta {
|
||||||
|
font-size: 15px;
|
||||||
|
@include light-theme {
|
||||||
|
color: var(--fw-gray-700);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
color: var(--fw-gray-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -5,8 +5,13 @@ import LibraryWidget from '~/components/federation/LibraryWidget.vue'
|
||||||
import ChannelEntries from '~/components/audio/ChannelEntries.vue'
|
import ChannelEntries from '~/components/audio/ChannelEntries.vue'
|
||||||
import TrackTable from '~/components/audio/track/Table.vue'
|
import TrackTable from '~/components/audio/track/Table.vue'
|
||||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||||
|
import TagsList from '~/components/tags/List.vue'
|
||||||
import Pagination from '~/components/vui/Pagination.vue'
|
import Pagination from '~/components/vui/Pagination.vue'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import Loader from '~/components/ui/Loader.vue'
|
||||||
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'libraries-loaded', libraries: Library[]): void
|
(e: 'libraries-loaded', libraries: Library[]): void
|
||||||
|
@ -24,26 +29,25 @@ interface Props {
|
||||||
|
|
||||||
const emit = defineEmits<Events>()
|
const emit = defineEmits<Events>()
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const getDiscKey = (disc: Track[]) => disc?.map(track => track.id).join('|') ?? ''
|
const getDiscKey = (disc: Track[]) => disc?.map(track => track.id).join('|') ?? ''
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
|
|
||||||
const discCount = computed(() => props.object.tracks.reduce((acc, track) => {
|
const discCount = computed(() => props.object?.tracks?.reduce((acc, track) => {
|
||||||
acc.add(track.disc_number)
|
acc.add(track.disc_number)
|
||||||
return acc
|
return acc
|
||||||
}, new Set()).size)
|
}, new Set()).size)
|
||||||
|
|
||||||
const discs = computed(() => props.object.tracks
|
const discs = computed(() => props.object?.tracks?.reduce((acc: Track[][], track: Track) => {
|
||||||
.reduce((acc: Track[][], track: Track) => {
|
const discNumber = track.disc_number - (props.object?.tracks?.[0]?.disc_number ?? 1)
|
||||||
const discNumber = track.disc_number - (props.object.tracks[0]?.disc_number ?? 1)
|
|
||||||
acc[discNumber].push(track)
|
acc[discNumber].push(track)
|
||||||
return acc
|
return acc
|
||||||
}, Array(discCount.value).fill(undefined).map(() => []))
|
}, Array(discCount.value).fill(undefined).map(() => [])))
|
||||||
)
|
|
||||||
|
|
||||||
const paginatedDiscs = computed(() => props.object.tracks.slice(props.paginateBy * (page.value - 1), props.paginateBy * page.value)
|
const paginatedDiscs = computed(() => props.object?.tracks?.slice(props.paginateBy * (page.value - 1), props.paginateBy * page.value)
|
||||||
.reduce((acc: Track[][], track: Track) => {
|
.reduce((acc: Track[][], track: Track) => {
|
||||||
const discNumber = track.disc_number - (props.object.tracks[0]?.disc_number ?? 1)
|
const discNumber = track.disc_number - (props.object?.tracks?.[0]?.disc_number ?? 1)
|
||||||
acc[discNumber].push(track)
|
acc[discNumber].push(track)
|
||||||
return acc
|
return acc
|
||||||
}, Array(discCount.value).fill(undefined).map(() => []))
|
}, Array(discCount.value).fill(undefined).map(() => []))
|
||||||
|
@ -54,38 +58,45 @@ const paginatedDiscs = computed(() => props.object.tracks.slice(props.paginateBy
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="!isLoadingTracks"
|
v-if="!isLoadingTracks"
|
||||||
class="ui vertical segment"
|
|
||||||
>
|
>
|
||||||
<h2 class="ui header">
|
<h2 class="ui header">
|
||||||
<span v-if="isSerie">
|
<span v-if="isSerie">
|
||||||
{{ $t('components.library.AlbumDetail.header.episodes') }}
|
{{ t('components.library.AlbumDetail.header.episodes') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ $t('components.library.AlbumDetail.header.tracks') }}
|
{{ t('components.library.AlbumDetail.header.tracks') }}
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<channel-entries
|
<channel-entries
|
||||||
v-if="artistCredit && artistCredit[0].artist.channel && isSerie"
|
v-if="artistCredit && artistCredit[0].artist.channel && isSerie"
|
||||||
|
:default-cover="null"
|
||||||
:is-podcast="isSerie"
|
:is-podcast="isSerie"
|
||||||
:limit="50"
|
:limit="50"
|
||||||
:filters="{channel: artistCredit[0].artist.channel.uuid, album: object.id, ordering: '-creation_date'}"
|
:filters="{channel: artistCredit[0].artist.channel, album: object.id, ordering: '-creation_date'}"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Loader v-if="isLoadingTracks" />
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<template v-if="discCount > 1">
|
<TagsList
|
||||||
|
v-if="object.tags && object.tags.length > 0"
|
||||||
|
style="margin-top: -16px;"
|
||||||
|
:tags="object.tags"
|
||||||
|
/>
|
||||||
|
<template v-if="(discCount || 0) > 1">
|
||||||
<div
|
<div
|
||||||
v-for="tracks, index in paginatedDiscs"
|
v-for="tracks, index in paginatedDiscs"
|
||||||
:key="index + getDiscKey(tracks)"
|
:key="index + getDiscKey(tracks)"
|
||||||
>
|
>
|
||||||
<template v-if="tracks.length > 0">
|
<template v-if="tracks.length > 0">
|
||||||
<div class="ui hidden divider" />
|
<div class="ui hidden divider" />
|
||||||
<play-button
|
<PlayButton
|
||||||
class="right floated mini inverted vibrant"
|
class="right floated mini inverted vibrant"
|
||||||
:tracks="discs[index]"
|
:tracks="discs ? discs[index] : []"
|
||||||
/>
|
/>
|
||||||
<h3>
|
<h3>
|
||||||
{{ $t('components.library.AlbumDetail.meta.volume', {number: tracks[0].disc_number}) }}
|
{{ t('components.library.AlbumDetail.meta.volume', { number: tracks[0].disc_number }) }}
|
||||||
</h3>
|
</h3>
|
||||||
<track-table
|
<track-table
|
||||||
:is-album="true"
|
:is-album="true"
|
||||||
|
@ -99,7 +110,7 @@ const paginatedDiscs = computed(() => props.object.tracks.slice(props.paginateBy
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else-if="object.tracks">
|
||||||
<track-table
|
<track-table
|
||||||
:is-album="true"
|
:is-album="true"
|
||||||
:tracks="object.tracks"
|
:tracks="object.tracks"
|
||||||
|
@ -124,14 +135,13 @@ const paginatedDiscs = computed(() => props.object.tracks.slice(props.paginateBy
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="artistCredit && !artistCredit[0]?.artist.channel && !isSerie">
|
<template v-if="artistCredit && !artistCredit[0]?.artist.channel && !isSerie">
|
||||||
<h2>
|
<Spacer />
|
||||||
{{ $t('components.library.AlbumDetail.header.libraries') }}
|
|
||||||
</h2>
|
|
||||||
<library-widget
|
<library-widget
|
||||||
:url="'albums/' + object.id + '/libraries/'"
|
:url="'albums/' + object.id + '/libraries/'"
|
||||||
|
:title="t('components.library.AlbumDetail.header.libraries')"
|
||||||
@loaded="emit('libraries-loaded', $event)"
|
@loaded="emit('libraries-loaded', $event)"
|
||||||
>
|
>
|
||||||
{{ $t('components.library.AlbumDetail.description.libraries') }}
|
{{ t('components.library.AlbumDetail.description.libraries') }}
|
||||||
</library-widget>
|
</library-widget>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -3,17 +3,18 @@ import type { Album, ArtistCredit, Library } from '~/types'
|
||||||
|
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
|
||||||
import { getDomain } from '~/utils'
|
import { getDomain } from '~/utils'
|
||||||
|
|
||||||
import useReport from '~/composables/moderation/useReport'
|
import useReport from '~/composables/moderation/useReport'
|
||||||
|
|
||||||
import EmbedWizard from '~/components/audio/EmbedWizard.vue'
|
import EmbedWizard from '~/components/audio/EmbedWizard.vue'
|
||||||
import SemanticModal from '~/components/semantic/Modal.vue'
|
|
||||||
|
|
||||||
interface Events {
|
import Modal from '~/components/ui/Modal.vue'
|
||||||
(e: 'remove'): void
|
import Popover from '~/components/ui/Popover.vue'
|
||||||
}
|
import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
|
||||||
|
import OptionsButton from '~/components/ui/button/Options.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
|
@ -23,9 +24,10 @@ interface Props {
|
||||||
isAlbum: boolean
|
isAlbum: boolean
|
||||||
isChannel: boolean
|
isChannel: boolean
|
||||||
isSerie: boolean
|
isSerie: boolean
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<Events>()
|
const store = useStore()
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const { report, getReportableObjects } = useReport()
|
const { report, getReportableObjects } = useReport()
|
||||||
|
|
||||||
|
@ -38,147 +40,131 @@ const labels = computed(() => ({
|
||||||
more: t('components.library.AlbumDropdown.button.more')
|
more: t('components.library.AlbumDropdown.button.more')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const isEmbedable = computed(() => (props.isChannel && props.artistCredit[0].artist?.channel?.actor) || props.publicLibraries.length)
|
// TODO: What is the condition for an album to be embeddable?
|
||||||
|
// (a) props.publicLibraries.length
|
||||||
|
// (b) I am the channel's artist: props.isChannel && props.artistCredit[0].artist?.channel?.actor)
|
||||||
|
const isEmbedable = computed(() => (props.publicLibraries.length))
|
||||||
const musicbrainzUrl = computed(() => props.object?.mbid ? `https://musicbrainz.org/release/${props.object.mbid}` : null)
|
const musicbrainzUrl = computed(() => props.object?.mbid ? `https://musicbrainz.org/release/${props.object.mbid}` : null)
|
||||||
const discogsUrl = computed(() => `https://discogs.com/search/?type=release&title=${encodeURI(props.object?.title)}&artist=${encodeURI(props.object?.artist_credit[0].artist.name)}`)
|
const discogsUrl = computed(() => `https://discogs.com/search/?type=release&title=${encodeURI(props.object?.title)}&artist=${encodeURI(props.object?.artist_credit[0].artist.name)}`)
|
||||||
|
|
||||||
const remove = () => emit('remove')
|
const open = ref(false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span>
|
<span>
|
||||||
|
<Modal
|
||||||
<semantic-modal
|
|
||||||
v-if="isEmbedable"
|
v-if="isEmbedable"
|
||||||
v-model:show="showEmbedModal"
|
v-model="showEmbedModal"
|
||||||
|
:title="t('components.library.AlbumDropdown.modal.embed.header')"
|
||||||
|
:cancel="t('components.library.AlbumDropdown.button.cancel')"
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
|
||||||
{{ $t('components.library.AlbumDropdown.modal.embed.header') }}
|
|
||||||
</h4>
|
|
||||||
<div class="scrolling content">
|
<div class="scrolling content">
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<embed-wizard
|
<embed-wizard
|
||||||
:id="object.id"
|
:id="object.id"
|
||||||
type="album"
|
type="album"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
</Modal>
|
||||||
<button class="ui basic deny button">
|
<Popover v-model="open">
|
||||||
{{ $t('components.library.AlbumDropdown.button.cancel') }}
|
<template #default="{ toggleOpen }">
|
||||||
</button>
|
<OptionsButton
|
||||||
</div>
|
|
||||||
</semantic-modal>
|
|
||||||
<button
|
|
||||||
v-dropdown="{direction: 'downward'}"
|
|
||||||
class="ui floating dropdown circular icon basic button"
|
|
||||||
:title="labels.more"
|
:title="labels.more"
|
||||||
>
|
is-square-small
|
||||||
<i class="ellipsis vertical icon" />
|
@click="toggleOpen()"
|
||||||
<div class="menu">
|
/>
|
||||||
<a
|
</template>
|
||||||
v-if="domain != $store.getters['instance/domain']"
|
|
||||||
:href="object.fid"
|
|
||||||
target="_blank"
|
|
||||||
class="basic item"
|
|
||||||
>
|
|
||||||
<i class="external icon" />
|
|
||||||
{{ $t('components.library.AlbumDropdown.link.domain') }}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div
|
<template #items>
|
||||||
|
<PopoverItem
|
||||||
|
v-if="domain != store.getters['instance/domain']"
|
||||||
|
:to="object.fid"
|
||||||
|
icon="bi-box-arrow-up-right"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{{ t('components.library.AlbumDropdown.link.domain') }}
|
||||||
|
</PopoverItem>
|
||||||
|
|
||||||
|
<PopoverItem
|
||||||
v-if="isEmbedable"
|
v-if="isEmbedable"
|
||||||
role="button"
|
icon="bi-code"
|
||||||
class="basic item"
|
|
||||||
@click="showEmbedModal = !showEmbedModal"
|
@click="showEmbedModal = !showEmbedModal"
|
||||||
>
|
>
|
||||||
<i class="code icon" />
|
{{ t('components.library.AlbumDropdown.button.embed') }}
|
||||||
{{ $t('components.library.AlbumDropdown.button.embed') }}
|
</PopoverItem>
|
||||||
</div>
|
|
||||||
<a
|
<PopoverItem
|
||||||
v-if="isAlbum && musicbrainzUrl"
|
v-if="isAlbum && musicbrainzUrl"
|
||||||
:href="musicbrainzUrl"
|
:to="musicbrainzUrl"
|
||||||
|
icon="bi-box-arrow-up-right"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
class="basic item"
|
|
||||||
>
|
>
|
||||||
<i class="external icon" />
|
{{ t('components.library.AlbumDropdown.link.musicbrainz') }}
|
||||||
{{ $t('components.library.AlbumDropdown.link.musicbrainz') }}
|
</PopoverItem>
|
||||||
</a>
|
|
||||||
<a
|
<PopoverItem
|
||||||
v-if="!isChannel && isAlbum"
|
v-if="!isChannel && isAlbum"
|
||||||
:href="discogsUrl"
|
:to="discogsUrl"
|
||||||
|
icon="bi-box-arrow-up-right"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
class="basic item"
|
|
||||||
>
|
>
|
||||||
<i class="external icon" />
|
{{ t('components.library.AlbumDropdown.link.discogs') }}
|
||||||
{{ $t('components.library.AlbumDropdown.link.discogs') }}
|
</PopoverItem>
|
||||||
</a>
|
|
||||||
<router-link
|
<PopoverItem
|
||||||
v-if="object.is_local"
|
v-if="object.is_local"
|
||||||
:to="{name: 'library.albums.edit', params: {id: object.id }}"
|
:to="{ name: 'library.albums.edit', params: { id: object.id } }"
|
||||||
class="basic item"
|
icon="bi-pencil"
|
||||||
>
|
>
|
||||||
<i class="edit icon" />
|
{{ t('components.library.AlbumDropdown.button.edit') }}
|
||||||
{{ $t('components.library.AlbumDropdown.button.edit') }}
|
</PopoverItem>
|
||||||
</router-link>
|
|
||||||
<dangerous-button
|
<hr>
|
||||||
v-if="artistCredit[0] && $store.state.auth.authenticated && artistCredit[0].artist.channel && artistCredit[0].artist.attributed_to?.full_username === $store.state.auth.fullUsername"
|
|
||||||
:class="['ui', {loading: isLoading}, 'item']"
|
<PopoverItem
|
||||||
@confirm="remove()"
|
v-for="obj in getReportableObjects({
|
||||||
>
|
album: object
|
||||||
<i class="ui trash icon" />
|
/*
|
||||||
{{ $t('components.library.AlbumDropdown.button.delete') }}
|
|
||||||
<template #modal-header>
|
TODO: The type of the following field has changed to number.
|
||||||
<p>
|
Find out if we want to load the corresponding channel instead.
|
||||||
{{ $t('components.library.AlbumDropdown.modal.delete.header') }}
|
|
||||||
</p>
|
, channel: artistCredit[0]?.artist.channel
|
||||||
</template>
|
*/
|
||||||
<template #modal-content>
|
})"
|
||||||
<div>
|
|
||||||
<p>
|
|
||||||
{{ $t('components.library.AlbumDropdown.modal.delete.content.warning') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #modal-confirm>
|
|
||||||
<p>
|
|
||||||
{{ $t('components.library.AlbumDropdown.button.delete') }}
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
</dangerous-button>
|
|
||||||
<div class="divider" />
|
|
||||||
<div
|
|
||||||
v-for="obj in getReportableObjects({album: object, channel: artistCredit[0]?.artist.channel})"
|
|
||||||
:key="obj.target.type + obj.target.id"
|
:key="obj.target.type + obj.target.id"
|
||||||
role="button"
|
icon="bi-flag"
|
||||||
class="basic item"
|
@click="report(obj)"
|
||||||
@click.stop.prevent="report(obj)"
|
|
||||||
>
|
>
|
||||||
<i class="share icon" /> {{ obj.label }}
|
{{ obj.label }}
|
||||||
</div>
|
</PopoverItem>
|
||||||
<div class="divider" />
|
|
||||||
<router-link
|
<hr>
|
||||||
v-if="$store.state.auth.availablePermissions['library']"
|
|
||||||
class="basic item"
|
<PopoverItem
|
||||||
:to="{name: 'manage.library.albums.detail', params: {id: object.id}}"
|
v-if="store.state.auth.availablePermissions['library']"
|
||||||
|
:to="{
|
||||||
|
name: 'manage.library.albums.detail',
|
||||||
|
params: { id: object.id }
|
||||||
|
}"
|
||||||
|
icon="bi-wrench"
|
||||||
>
|
>
|
||||||
<i class="wrench icon" />
|
{{ t('components.library.AlbumDropdown.link.moderation') }}
|
||||||
{{ $t('components.library.AlbumDropdown.link.moderation') }}
|
</PopoverItem>
|
||||||
</router-link>
|
|
||||||
<a
|
<PopoverItem
|
||||||
v-if="$store.state.auth.profile && $store.state.auth.profile?.is_superuser"
|
v-if="store.state.auth.profile?.is_superuser"
|
||||||
class="basic item"
|
:to="store.getters['instance/absoluteUrl'](`/api/admin/music/album/${object.id}`)"
|
||||||
:href="$store.getters['instance/absoluteUrl'](`/api/admin/music/album/${object.id}`)"
|
icon="bi-wrench"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<i class="wrench icon" />
|
{{ t('components.library.AlbumDropdown.link.django') }}
|
||||||
{{ $t('components.library.AlbumDropdown.link.django') }}
|
</PopoverItem>
|
||||||
</a>
|
</template>
|
||||||
</div>
|
</Popover>
|
||||||
</button>
|
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -3,6 +3,7 @@ import type { EditObjectType } from '~/composables/moderation/useEditConfigs'
|
||||||
import type { Album, Library, Actor } from '~/types'
|
import type { Album, Library, Actor } from '~/types'
|
||||||
|
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import EditForm from '~/components/library/EditForm.vue'
|
import EditForm from '~/components/library/EditForm.vue'
|
||||||
|
|
||||||
|
@ -14,32 +15,29 @@ interface Props {
|
||||||
|
|
||||||
defineProps<Props>()
|
defineProps<Props>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
const canEdit = store.state.auth.availablePermissions.library
|
const canEdit = store.state.auth.availablePermissions.library
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="ui vertical stripe segment">
|
|
||||||
<div class="ui text container">
|
|
||||||
<h2>
|
<h2>
|
||||||
<span v-if="canEdit">
|
<span v-if="canEdit">
|
||||||
{{ $t('components.library.AlbumEdit.header.edit') }}
|
{{ t('components.library.AlbumEdit.header.edit') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ $t('components.library.AlbumEdit.header.suggest') }}
|
{{ t('components.library.AlbumEdit.header.suggest') }}
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div
|
<div
|
||||||
v-if="!object.is_local"
|
v-if="!object.is_local"
|
||||||
class="ui message"
|
class="ui message"
|
||||||
>
|
>
|
||||||
{{ $t('components.library.AlbumEdit.message.remote') }}
|
{{ t('components.library.AlbumEdit.message.remote') }}
|
||||||
</div>
|
</div>
|
||||||
<edit-form
|
<edit-form
|
||||||
v-else
|
v-else
|
||||||
:object-type="objectType"
|
:object-type="objectType"
|
||||||
:object="object"
|
:object="object"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,22 +1,30 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { OrderingProps } from '~/composables/navigation/useOrdering'
|
import type { OrderingProps } from '~/composables/navigation/useOrdering'
|
||||||
import type { Album, BackendResponse } from '~/types'
|
import type { PaginatedAlbumList } from '~/types'
|
||||||
|
import type { operations } from '~/generated/types.ts'
|
||||||
import type { RouteRecordName } from 'vue-router'
|
import type { RouteRecordName } from 'vue-router'
|
||||||
import type { OrderingField } from '~/store/ui'
|
import type { OrderingField } from '~/store/ui'
|
||||||
|
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useRouteQuery } from '@vueuse/router'
|
import { useRouteQuery } from '@vueuse/router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { syncRef } from '@vueuse/core'
|
import { syncRef } from '@vueuse/core'
|
||||||
import { sortedUniq } from 'lodash-es'
|
import { sortedUniq } from 'lodash-es'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
import { useModal } from '~/ui/composables/useModal.ts'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import $ from 'jquery'
|
|
||||||
|
|
||||||
import TagsSelector from '~/components/library/TagsSelector.vue'
|
import Pagination from '~/components/ui/Pagination.vue'
|
||||||
import AlbumCard from '~/components/audio/album/Card.vue'
|
import Card from '~/components/ui/Card.vue'
|
||||||
import Pagination from '~/components/vui/Pagination.vue'
|
import AlbumCard from '~/components/album/Card.vue'
|
||||||
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
|
import Header from '~/components/ui/Header.vue'
|
||||||
|
import Input from '~/components/ui/Input.vue'
|
||||||
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
|
import Pills from '~/components/ui/Pills.vue'
|
||||||
|
import Loader from '~/components/ui/Loader.vue'
|
||||||
|
|
||||||
import useSharedLabels from '~/composables/locale/useSharedLabels'
|
import useSharedLabels from '~/composables/locale/useSharedLabels'
|
||||||
import useOrdering from '~/composables/navigation/useOrdering'
|
import useOrdering from '~/composables/navigation/useOrdering'
|
||||||
|
@ -38,13 +46,13 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
|
|
||||||
const page = usePage()
|
const page = usePage()
|
||||||
|
|
||||||
const tags = useRouteQuery<string[]>('tag', [])
|
const tags = useRouteQuery<string[]>('tag', [], { transform: (param: string | string[] | null) => (param === null ? [] : Array.isArray(param) ? param : [param]).filter(p => p.trim() !== '') })
|
||||||
|
|
||||||
const q = useRouteQuery('query', '')
|
const q = useRouteQuery('query', '')
|
||||||
const query = ref(q.value)
|
const query = ref(q.value ?? '')
|
||||||
syncRef(q, query, { direction: 'ltr' })
|
syncRef(q, query, { direction: 'ltr' })
|
||||||
|
|
||||||
const result = ref<BackendResponse<Album>>()
|
const result = ref<PaginatedAlbumList>()
|
||||||
|
|
||||||
const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [
|
const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [
|
||||||
['creation_date', 'creation_date'],
|
['creation_date', 'creation_date'],
|
||||||
|
@ -60,21 +68,22 @@ const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirectio
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
const params = {
|
const params : operations['get_album_fetches_2']['parameters']['query'] = {
|
||||||
scope: props.scope,
|
scope: props.scope,
|
||||||
page: page.value,
|
page: page.value,
|
||||||
page_size: paginateBy.value,
|
page_size: paginateBy.value,
|
||||||
q: query.value,
|
q: query.value,
|
||||||
|
// @ts-expect-error TODO: add strict types to useOrdering
|
||||||
ordering: orderingString.value,
|
ordering: orderingString.value,
|
||||||
playable: 'true',
|
playable: true,
|
||||||
tag: tags.value,
|
tag: tags.value,
|
||||||
include_channels: 'true',
|
include_channels: true,
|
||||||
content_category: 'music'
|
content_category: 'music'
|
||||||
}
|
}
|
||||||
|
|
||||||
const measureLoading = logger.time('Fetching albums')
|
const measureLoading = logger.time('Fetching albums')
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('albums/', {
|
const response = await axios.get<PaginatedAlbumList>('albums/', {
|
||||||
params,
|
params,
|
||||||
paramsSerializer: {
|
paramsSerializer: {
|
||||||
indexes: null
|
indexes: null
|
||||||
|
@ -93,7 +102,7 @@ const fetchData = async () => {
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
watch(() => store.state.moderation.lastUpdate, fetchData)
|
watch(() => store.state.moderation.lastUpdate, fetchData)
|
||||||
watch([page, tags, q, () => props.scope], fetchData)
|
watch([page, tags, q, ordering, orderingDirection, () => props.scope], fetchData)
|
||||||
fetchData()
|
fetchData()
|
||||||
|
|
||||||
const search = () => {
|
const search = () => {
|
||||||
|
@ -106,59 +115,62 @@ onOrderingUpdate(() => {
|
||||||
fetchData()
|
fetchData()
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => $('.ui.dropdown').dropdown())
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const labels = computed(() => ({
|
const labels = computed(() => ({
|
||||||
searchPlaceholder: t('components.library.Albums.placeholder.search'),
|
searchPlaceholder: t('components.library.Albums.placeholder.search'),
|
||||||
title: t('components.library.Albums.title')
|
title: t('components.library.Albums.title')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value].sort((a, b) => a - b)))
|
const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value].sort((a, b) => a - b)))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main v-title="labels.title">
|
<Layout
|
||||||
<section class="ui vertical stripe segment">
|
v-title="labels.title"
|
||||||
<h2 class="ui header">
|
stack
|
||||||
{{ $t('components.library.Albums.header.browse') }}
|
main
|
||||||
</h2>
|
>
|
||||||
<form
|
<Header
|
||||||
|
page-heading
|
||||||
|
:h1="t('components.library.Albums.header.browse')"
|
||||||
|
/>
|
||||||
|
<Layout
|
||||||
|
form
|
||||||
|
flex
|
||||||
:class="['ui', {'loading': isLoading}, 'form']"
|
:class="['ui', {'loading': isLoading}, 'form']"
|
||||||
@submit.prevent="search"
|
@submit.prevent="search"
|
||||||
>
|
>
|
||||||
<div class="fields">
|
<Input
|
||||||
<div class="field">
|
id="album-search"
|
||||||
<label for="albums-search">
|
|
||||||
{{ $t('components.library.Albums.label.search') }}
|
|
||||||
</label>
|
|
||||||
<div class="ui action input">
|
|
||||||
<input
|
|
||||||
id="albums-search"
|
|
||||||
v-model="query"
|
v-model="query"
|
||||||
type="text"
|
search
|
||||||
name="search"
|
name="search"
|
||||||
|
:label="t('components.library.Albums.label.search')"
|
||||||
|
autofocus
|
||||||
:placeholder="labels.searchPlaceholder"
|
:placeholder="labels.searchPlaceholder"
|
||||||
|
/>
|
||||||
|
<Pills
|
||||||
|
:get="model => { tags = model.currents.map(({ label }) => label) }"
|
||||||
|
:set="model => ({
|
||||||
|
...model,
|
||||||
|
currents: tags.map(tag => ({ type: 'custom' as const, label: tag })),
|
||||||
|
})"
|
||||||
|
:label="t('components.library.Albums.label.tags')"
|
||||||
|
style="max-width: 150px;"
|
||||||
|
/>
|
||||||
|
<Layout
|
||||||
|
stack
|
||||||
|
no-gap
|
||||||
|
label
|
||||||
|
for="album-ordering"
|
||||||
>
|
>
|
||||||
<button
|
<span class="label">
|
||||||
class="ui icon button"
|
{{ t('components.library.Albums.ordering.label') }}
|
||||||
type="submit"
|
</span>
|
||||||
:aria-label="t('components.library.Albums.button.search')"
|
|
||||||
>
|
|
||||||
<i class="search icon" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="tags-search">{{ $t('components.library.Albums.label.tags') }}</label>
|
|
||||||
<tags-selector v-model="tags" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="album-ordering">{{ $t('components.library.Albums.ordering.label') }}</label>
|
|
||||||
<select
|
<select
|
||||||
id="album-ordering"
|
id="album-ordering"
|
||||||
v-model="ordering"
|
v-model="ordering"
|
||||||
class="ui dropdown"
|
class="dropdown"
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
v-for="(option, key) in orderingOptions"
|
v-for="(option, key) in orderingOptions"
|
||||||
|
@ -168,28 +180,42 @@ const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value]
|
||||||
{{ sharedLabels.filters[option[1]] }}
|
{{ sharedLabels.filters[option[1]] }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</Layout>
|
||||||
<div class="field">
|
<Layout
|
||||||
<label for="album-ordering-direction">{{ $t('components.library.Albums.ordering.direction.label') }}</label>
|
stack
|
||||||
|
no-gap
|
||||||
|
label
|
||||||
|
for="album-ordering-direction"
|
||||||
|
>
|
||||||
|
<span class="label">
|
||||||
|
{{ t('components.library.Albums.ordering.direction.label') }}
|
||||||
|
</span>
|
||||||
<select
|
<select
|
||||||
id="album-ordering-direction"
|
id="album-ordering-direction"
|
||||||
v-model="orderingDirection"
|
v-model="orderingDirection"
|
||||||
class="ui dropdown"
|
class="dropdown"
|
||||||
>
|
>
|
||||||
<option value="+">
|
<option value="+">
|
||||||
{{ $t('components.library.Albums.ordering.direction.ascending') }}
|
{{ t('components.library.Albums.ordering.direction.ascending') }}
|
||||||
</option>
|
</option>
|
||||||
<option value="-">
|
<option value="-">
|
||||||
{{ $t('components.library.Albums.ordering.direction.descending') }}
|
{{ t('components.library.Albums.ordering.direction.descending') }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</Layout>
|
||||||
<div class="field">
|
<Layout
|
||||||
<label for="album-results">{{ $t('components.library.Albums.pagination.results') }}</label>
|
stack
|
||||||
|
no-gap
|
||||||
|
label
|
||||||
|
for="album-results"
|
||||||
|
>
|
||||||
|
<span class="label">
|
||||||
|
{{ t('components.library.Albums.pagination.results') }}
|
||||||
|
</span>
|
||||||
<select
|
<select
|
||||||
id="album-results"
|
id="album-results"
|
||||||
v-model="paginateBy"
|
v-model="paginateBy"
|
||||||
class="ui dropdown"
|
class="dropdown"
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
v-for="opt in paginateOptions"
|
v-for="opt in paginateOptions"
|
||||||
|
@ -199,55 +225,55 @@ const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value]
|
||||||
{{ opt }}
|
{{ opt }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</Layout>
|
||||||
</div>
|
</Layout>
|
||||||
</form>
|
<Loader v-if="isLoading" />
|
||||||
<div class="ui hidden divider" />
|
<Pagination
|
||||||
<div
|
v-if="page && result && result.count > paginateBy"
|
||||||
v-if="result"
|
v-model:page="page"
|
||||||
transition-duration="0"
|
:pages="Math.ceil((result.count || 0)/paginateBy)"
|
||||||
item-selector=".column"
|
/>
|
||||||
percent-position="true"
|
<Layout
|
||||||
stagger="0"
|
v-if="result && result.results.length > 0"
|
||||||
class=""
|
grid
|
||||||
|
style="display:flex; flex-wrap:wrap; gap: 32px; margin-top:32px;"
|
||||||
>
|
>
|
||||||
<div
|
<AlbumCard
|
||||||
v-if="result.results.length > 0"
|
|
||||||
class="ui app-cards cards"
|
|
||||||
>
|
|
||||||
<album-card
|
|
||||||
v-for="album in result.results"
|
v-for="album in result.results"
|
||||||
:key="album.id"
|
:key="album.id"
|
||||||
:album="album"
|
:album="album"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Layout>
|
||||||
<div
|
<Layout
|
||||||
v-else
|
v-else-if="result && result.results.length === 0"
|
||||||
class="ui placeholder segment sixteen wide column"
|
stack
|
||||||
style="text-align: center; display: flex; align-items: center"
|
|
||||||
>
|
>
|
||||||
<div class="ui icon header">
|
<Alert blue>
|
||||||
<i class="compact disc icon" />
|
<i class="bi bi-disc" />
|
||||||
{{ $t('components.library.Albums.empty.noResults') }}
|
{{ t('components.library.Albums.empty.noResults') }}
|
||||||
</div>
|
</Alert>
|
||||||
<router-link
|
<Card
|
||||||
v-if="$store.state.auth.authenticated"
|
v-if="store.state.auth.authenticated"
|
||||||
:to="{name: 'content.index'}"
|
:title="t('components.library.Albums.link.addMusic')"
|
||||||
class="ui success button labeled icon"
|
solid
|
||||||
|
small
|
||||||
|
primary
|
||||||
|
style="text-align: center;"
|
||||||
|
:to="useModal('upload').to"
|
||||||
>
|
>
|
||||||
<i class="upload icon" />
|
<template #image>
|
||||||
{{ $t('components.library.Albums.link.addMusic') }}
|
<i
|
||||||
</router-link>
|
class="bi bi-upload"
|
||||||
</div>
|
style="font-size: 100px; position: relative; top: 50px;"
|
||||||
</div>
|
|
||||||
<div class="ui center aligned basic segment">
|
|
||||||
<pagination
|
|
||||||
v-if="result && result.count > paginateBy"
|
|
||||||
v-model:current="page"
|
|
||||||
:paginate-by="paginateBy"
|
|
||||||
:total="result.count"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
</section>
|
</Card>
|
||||||
</main>
|
</Layout>
|
||||||
|
<Spacer grow />
|
||||||
|
<Pagination
|
||||||
|
v-if="page && result && result.count > paginateBy"
|
||||||
|
v-model:page="page"
|
||||||
|
:pages="Math.ceil((result.count || 0)/paginateBy)"
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,25 +1,36 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Track, Album, Artist, Library } from '~/types'
|
import type { Track, Album, Artist, Library, Cover } from '~/types'
|
||||||
|
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { sum } from 'lodash-es'
|
||||||
import { getDomain } from '~/utils'
|
import { getDomain } from '~/utils'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
import EmbedWizard from '~/components/audio/EmbedWizard.vue'
|
|
||||||
import SemanticModal from '~/components/semantic/Modal.vue'
|
|
||||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
|
||||||
import RadioButton from '~/components/radios/Button.vue'
|
|
||||||
import TagsList from '~/components/tags/List.vue'
|
|
||||||
|
|
||||||
import useReport from '~/composables/moderation/useReport'
|
import useReport from '~/composables/moderation/useReport'
|
||||||
import useLogger from '~/composables/useLogger'
|
import useLogger from '~/composables/useLogger'
|
||||||
|
import { useModal } from '~/ui/composables/useModal.ts'
|
||||||
|
|
||||||
|
import HumanDuration from '~/components/common/HumanDuration.vue'
|
||||||
|
import EmbedWizard from '~/components/audio/EmbedWizard.vue'
|
||||||
|
import Loader from '~/components/ui/Loader.vue'
|
||||||
|
import Header from '~/components/ui/Header.vue'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import Link from '~/components/ui/Link.vue'
|
||||||
|
import OptionsButton from '~/components/ui/button/Options.vue'
|
||||||
|
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||||
|
import RadioButton from '~/components/radios/Button.vue'
|
||||||
|
import Popover from '~/components/ui/Popover.vue'
|
||||||
|
import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
|
||||||
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
|
import Modal from '~/components/ui/Modal.vue'
|
||||||
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
|
import RenderedDescription from '../common/RenderedDescription.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: number
|
id: number | string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
@ -36,26 +47,50 @@ const nextTracksUrl = ref(null)
|
||||||
const totalAlbums = ref(0)
|
const totalAlbums = ref(0)
|
||||||
const totalTracks = ref(0)
|
const totalTracks = ref(0)
|
||||||
|
|
||||||
const dropdown = ref()
|
|
||||||
|
|
||||||
const logger = useLogger()
|
const logger = useLogger()
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const domain = computed(() => getDomain(object.value?.fid ?? ''))
|
const domain = computed(() => getDomain(object.value?.fid ?? ''))
|
||||||
const isPlayable = computed(() => !!object.value?.albums.some(album => album.is_playable))
|
|
||||||
|
// TODO: Re-implement `!!object.value?.albums.some(album => album.is_playable)` instead of `true`
|
||||||
|
const isPlayable = computed(() => true)
|
||||||
const wikipediaUrl = computed(() => `https://en.wikipedia.org/w/index.php?search=${encodeURI(object.value?.name ?? '')}`)
|
const wikipediaUrl = computed(() => `https://en.wikipedia.org/w/index.php?search=${encodeURI(object.value?.name ?? '')}`)
|
||||||
const musicbrainzUrl = computed(() => object.value?.mbid ? `https://musicbrainz.org/artist/${object.value.mbid}` : null)
|
const musicbrainzUrl = computed(() => object.value?.mbid ? `https://musicbrainz.org/artist/${object.value.mbid}` : null)
|
||||||
const discogsUrl = computed(() => `https://discogs.com/search/?type=artist&title=${encodeURI(object.value?.name ?? '')}`)
|
const discogsUrl = computed(() => `https://discogs.com/search/?type=artist&title=${encodeURI(object.value?.name ?? '')}`)
|
||||||
const publicLibraries = computed(() => libraries.value?.filter(library => library.privacy_level === 'everyone') ?? [])
|
const publicLibraries = computed(() => libraries.value?.filter(library => library.privacy_level === 'everyone') ?? [])
|
||||||
const cover = computed(() => object.value?.cover?.urls.original
|
|
||||||
? object.value.cover
|
// TODO: This is cover logic. We use it a lot. Should all go into a single, smart, parametrised function.
|
||||||
: object.value?.albums.find(album => album.cover?.urls.original)?.cover
|
// Something like `useCover.ts`!
|
||||||
)
|
const cover = computed(() => {
|
||||||
const headerStyle = computed(() => cover.value?.urls.original
|
const artistCover = object.value?.cover
|
||||||
? { backgroundImage: `url(${store.getters['instance/absoluteUrl'](cover.value.urls.original)})` }
|
|
||||||
: ''
|
// const albumCover: Cover | null = object.value?.albums
|
||||||
)
|
// .find(album => album.cover?.urls.large_square_crop)?.cover
|
||||||
|
|
||||||
|
const trackCover = tracks.value?.find(
|
||||||
|
(track: Track) => track.cover
|
||||||
|
)?.cover
|
||||||
|
|
||||||
|
const fallback : Cover = {
|
||||||
|
uuid: '',
|
||||||
|
mimetype: 'jpeg',
|
||||||
|
creation_date: '',
|
||||||
|
size: 0,
|
||||||
|
urls: {
|
||||||
|
original: `${import.meta.env.BASE_URL}embed-default-cover.jpeg`,
|
||||||
|
small_square_crop: `${import.meta.env.BASE_URL}embed-default-cover.jpeg`,
|
||||||
|
medium_square_crop: `${import.meta.env.BASE_URL}embed-default-cover.jpeg`,
|
||||||
|
large_square_crop: `${import.meta.env.BASE_URL}embed-default-cover.jpeg`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return artistCover
|
||||||
|
// || albumCover
|
||||||
|
|| trackCover
|
||||||
|
|| fallback
|
||||||
|
})
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const labels = computed(() => ({
|
const labels = computed(() => ({
|
||||||
|
@ -91,187 +126,216 @@ const fetchData = async () => {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalDuration = computed(() => sum((tracks.value ?? []).map(track => track.uploads[0]?.duration ?? 0)))
|
||||||
|
|
||||||
watch(() => props.id, fetchData, { immediate: true })
|
watch(() => props.id, fetchData, { immediate: true })
|
||||||
|
|
||||||
|
const isOpen = useModal('artist-description').isOpen
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main v-title="labels.title">
|
<Loader v-if="isLoading" />
|
||||||
<div
|
<Header
|
||||||
v-if="isLoading"
|
v-if="object && !isLoading"
|
||||||
class="ui vertical segment"
|
v-title="labels.title"
|
||||||
|
:h1="object.name"
|
||||||
|
page-heading
|
||||||
>
|
>
|
||||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" />
|
<template #image>
|
||||||
</div>
|
<img
|
||||||
<template v-if="object && !isLoading">
|
v-lazy="cover.urls.large_square_crop"
|
||||||
<section
|
:alt="object.name"
|
||||||
v-title="object.name"
|
class="channel-image"
|
||||||
:class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
|
>
|
||||||
:style="headerStyle"
|
</template>
|
||||||
|
<Layout
|
||||||
|
flex
|
||||||
|
class="meta"
|
||||||
|
no-gap
|
||||||
>
|
>
|
||||||
<div class="segment-content">
|
|
||||||
<h2 class="ui center aligned icon header">
|
|
||||||
<i class="circular inverted users violet icon" />
|
|
||||||
<div class="content">
|
|
||||||
{{ object.name }}
|
|
||||||
<div
|
<div
|
||||||
v-if="albums"
|
v-if="albums"
|
||||||
class="sub header"
|
|
||||||
>
|
>
|
||||||
{{ $t('components.library.ArtistBase.meta.tracks', totalTracks) }}
|
{{ t('components.library.ArtistBase.meta.tracks', totalTracks) }}
|
||||||
{{ $t('components.library.ArtistBase.meta.albums', totalAlbums) }}
|
{{ t('components.library.ArtistBase.meta.albums', totalAlbums) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div v-if="totalDuration > 0">
|
||||||
</h2>
|
<i class="bi bi-dot" />
|
||||||
<tags-list
|
<human-duration
|
||||||
v-if="object.tags && object.tags.length > 0"
|
v-if="totalDuration > 0"
|
||||||
:tags="object.tags"
|
:duration="totalDuration"
|
||||||
/>
|
/>
|
||||||
<div class="ui hidden divider" />
|
</div>
|
||||||
<div class="header-buttons">
|
</Layout>
|
||||||
<div class="ui buttons">
|
<Layout
|
||||||
|
flex
|
||||||
|
gap-4
|
||||||
|
>
|
||||||
|
<RenderedDescription
|
||||||
|
v-if="object.description"
|
||||||
|
class="description"
|
||||||
|
:content="{ ...object.description, text: object.description.text ?? undefined }"
|
||||||
|
:truncate-length="100"
|
||||||
|
:more-link="false"
|
||||||
|
/>
|
||||||
|
<Spacer grow />
|
||||||
|
<Link
|
||||||
|
v-if="object.description"
|
||||||
|
:to="useModal('artist-description').to"
|
||||||
|
style="color: var(--fw-primary); text-decoration: underline;"
|
||||||
|
thin-font
|
||||||
|
force-underline
|
||||||
|
>
|
||||||
|
{{ t('components.common.RenderedDescription.button.more') }}
|
||||||
|
</Link>
|
||||||
|
</Layout>
|
||||||
|
<Modal
|
||||||
|
v-if="object.description"
|
||||||
|
v-model="isOpen"
|
||||||
|
:title="object.name"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="object.cover"
|
||||||
|
v-lazy="object.cover.urls.original"
|
||||||
|
:alt="object.name"
|
||||||
|
style="object-fit: cover; width: 100%; height: 100%;"
|
||||||
|
>
|
||||||
|
<sanitized-html
|
||||||
|
v-if="object.description"
|
||||||
|
:html="object.description.html"
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Layout flex>
|
||||||
|
<PlayButton
|
||||||
|
:is-playable="isPlayable"
|
||||||
|
split
|
||||||
|
:artist="object"
|
||||||
|
low-height
|
||||||
|
>
|
||||||
|
{{ t('components.library.ArtistBase.button.play') }}
|
||||||
|
</PlayButton>
|
||||||
<radio-button
|
<radio-button
|
||||||
type="artist"
|
type="artist"
|
||||||
:object-id="object.id"
|
:object-id="object.id"
|
||||||
|
low-height
|
||||||
/>
|
/>
|
||||||
</div>
|
<Spacer grow />
|
||||||
<div class="ui buttons">
|
<Popover>
|
||||||
<play-button
|
<template #default="{ toggleOpen }">
|
||||||
:is-playable="isPlayable"
|
<OptionsButton
|
||||||
class="vibrant"
|
is-square-small
|
||||||
:artist="object"
|
@click="toggleOpen"
|
||||||
>
|
/>
|
||||||
{{ $t('components.library.ArtistBase.button.play') }}
|
</template>
|
||||||
</play-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<semantic-modal
|
<template #items>
|
||||||
v-if="publicLibraries.length > 0"
|
<PopoverItem
|
||||||
v-model:show="showEmbedModal"
|
v-if="object.fid && domain != store.getters['instance/domain']"
|
||||||
|
:to="object.fid"
|
||||||
|
target="_blank"
|
||||||
|
icon="bi-box-arrow-up-right"
|
||||||
|
>
|
||||||
|
{{ t('components.library.ArtistBase.link.domain', {domain: domain}) }}
|
||||||
|
</PopoverItem>
|
||||||
|
|
||||||
|
<PopoverItem
|
||||||
|
v-if="publicLibraries.length > 0"
|
||||||
|
icon="bi-code-square"
|
||||||
|
@click="showEmbedModal = true"
|
||||||
|
>
|
||||||
|
{{ t('components.library.ArtistBase.button.embed') }}
|
||||||
|
</PopoverItem>
|
||||||
|
|
||||||
|
<PopoverItem
|
||||||
|
:to="wikipediaUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
icon="bi-wikipedia"
|
||||||
|
>
|
||||||
|
{{ t('components.library.ArtistBase.link.wikipedia') }}
|
||||||
|
</PopoverItem>
|
||||||
|
|
||||||
|
<PopoverItem
|
||||||
|
v-if="musicbrainzUrl"
|
||||||
|
:to="musicbrainzUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
icon="bi-box-arrow-up-right"
|
||||||
|
>
|
||||||
|
{{ t('components.library.ArtistBase.link.musicbrainz') }}
|
||||||
|
</PopoverItem>
|
||||||
|
|
||||||
|
<PopoverItem
|
||||||
|
:to="discogsUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
icon="bi-box-arrow-up-right"
|
||||||
|
>
|
||||||
|
{{ t('components.library.ArtistBase.link.discogs') }}
|
||||||
|
</PopoverItem>
|
||||||
|
|
||||||
|
<PopoverItem
|
||||||
|
v-if="object.is_local"
|
||||||
|
:to="{name: 'library.artists.edit', params: {id: object.id }}"
|
||||||
|
icon="bi-pencil-fill"
|
||||||
|
>
|
||||||
|
{{ t('components.library.ArtistBase.button.edit') }}
|
||||||
|
</PopoverItem>
|
||||||
|
|
||||||
|
<hr v-if="getReportableObjects({artist: object}).length>0">
|
||||||
|
|
||||||
|
<PopoverItem
|
||||||
|
v-for="obj in getReportableObjects({artist: object})"
|
||||||
|
:key="obj.target.type + obj.target.id"
|
||||||
|
icon="bi-share-fill"
|
||||||
|
@click="report(obj)"
|
||||||
|
>
|
||||||
|
{{ obj.label }}
|
||||||
|
</PopoverItem>
|
||||||
|
|
||||||
|
<hr v-if="getReportableObjects({artist: object}).length>0">
|
||||||
|
|
||||||
|
<PopoverItem
|
||||||
|
v-if="store.state.auth.availablePermissions['library']"
|
||||||
|
:to="{name: 'manage.library.artists.detail', params: {id: object.id}}"
|
||||||
|
icon="bi-wrench"
|
||||||
|
>
|
||||||
|
{{ t('components.library.ArtistBase.link.moderation') }}
|
||||||
|
</PopoverItem>
|
||||||
|
|
||||||
|
<PopoverItem
|
||||||
|
v-if="store.state.auth.profile && store.state.auth.profile.is_superuser"
|
||||||
|
:to="store.getters['instance/absoluteUrl'](`/api/admin/music/artist/${object.id}`)"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
icon="bi-wrench"
|
||||||
|
>
|
||||||
|
{{ t('components.library.ArtistBase.link.django') }}
|
||||||
|
</PopoverItem>
|
||||||
|
</template>
|
||||||
|
</Popover>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
v-if="publicLibraries.length > 0"
|
||||||
|
v-model="showEmbedModal"
|
||||||
|
:title="t('components.library.ArtistBase.modal.embed.header')"
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
|
||||||
{{ $t('components.library.ArtistBase.modal.embed.header') }}
|
|
||||||
</h4>
|
|
||||||
<div class="scrolling content">
|
|
||||||
<div class="description">
|
|
||||||
<embed-wizard
|
<embed-wizard
|
||||||
:id="object.id"
|
:id="object.id"
|
||||||
type="artist"
|
type="artist"
|
||||||
/>
|
/>
|
||||||
</div>
|
<template #actions>
|
||||||
</div>
|
<Button secondary>
|
||||||
<div class="actions">
|
{{ t('components.library.ArtistBase.button.cancel') }}
|
||||||
<button class="ui deny button">
|
</Button>
|
||||||
{{ $t('components.library.ArtistBase.button.cancel') }}
|
</template>
|
||||||
</button>
|
</Modal>
|
||||||
</div>
|
</Header>
|
||||||
</semantic-modal>
|
<hr>
|
||||||
<div class="ui buttons">
|
|
||||||
<button
|
|
||||||
class="ui button"
|
|
||||||
@click="dropdown.click()"
|
|
||||||
>
|
|
||||||
{{ $t('components.library.ArtistBase.button.more') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
ref="dropdown"
|
|
||||||
v-dropdown
|
|
||||||
class="ui floating dropdown icon button"
|
|
||||||
>
|
|
||||||
<i class="dropdown icon" />
|
|
||||||
<div class="menu">
|
|
||||||
<a
|
|
||||||
v-if="domain != $store.getters['instance/domain']"
|
|
||||||
:href="object.fid"
|
|
||||||
target="_blank"
|
|
||||||
class="basic item"
|
|
||||||
>
|
|
||||||
<i class="external icon" />
|
|
||||||
{{ $t('components.library.ArtistBase.link.domain', {domain: domain}) }}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="publicLibraries.length > 0"
|
|
||||||
role="button"
|
|
||||||
class="basic item"
|
|
||||||
@click.prevent="showEmbedModal = !showEmbedModal"
|
|
||||||
>
|
|
||||||
<i class="code icon" />
|
|
||||||
{{ $t('components.library.ArtistBase.button.embed') }}
|
|
||||||
</button>
|
|
||||||
<a
|
|
||||||
:href="wikipediaUrl"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
class="basic item"
|
|
||||||
>
|
|
||||||
<i class="wikipedia w icon" />
|
|
||||||
{{ $t('components.library.ArtistBase.link.wikipedia') }}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
v-if="musicbrainzUrl"
|
|
||||||
:href="musicbrainzUrl"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
class="basic item"
|
|
||||||
>
|
|
||||||
<i class="external icon" />
|
|
||||||
{{ $t('components.library.ArtistBase.link.musicbrainz') }}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
:href="discogsUrl"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
class="basic item"
|
|
||||||
>
|
|
||||||
<i class="external icon" />
|
|
||||||
{{ $t('components.library.ArtistBase.link.discogs') }}
|
|
||||||
</a>
|
|
||||||
<router-link
|
|
||||||
v-if="object.is_local"
|
|
||||||
:to="{name: 'library.artists.edit', params: {id: object.id }}"
|
|
||||||
class="basic item"
|
|
||||||
>
|
|
||||||
<i class="edit icon" />
|
|
||||||
{{ $t('components.library.ArtistBase.button.edit') }}
|
|
||||||
</router-link>
|
|
||||||
<div class="divider" />
|
|
||||||
<div
|
|
||||||
v-for="obj in getReportableObjects({artist: object})"
|
|
||||||
:key="obj.target.type + obj.target.id"
|
|
||||||
role="button"
|
|
||||||
class="basic item"
|
|
||||||
@click.stop.prevent="report(obj)"
|
|
||||||
>
|
|
||||||
<i class="share icon" /> {{ obj.label }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider" />
|
|
||||||
<router-link
|
|
||||||
v-if="$store.state.auth.availablePermissions['library']"
|
|
||||||
class="basic item"
|
|
||||||
:to="{name: 'manage.library.artists.detail', params: {id: object.id}}"
|
|
||||||
>
|
|
||||||
<i class="wrench icon" />
|
|
||||||
{{ $t('components.library.ArtistBase.link.moderation') }}
|
|
||||||
</router-link>
|
|
||||||
<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('components.library.ArtistBase.link.django') }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<router-view
|
<router-view
|
||||||
:key="$route.fullPath"
|
:key="route.fullPath"
|
||||||
:tracks="tracks"
|
:tracks="tracks"
|
||||||
:next-tracks-url="nextTracksUrl"
|
:next-tracks-url="nextTracksUrl"
|
||||||
:next-albums-url="nextAlbumsUrl"
|
:next-albums-url="nextAlbumsUrl"
|
||||||
|
@ -281,6 +345,30 @@ watch(() => props.id, fetchData, { immediate: true })
|
||||||
object-type="artist"
|
object-type="artist"
|
||||||
@libraries-loaded="libraries = $event"
|
@libraries-loaded="libraries = $event"
|
||||||
/>
|
/>
|
||||||
</template>
|
|
||||||
</main>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.channel-image {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
font-size: 15px;
|
||||||
|
@include light-theme {
|
||||||
|
color: var(--fw-gray-700);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
color: var(--fw-gray-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
white-space: normal;
|
||||||
|
-webkit-line-clamp: 1; /* Number of lines to show */
|
||||||
|
line-clamp: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -4,12 +4,21 @@ import type { ContentFilter } from '~/store/moderation'
|
||||||
|
|
||||||
import { ref, computed, reactive } from 'vue'
|
import { ref, computed, reactive } from 'vue'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
import LibraryWidget from '~/components/federation/LibraryWidget.vue'
|
import LibraryWidget from '~/components/federation/LibraryWidget.vue'
|
||||||
import TrackTable from '~/components/audio/track/Table.vue'
|
import TrackTable from '~/components/audio/track/Table.vue'
|
||||||
import AlbumCard from '~/components/audio/album/Card.vue'
|
import TagsList from '~/components/tags/List.vue'
|
||||||
|
import AlbumCard from '~/components/album/Card.vue'
|
||||||
|
import Layout from '~/components/ui/Layout.vue'
|
||||||
|
import Heading from '~/components/ui/Heading.vue'
|
||||||
|
import Loader from '~/components/ui/Loader.vue'
|
||||||
|
import Link from '~/components/ui/Link.vue'
|
||||||
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
|
|
||||||
import useErrorHandler from '~/composables/useErrorHandler'
|
import useErrorHandler from '~/composables/useErrorHandler'
|
||||||
|
|
||||||
|
@ -26,6 +35,8 @@ interface Props {
|
||||||
nextAlbumsUrl?: string | null
|
nextAlbumsUrl?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const emit = defineEmits<Events>()
|
const emit = defineEmits<Events>()
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
nextTracksUrl: null,
|
nextTracksUrl: null,
|
||||||
|
@ -57,87 +68,82 @@ const loadMoreAlbums = async () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="object">
|
<Layout
|
||||||
<div
|
v-if="object"
|
||||||
v-if="contentFilter"
|
stack
|
||||||
class="ui small text container"
|
>
|
||||||
|
<TagsList
|
||||||
|
v-if="object.tags && object.tags.length > 0"
|
||||||
|
style="margin-top: -16px;"
|
||||||
|
:tags="object.tags"
|
||||||
|
/>
|
||||||
|
<Alert
|
||||||
|
v-if="contentFilter"
|
||||||
|
blue
|
||||||
>
|
>
|
||||||
<div class="ui hidden divider" />
|
|
||||||
<div class="ui message">
|
|
||||||
<p>
|
<p>
|
||||||
{{ $t('components.library.ArtistDetail.message.filter') }}
|
{{ t('components.library.ArtistDetail.message.filter') }}
|
||||||
</p>
|
</p>
|
||||||
<router-link
|
<Link
|
||||||
class="right floated"
|
class="right floated"
|
||||||
:to="{name: 'settings'}"
|
:to="{name: 'settings'}"
|
||||||
>
|
>
|
||||||
{{ $t('components.library.ArtistDetail.link.filter') }}
|
{{ t('components.library.ArtistDetail.link.filter') }}
|
||||||
</router-link>
|
</Link>
|
||||||
<button
|
<Button
|
||||||
class="ui basic tiny button"
|
class="tiny"
|
||||||
@click="$store.dispatch('moderation/deleteContentFilter', contentFilter.uuid)"
|
@click="store.dispatch('moderation/deleteContentFilter', contentFilter.uuid)"
|
||||||
>
|
>
|
||||||
{{ $t('components.library.ArtistDetail.button.filter') }}
|
{{ t('components.library.ArtistDetail.button.filter') }}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Alert>
|
||||||
</div>
|
<Loader v-if="isLoadingAlbums" />
|
||||||
<section
|
<template v-else-if="albums && albums.length > 0">
|
||||||
v-if="isLoadingAlbums"
|
<Heading
|
||||||
class="ui vertical stripe segment"
|
:h2="t('components.library.ArtistDetail.header.album')"
|
||||||
>
|
section-heading
|
||||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" />
|
/>
|
||||||
</section>
|
<Layout flex>
|
||||||
<section
|
|
||||||
v-else-if="albums && albums.length > 0"
|
|
||||||
class="ui vertical stripe segment"
|
|
||||||
>
|
|
||||||
<h2>
|
|
||||||
{{ $t('components.library.ArtistDetail.header.album') }}
|
|
||||||
</h2>
|
|
||||||
<div class="ui cards app-cards">
|
|
||||||
<album-card
|
<album-card
|
||||||
v-for="album in allAlbums"
|
v-for="album in allAlbums"
|
||||||
:key="album.id"
|
:key="album.id"
|
||||||
:album="album"
|
:album="album"
|
||||||
/>
|
/>
|
||||||
</div>
|
<Spacer
|
||||||
<div class="ui hidden divider" />
|
h
|
||||||
<button
|
grow
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
v-if="loadMoreAlbumsUrl !== null"
|
v-if="loadMoreAlbumsUrl !== null"
|
||||||
:class="['ui', {loading: isLoadingMoreAlbums}, 'button']"
|
primary
|
||||||
|
:is-loading="isLoadingMoreAlbums"
|
||||||
@click="loadMoreAlbums()"
|
@click="loadMoreAlbums()"
|
||||||
>
|
>
|
||||||
{{ $t('components.library.ArtistDetail.button.more') }}
|
{{ t('components.library.ArtistDetail.button.more') }}
|
||||||
</button>
|
</Button>
|
||||||
</section>
|
</Layout>
|
||||||
<section
|
</template>
|
||||||
v-if="tracks.length > 0"
|
<template v-if="tracks.length > 0">
|
||||||
class="ui vertical stripe segment"
|
<Heading
|
||||||
>
|
:h2="t('components.library.ArtistDetail.header.track')"
|
||||||
<track-table
|
section-heading
|
||||||
|
/>
|
||||||
|
<TrackTable
|
||||||
:is-artist="true"
|
:is-artist="true"
|
||||||
:show-position="false"
|
:show-position="false"
|
||||||
:track-only="true"
|
:track-only="true"
|
||||||
:tracks="tracks.slice(0,5)"
|
:tracks="tracks.slice(0,5)"
|
||||||
>
|
/>
|
||||||
<template #header>
|
|
||||||
<h2>
|
|
||||||
{{ $t('components.library.ArtistDetail.header.track') }}
|
|
||||||
</h2>
|
|
||||||
<div class="ui hidden divider" />
|
|
||||||
</template>
|
</template>
|
||||||
</track-table>
|
<Heading
|
||||||
</section>
|
:h2="t('components.library.ArtistDetail.header.library')"
|
||||||
<section class="ui vertical stripe segment">
|
section-heading
|
||||||
<h2>
|
/>
|
||||||
{{ $t('components.library.ArtistDetail.header.library') }}
|
<LibraryWidget
|
||||||
</h2>
|
|
||||||
<library-widget
|
|
||||||
:url="'artists/' + object.id + '/libraries/'"
|
:url="'artists/' + object.id + '/libraries/'"
|
||||||
@loaded="emit('libraries-loaded', $event)"
|
@loaded="emit('libraries-loaded', $event)"
|
||||||
>
|
>
|
||||||
{{ $t('components.library.ArtistDetail.description.library') }}
|
{{ t('components.library.ArtistDetail.description.library') }}
|
||||||
</library-widget>
|
</LibraryWidget>
|
||||||
</section>
|
</Layout>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue