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 { computed } from 'vue'
|
||||
|
||||
import type { components } from '~/generated/types.ts'
|
||||
|
||||
import SignupForm from '~/components/auth/SignupForm.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 nodeinfo = computed(() => store.state.instance.nodeinfo)
|
||||
|
@ -16,7 +24,8 @@ const labels = computed(() => ({
|
|||
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 shortDescription = computed(() => get(nodeinfo.value, 'metadata.shortDescription'))
|
||||
|
||||
|
@ -28,10 +37,22 @@ const stats = computed(() => {
|
|||
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 defaultUploadQuota = computed(() => humanSize(get(nodeinfo.value, 'metadata.defaultUploadQuota', 0) * 1000 * 1000))
|
||||
|
||||
const headerStyle = computed(() => {
|
||||
|
@ -43,219 +64,554 @@ const headerStyle = computed(() => {
|
|||
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>
|
||||
|
||||
<template>
|
||||
<main
|
||||
<Layout
|
||||
v-title="labels.title"
|
||||
class="main pusher page-about"
|
||||
stack
|
||||
main
|
||||
style="align-items: center;"
|
||||
>
|
||||
<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 />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column" />
|
||||
<!-- About funkwhale -->
|
||||
|
||||
<Link
|
||||
to="/"
|
||||
width="full"
|
||||
align-text="stretch"
|
||||
style="width:min(480px, 100%)"
|
||||
>
|
||||
<logo-text />
|
||||
</Link>
|
||||
|
||||
<h2 class="header">
|
||||
{{ t('components.About.header.funkwhale') }}
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
{{ t('components.About.description.funkwhale') }}
|
||||
</p>
|
||||
|
||||
<Layout
|
||||
flex
|
||||
style="justify-content: center;"
|
||||
>
|
||||
<Card
|
||||
v-if="!store.state.auth.authenticated"
|
||||
:title="t('components.About.header.signup')"
|
||||
width="256px"
|
||||
>
|
||||
<template v-if="openRegistrations">
|
||||
<p>
|
||||
{{ t('components.About.description.signup') }}
|
||||
</p>
|
||||
<p v-if="defaultUploadQuota">
|
||||
{{ t('components.About.description.quota', {quota: defaultUploadQuota}) }}
|
||||
</p>
|
||||
<signup-form
|
||||
button-classes="success"
|
||||
:show-login="true"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-else>
|
||||
<p>
|
||||
{{ t('components.About.help.closedRegistrations') }}
|
||||
</p>
|
||||
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
href="https://funkwhale.audio/#get-started"
|
||||
>
|
||||
{{ t('components.About.link.findOtherPod') }}
|
||||
<i class="external alternate icon" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!(store.state.auth.authenticated || openRegistrations)"
|
||||
class="signup-form content"
|
||||
>
|
||||
<h3 class="header">
|
||||
{{ t('components.About.header.signup') }}
|
||||
<div class="ui positive message">
|
||||
<div class="header">
|
||||
{{ t('components.About.message.loggedIn') }}
|
||||
</div>
|
||||
<h2 class="header">
|
||||
{{ $t('components.About.header.funkwhale') }}
|
||||
</h2>
|
||||
<p>
|
||||
{{ $t('components.About.description.funkwhale') }}
|
||||
{{ t('components.About.message.greeting', {username: store.state.auth.username}) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="ui hidden divider" />
|
||||
<div class="ui vertically fitted basic stripe segment">
|
||||
<div class="ui two stackable cards">
|
||||
<div class="ui card">
|
||||
<div
|
||||
v-if="!$store.state.auth.authenticated"
|
||||
class="signup-form content"
|
||||
>
|
||||
<h3 class="header">
|
||||
{{ $t('components.About.header.signup') }}
|
||||
</h3>
|
||||
<template v-if="openRegistrations">
|
||||
<p>
|
||||
{{ $t('components.About.description.signup') }}
|
||||
</p>
|
||||
<p v-if="defaultUploadQuota">
|
||||
{{ $t('components.About.description.quota', {quota: defaultUploadQuota}) }}
|
||||
</p>
|
||||
<signup-form
|
||||
button-classes="success"
|
||||
:show-login="false"
|
||||
/>
|
||||
</template>
|
||||
<div v-else>
|
||||
<p>
|
||||
{{ $t('components.About.help.closedRegistrations') }}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
href="https://funkwhale.audio/#get-started"
|
||||
>
|
||||
{{ $t('components.About.link.findOtherPod') }}
|
||||
<i class="external alternate icon" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="signup-form content"
|
||||
>
|
||||
<h3 class="header">
|
||||
{{ $t('components.About.header.signup') }}
|
||||
<div class="ui positive message">
|
||||
<div class="header">
|
||||
{{ $t('components.About.message.loggedIn') }}
|
||||
</div>
|
||||
<p>
|
||||
{{ $t('components.About.message.greeting', {username: $store.state.auth.username}) }}
|
||||
</p>
|
||||
</div>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui card">
|
||||
<section
|
||||
:class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
|
||||
:style="headerStyle"
|
||||
>
|
||||
<h1>
|
||||
<i class="music icon" />
|
||||
{{ podName }}
|
||||
</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>
|
||||
<p v-else>
|
||||
{{ $t('components.About.placeholder.noDescription') }}
|
||||
</p>
|
||||
<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 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>
|
||||
{{ $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.toLocaleString($store.state.ui.momentLocale) }}</strong></span>
|
||||
<br>
|
||||
{{ $t('components.About.stat.hoursOfMusic', stats.hours) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<router-link
|
||||
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;"
|
||||
<template #action>
|
||||
<Button
|
||||
full
|
||||
disabled
|
||||
>
|
||||
<router-link
|
||||
to="/"
|
||||
class="ui card"
|
||||
>
|
||||
<div class="content">
|
||||
<h3
|
||||
id="description"
|
||||
class="ui header"
|
||||
>
|
||||
{{ $t('components.About.header.publicContent') }}
|
||||
</h3>
|
||||
<p>
|
||||
{{ $t('components.About.description.publicContent') }}
|
||||
</p>
|
||||
</div>
|
||||
</router-link>
|
||||
<a
|
||||
href="https://funkwhale.audio/#get-started"
|
||||
class="ui card"
|
||||
target="_blank"
|
||||
>
|
||||
<div class="content">
|
||||
<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
|
||||
href="https://funkwhale.audio/apps"
|
||||
class="ui card"
|
||||
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>
|
||||
{{ 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>
|
||||
<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
|
||||
:class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
|
||||
:style="headerStyle"
|
||||
>
|
||||
<h1>
|
||||
<i class="music icon" />
|
||||
{{ podName }}
|
||||
</h1>
|
||||
</section>
|
||||
|
||||
<!-- About Pod -->
|
||||
<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"
|
||||
>
|
||||
{{ t('components.AboutPod.header.about') }}
|
||||
</h2>
|
||||
<sanitized-html
|
||||
v-if="longDescription"
|
||||
:html="longDescription"
|
||||
/>
|
||||
<p v-else>
|
||||
{{ t('components.AboutPod.placeholder.noDescription') }}
|
||||
</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">
|
||||
<h3
|
||||
id="statistics"
|
||||
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="ui big text"><strong>{{ stats.hours.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||
<br>
|
||||
{{ t('components.AboutPod.stat.hoursOfMusic', stats.hours) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="stats.data.artists"
|
||||
class="statistics-statistic"
|
||||
>
|
||||
<span class="statistics-figure ui text">
|
||||
<span class="ui big text"><strong>{{ stats.data.artists.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
|
||||
<br>
|
||||
{{ t('components.AboutPod.stat.artistsCount', stats.data.artists) }}
|
||||
</span>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<template v-if="contactEmail">
|
||||
<h3
|
||||
id="contact"
|
||||
class="ui header"
|
||||
>
|
||||
{{ t('components.AboutPod.header.contact') }}
|
||||
</h3>
|
||||
<a
|
||||
v-if="contactEmail"
|
||||
:href="`mailto:${contactEmail}`"
|
||||
>
|
||||
{{ t('components.AboutPod.message.contact', { contactEmail }) }}
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<div class="ui hidden divider" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
</template>
|
||||
|
|
|
@ -5,7 +5,6 @@ import { get } from 'lodash-es'
|
|||
import { computed } from 'vue'
|
||||
|
||||
import useMarkdown from '~/composables/useMarkdown'
|
||||
import type { NodeInfo } from '~/store/instance'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const store = useStore()
|
||||
|
@ -39,24 +38,14 @@ const federationEnabled = computed(() => {
|
|||
|
||||
const onDesktop = computed(() => window.innerWidth > 800)
|
||||
|
||||
const stats = computed(() => {
|
||||
const info = nodeinfo.value ?? {} as NodeInfo
|
||||
|
||||
const data = {
|
||||
users: get(info, 'usage.users.activeMonth', null),
|
||||
hours: get(info, 'metadata.content.local.hoursOfContent', null),
|
||||
artists: get(info, 'metadata.content.local.artists.total', null),
|
||||
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 stats = computed(() => ({
|
||||
users: nodeinfo.value?.usage.users.activeMonth,
|
||||
hours: nodeinfo.value?.metadata.content.local.hoursOfContent,
|
||||
artists: nodeinfo.value?.metadata.content.local.artists,
|
||||
albums: nodeinfo.value?.metadata.content.local.releases, // TODO: Check where to get 'metadata.content.local.albums.total'
|
||||
tracks: nodeinfo.value?.metadata.content.local.recordings, // TODO: 'metadata.content.local.tracks.total'
|
||||
listenings: nodeinfo.value?.metadata.usage?.listenings.total
|
||||
}))
|
||||
|
||||
const headerStyle = computed(() => {
|
||||
if (!banner.value) {
|
||||
|
@ -72,7 +61,7 @@ const headerStyle = computed(() => {
|
|||
<template>
|
||||
<main
|
||||
v-title="labels.title"
|
||||
class="main pusher page-about"
|
||||
class="main page-about"
|
||||
>
|
||||
<div
|
||||
class="ui"
|
||||
|
@ -99,32 +88,32 @@ const headerStyle = computed(() => {
|
|||
to="/about/pod"
|
||||
class="item"
|
||||
>
|
||||
{{ $t('components.AboutPod.link.about') }}
|
||||
{{ t('components.AboutPod.link.about') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/about/pod#rules"
|
||||
class="item"
|
||||
>
|
||||
{{ $t('components.AboutPod.link.rules') }}
|
||||
{{ t('components.AboutPod.link.rules') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/about/pod#terms"
|
||||
class="item"
|
||||
>
|
||||
{{ $t('components.AboutPod.link.terms') }}
|
||||
{{ t('components.AboutPod.link.terms') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/about/pod#features"
|
||||
class="item"
|
||||
>
|
||||
{{ $t('components.AboutPod.link.features') }}
|
||||
{{ t('components.AboutPod.link.features') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="stats"
|
||||
to="/about/pod#statistics"
|
||||
class="item"
|
||||
>
|
||||
{{ $t('components.AboutPod.link.statistics') }}
|
||||
{{ t('components.AboutPod.link.statistics') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -134,49 +123,49 @@ const headerStyle = computed(() => {
|
|||
id="description about-this-pod"
|
||||
class="ui header"
|
||||
>
|
||||
{{ $t('components.AboutPod.header.about') }}
|
||||
{{ t('components.AboutPod.header.about') }}
|
||||
</h2>
|
||||
<sanitized-html
|
||||
v-if="longDescription"
|
||||
:html="longDescription"
|
||||
/>
|
||||
<p v-else>
|
||||
{{ $t('components.AboutPod.placeholder.noDescription') }}
|
||||
{{ t('components.AboutPod.placeholder.noDescription') }}
|
||||
</p>
|
||||
|
||||
<h3
|
||||
id="rules"
|
||||
class="ui header"
|
||||
>
|
||||
{{ $t('components.AboutPod.header.rules') }}
|
||||
{{ t('components.AboutPod.header.rules') }}
|
||||
</h3>
|
||||
<sanitized-html
|
||||
v-if="rules"
|
||||
:html="rules"
|
||||
/>
|
||||
<p v-else>
|
||||
{{ $t('components.AboutPod.placeholder.noRules') }}
|
||||
{{ t('components.AboutPod.placeholder.noRules') }}
|
||||
</p>
|
||||
|
||||
<h3
|
||||
id="terms"
|
||||
class="ui header"
|
||||
>
|
||||
{{ $t('components.AboutPod.header.terms') }}
|
||||
{{ t('components.AboutPod.header.terms') }}
|
||||
</h3>
|
||||
<sanitized-html
|
||||
v-if="terms"
|
||||
:html="terms"
|
||||
/>
|
||||
<p v-else>
|
||||
{{ $t('components.AboutPod.placeholder.noTerms') }}
|
||||
{{ t('components.AboutPod.placeholder.noTerms') }}
|
||||
</p>
|
||||
|
||||
<h3
|
||||
id="features"
|
||||
class="header"
|
||||
>
|
||||
{{ $t('components.AboutPod.header.features') }}
|
||||
{{ t('components.AboutPod.header.features') }}
|
||||
</h3>
|
||||
<div class="features-container ui two column stackable grid">
|
||||
<div class="column">
|
||||
|
@ -184,7 +173,7 @@ const headerStyle = computed(() => {
|
|||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
{{ $t('components.AboutPod.feature.version') }}
|
||||
{{ t('components.AboutPod.feature.version') }}
|
||||
</td>
|
||||
<td
|
||||
v-if="version"
|
||||
|
@ -199,13 +188,13 @@ const headerStyle = computed(() => {
|
|||
class="right aligned"
|
||||
>
|
||||
<span class="features-status ui text">
|
||||
{{ $t('components.AboutPod.notApplicable') }}
|
||||
{{ t('components.AboutPod.notApplicable') }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
{{ $t('components.AboutPod.feature.federation') }}
|
||||
{{ t('components.AboutPod.feature.federation') }}
|
||||
</td>
|
||||
<td
|
||||
v-if="federationEnabled"
|
||||
|
@ -213,7 +202,7 @@ const headerStyle = computed(() => {
|
|||
>
|
||||
<span class="features-status ui text">
|
||||
<i class="check icon" />
|
||||
{{ $t('components.AboutPod.feature.status.enabled') }}
|
||||
{{ t('components.AboutPod.feature.status.enabled') }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
|
@ -222,13 +211,13 @@ const headerStyle = computed(() => {
|
|||
>
|
||||
<span class="features-status ui text">
|
||||
<i class="x icon" />
|
||||
{{ $t('components.AboutPod.feature.status.disabled') }}
|
||||
{{ t('components.AboutPod.feature.status.disabled') }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
{{ $t('components.AboutPod.feature.allowList') }}
|
||||
{{ t('components.AboutPod.feature.allowList') }}
|
||||
</td>
|
||||
<td
|
||||
v-if="allowListEnabled"
|
||||
|
@ -236,7 +225,7 @@ const headerStyle = computed(() => {
|
|||
>
|
||||
<span class="features-status ui text">
|
||||
<i class="check icon" />
|
||||
{{ $t('components.AboutPod.feature.status.enabled') }}
|
||||
{{ t('components.AboutPod.feature.status.enabled') }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
|
@ -245,7 +234,7 @@ const headerStyle = computed(() => {
|
|||
>
|
||||
<span class="features-status ui text">
|
||||
<i class="x icon" />
|
||||
{{ $t('components.AboutPod.feature.status.disabled') }}
|
||||
{{ t('components.AboutPod.feature.status.disabled') }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -257,7 +246,7 @@ const headerStyle = computed(() => {
|
|||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
{{ $t('components.AboutPod.feature.anonymousAccess') }}
|
||||
{{ t('components.AboutPod.feature.anonymousAccess') }}
|
||||
</td>
|
||||
<td
|
||||
v-if="anonymousCanListen"
|
||||
|
@ -265,7 +254,7 @@ const headerStyle = computed(() => {
|
|||
>
|
||||
<span class="features-status ui text">
|
||||
<i class="check icon" />
|
||||
{{ $t('components.AboutPod.feature.status.enabled') }}
|
||||
{{ t('components.AboutPod.feature.status.enabled') }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
|
@ -274,13 +263,13 @@ const headerStyle = computed(() => {
|
|||
>
|
||||
<span class="features-status ui text">
|
||||
<i class="x icon" />
|
||||
{{ $t('components.AboutPod.feature.status.disabled') }}
|
||||
{{ t('components.AboutPod.feature.status.disabled') }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
{{ $t('components.AboutPod.feature.registrations') }}
|
||||
{{ t('components.AboutPod.feature.registrations') }}
|
||||
</td>
|
||||
<td
|
||||
v-if="openRegistrations"
|
||||
|
@ -288,7 +277,7 @@ const headerStyle = computed(() => {
|
|||
>
|
||||
<span class="features-status ui text">
|
||||
<i class="check icon" />
|
||||
{{ $t('components.AboutPod.feature.status.open') }}
|
||||
{{ t('components.AboutPod.feature.status.open') }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
|
@ -297,13 +286,13 @@ const headerStyle = computed(() => {
|
|||
>
|
||||
<span class="features-status ui text">
|
||||
<i class="x icon" />
|
||||
{{ $t('components.AboutPod.feature.status.closed') }}
|
||||
{{ t('components.AboutPod.feature.status.closed') }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
{{ $t('components.AboutPod.feature.quota') }}
|
||||
{{ t('components.AboutPod.feature.quota') }}
|
||||
</td>
|
||||
<td
|
||||
v-if="defaultUploadQuota"
|
||||
|
@ -318,7 +307,7 @@ const headerStyle = computed(() => {
|
|||
class="right aligned"
|
||||
>
|
||||
<span class="features-status ui text">
|
||||
{{ $t('components.AboutPod.notApplicable') }}
|
||||
{{ t('components.AboutPod.notApplicable') }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -332,7 +321,7 @@ const headerStyle = computed(() => {
|
|||
id="statistics"
|
||||
class="header"
|
||||
>
|
||||
{{ $t('components.AboutPod.header.statistics') }}
|
||||
{{ t('components.AboutPod.header.statistics') }}
|
||||
</h3>
|
||||
<div class="statistics-container">
|
||||
<div
|
||||
|
@ -340,9 +329,9 @@ const headerStyle = computed(() => {
|
|||
class="statistics-statistic"
|
||||
>
|
||||
<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>
|
||||
{{ $t('components.AboutPod.stat.hoursOfMusic', stats.hours) }}
|
||||
{{ t('components.AboutPod.stat.hoursOfMusic', stats.hours) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
@ -350,9 +339,9 @@ const headerStyle = computed(() => {
|
|||
class="statistics-statistic"
|
||||
>
|
||||
<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>
|
||||
{{ $t('components.AboutPod.stat.artistsCount', stats.artists) }}
|
||||
{{ t('components.AboutPod.stat.artistsCount', stats.artists) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
@ -360,9 +349,9 @@ const headerStyle = computed(() => {
|
|||
class="statistics-statistic"
|
||||
>
|
||||
<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>
|
||||
{{ $t('components.AboutPod.stat.albumsCount', stats.albums) }}
|
||||
{{ t('components.AboutPod.stat.albumsCount', stats.albums) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
@ -370,9 +359,9 @@ const headerStyle = computed(() => {
|
|||
class="statistics-statistic"
|
||||
>
|
||||
<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>
|
||||
{{ $t('components.AboutPod.stat.tracksCount', stats.tracks) }}
|
||||
{{ t('components.AboutPod.stat.tracksCount', stats.tracks) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
@ -380,9 +369,9 @@ const headerStyle = computed(() => {
|
|||
class="statistics-statistic"
|
||||
>
|
||||
<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>
|
||||
{{ $t('components.AboutPod.stat.activeUsers', stats.users) }}
|
||||
{{ t('components.AboutPod.stat.activeUsers', stats.users) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
@ -390,9 +379,9 @@ const headerStyle = computed(() => {
|
|||
class="statistics-statistic"
|
||||
>
|
||||
<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>
|
||||
{{ $t('components.AboutPod.stat.listeningsCount', stats.listenings) }}
|
||||
{{ t('components.AboutPod.stat.listeningsCount', stats.listenings) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -403,13 +392,13 @@ const headerStyle = computed(() => {
|
|||
id="contact"
|
||||
class="ui header"
|
||||
>
|
||||
{{ $t('components.AboutPod.header.contact') }}
|
||||
{{ t('components.AboutPod.header.contact') }}
|
||||
</h3>
|
||||
<a
|
||||
v-if="contactEmail"
|
||||
:href="`mailto:${contactEmail}`"
|
||||
>
|
||||
{{ $t('components.AboutPod.message.contact', { contactEmail }) }}
|
||||
{{ t('components.AboutPod.message.contact', { contactEmail }) }}
|
||||
</a>
|
||||
</template>
|
||||
|
||||
|
@ -420,7 +409,7 @@ const headerStyle = computed(() => {
|
|||
class="ui left floated basic secondary button"
|
||||
>
|
||||
<i class="icon arrow left" />
|
||||
{{ $t('components.AboutPod.link.introduction') }}
|
||||
{{ t('components.AboutPod.link.introduction') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
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 LoginForm from '~/components/auth/LoginForm.vue'
|
||||
import SignupForm from '~/components/auth/SignupForm.vue'
|
||||
|
@ -64,7 +64,7 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
<template>
|
||||
<main
|
||||
v-title="labels.title"
|
||||
class="main pusher page-home"
|
||||
class="main page-home"
|
||||
>
|
||||
<section
|
||||
:class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
|
||||
|
@ -73,7 +73,7 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
<div class="segment-content">
|
||||
<h1 class="ui center aligned large header">
|
||||
<span>
|
||||
{{ $t('components.Home.header.welcome', {podName: podName}) }}
|
||||
{{ t('components.Home.header.welcome', {podName: podName}) }}
|
||||
</span>
|
||||
<div
|
||||
v-if="shortDescription"
|
||||
|
@ -88,7 +88,7 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
<div class="ui stackable grid">
|
||||
<div class="ten wide column">
|
||||
<h2 class="header">
|
||||
{{ $t('components.Home.header.about') }}
|
||||
{{ t('components.Home.header.about') }}
|
||||
</h2>
|
||||
<div
|
||||
id="pod"
|
||||
|
@ -97,7 +97,7 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
<div class="ui stackable grid">
|
||||
<div class="eight wide column">
|
||||
<p v-if="!longDescription">
|
||||
{{ $t('components.Home.placeholder.noDescription') }}
|
||||
{{ t('components.Home.placeholder.noDescription') }}
|
||||
</p>
|
||||
<template v-if="longDescription || rules">
|
||||
<sanitized-html
|
||||
|
@ -120,7 +120,7 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
class="ui link"
|
||||
:to="{name: 'about'}"
|
||||
>
|
||||
{{ $t('components.Home.link.learnMore') }}
|
||||
{{ t('components.Home.link.learnMore') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -135,7 +135,7 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
class="ui link"
|
||||
:to="{name: 'about', hash: '#rules'}"
|
||||
>
|
||||
{{ $t('components.Home.link.rules') }}
|
||||
{{ t('components.Home.link.rules') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -145,20 +145,20 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
<div class="eight wide column">
|
||||
<template v-if="stats">
|
||||
<h3 class="sub header">
|
||||
{{ $t('components.Home.header.statistics') }}
|
||||
{{ t('components.Home.header.statistics') }}
|
||||
</h3>
|
||||
<p>
|
||||
<i class="user icon" />
|
||||
{{ $t('components.Home.stat.activeUsers', stats.users) }}
|
||||
{{ t('components.Home.stat.activeUsers', stats.users) }}
|
||||
</p>
|
||||
<p>
|
||||
<i class="music icon" />
|
||||
{{ $t('components.Home.stat.hoursOfMusic', stats.hours) }}
|
||||
{{ t('components.Home.stat.hoursOfMusic', stats.hours) }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-if="contactEmail">
|
||||
<h3 class="sub header">
|
||||
{{ $t('components.Home.header.contact') }}
|
||||
{{ t('components.Home.header.contact') }}
|
||||
</h3>
|
||||
<i class="at icon" />
|
||||
<a :href="`mailto:${contactEmail}`">{{ contactEmail }}</a>
|
||||
|
@ -181,13 +181,13 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
<div class="ui stackable grid">
|
||||
<div class="four wide column">
|
||||
<h3 class="header">
|
||||
{{ $t('components.Home.header.aboutFunkwhale') }}
|
||||
{{ t('components.Home.header.aboutFunkwhale') }}
|
||||
</h3>
|
||||
<p>
|
||||
{{ $t('components.Home.description.funkwhale.paragraph1') }}
|
||||
{{ t('components.Home.description.funkwhale.paragraph1') }}
|
||||
</p>
|
||||
<p>
|
||||
{{ $t('components.Home.description.funkwhale.paragraph2') }}
|
||||
{{ t('components.Home.description.funkwhale.paragraph2') }}
|
||||
</p>
|
||||
<a
|
||||
target="_blank"
|
||||
|
@ -195,12 +195,12 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
href="https://funkwhale.audio"
|
||||
>
|
||||
<i class="external alternate icon" />
|
||||
{{ $t('components.Home.link.funkwhale') }}
|
||||
{{ t('components.Home.link.funkwhale') }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="four wide column">
|
||||
<h3 class="header">
|
||||
{{ $t('components.Home.header.login') }}
|
||||
{{ t('components.Home.header.login') }}
|
||||
</h3>
|
||||
<login-form
|
||||
button-classes="success"
|
||||
|
@ -210,14 +210,14 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
</div>
|
||||
<div class="four wide column">
|
||||
<h3 class="header">
|
||||
{{ $t('components.Home.header.signup') }}
|
||||
{{ t('components.Home.header.signup') }}
|
||||
</h3>
|
||||
<template v-if="openRegistrations">
|
||||
<p>
|
||||
{{ $t('components.Home.description.signup') }}
|
||||
{{ t('components.Home.description.signup') }}
|
||||
</p>
|
||||
<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>
|
||||
<signup-form
|
||||
button-classes="success"
|
||||
|
@ -226,7 +226,7 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
</template>
|
||||
<div v-else>
|
||||
<p>
|
||||
{{ $t('components.Home.help.registrationsClosed') }}
|
||||
{{ t('components.Home.help.registrationsClosed') }}
|
||||
</p>
|
||||
<a
|
||||
target="_blank"
|
||||
|
@ -234,14 +234,14 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
href="https://funkwhale.audio/#get-started"
|
||||
>
|
||||
<i class="external alternate icon" />
|
||||
{{ $t('components.Home.link.findOtherPod') }}
|
||||
{{ t('components.Home.link.findOtherPod') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="four wide column">
|
||||
<h3 class="header">
|
||||
{{ $t('components.Home.header.links') }}
|
||||
{{ t('components.Home.header.links') }}
|
||||
</h3>
|
||||
<div class="ui relaxed list">
|
||||
<div class="item">
|
||||
|
@ -252,10 +252,10 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
class="header"
|
||||
to="/library"
|
||||
>
|
||||
{{ $t('components.Home.link.publicContent.label') }}
|
||||
{{ t('components.Home.link.publicContent.label') }}
|
||||
</router-link>
|
||||
<div class="description">
|
||||
{{ $t('components.Home.link.publicContent.description') }}
|
||||
{{ t('components.Home.link.publicContent.description') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -268,10 +268,10 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{{ $t('components.Home.link.mobileApps.label') }}
|
||||
{{ t('components.Home.link.mobileApps.label') }}
|
||||
</a>
|
||||
<div class="description">
|
||||
{{ $t('components.Home.link.mobileApps.description') }}
|
||||
{{ t('components.Home.link.mobileApps.description') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -284,10 +284,10 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{{ $t('components.Home.link.userGuides.label') }}
|
||||
{{ t('components.Home.link.userGuides.label') }}
|
||||
</a>
|
||||
<div class="description">
|
||||
{{ $t('components.Home.link.userGuides.description') }}
|
||||
{{ t('components.Home.link.userGuides.description') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -304,16 +304,16 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
:limit="10"
|
||||
>
|
||||
<template #title>
|
||||
{{ $t('components.Home.header.newAlbums') }}
|
||||
{{ t('components.Home.header.newAlbums') }}
|
||||
</template>
|
||||
<router-link to="/library">
|
||||
{{ $t('components.Home.link.viewMore') }}
|
||||
{{ t('components.Home.link.viewMore') }}
|
||||
<div class="ui hidden divider" />
|
||||
</router-link>
|
||||
</album-widget>
|
||||
<div class="ui hidden section divider" />
|
||||
<h3 class="ui header">
|
||||
{{ $t('components.Home.header.newChannels') }}
|
||||
{{ t('components.Home.header.newChannels') }}
|
||||
</h3>
|
||||
<channels-widget
|
||||
:show-modification-date="true"
|
||||
|
|
|
@ -4,7 +4,7 @@ interface Props {
|
|||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
fill: '#222222'
|
||||
fill: 'var(--color)'
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ const labels = computed(() => ({
|
|||
|
||||
<template>
|
||||
<main
|
||||
class="main pusher"
|
||||
class="main"
|
||||
:v-title="labels.title"
|
||||
>
|
||||
<section class="ui vertical stripe segment">
|
||||
|
@ -20,11 +20,11 @@ const labels = computed(() => ({
|
|||
<h1 class="ui huge header">
|
||||
<i class="warning icon" />
|
||||
<div class="content">
|
||||
{{ $t('components.PageNotFound.header.pageNotFound') }}
|
||||
{{ t('components.PageNotFound.header.pageNotFound') }}
|
||||
</div>
|
||||
</h1>
|
||||
<p>
|
||||
{{ $t('components.PageNotFound.message.pageNotFound') }}
|
||||
{{ t('components.PageNotFound.message.pageNotFound') }}
|
||||
</p>
|
||||
<a :href="path">{{ path }}</a>
|
||||
<div class="ui hidden divider" />
|
||||
|
@ -32,7 +32,7 @@ const labels = computed(() => ({
|
|||
class="ui icon labeled right button"
|
||||
to="/"
|
||||
>
|
||||
{{ $t('components.PageNotFound.link.home') }}
|
||||
{{ t('components.PageNotFound.link.home') }}
|
||||
<i class="right arrow icon" />
|
||||
</router-link>
|
||||
</div>
|
||||
|
|
|
@ -5,7 +5,6 @@ import { whenever, watchDebounced, useCurrentElement, useScrollLock, useFullscre
|
|||
import { nextTick, ref, computed, watchEffect, defineAsyncComponent } from 'vue'
|
||||
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
import { usePlayer } from '~/composables/audio/player'
|
||||
|
@ -14,6 +13,8 @@ import { useQueue } from '~/composables/audio/queue'
|
|||
|
||||
import time from '~/utils/time'
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
||||
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.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 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 {
|
||||
|
@ -173,7 +180,7 @@ if (!isWebGLSupported) {
|
|||
|
||||
<template>
|
||||
<section
|
||||
class="main with-background component-queue"
|
||||
class="main opaque component-queue"
|
||||
:aria-label="labels.queue"
|
||||
>
|
||||
<div
|
||||
|
@ -194,12 +201,12 @@ if (!isWebGLSupported) {
|
|||
<img
|
||||
v-if="fullscreen"
|
||||
class="cover-shadow"
|
||||
:src="$store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
||||
:src="store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
||||
>
|
||||
<img
|
||||
ref="cover"
|
||||
alt=""
|
||||
:src="$store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
||||
:src="store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
||||
>
|
||||
</template>
|
||||
<milk-drop
|
||||
|
@ -212,47 +219,40 @@ if (!isWebGLSupported) {
|
|||
v-if="!fullscreen || !idle"
|
||||
class="cover-buttons"
|
||||
>
|
||||
<tooltip :content="!isWebGLSupported && $t('components.Queue.message.webglUnsupported')">
|
||||
<button
|
||||
<tooltip :content="!isWebGLSupported && t('components.Queue.message.webglUnsupported')">
|
||||
<Button
|
||||
v-if="coverType === CoverType.COVER_ART"
|
||||
class="ui secondary button"
|
||||
:aria-label="labels.showVisualizer"
|
||||
:title="labels.showVisualizer"
|
||||
:disabled="!isWebGLSupported"
|
||||
icon="bi-display"
|
||||
@click="coverType = CoverType.MILK_DROP"
|
||||
>
|
||||
<i class="icon signal" />
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<Button
|
||||
v-else-if="coverType === CoverType.MILK_DROP"
|
||||
class="ui secondary button"
|
||||
:aria-label="labels.showCoverArt"
|
||||
:title="labels.showCoverArt"
|
||||
:disabled="!isWebGLSupported"
|
||||
icon="bi-image-fill"
|
||||
@click="coverType = CoverType.COVER_ART"
|
||||
>
|
||||
<i class="icon image outline" />
|
||||
</button>
|
||||
/>
|
||||
</tooltip>
|
||||
|
||||
<button
|
||||
<Button
|
||||
v-if="!fullscreen"
|
||||
class="ui secondary button"
|
||||
:aria-label="labels.fullscreen"
|
||||
:title="labels.fullscreen"
|
||||
icon="bi-arrows-fullscreen"
|
||||
@click="enter"
|
||||
>
|
||||
<i class="icon expand" />
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
class="ui secondary button"
|
||||
secondary
|
||||
:aria-label="labels.exitFullscreen"
|
||||
:title="labels.exitFullscreen"
|
||||
icon="bi-fullscreen-exit"
|
||||
@click="exit"
|
||||
>
|
||||
<i class="icon compress" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition name="queue">
|
||||
|
@ -267,65 +267,53 @@ if (!isWebGLSupported) {
|
|||
v-for="ac in currentTrack.artistCredit"
|
||||
:key="ac.artist.id"
|
||||
>
|
||||
{{ ac.credit ?? $t('components.Queue.meta.unknownArtist') }}
|
||||
{{ ac.credit ?? t('components.Queue.meta.unknownArtist') }}
|
||||
<span>{{ ac.joinphrase }}</span>
|
||||
</div>
|
||||
<span class="symbol hyphen middle" />
|
||||
{{ currentTrack.albumTitle ?? $t('components.Queue.meta.unknownAlbum') }}
|
||||
{{ currentTrack.albumTitle ?? t('components.Queue.meta.unknownAlbum') }}
|
||||
</h2>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="ui header">
|
||||
<div class="content ellipsis">
|
||||
<router-link
|
||||
class="small header discrete link track"
|
||||
:to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}"
|
||||
>
|
||||
{{ currentTrack.title }}
|
||||
</router-link>
|
||||
<div class="sub header ellipsis">
|
||||
<span>
|
||||
<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">
|
||||
<span class="middle slash symbol" />
|
||||
<router-link
|
||||
class="discrete link album"
|
||||
:to="{name: 'library.albums.detail', params: {id: currentTrack.albumId }}"
|
||||
>
|
||||
{{ currentTrack.albumTitle ?? $t('components.Queue.meta.unknownAlbum') }}
|
||||
</router-link>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
class="track"
|
||||
:to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}"
|
||||
>
|
||||
{{ currentTrack.title }}
|
||||
</Link>
|
||||
</h1>
|
||||
<h2>
|
||||
<template v-if="currentTrack.albumId !== -1">
|
||||
<Link
|
||||
class="album"
|
||||
:to="{name: 'library.albums.detail', params: {id: currentTrack.albumId }}"
|
||||
>
|
||||
{{ currentTrack.albumTitle ?? t('components.Queue.meta.unknownAlbum') }}
|
||||
</Link>
|
||||
</template>
|
||||
</h2>
|
||||
<span>
|
||||
<ArtistCreditLabel
|
||||
v-if="currentTrack.artistCredit"
|
||||
:artist-credit="currentTrack.artistCredit"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
v-if="currentTrack && errored"
|
||||
class="ui small warning message"
|
||||
>
|
||||
<h3 class="header">
|
||||
{{ $t('components.Queue.header.failure') }}
|
||||
{{ t('components.Queue.header.failure') }}
|
||||
</h3>
|
||||
<p v-if="hasNext && isPlaying">
|
||||
{{ $t('components.Queue.message.automaticPlay') }}
|
||||
{{ t('components.Queue.message.automaticPlay') }}
|
||||
<i class="loading spinner icon" />
|
||||
</p>
|
||||
<p>
|
||||
{{ $t('components.Queue.warning.connectivity') }}
|
||||
{{ t('components.Queue.warning.connectivity') }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
@ -333,32 +321,40 @@ if (!isWebGLSupported) {
|
|||
class="ui small warning message"
|
||||
>
|
||||
<h3 class="header">
|
||||
{{ $t('components.Queue.header.noSources') }}
|
||||
{{ t('components.Queue.header.noSources') }}
|
||||
</h3>
|
||||
<p v-if="hasNext && isPlaying">
|
||||
{{ $t('components.Queue.message.automaticPlay') }}
|
||||
{{ t('components.Queue.message.automaticPlay') }}
|
||||
<i class="loading spinner icon" />
|
||||
</p>
|
||||
</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
|
||||
v-if="$store.state.auth.authenticated"
|
||||
v-if="store.state.auth.authenticated"
|
||||
:track="currentTrack"
|
||||
ghost
|
||||
/>
|
||||
<track-playlist-icon
|
||||
v-if="$store.state.auth.authenticated"
|
||||
v-if="store.state.auth.authenticated"
|
||||
:track="currentTrack"
|
||||
ghost
|
||||
/>
|
||||
<button
|
||||
v-if="$store.state.auth.authenticated"
|
||||
:class="['ui', 'really', 'basic', 'circular', 'icon', 'button']"
|
||||
<Button
|
||||
v-if="store.state.auth.authenticated"
|
||||
ghost
|
||||
icon="bi-eye-slash"
|
||||
:aria-label="labels.addArtistContentFilter"
|
||||
:title="labels.addArtistContentFilter"
|
||||
@click="hideArtist"
|
||||
>
|
||||
<i :class="['eye slash outline', 'basic', 'icon']" />
|
||||
</button>
|
||||
</div>
|
||||
/>
|
||||
</Layout>
|
||||
<div class="progress-wrapper">
|
||||
<div class="progress-area">
|
||||
<div
|
||||
|
@ -386,28 +382,33 @@ if (!isWebGLSupported) {
|
|||
<span class="right floated timer total">{{ time.parse(Math.round(duration)) }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="left floated timer">{{ $t('components.Queue.meta.startTime') }}</span>
|
||||
<span class="right 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>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<player-controls class="desktop-and-below" />
|
||||
<player-controls class="desktop-and-below queue-controls" />
|
||||
</template>
|
||||
</div>
|
||||
<div id="queue">
|
||||
<div class="ui basic clearing segment">
|
||||
<h2 class="ui header">
|
||||
<div class="content">
|
||||
<button
|
||||
v-t="'components.Queue.button.close'"
|
||||
class="ui right floated basic button"
|
||||
@click="$store.commit('ui/queueFocused', null)"
|
||||
<Button
|
||||
ghost
|
||||
icon="bi-chevron-down"
|
||||
style="float: right; margin-right: 24px;"
|
||||
@click="store.commit('ui/queueFocused', null)"
|
||||
/>
|
||||
<button
|
||||
v-t="'components.Queue.button.clear'"
|
||||
class="ui right floated basic button danger"
|
||||
<Button
|
||||
red
|
||||
outline
|
||||
icon="bi-trash-fill"
|
||||
style="float: right; margin-right: 16px;"
|
||||
@click="clear"
|
||||
/>
|
||||
>
|
||||
{{ t('components.Queue.button.clear') }}
|
||||
</Button>
|
||||
{{ labels.queue }}
|
||||
<div class="sub header">
|
||||
<div>
|
||||
|
@ -420,7 +421,10 @@ if (!isWebGLSupported) {
|
|||
</template>
|
||||
</i18n-t>
|
||||
<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">
|
||||
{{ endsIn }}
|
||||
</span>
|
||||
|
@ -451,30 +455,31 @@ if (!isWebGLSupported) {
|
|||
</template>
|
||||
<template #footer>
|
||||
<div
|
||||
v-if="$store.state.radios.populating"
|
||||
v-if="store.state.radios.populating"
|
||||
class="radio-populating"
|
||||
>
|
||||
<i class="loading spinner icon" />
|
||||
{{ labels.populating }}
|
||||
</div>
|
||||
<div
|
||||
v-if="$store.state.radios.running"
|
||||
v-if="store.state.radios.running"
|
||||
class="ui info message radio-message"
|
||||
>
|
||||
<div class="content">
|
||||
<h3 class="header">
|
||||
<i class="feed icon" />
|
||||
{{ $t('components.Queue.header.radio') }}
|
||||
<i class="bi bi-boombox-fill" />
|
||||
{{ t('components.Queue.header.radio') }}
|
||||
</h3>
|
||||
<p>
|
||||
{{ $t('components.Queue.message.radio') }}
|
||||
{{ t('components.Queue.message.radio') }}
|
||||
</p>
|
||||
<button
|
||||
class="ui basic primary button"
|
||||
@click="$store.dispatch('radios/stop')"
|
||||
<Button
|
||||
primary
|
||||
icon="bi-stop-fill"
|
||||
@click="store.dispatch('radios/stop')"
|
||||
>
|
||||
{{ $t('components.Queue.button.stopRadio') }}
|
||||
</button>
|
||||
{{ t('components.Queue.button.stopRadio') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -3,6 +3,11 @@ import type { QueueItemSource } from '~/types'
|
|||
|
||||
import time from '~/utils/time'
|
||||
import { generateTrackCreditStringFromQueue } from '~/utils/utils'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
|
||||
const store = useStore()
|
||||
|
||||
interface Events {
|
||||
(e: 'play', index: number): void
|
||||
|
@ -24,7 +29,7 @@ defineProps<Props>()
|
|||
tabindex="0"
|
||||
>
|
||||
<div class="handle">
|
||||
<i class="grip lines icon" />
|
||||
<i class="bi bi-list" />
|
||||
</div>
|
||||
<div
|
||||
class="image-cell"
|
||||
|
@ -37,7 +42,7 @@ defineProps<Props>()
|
|||
>
|
||||
</div>
|
||||
<div @click="$emit('play', index)">
|
||||
<button
|
||||
<div
|
||||
class="title reset ellipsis"
|
||||
:title="source.title"
|
||||
:aria-label="source.labels.selectTrack"
|
||||
|
@ -46,7 +51,7 @@ defineProps<Props>()
|
|||
<span>
|
||||
{{ generateTrackCreditStringFromQueue(source) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="duration-cell">
|
||||
<template v-if="source.sources.length > 0">
|
||||
|
@ -54,26 +59,28 @@ defineProps<Props>()
|
|||
</template>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button
|
||||
v-if="$store.state.auth.authenticated"
|
||||
<Button
|
||||
v-if="store.state.auth.authenticated"
|
||||
:aria-label="source.labels.favorite"
|
||||
:title="source.labels.favorite"
|
||||
class="ui really basic circular icon button"
|
||||
@click.stop="$store.dispatch('favorites/toggle', source.id)"
|
||||
>
|
||||
<i
|
||||
:class="$store.getters['favorites/isFavorite'](source.id) ? 'pink' : ''"
|
||||
class="heart icon"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
:icon="store.getters['favorites/isFavorite'](source.id) ? 'bi-heart-fill' : 'bi-heart'"
|
||||
round
|
||||
ghost
|
||||
square-small
|
||||
style="align-self: center;"
|
||||
:class="store.getters['favorites/isFavorite'](source.id) ? 'pink' : ''"
|
||||
@click.stop="store.dispatch('favorites/toggle', source.id)"
|
||||
/>
|
||||
<Button
|
||||
:aria-label="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)"
|
||||
>
|
||||
<i class="x icon" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -8,6 +8,11 @@ import { useStore } from '~/store'
|
|||
|
||||
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 useLogger from '~/composables/useLogger'
|
||||
|
||||
|
@ -165,85 +170,85 @@ watch(() => props.initialId, () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
<Layout
|
||||
v-if="type === 'both'"
|
||||
class="two ui buttons"
|
||||
stack
|
||||
>
|
||||
<button
|
||||
class="ui left floated labeled icon button"
|
||||
<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'"
|
||||
@split-click.prevent="type = 'artists'"
|
||||
>
|
||||
<i class="feed icon" />
|
||||
{{ $t('components.RemoteSearchForm.button.rss') }}
|
||||
</button>
|
||||
<div class="or" />
|
||||
<button
|
||||
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"
|
||||
:class="['ui', {loading: isLoading}, 'form']"
|
||||
@submit.stop.prevent="submit"
|
||||
>
|
||||
<div
|
||||
v-if="errors.length > 0"
|
||||
role="alert"
|
||||
class="ui negative message"
|
||||
>
|
||||
<h3 class="header">
|
||||
{{ $t('components.RemoteSearchForm.header.fetchFailed') }}
|
||||
</h3>
|
||||
<ul class="list">
|
||||
<li
|
||||
v-for="(error, key) in errors"
|
||||
:key="key"
|
||||
>
|
||||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="ui required field">
|
||||
<label for="object-id">
|
||||
{{ labels.fieldLabel }}
|
||||
</label>
|
||||
<p v-if="type === 'rss'">
|
||||
{{ $t('components.RemoteSearchForm.description.rss') }}
|
||||
</p>
|
||||
<p v-else-if="type === 'artists'">
|
||||
{{ $t('components.RemoteSearchForm.description.fediverse') }}
|
||||
</p>
|
||||
<input
|
||||
id="object-id"
|
||||
v-model="id"
|
||||
type="text"
|
||||
name="object-id"
|
||||
:placeholder="labels.fieldPlaceholder"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
v-if="showSubmit"
|
||||
type="submit"
|
||||
:class="['ui', 'primary', {loading: isLoading}, 'button']"
|
||||
:disabled="isLoading || !id || id.length === 0"
|
||||
>
|
||||
{{ $t('components.RemoteSearchForm.button.search') }}
|
||||
</button>
|
||||
</form>
|
||||
<div
|
||||
v-if="!isLoading && obj?.status === 'finished' && !redirectRoute"
|
||||
{{ t('components.RemoteSearchForm.button.rss') }}
|
||||
</Button>
|
||||
</Layout>
|
||||
<Layout
|
||||
v-else
|
||||
id="remote-search"
|
||||
form
|
||||
:class="['ui', {loading: isLoading}, 'form']"
|
||||
@submit.stop.prevent="submit"
|
||||
>
|
||||
<Alert
|
||||
v-if="errors.length > 0"
|
||||
red
|
||||
role="alert"
|
||||
class="ui warning message"
|
||||
title="t('components.RemoteSearchForm.header.fetchFailed')"
|
||||
>
|
||||
<p>
|
||||
{{ $t('components.RemoteSearchForm.warning.unsupported') }}
|
||||
<ul
|
||||
v-if="errors.length > 1"
|
||||
class="list"
|
||||
>
|
||||
<li
|
||||
v-for="(error, key) in errors"
|
||||
:key="key"
|
||||
>
|
||||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else>
|
||||
{{ errors[0] }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
<p v-if="type === 'rss'">
|
||||
{{ t('components.RemoteSearchForm.description.rss') }}
|
||||
</p>
|
||||
<p v-else-if="type === 'artists'">
|
||||
{{ t('components.RemoteSearchForm.description.fediverse') }}
|
||||
</p>
|
||||
|
||||
<Input
|
||||
id="object-id"
|
||||
v-model="id"
|
||||
type="text"
|
||||
name="object-id"
|
||||
:label="labels.fieldLabel"
|
||||
:placeholder="labels.fieldPlaceholder"
|
||||
style="width: 100%;"
|
||||
required
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="showSubmit"
|
||||
primary
|
||||
type="submit"
|
||||
:class="{loading: isLoading}"
|
||||
:disabled="isLoading || !id || id.length === 0"
|
||||
>
|
||||
{{ t('components.RemoteSearchForm.button.search') }}
|
||||
</Button>
|
||||
</Layout>
|
||||
<Alert
|
||||
v-if="!isLoading && obj?.status === 'finished' && !redirectRoute"
|
||||
red
|
||||
>
|
||||
{{ t('components.RemoteSearchForm.warning.unsupported') }}
|
||||
</Alert>
|
||||
</template>
|
||||
|
|
|
@ -1,7 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { useStore } from '~/store'
|
||||
|
||||
const store = useStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ui toast-container">
|
||||
<message
|
||||
v-for="message in $store.state.ui.messages"
|
||||
v-for="message in store.state.ui.messages"
|
||||
:key="message.key"
|
||||
: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 { useStore } from '~/store'
|
||||
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 {
|
||||
group: SettingsGroup
|
||||
|
@ -96,166 +107,180 @@ const save = async () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
:id="group.id"
|
||||
class="ui form component-settings-group"
|
||||
@submit.prevent="save"
|
||||
<!-- TODO: type the different values in `settings` (use generics) -->
|
||||
<!-- eslint-disable vue/valid-v-model -->
|
||||
<Section
|
||||
align-left
|
||||
:h2="group.label"
|
||||
large-section-heading
|
||||
>
|
||||
<div class="ui divider" />
|
||||
<h3 class="ui header">
|
||||
{{ group.label }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="errors.length > 0"
|
||||
role="alert"
|
||||
class="ui negative message"
|
||||
<form
|
||||
:id="group.id"
|
||||
class="ui form component-settings-group"
|
||||
style="grid-column: 1 / -1;"
|
||||
@submit.prevent="save"
|
||||
>
|
||||
<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
|
||||
v-for="(setting, key) in settings"
|
||||
:key="key"
|
||||
class="ui field"
|
||||
>
|
||||
<template v-if="setting.field.widget.class !== 'CheckboxInput'">
|
||||
<label :for="setting.identifier">{{ setting.verbose_name }}</label>
|
||||
<p v-if="setting.help_text">
|
||||
{{ setting.help_text }}
|
||||
</p>
|
||||
</template>
|
||||
<content-form
|
||||
v-if="setting.fieldType === 'markdown'"
|
||||
v-bind="setting.fieldParams"
|
||||
v-model="values[setting.identifier]"
|
||||
/>
|
||||
<!-- eslint-disable vue/valid-v-model -->
|
||||
<signup-form-builder
|
||||
v-else-if="setting.fieldType === 'formBuilder'"
|
||||
v-model="values[setting.identifier] as Form"
|
||||
:signup-approval-enabled="!!values.moderation__signup_approval_enabled"
|
||||
/>
|
||||
<!-- eslint-enable vue/valid-v-model -->
|
||||
<input
|
||||
v-else-if="setting.field.widget.class === 'PasswordInput'"
|
||||
:id="setting.identifier"
|
||||
v-model="values[setting.identifier]"
|
||||
:name="setting.identifier"
|
||||
type="password"
|
||||
class="ui input"
|
||||
>
|
||||
<input
|
||||
v-else-if="setting.field.widget.class === 'TextInput'"
|
||||
:id="setting.identifier"
|
||||
v-model="values[setting.identifier]"
|
||||
:name="setting.identifier"
|
||||
type="text"
|
||||
class="ui input"
|
||||
>
|
||||
<input
|
||||
v-else-if="setting.field.class === 'IntegerField'"
|
||||
:id="setting.identifier"
|
||||
v-model.number="values[setting.identifier]"
|
||||
:name="setting.identifier"
|
||||
type="number"
|
||||
class="ui input"
|
||||
>
|
||||
<!-- eslint-disable vue/valid-v-model -->
|
||||
<textarea
|
||||
v-else-if="setting.field.widget.class === 'Textarea'"
|
||||
:id="setting.identifier"
|
||||
v-model="values[setting.identifier] as string"
|
||||
:name="setting.identifier"
|
||||
type="text"
|
||||
class="ui input"
|
||||
/>
|
||||
<!-- eslint-enable vue/valid-v-model -->
|
||||
<Spacer :size="16" />
|
||||
<div
|
||||
v-else-if="setting.field.widget.class === 'CheckboxInput'"
|
||||
class="ui toggle checkbox"
|
||||
v-for="(setting, key) in settings"
|
||||
:key="key"
|
||||
:class="[$style.field, 'ui', 'field']"
|
||||
>
|
||||
<!-- eslint-disable vue/valid-v-model -->
|
||||
<input
|
||||
:id="setting.identifier"
|
||||
v-model="values[setting.identifier] as boolean"
|
||||
:name="setting.identifier"
|
||||
type="checkbox"
|
||||
>
|
||||
<template v-if="setting.field.widget.class !== 'CheckboxInput'">
|
||||
<label :for="setting.identifier">{{ setting.verbose_name }}</label>
|
||||
<p v-if="setting.help_text">
|
||||
{{ setting.help_text }}
|
||||
</p>
|
||||
</template>
|
||||
<content-form
|
||||
v-if="setting.fieldType === 'markdown'"
|
||||
v-bind="setting.fieldParams"
|
||||
v-model="values[setting.identifier]"
|
||||
/>
|
||||
<signup-form-builder
|
||||
v-else-if="setting.fieldType === 'formBuilder'"
|
||||
v-model="values[setting.identifier] as Form"
|
||||
:signup-approval-enabled="!!values.moderation__signup_approval_enabled"
|
||||
/>
|
||||
<Input
|
||||
v-else-if="setting.field.widget.class === 'PasswordInput'"
|
||||
v-model="values[setting.identifier] as string"
|
||||
password
|
||||
type="password"
|
||||
class="ui input"
|
||||
/>
|
||||
<Input
|
||||
v-else-if="setting.field.widget.class === 'TextInput'"
|
||||
v-model="values[setting.identifier] as string"
|
||||
type="text"
|
||||
class="ui input"
|
||||
/>
|
||||
<Input
|
||||
v-else-if="setting.field.class === 'IntegerField'"
|
||||
v-model.number="values[setting.identifier] as number"
|
||||
type="number"
|
||||
class="ui input"
|
||||
/>
|
||||
<textarea
|
||||
v-else-if="setting.field.widget.class === 'Textarea'"
|
||||
v-model="values[setting.identifier] as string"
|
||||
type="text"
|
||||
class="ui input"
|
||||
/>
|
||||
<!-- eslint-enable vue/valid-v-model -->
|
||||
<label :for="setting.identifier">{{ setting.verbose_name }}</label>
|
||||
<p v-if="setting.help_text">
|
||||
{{ setting.help_text }}
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
v-else-if="setting.field.class === 'MultipleChoiceField'"
|
||||
:id="setting.identifier"
|
||||
v-model="values[setting.identifier]"
|
||||
multiple
|
||||
class="ui search selection dropdown"
|
||||
>
|
||||
<option
|
||||
v-for="v in setting.additional_data?.choices"
|
||||
:key="v[0]"
|
||||
:value="v[0]"
|
||||
<div
|
||||
v-else-if="setting.field.widget.class === 'CheckboxInput'"
|
||||
>
|
||||
{{ v[1] }}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
v-else-if="setting.field.class === 'ChoiceField'"
|
||||
:id="setting.identifier"
|
||||
v-model="values[setting.identifier]"
|
||||
class="ui search selection dropdown"
|
||||
>
|
||||
<option
|
||||
v-for="v in setting.additional_data?.choices"
|
||||
:key="v[0]"
|
||||
:value="v[0]"
|
||||
>
|
||||
{{ v[1] }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-else-if="setting.field.widget.class === 'ImageWidget'">
|
||||
<input
|
||||
:id="setting.identifier"
|
||||
:ref="setFileRef(setting.identifier)"
|
||||
type="file"
|
||||
>
|
||||
<div v-if="values[setting.identifier]">
|
||||
<div class="ui hidden divider" />
|
||||
<h3 class="ui header">
|
||||
{{ $t('components.admin.SettingsGroup.header.image') }}
|
||||
</h3>
|
||||
<img
|
||||
v-if="values[setting.identifier]"
|
||||
class="ui image"
|
||||
alt=""
|
||||
:src="$store.getters['instance/absoluteUrl'](values[setting.identifier])"
|
||||
>
|
||||
<Toggle
|
||||
v-model="values[setting.identifier] as boolean"
|
||||
big
|
||||
:label="setting.verbose_name"
|
||||
/>
|
||||
<Spacer :size="8" />
|
||||
<p v-if="setting.help_text">
|
||||
{{ setting.help_text }}
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
v-else-if="setting.field.class === 'MultipleChoiceField'"
|
||||
:id="setting.identifier"
|
||||
v-model="values[setting.identifier]"
|
||||
multiple
|
||||
class="ui search selection dropdown"
|
||||
style="height: 150px;"
|
||||
>
|
||||
<option
|
||||
v-for="v in setting.additional_data?.choices"
|
||||
:key="v[0]"
|
||||
:value="v[0]"
|
||||
>
|
||||
{{ v[1] }}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
v-else-if="setting.field.class === 'ChoiceField'"
|
||||
:id="setting.identifier"
|
||||
v-model="values[setting.identifier]"
|
||||
class="ui search selection dropdown"
|
||||
>
|
||||
<option
|
||||
v-for="v in setting.additional_data?.choices"
|
||||
:key="v[0]"
|
||||
:value="v[0]"
|
||||
>
|
||||
{{ v[1] }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-else-if="setting.field.widget.class === 'ImageWidget'">
|
||||
<!-- TODO: Implement image input -->
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<Input
|
||||
:id="setting.identifier"
|
||||
:ref="setFileRef(setting.identifier)"
|
||||
type="file"
|
||||
/>
|
||||
|
||||
<div v-if="values[setting.identifier]">
|
||||
<h3 class="ui header">
|
||||
{{ t('components.admin.SettingsGroup.header.image') }}
|
||||
</h3>
|
||||
<img
|
||||
v-if="values[setting.identifier]"
|
||||
class="ui image"
|
||||
alt=""
|
||||
:src="store.getters['instance/absoluteUrl'](values[setting.identifier])"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<Spacer />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'success', 'button']"
|
||||
>
|
||||
{{ $t('components.admin.SettingsGroup.button.save') }}
|
||||
</button>
|
||||
</form>
|
||||
<Layout flex>
|
||||
<Spacer grow />
|
||||
<Button
|
||||
type="submit"
|
||||
:class="[{'loading': isLoading}]"
|
||||
primary
|
||||
>
|
||||
{{ t('components.admin.SettingsGroup.button.save') }}
|
||||
</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>
|
||||
</Section>
|
||||
<hr :class="$style.separator">
|
||||
<Spacer size-64 />
|
||||
<!-- eslint-enable vue/valid-v-model -->
|
||||
</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 SignupForm from '~/components/auth/SignupForm.vue'
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
@ -65,18 +67,20 @@ const move = (idx: number, increment: number) => {
|
|||
<template>
|
||||
<div>
|
||||
<div class="ui top attached tabular menu">
|
||||
<button
|
||||
<Button
|
||||
color="primary"
|
||||
:class="[{active: !isPreviewing}, 'item']"
|
||||
@click.stop.prevent="isPreviewing = false"
|
||||
>
|
||||
{{ $t('components.admin.SignupFormBuilder.button.edit') }}
|
||||
</button>
|
||||
<button
|
||||
{{ t('components.admin.SignupFormBuilder.button.edit') }}
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
:class="[{active: isPreviewing}, 'item']"
|
||||
@click.stop.prevent="isPreviewing = true"
|
||||
>
|
||||
{{ $t('components.admin.SignupFormBuilder.button.preview') }}
|
||||
</button>
|
||||
{{ t('components.admin.SignupFormBuilder.button.preview') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="isPreviewing"
|
||||
|
@ -95,10 +99,10 @@ const move = (idx: number, increment: number) => {
|
|||
>
|
||||
<div class="field">
|
||||
<label for="help-text">
|
||||
{{ $t('components.admin.SignupFormBuilder.label.helpText') }}
|
||||
{{ t('components.admin.SignupFormBuilder.label.helpText') }}
|
||||
</label>
|
||||
<p>
|
||||
{{ $t('components.admin.SignupFormBuilder.help.helpText') }}
|
||||
{{ t('components.admin.SignupFormBuilder.help.helpText') }}
|
||||
</p>
|
||||
<content-form
|
||||
v-if="value.help_text"
|
||||
|
@ -109,24 +113,24 @@ const move = (idx: number, increment: number) => {
|
|||
</div>
|
||||
<div class="field">
|
||||
<label>
|
||||
{{ $t('components.admin.SignupFormBuilder.label.additionalFields') }}
|
||||
{{ t('components.admin.SignupFormBuilder.label.additionalFields') }}
|
||||
</label>
|
||||
<p>
|
||||
{{ $t('components.admin.SignupFormBuilder.help.additionalFields') }}
|
||||
{{ t('components.admin.SignupFormBuilder.help.additionalFields') }}
|
||||
</p>
|
||||
<table v-if="value.fields?.length > 0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.header.label') }}
|
||||
{{ t('components.admin.SignupFormBuilder.table.additionalFields.header.label') }}
|
||||
</th>
|
||||
<th>
|
||||
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.header.type') }}
|
||||
{{ t('components.admin.SignupFormBuilder.table.additionalFields.header.type') }}
|
||||
</th>
|
||||
<th>
|
||||
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.header.required') }}
|
||||
{{ t('components.admin.SignupFormBuilder.table.additionalFields.header.required') }}
|
||||
</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>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
@ -144,20 +148,20 @@ const move = (idx: number, increment: number) => {
|
|||
<td>
|
||||
<select v-model="field.input_type">
|
||||
<option value="short_text">
|
||||
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.type.short') }}
|
||||
{{ t('components.admin.SignupFormBuilder.table.additionalFields.type.short') }}
|
||||
</option>
|
||||
<option value="long_text">
|
||||
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.type.long') }}
|
||||
{{ t('components.admin.SignupFormBuilder.table.additionalFields.type.long') }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select v-model="field.required">
|
||||
<option :value="true">
|
||||
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.required.true') }}
|
||||
{{ t('components.admin.SignupFormBuilder.table.additionalFields.required.true') }}
|
||||
</option>
|
||||
<option :value="false">
|
||||
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.required.false') }}
|
||||
{{ t('components.admin.SignupFormBuilder.table.additionalFields.required.false') }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
|
@ -187,13 +191,13 @@ const move = (idx: number, increment: number) => {
|
|||
</tbody>
|
||||
</table>
|
||||
<div class="ui hidden divider" />
|
||||
<button
|
||||
<Button
|
||||
v-if="value.fields?.length < maxFields"
|
||||
class="ui basic button"
|
||||
color="primary"
|
||||
@click.stop.prevent="addField"
|
||||
>
|
||||
{{ $t('components.admin.SignupFormBuilder.button.add') }}
|
||||
</button>
|
||||
{{ t('components.admin.SignupFormBuilder.button.add') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<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 AlbumCard from '~/components/audio/album/Card.vue'
|
||||
|
||||
import usePage from '~/composables/navigation/usePage'
|
||||
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 {
|
||||
filters: Record<string, string | boolean>
|
||||
showCount?: boolean
|
||||
search?: boolean
|
||||
limit?: number
|
||||
title?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showCount: false,
|
||||
search: false,
|
||||
limit: 12
|
||||
limit: 12,
|
||||
title: undefined
|
||||
})
|
||||
|
||||
const store = useStore()
|
||||
|
@ -28,6 +34,7 @@ const store = useStore()
|
|||
const query = ref('')
|
||||
const albums = reactive([] as Album[])
|
||||
const count = ref(0)
|
||||
const page = usePage()
|
||||
const nextPage = ref()
|
||||
|
||||
const isLoading = ref(false)
|
||||
|
@ -38,13 +45,14 @@ const fetchData = async (url = 'albums/') => {
|
|||
const params = {
|
||||
q: query.value,
|
||||
...props.filters,
|
||||
page: page.value,
|
||||
page_size: props.limit
|
||||
}
|
||||
|
||||
const response = await axios.get(url, { params })
|
||||
nextPage.value = response.data.next
|
||||
count.value = response.data.count
|
||||
albums.push(...response.data.results)
|
||||
albums.splice(0, albums.length, ...response.data.results)
|
||||
} catch (error) {
|
||||
useErrorHandler(error as Error)
|
||||
}
|
||||
|
@ -52,68 +60,58 @@ const fetchData = async (url = 'albums/') => {
|
|||
isLoading.value = false
|
||||
}
|
||||
|
||||
setTimeout(fetchData, 1000)
|
||||
|
||||
const performSearch = () => {
|
||||
albums.length = 0
|
||||
fetchData()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => store.state.moderation.lastUpdate,
|
||||
[() => store.state.moderation.lastUpdate, page],
|
||||
() => fetchData(),
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrapper">
|
||||
<h3
|
||||
v-if="!!$slots.title"
|
||||
class="ui header"
|
||||
>
|
||||
<slot name="title" />
|
||||
<span
|
||||
v-if="showCount"
|
||||
class="ui tiny circular label"
|
||||
>{{ count }}</span>
|
||||
</h3>
|
||||
<slot />
|
||||
<Section
|
||||
align-left
|
||||
:h2="title"
|
||||
:columns-per-item="1"
|
||||
>
|
||||
<inline-search-bar
|
||||
v-if="search"
|
||||
v-model="query"
|
||||
style="grid-column: 1 / -1;"
|
||||
@search="performSearch"
|
||||
/>
|
||||
<div class="ui hidden divider" />
|
||||
<div class="ui app-cards cards">
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="ui inverted active dimmer"
|
||||
>
|
||||
<div class="ui loader" />
|
||||
</div>
|
||||
<Loader
|
||||
v-if="isLoading"
|
||||
style="grid-column: 1 / -1;"
|
||||
/>
|
||||
<template v-if="!isLoading && albums.length > 0">
|
||||
<album-card
|
||||
v-for="album in albums"
|
||||
:key="album.id"
|
||||
:album="album"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<slot
|
||||
v-if="!isLoading && albums.length === 0"
|
||||
name="empty-state"
|
||||
>
|
||||
<empty-state
|
||||
:refresh="true"
|
||||
style="grid-column: 1 / -1;"
|
||||
@refresh="fetchData"
|
||||
/>
|
||||
</slot>
|
||||
<template v-if="nextPage">
|
||||
<div class="ui hidden divider" />
|
||||
<button
|
||||
v-if="nextPage"
|
||||
:class="['ui', 'basic', 'button']"
|
||||
@click="fetchData(nextPage)"
|
||||
>
|
||||
{{ $t('components.audio.album.Widget.button.more') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<Pagination
|
||||
v-if="page && albums && count > props.limit"
|
||||
v-model:page="page"
|
||||
:pages="Math.ceil((count || 0) / props.limit)"
|
||||
style="grid-column: 1 / -1;"
|
||||
/>
|
||||
</Section>
|
||||
</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">
|
||||
import type { Artist } from '~/types'
|
||||
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { reactive, ref, watch, onMounted } from 'vue'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
import ArtistCard from '~/components/audio/artist/Card.vue'
|
||||
|
||||
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 {
|
||||
filters: Record<string, string | boolean>
|
||||
search?: boolean
|
||||
header?: boolean
|
||||
limit?: number
|
||||
title?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
search: false,
|
||||
header: true,
|
||||
limit: 12
|
||||
limit: 12,
|
||||
title: undefined
|
||||
})
|
||||
|
||||
const store = useStore()
|
||||
|
@ -28,6 +34,7 @@ const store = useStore()
|
|||
const query = ref('')
|
||||
const artists = reactive([] as Artist[])
|
||||
const count = ref(0)
|
||||
const page = usePage()
|
||||
const nextPage = ref()
|
||||
|
||||
const isLoading = ref(false)
|
||||
|
@ -38,13 +45,14 @@ const fetchData = async (url = 'artists/') => {
|
|||
const params = {
|
||||
q: query.value,
|
||||
...props.filters,
|
||||
page: page.value,
|
||||
page_size: props.limit
|
||||
}
|
||||
|
||||
const response = await axios.get(url, { params })
|
||||
nextPage.value = response.data.next
|
||||
count.value = response.data.count
|
||||
artists.push(...response.data.results)
|
||||
artists.splice(0, artists.length, ...response.data.results)
|
||||
} catch (error) {
|
||||
useErrorHandler(error as Error)
|
||||
}
|
||||
|
@ -52,64 +60,58 @@ const fetchData = async (url = 'artists/') => {
|
|||
isLoading.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(fetchData, 1000)
|
||||
})
|
||||
|
||||
const performSearch = () => {
|
||||
artists.length = 0
|
||||
fetchData()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => store.state.moderation.lastUpdate,
|
||||
[() => store.state.moderation.lastUpdate, page],
|
||||
() => fetchData(),
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrapper">
|
||||
<h3
|
||||
v-if="header"
|
||||
class="ui header"
|
||||
>
|
||||
<slot name="title" />
|
||||
<span class="ui tiny circular label">{{ count }}</span>
|
||||
</h3>
|
||||
<inline-search-bar
|
||||
v-if="search"
|
||||
v-model="query"
|
||||
@search="performSearch"
|
||||
<Section
|
||||
align-left
|
||||
:columns-per-item="1"
|
||||
:h2="title"
|
||||
>
|
||||
<Loader
|
||||
v-if="isLoading"
|
||||
style="grid-column: 1 / -1;"
|
||||
/>
|
||||
<div class="ui hidden divider" />
|
||||
<div class="ui five app-cards cards">
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="ui inverted active dimmer"
|
||||
>
|
||||
<div class="ui loader" />
|
||||
</div>
|
||||
<artist-card
|
||||
v-for="artist in artists"
|
||||
:key="artist.id"
|
||||
:artist="artist"
|
||||
/>
|
||||
</div>
|
||||
<slot
|
||||
v-if="!isLoading && artists.length === 0"
|
||||
name="empty-state"
|
||||
>
|
||||
<empty-state
|
||||
style="grid-column: 1 / -1;"
|
||||
:refresh="true"
|
||||
@refresh="fetchData"
|
||||
/>
|
||||
</slot>
|
||||
<template v-if="nextPage">
|
||||
<div class="ui hidden divider" />
|
||||
<button
|
||||
v-if="nextPage"
|
||||
:class="['ui', 'basic', 'button']"
|
||||
@click="fetchData(nextPage)"
|
||||
>
|
||||
{{ $t('components.audio.artist.Widget.button.more') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<inline-search-bar
|
||||
v-if="!isLoading && search"
|
||||
v-model="query"
|
||||
style="grid-column: 1 / -1;"
|
||||
@search="performSearch"
|
||||
/>
|
||||
<artist-card
|
||||
v-for="artist in artists"
|
||||
:key="artist.id"
|
||||
:artist="artist"
|
||||
/>
|
||||
<Pagination
|
||||
v-if="page && artists && count > limit"
|
||||
v-model:page="page"
|
||||
style="grid-column: 1 / -1;"
|
||||
:pages="Math.ceil((count || 0) / limit)"
|
||||
/>
|
||||
</Section>
|
||||
</template>
|
|
@ -1,5 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
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 {
|
||||
artistCredit: ArtistCredit[]
|
||||
|
@ -7,6 +13,10 @@ interface Props {
|
|||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// TODO: Fix getRoute
|
||||
|
||||
// TODO: check if still needed:
|
||||
/*
|
||||
const getRoute = (ac: ArtistCredit) => {
|
||||
return {
|
||||
name: ac.artist.channel ? 'channels.detail' : 'library.artists.detail',
|
||||
|
@ -15,30 +25,47 @@ const getRoute = (ac: ArtistCredit) => {
|
|||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="artist-label ui image label">
|
||||
<Layout
|
||||
flex
|
||||
gap-8
|
||||
>
|
||||
<template
|
||||
v-for="ac in props.artistCredit"
|
||||
:key="ac.artist.id"
|
||||
>
|
||||
<router-link
|
||||
:to="getRoute(ac)"
|
||||
:to="{name: 'library.artists.detail', params: {id: ac.artist.id }}"
|
||||
class="username"
|
||||
@click.stop.prevent=""
|
||||
>
|
||||
<img
|
||||
v-if="ac.index === 0 && ac.artist.cover && ac.artist.cover.urls.original"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](ac.artist.cover.urls.medium_square_crop)"
|
||||
alt=""
|
||||
:class="[{circular: ac.artist.content_category != 'podcast'}]"
|
||||
>
|
||||
<i
|
||||
v-else-if="ac.index === 0"
|
||||
:class="[ac.artist.content_category != 'podcast' ? 'circular' : 'bordered', 'inverted violet users icon']"
|
||||
/>
|
||||
{{ ac.credit }}
|
||||
<Pill>
|
||||
<template #image>
|
||||
<img
|
||||
v-if="ac.artist.cover && ac.artist.cover.urls.original"
|
||||
v-lazy="store.getters['instance/absoluteUrl'](ac.artist.cover.urls.small_square_crop)"
|
||||
:alt="ac.artist.name"
|
||||
>
|
||||
<i
|
||||
v-else
|
||||
class="bi bi-person-circle"
|
||||
style="font-size: 24px;"
|
||||
/>
|
||||
</template>
|
||||
{{ ac.credit }}
|
||||
</Pill>
|
||||
</router-link>
|
||||
<span>{{ ac.joinphrase }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
a.username {
|
||||
text-decoration: none;
|
||||
height: 25px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -2,6 +2,9 @@
|
|||
import type { Artist } from '~/types'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
const store = useStore()
|
||||
|
||||
interface Props {
|
||||
artist: Artist
|
||||
|
@ -10,7 +13,7 @@ interface Props {
|
|||
const props = defineProps<Props>()
|
||||
|
||||
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 } }
|
||||
)
|
||||
</script>
|
||||
|
@ -22,7 +25,7 @@ const route = computed(() => props.artist.channel
|
|||
>
|
||||
<img
|
||||
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=""
|
||||
:class="[{circular: artist.content_category != 'podcast'}]"
|
||||
>
|
||||
|
|
|
@ -9,7 +9,9 @@ import { computed } from 'vue'
|
|||
import moment from 'moment'
|
||||
|
||||
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 {
|
||||
object: Channel
|
||||
|
@ -41,64 +43,92 @@ const updatedAgo = computed(() => moment(props.object.artist?.modification_date)
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card app-card">
|
||||
<div
|
||||
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']"
|
||||
<Card
|
||||
:title="object.artist?.name"
|
||||
: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>
|
||||
<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 ?? []"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extra content">
|
||||
</template>
|
||||
|
||||
<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
|
||||
class="meta ellipsis"
|
||||
:datetime="object.artist?.modification_date"
|
||||
:title="updatedTitle"
|
||||
>
|
||||
{{ updatedAgo }}
|
||||
</time>
|
||||
<play-button
|
||||
class="right floated basic icon"
|
||||
<i class="bi bi-dot" />
|
||||
<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"
|
||||
:is-playable="true"
|
||||
:dropdown-icon-classes="['ellipsis', 'horizontal', 'large really discrete']"
|
||||
:artist="object.artist"
|
||||
:channel="object"
|
||||
:account="object.attributed_to"
|
||||
discrete
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
|
|
@ -3,11 +3,14 @@ import type { Cover, Track, BackendResponse, BackendError } from '~/types'
|
|||
|
||||
import { clone } from 'lodash-es'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import axios from 'axios'
|
||||
import PodcastTable from '~/components/audio/podcast/Table.vue'
|
||||
import TrackTable from '~/components/audio/track/Table.vue'
|
||||
|
||||
import Loader from '~/components/ui/Loader.vue'
|
||||
|
||||
interface Events {
|
||||
(e: 'fetched', data: BackendResponse<Track[]>): void
|
||||
}
|
||||
|
@ -18,6 +21,7 @@ interface Props {
|
|||
defaultCover: Cover | null
|
||||
isPodcast: boolean
|
||||
}
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
|
@ -61,51 +65,45 @@ watch(page, fetchData, { immediate: true })
|
|||
<template>
|
||||
<div>
|
||||
<slot />
|
||||
<div class="ui hidden divider" />
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="ui inverted active dimmer"
|
||||
>
|
||||
<div class="ui loader" />
|
||||
</div>
|
||||
<podcast-table
|
||||
v-if="isPodcast"
|
||||
v-model:page="page"
|
||||
:paginate-by="limit"
|
||||
:default-cover="defaultCover"
|
||||
:is-podcast="isPodcast"
|
||||
:show-art="true"
|
||||
:show-position="false"
|
||||
:tracks="channels"
|
||||
:show-artist="false"
|
||||
:show-album="false"
|
||||
:paginate-results="true"
|
||||
:total="count"
|
||||
/>
|
||||
<track-table
|
||||
v-else
|
||||
v-model:page="page"
|
||||
:default-cover="defaultCover"
|
||||
:is-podcast="isPodcast"
|
||||
:show-art="true"
|
||||
:show-position="false"
|
||||
:tracks="channels"
|
||||
:show-artist="false"
|
||||
:show-album="false"
|
||||
:paginate-results="true"
|
||||
:total="count"
|
||||
:paginate-by="limit"
|
||||
:filters="filters"
|
||||
/>
|
||||
<template v-if="!isLoading && channels.length === 0">
|
||||
<empty-state
|
||||
:refresh="true"
|
||||
@refresh="fetchData()"
|
||||
>
|
||||
<p>
|
||||
{{ $t('components.audio.ChannelEntries.help.subscribe') }}
|
||||
</p>
|
||||
</empty-state>
|
||||
</template>
|
||||
<Loader v-if="isLoading" />
|
||||
</div>
|
||||
<podcast-table
|
||||
v-if="isPodcast"
|
||||
v-model:page="page"
|
||||
:paginate-by="limit"
|
||||
:default-cover="defaultCover"
|
||||
:is-podcast="isPodcast"
|
||||
:show-art="true"
|
||||
:show-position="false"
|
||||
:tracks="channels"
|
||||
:show-artist="false"
|
||||
:show-album="false"
|
||||
:paginate-results="true"
|
||||
:total="count"
|
||||
/>
|
||||
<track-table
|
||||
v-else
|
||||
v-model:page="page"
|
||||
:default-cover="defaultCover"
|
||||
:is-podcast="isPodcast"
|
||||
:show-art="true"
|
||||
:show-position="false"
|
||||
:tracks="channels"
|
||||
:show-artist="false"
|
||||
:show-album="false"
|
||||
:paginate-results="true"
|
||||
:total="count"
|
||||
:paginate-by="limit"
|
||||
:filters="filters"
|
||||
/>
|
||||
<template v-if="!isLoading && channels.length === 0">
|
||||
<empty-state
|
||||
:refresh="true"
|
||||
@refresh="fetchData()"
|
||||
>
|
||||
<p>
|
||||
{{ t('components.audio.ChannelEntries.help.subscribe') }}
|
||||
</p>
|
||||
</empty-state>
|
||||
</template>
|
||||
</template>
|
||||
|
|
|
@ -5,10 +5,15 @@ import { computed } from 'vue'
|
|||
|
||||
import { usePlayer } from '~/composables/audio/player'
|
||||
import { useQueue } from '~/composables/audio/queue'
|
||||
import { useStore } from '~/store'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||
|
||||
const store = useStore()
|
||||
const router = useRouter()
|
||||
|
||||
interface Props {
|
||||
entry: Track
|
||||
defaultCover: Cover
|
||||
|
@ -37,30 +42,30 @@ const duration = computed(() => props.entry.uploads.find(upload => upload.durati
|
|||
</div>
|
||||
<img
|
||||
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=""
|
||||
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
|
||||
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"
|
||||
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
|
||||
@click="router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
|
||||
>
|
||||
<img
|
||||
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=""
|
||||
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
|
||||
v-else
|
||||
alt=""
|
||||
class="channel-image image"
|
||||
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">
|
||||
<strong>
|
||||
|
@ -78,7 +83,7 @@ const duration = computed(() => props.entry.uploads.find(upload => upload.durati
|
|||
/>
|
||||
</div>
|
||||
<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
|
||||
class="tiny"
|
||||
:track="entry"
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { ContentCategory, Channel, BackendError } from '~/types'
|
||||
import type { paths } from '~/generated/types'
|
||||
|
||||
import { slugify } from 'transliteration'
|
||||
import { reactive, computed, ref, watchEffect, watch } from 'vue'
|
||||
|
@ -7,23 +8,27 @@ import { useI18n } from 'vue-i18n'
|
|||
|
||||
import axios from 'axios'
|
||||
import AttachmentInput from '~/components/common/AttachmentInput.vue'
|
||||
import TagsSelector from '~/components/library/TagsSelector.vue'
|
||||
|
||||
interface Events {
|
||||
(e: 'category', contentCategory: ContentCategory): void
|
||||
(e: 'submittable', value: boolean): void
|
||||
(e: 'loading', value: boolean): void
|
||||
(e: 'errored', errors: string[]): void
|
||||
(e: 'created', channel: Channel): void
|
||||
(e: 'updated', channel: Channel): void
|
||||
}
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
import Alert from '~/components/ui/Alert.vue'
|
||||
import Input from '~/components/ui/Input.vue'
|
||||
import Textarea from '~/components/ui/Textarea.vue'
|
||||
import Pills from '~/components/ui/Pills.vue'
|
||||
|
||||
|
||||
interface Props {
|
||||
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>(), {
|
||||
object: null,
|
||||
step: 1
|
||||
|
@ -38,9 +43,11 @@ const newValues = reactive({
|
|||
description: props.object?.artist?.description?.text ?? '',
|
||||
cover: props.object?.artist?.cover?.uuid ?? null,
|
||||
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 categoryChoices = computed(() => [
|
||||
{
|
||||
|
@ -72,6 +79,8 @@ interface MetadataChoices {
|
|||
const metadataChoices = ref({ itunes_category: null } as MetadataChoices)
|
||||
const itunesSubcategories = computed(() => {
|
||||
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) {
|
||||
return element.children ?? []
|
||||
}
|
||||
|
@ -87,6 +96,7 @@ const labels = computed(() => ({
|
|||
|
||||
const submittable = computed(() => !!(
|
||||
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
|
||||
))
|
||||
|
@ -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, () => {
|
||||
// @ts-expect-error No types defined by backend schema for `metadata` field
|
||||
newValues.metadata.itunes_subcategory = null
|
||||
})
|
||||
|
||||
const isLoading = ref(false)
|
||||
const errors = ref([] as string[])
|
||||
|
||||
// @ts-expect-error Re-check emits
|
||||
watchEffect(() => emit('category', newValues.content_category))
|
||||
watchEffect(() => emit('loading', isLoading.value))
|
||||
watchEffect(() => emit('submittable', submittable.value))
|
||||
|
@ -111,8 +124,9 @@ watchEffect(() => emit('submittable', submittable.value))
|
|||
// TODO (wvffle): Add loader / Use Suspense
|
||||
const fetchMetadataChoices = async () => {
|
||||
try {
|
||||
const response = await axios.get('channels/metadata-choices')
|
||||
metadataChoices.value = response.data
|
||||
const response = await axios.get<paths['/api/v2/channels/metadata-choices/']['get']['responses']['200']['content']['application/json']>('channels/metadata-choices/')
|
||||
// TODO: Fix schema generation so we don't need to typecast here!
|
||||
metadataChoices.value = response.data as unknown as MetadataChoices
|
||||
} catch (error) {
|
||||
errors.value = (error as BackendError).backendErrors
|
||||
}
|
||||
|
@ -155,17 +169,17 @@ defineExpose({
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
<Layout
|
||||
form
|
||||
class="ui form"
|
||||
@submit.prevent.stop="submit"
|
||||
>
|
||||
<div
|
||||
<Alert
|
||||
v-if="errors.length > 0"
|
||||
role="alert"
|
||||
class="ui negative message"
|
||||
red
|
||||
>
|
||||
<h4 class="header">
|
||||
{{ $t('components.audio.ChannelForm.header.error') }}
|
||||
{{ t('components.audio.ChannelForm.header.error') }}
|
||||
</h4>
|
||||
<ul class="list">
|
||||
<li
|
||||
|
@ -175,14 +189,14 @@ defineExpose({
|
|||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Alert>
|
||||
<template v-if="metadataChoices">
|
||||
<fieldset
|
||||
v-if="creating && step === 1"
|
||||
class="ui grouped channel-type required field"
|
||||
>
|
||||
<legend>
|
||||
{{ $t('components.audio.ChannelForm.legend.purpose') }}
|
||||
{{ t('components.audio.ChannelForm.legend.purpose') }}
|
||||
</legend>
|
||||
<div class="ui hidden divider" />
|
||||
<div class="field">
|
||||
|
@ -199,7 +213,7 @@ defineExpose({
|
|||
:value="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>
|
||||
<div class="ui small hidden divider" />
|
||||
{{ choice.helpText }}
|
||||
|
@ -207,38 +221,30 @@ defineExpose({
|
|||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<template v-if="!creating || step === 2">
|
||||
<div class="ui required field">
|
||||
<label for="channel-name">
|
||||
{{ $t('components.audio.ChannelForm.label.name') }}
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
v-model="newValues.name"
|
||||
type="text"
|
||||
required
|
||||
:placeholder="labels.namePlaceholder"
|
||||
>
|
||||
:label="t('components.audio.ChannelForm.label.name')"
|
||||
/>
|
||||
</div>
|
||||
<div class="ui required field">
|
||||
<label for="channel-username">
|
||||
{{ $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"
|
||||
type="text"
|
||||
:required="creating"
|
||||
:disabled="!creating"
|
||||
:placeholder="labels.usernamePlaceholder"
|
||||
>
|
||||
</div>
|
||||
<Input
|
||||
v-model="newValues.username"
|
||||
type="text"
|
||||
:required="creating"
|
||||
:disabled="!creating"
|
||||
:placeholder="labels.usernamePlaceholder"
|
||||
:label="t('components.audio.ChannelForm.label.username')"
|
||||
/>
|
||||
<template v-if="creating">
|
||||
<div class="ui small hidden divider" />
|
||||
<p>
|
||||
{{ $t('components.audio.ChannelForm.help.username') }}
|
||||
{{ t('components.audio.ChannelForm.help.username') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
@ -248,64 +254,57 @@ defineExpose({
|
|||
:image-class="newValues.content_category === 'podcast' ? '' : 'circular'"
|
||||
@delete="newValues.cover = null"
|
||||
>
|
||||
{{ $t('components.audio.ChannelForm.label.image') }}
|
||||
{{ t('components.audio.ChannelForm.label.image') }}
|
||||
</attachment-input>
|
||||
</div>
|
||||
<div class="ui small hidden divider" />
|
||||
<div class="ui stackable grid row">
|
||||
<div class="ten wide column">
|
||||
<div class="ui field">
|
||||
<label for="channel-tags">
|
||||
{{ $t('components.audio.ChannelForm.label.tags') }}
|
||||
</label>
|
||||
<tags-selector
|
||||
id="channel-tags"
|
||||
v-model="newValues.tags"
|
||||
:required="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="newValues.content_category === 'podcast'"
|
||||
class="six wide column"
|
||||
>
|
||||
<div class="ui required field">
|
||||
<label for="channel-language">
|
||||
{{ $t('components.audio.ChannelForm.label.language') }}
|
||||
</label>
|
||||
<select
|
||||
id="channel-language"
|
||||
v-model="newValues.metadata.language"
|
||||
name="channel-language"
|
||||
required
|
||||
class="ui search selection dropdown"
|
||||
>
|
||||
<option
|
||||
v-for="(v, key) in metadataChoices.language"
|
||||
:key="key"
|
||||
:value="v.value"
|
||||
>
|
||||
{{ v.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui small hidden divider" />
|
||||
<div class="ui field">
|
||||
<label for="channel-name">
|
||||
{{ $t('components.audio.ChannelForm.label.description') }}
|
||||
</label>
|
||||
<content-form v-model="newValues.description" />
|
||||
</div>
|
||||
<Pills
|
||||
:get="model => { newValues.tags = model.currents.map(({ label }) => label) }"
|
||||
:set="model => ({
|
||||
currents: newValues.tags.map(tag => ({ type: 'custom' as const, label: tag })),
|
||||
others: [].map(tag => ({ type: 'custom' as const, label: tag }))
|
||||
})"
|
||||
:label="t('components.audio.ChannelForm.label.tags')"
|
||||
/>
|
||||
<div
|
||||
v-if="newValues.content_category === 'podcast'"
|
||||
class="ui two fields"
|
||||
>
|
||||
<label for="channel-language">
|
||||
{{ t('components.audio.ChannelForm.label.language') }}
|
||||
</label>
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<select
|
||||
id="channel-language"
|
||||
v-model="newValues.metadata.language"
|
||||
name="channel-language"
|
||||
required
|
||||
class="ui search selection dropdown"
|
||||
>
|
||||
<option
|
||||
v-for="(v, key) in metadataChoices.language"
|
||||
:key="key"
|
||||
:value="v.value"
|
||||
>
|
||||
{{ v.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ui field">
|
||||
<Textarea
|
||||
v-model="newValues.description"
|
||||
:label="t('components.audio.ChannelForm.label.description')"
|
||||
initial-lines="3"
|
||||
/>
|
||||
</div>
|
||||
<template
|
||||
v-if="newValues.content_category === 'podcast'"
|
||||
>
|
||||
<div class="ui required field">
|
||||
<label for="channel-itunes-category">
|
||||
{{ $t('components.audio.ChannelForm.label.category') }}
|
||||
{{ t('components.audio.ChannelForm.label.category') }}
|
||||
</label>
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<select
|
||||
id="itunes-category"
|
||||
v-model="newValues.metadata.itunes_category"
|
||||
|
@ -324,8 +323,10 @@ defineExpose({
|
|||
</div>
|
||||
<div class="ui field">
|
||||
<label for="channel-itunes-category">
|
||||
{{ $t('components.audio.ChannelForm.label.subcategory') }}
|
||||
{{ t('components.audio.ChannelForm.label.subcategory') }}
|
||||
</label>
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<select
|
||||
id="itunes-category"
|
||||
v-model="newValues.metadata.itunes_subcategory"
|
||||
|
@ -342,37 +343,37 @@ defineExpose({
|
|||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
</template>
|
||||
<template
|
||||
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">
|
||||
<label for="channel-itunes-email">
|
||||
{{ $t('components.audio.ChannelForm.label.email') }}
|
||||
</label>
|
||||
<input
|
||||
<!-- @vue-ignore -->
|
||||
<Input
|
||||
id="channel-itunes-email"
|
||||
v-model="newValues.metadata.owner_email"
|
||||
name="channel-itunes-email"
|
||||
type="email"
|
||||
>
|
||||
:label="t('components.audio.ChannelForm.label.email')"
|
||||
/>
|
||||
</div>
|
||||
<div class="ui field">
|
||||
<label for="channel-itunes-name">
|
||||
{{ $t('components.audio.ChannelForm.label.owner') }}
|
||||
</label>
|
||||
<input
|
||||
<!-- @vue-ignore -->
|
||||
<Input
|
||||
id="channel-itunes-name"
|
||||
v-model="newValues.metadata.owner_name"
|
||||
name="channel-itunes-name"
|
||||
maxlength="255"
|
||||
>
|
||||
:label="t('components.audio.ChannelForm.label.owner')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
{{ $t('components.audio.ChannelForm.help.podcastFields') }}
|
||||
</p>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<div
|
||||
|
@ -380,8 +381,8 @@ defineExpose({
|
|||
class="ui active inverted dimmer"
|
||||
>
|
||||
<div class="ui text loader">
|
||||
{{ $t('components.audio.ChannelForm.loader.loading') }}
|
||||
{{ t('components.audio.ChannelForm.loader.loading') }}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Layout>
|
||||
</template>
|
||||
|
|
|
@ -1,72 +1,74 @@
|
|||
<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 { computed } from 'vue'
|
||||
import Card from '~/components/ui/Card.vue'
|
||||
import Spacer from '~/components/ui/Spacer.vue'
|
||||
|
||||
import { type Album } from '~/types'
|
||||
|
||||
interface Props {
|
||||
serie: Album
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="channel-serie-card">
|
||||
<div class="two-images">
|
||||
<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}})"
|
||||
>
|
||||
<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']"
|
||||
<Card
|
||||
:title="serie?.title"
|
||||
:image="imageUrl"
|
||||
:tags="serie?.tags"
|
||||
:to="{name: 'library.albums.detail', params: {id: serie?.id}}"
|
||||
small
|
||||
>
|
||||
<template #topright>
|
||||
<PlayButton
|
||||
icon-only
|
||||
:is-playable="serie?.is_playable"
|
||||
:album="serie"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
|
||||
<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 { ref, reactive } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import axios from 'axios'
|
||||
import ChannelSerieCard from '~/components/audio/ChannelSerieCard.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 {
|
||||
filters: object
|
||||
|
@ -14,6 +18,8 @@ interface Props {
|
|||
limit?: number
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isPodcast: true,
|
||||
limit: 5
|
||||
|
@ -51,15 +57,9 @@ fetchData()
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<slot />
|
||||
<div class="ui hidden divider" />
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="ui inverted active dimmer"
|
||||
>
|
||||
<div class="ui loader" />
|
||||
</div>
|
||||
<slot />
|
||||
<Layout flex>
|
||||
<Loader v-if="isLoading" />
|
||||
<template v-if="isPodcast">
|
||||
<channel-serie-card
|
||||
v-for="serie in albums"
|
||||
|
@ -67,35 +67,31 @@ fetchData()
|
|||
:serie="serie"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="ui app-cards cards"
|
||||
>
|
||||
<template v-else>
|
||||
<album-card
|
||||
v-for="album in albums"
|
||||
:key="album.id"
|
||||
:album="album"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="nextPage">
|
||||
<div class="ui hidden divider" />
|
||||
<button
|
||||
<Button
|
||||
v-if="nextPage"
|
||||
:class="['ui', 'basic', 'button']"
|
||||
secondary
|
||||
@click="fetchData(nextPage)"
|
||||
>
|
||||
{{ $t('components.audio.ChannelSeries.button.showMore') }}
|
||||
</button>
|
||||
{{ t('components.audio.ChannelSeries.button.showMore') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-if="!isLoading && albums.length === 0">
|
||||
<empty-state
|
||||
:refresh="true"
|
||||
@refresh="fetchData()"
|
||||
>
|
||||
<p>
|
||||
{{ $t('components.audio.ChannelSeries.help.subscribe') }}
|
||||
</p>
|
||||
</empty-state>
|
||||
</template>
|
||||
</div>
|
||||
</Layout>
|
||||
<template v-if="!isLoading && albums.length === 0">
|
||||
<empty-state
|
||||
:refresh="true"
|
||||
@refresh="fetchData()"
|
||||
>
|
||||
<p>
|
||||
{{ t('components.audio.ChannelSeries.help.subscribe') }}
|
||||
</p>
|
||||
</empty-state>
|
||||
</template>
|
||||
</template>
|
||||
|
|
|
@ -1,47 +1,57 @@
|
|||
<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 axios from 'axios'
|
||||
|
||||
import usePage from '~/composables/navigation/usePage'
|
||||
|
||||
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 {
|
||||
(e: 'fetched', channels: BackendResponse<Channel>): void
|
||||
(e: 'fetched', channels: PaginatedChannelList): void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
filters: object
|
||||
limit?: number
|
||||
title?: string
|
||||
}
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
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 nextPage = ref()
|
||||
const page = usePage()
|
||||
const count = ref(0)
|
||||
|
||||
const isLoading = ref(false)
|
||||
|
||||
const fetchData = async (url = 'channels/') => {
|
||||
isLoading.value = true
|
||||
|
||||
const params = {
|
||||
const params: operations['get_channels_2']['parameters']['query'] = {
|
||||
...clone(props.filters),
|
||||
page_size: props.limit,
|
||||
include_channels: true
|
||||
page: page.value,
|
||||
page_size: props.limit
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(url, { params })
|
||||
const response = await axios.get<PaginatedChannelList>(url, { params })
|
||||
nextPage.value = response.data.next
|
||||
count.value = response.data.count
|
||||
channels.push(...response.data.results)
|
||||
result.value = response.data
|
||||
emit('fetched', response.data)
|
||||
} catch (error) {
|
||||
errors.value = (error as BackendError).backendErrors
|
||||
|
@ -50,41 +60,51 @@ const fetchData = async (url = 'channels/') => {
|
|||
isLoading.value = false
|
||||
}
|
||||
|
||||
fetchData()
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
watch([() => props.filters, page],
|
||||
() => fetchData(),
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<slot />
|
||||
<div class="ui hidden divider" />
|
||||
<div class="ui app-cards cards">
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="ui inverted active dimmer"
|
||||
>
|
||||
<div class="ui loader" />
|
||||
</div>
|
||||
<channel-card
|
||||
v-for="object in channels"
|
||||
:key="object.uuid"
|
||||
:object="object"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="nextPage">
|
||||
<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">
|
||||
<Section
|
||||
align-left
|
||||
:columns-per-item="1"
|
||||
:h2="title || undefined"
|
||||
>
|
||||
<Loader
|
||||
v-if="isLoading"
|
||||
style="grid-column: 1 / -1;"
|
||||
/>
|
||||
<template
|
||||
v-if="!isLoading && result?.count === 0"
|
||||
>
|
||||
<empty-state
|
||||
:refresh="true"
|
||||
style="grid-column: 1 / -1;"
|
||||
@refresh="fetchData('channels/')"
|
||||
/>
|
||||
</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>
|
||||
|
|
|
@ -3,14 +3,25 @@ import { get } from 'lodash-es'
|
|||
import { ref, computed } from 'vue'
|
||||
import { useStore } from '~/store'
|
||||
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 {
|
||||
type: string
|
||||
id: number
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
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 minHeight = ref(100)
|
||||
|
||||
|
@ -51,69 +62,70 @@ const { copy, copied } = useClipboard({ source: embedCode })
|
|||
>
|
||||
<p>
|
||||
<strong>
|
||||
{{ $t('components.audio.EmbedWizard.warning.anonymous') }}
|
||||
{{ t('components.audio.EmbedWizard.warning.anonymous') }}
|
||||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
{{ $t('components.audio.EmbedWizard.help.anonymous') }}
|
||||
{{ t('components.audio.EmbedWizard.help.anonymous') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ui form">
|
||||
<div class="two fields">
|
||||
<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>
|
||||
{{ $t('components.audio.EmbedWizard.help.width') }}
|
||||
{{ t('components.audio.EmbedWizard.help.width') }}
|
||||
</p>
|
||||
<input
|
||||
<Input
|
||||
id="embed-width"
|
||||
v-model.number="width"
|
||||
type="number"
|
||||
min="0"
|
||||
step="10"
|
||||
>
|
||||
/>
|
||||
</div>
|
||||
<template v-if="type != 'track'">
|
||||
<br>
|
||||
<div class="field">
|
||||
<label for="embed-height">{{ $t('components.audio.EmbedWizard.label.height') }}</label>
|
||||
<input
|
||||
<label for="embed-height">{{ t('components.audio.EmbedWizard.label.height') }}</label>
|
||||
<Input
|
||||
id="embed-height"
|
||||
v-model="height"
|
||||
type="number"
|
||||
:min="minHeight"
|
||||
max="1000"
|
||||
step="10"
|
||||
>
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<Spacer />
|
||||
<div class="field">
|
||||
<button
|
||||
class="ui right accent labeled icon floated button"
|
||||
<Button
|
||||
class="right floated"
|
||||
icon="bi-copy"
|
||||
secondary
|
||||
@click="copy()"
|
||||
>
|
||||
<i class="copy icon" />
|
||||
{{ $t('components.audio.EmbedWizard.button.copy') }}
|
||||
</button>
|
||||
<label for="embed-width">{{ $t('components.audio.EmbedWizard.label.embed') }}</label>
|
||||
{{ t('components.audio.EmbedWizard.button.copy') }}
|
||||
</Button>
|
||||
<label for="embed-width">{{ t('components.audio.EmbedWizard.label.embed') }}</label>
|
||||
<p>
|
||||
{{ $t('components.audio.EmbedWizard.help.embed') }}
|
||||
{{ t('components.audio.EmbedWizard.help.embed') }}
|
||||
</p>
|
||||
<textarea
|
||||
v-model="embedCode"
|
||||
rows="5"
|
||||
rows="3"
|
||||
readonly
|
||||
style="width: 100%;"
|
||||
/>
|
||||
<div class="ui right">
|
||||
<p
|
||||
v-if="copied"
|
||||
class="message"
|
||||
>
|
||||
{{ $t('components.audio.EmbedWizard.message.copy') }}
|
||||
</p>
|
||||
</div>
|
||||
<Alert
|
||||
v-if="copied"
|
||||
green
|
||||
>
|
||||
{{ t('components.audio.EmbedWizard.message.copy') }}
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -123,7 +135,7 @@ const { copy, copied } = useClipboard({ source: embedCode })
|
|||
:href="iframeSrc"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('components.audio.EmbedWizard.header.preview') }}
|
||||
{{ t('components.audio.EmbedWizard.header.preview') }}
|
||||
</a>
|
||||
</h3>
|
||||
<iframe
|
||||
|
|
|
@ -3,6 +3,7 @@ import type { Library } from '~/types'
|
|||
|
||||
import { computed } from 'vue'
|
||||
import { useStore } from '~/store'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface Events {
|
||||
(e: 'unfollowed'): void
|
||||
|
@ -13,6 +14,7 @@ interface Props {
|
|||
library: Library
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<Events>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
|
@ -39,13 +41,13 @@ const toggle = () => {
|
|||
>
|
||||
<i class="heart icon" />
|
||||
<span v-if="isApproved">
|
||||
{{ $t('components.audio.LibraryFollowButton.button.unfollow') }}
|
||||
{{ t('components.audio.LibraryFollowButton.button.unfollow') }}
|
||||
</span>
|
||||
<span v-else-if="isPending">
|
||||
{{ $t('components.audio.LibraryFollowButton.button.cancel') }}
|
||||
{{ t('components.audio.LibraryFollowButton.button.cancel') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('components.audio.LibraryFollowButton.button.follow') }}
|
||||
{{ t('components.audio.LibraryFollowButton.button.follow') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
|
|
@ -1,15 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
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 { ref, computed, onMounted } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import usePlayOptions from '~/composables/audio/usePlayOptions'
|
||||
import useReport from '~/composables/moderation/useReport'
|
||||
import { useCurrentElement } from '@vueuse/core'
|
||||
import { setupDropdown } from '~/utils/fomantic'
|
||||
import { useStore } from '~/store'
|
||||
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 {
|
||||
split?: boolean
|
||||
dropdownIconClasses?: string[]
|
||||
playIconClass?: string
|
||||
buttonClasses?: string[]
|
||||
|
@ -18,20 +25,22 @@ interface Props extends PlayOptionsProps {
|
|||
iconOnly?: boolean
|
||||
playing?: boolean
|
||||
paused?: boolean
|
||||
lowHeight?: boolean
|
||||
|
||||
// TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged
|
||||
isPlayable?: boolean
|
||||
tracks?: Track[]
|
||||
track?: Track | null
|
||||
artist?: Artist | null
|
||||
artist?: Artist | components["schemas"]["SimpleChannelArtist"] | components['schemas']['ArtistWithAlbums'] | null
|
||||
album?: Album | null
|
||||
playlist?: Playlist | null
|
||||
library?: Library | null
|
||||
channel?: Channel | null
|
||||
account?: Actor | null
|
||||
account?: Actor | components['schemas']['APIActor'] | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
split: false,
|
||||
tracks: () => [],
|
||||
track: null,
|
||||
artist: null,
|
||||
|
@ -40,17 +49,22 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
library: null,
|
||||
channel: null,
|
||||
account: null,
|
||||
dropdownIconClasses: () => ['dropdown'],
|
||||
playIconClass: () => 'play icon',
|
||||
dropdownIconClasses: () => ['bi-caret-down-fill'],
|
||||
playIconClass: () => 'bi-play-fill',
|
||||
buttonClasses: () => ['button'],
|
||||
discrete: () => false,
|
||||
dropdownOnly: () => false,
|
||||
iconOnly: () => false,
|
||||
isPlayable: () => 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 {
|
||||
playable,
|
||||
filterableArtist,
|
||||
|
@ -64,6 +78,10 @@ const {
|
|||
const { report, getReportableObjects } = useReport()
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const labels = computed(() => ({
|
||||
playNow: t('components.audio.PlayButton.button.playNow'),
|
||||
addToQueue: t('components.audio.PlayButton.button.addToQueue'),
|
||||
|
@ -83,149 +101,163 @@ const labels = computed(() => ({
|
|||
: t('components.audio.PlayButton.button.playTracks')
|
||||
}))
|
||||
|
||||
const title = computed(() => {
|
||||
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;`
|
||||
})
|
||||
}
|
||||
const isOpen = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:title="title"
|
||||
:class="['ui', {'tiny': discrete, 'icon': !discrete, 'buttons': !dropdownOnly && !iconOnly}, 'play-button component-play-button']"
|
||||
<Popover
|
||||
v-if="split || (!iconOnly && dropdownOnly)"
|
||||
v-model="isOpen"
|
||||
>
|
||||
<button
|
||||
v-if="!dropdownOnly"
|
||||
:disabled="!playable"
|
||||
<OptionsButton
|
||||
v-if="dropdownOnly"
|
||||
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"
|
||||
: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()"
|
||||
@split-click="isOpen = !isOpen"
|
||||
>
|
||||
<i
|
||||
v-if="playing"
|
||||
class="pause icon"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
:class="[playIconClass, 'icon']"
|
||||
/>
|
||||
<template v-if="!discrete && !iconOnly"> <slot>{{ $t('components.audio.PlayButton.button.discretePlay') }}</slot></template>
|
||||
</button>
|
||||
<button
|
||||
v-if="!discrete && !iconOnly"
|
||||
:class="['ui', {disabled: !playable && !filterableArtist}, 'floating', 'dropdown', {'icon': !dropdownOnly}, {'button': !dropdownOnly}]"
|
||||
@click.stop.prevent="openMenu"
|
||||
>
|
||||
<i
|
||||
:class="dropdownIconClasses.concat(['icon'])"
|
||||
:title="title"
|
||||
/>
|
||||
<div class="menu">
|
||||
<button
|
||||
class="item basic"
|
||||
:disabled="!playable"
|
||||
:title="labels.addToQueue"
|
||||
@click.stop.prevent="enqueue"
|
||||
>
|
||||
<i class="plus icon" />{{ labels.addToQueue }}
|
||||
</button>
|
||||
<button
|
||||
class="item basic"
|
||||
:disabled="!playable"
|
||||
:title="labels.playNext"
|
||||
@click.stop.prevent="enqueueNext()"
|
||||
>
|
||||
<i class="step forward icon" />{{ labels.playNext }}
|
||||
</button>
|
||||
<button
|
||||
class="item basic"
|
||||
:disabled="!playable"
|
||||
:title="labels.playNow"
|
||||
@click.stop.prevent="enqueueNext(true)"
|
||||
>
|
||||
<i class="play icon" />{{ labels.playNow }}
|
||||
</button>
|
||||
<button
|
||||
v-if="track"
|
||||
class="item basic"
|
||||
:disabled="!playable"
|
||||
:title="labels.startRadio"
|
||||
@click.stop.prevent="$store.dispatch('radios/start', {type: 'similar', objectId: track?.id})"
|
||||
>
|
||||
<i class="feed icon" />{{ labels.startRadio }}
|
||||
</button>
|
||||
<button
|
||||
v-if="track"
|
||||
class="item basic"
|
||||
:disabled="!playable"
|
||||
@click.stop="$store.commit('playlists/chooseTrack', track)"
|
||||
>
|
||||
<i class="list icon" />
|
||||
{{ labels.addToPlaylist }}
|
||||
</button>
|
||||
<button
|
||||
v-if="track && $route.name !== 'library.tracks.detail'"
|
||||
class="item basic"
|
||||
@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')">
|
||||
{{ $t('components.audio.PlayButton.button.episodeDetails') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('components.audio.PlayButton.button.trackDetails') }}
|
||||
</span>
|
||||
</button>
|
||||
<div class="divider" />
|
||||
<button
|
||||
v-if="filterableArtist"
|
||||
class="item basic"
|
||||
:disabled="!filterableArtist"
|
||||
:title="labels.hideArtist"
|
||||
@click.stop.prevent="filterArtist"
|
||||
>
|
||||
<i class="eye slash outline icon" />
|
||||
{{ labels.hideArtist }}
|
||||
</button>
|
||||
<button
|
||||
v-for="obj in getReportableObjects({track, album, artist, playlist, account, channel})"
|
||||
:key="obj.target.type + obj.target.id"
|
||||
class="item basic"
|
||||
@click.stop.prevent="report(obj)"
|
||||
>
|
||||
<i class="share icon" /> {{ obj.label }}
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
</span>
|
||||
<template #main>
|
||||
<i
|
||||
v-if="playing"
|
||||
class="bi bi-pause-fill"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
:class="['bi', playIconClass]"
|
||||
/>
|
||||
<template v-if="!discrete && !iconOnly">
|
||||
<slot>{{ t('components.audio.PlayButton.button.discretePlay') }}</slot>
|
||||
</template>
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<template #items>
|
||||
<PopoverItem
|
||||
:disabled="!playable"
|
||||
:title="labels.addToQueue"
|
||||
icon="bi-plus"
|
||||
@click.stop.prevent="enqueue"
|
||||
>
|
||||
{{ labels.addToQueue }}
|
||||
</PopoverItem>
|
||||
|
||||
<PopoverItem
|
||||
:disabled="!playable"
|
||||
:title="labels.playNext"
|
||||
icon="bi-skip-forward-fill"
|
||||
@click.stop.prevent="enqueueNext()"
|
||||
>
|
||||
{{ labels.playNext }}
|
||||
</PopoverItem>
|
||||
|
||||
<PopoverItem
|
||||
:disabled="!playable"
|
||||
:title="labels.playNow"
|
||||
icon="bi-play-fill"
|
||||
@click.stop.prevent="enqueueNext(true)"
|
||||
>
|
||||
{{ labels.playNow }}
|
||||
</PopoverItem>
|
||||
|
||||
<PopoverItem
|
||||
v-if="track"
|
||||
:disabled="!playable"
|
||||
:title="labels.startRadio"
|
||||
icon="bi-broadcast"
|
||||
@click.stop.prevent="store.dispatch('radios/start', {type: 'similar', objectId: track?.id})"
|
||||
>
|
||||
{{ labels.startRadio }}
|
||||
</PopoverItem>
|
||||
|
||||
<PopoverItem
|
||||
v-if="track"
|
||||
:disabled="!playable"
|
||||
icon="bi-list"
|
||||
@click.stop="store.commit('playlists/chooseTrack', track)"
|
||||
>
|
||||
{{ labels.addToPlaylist }}
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
v-if="track && route.name !== 'library.tracks.detail'"
|
||||
icon="bi-info-circle"
|
||||
@click.stop.prevent="router.push(`/library/tracks/${track?.id}/`)"
|
||||
>
|
||||
<span v-if="track.artist_credit?.some(ac => ac.artist.content_category === 'podcast')">
|
||||
{{ t('components.audio.PlayButton.button.episodeDetails') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ t('components.audio.PlayButton.button.trackDetails') }}
|
||||
</span>
|
||||
</PopoverItem>
|
||||
|
||||
<hr v-if="filterableArtist || Object.keys(getReportableObjects({ track, album, artist, playlist, account, channel })).length > 0">
|
||||
|
||||
<PopoverItem
|
||||
v-if="filterableArtist"
|
||||
:disabled="!filterableArtist"
|
||||
:title="labels.hideArtist"
|
||||
icon="bi-eye-slash"
|
||||
@click.stop.prevent="filterArtist"
|
||||
>
|
||||
{{ labels.hideArtist }}
|
||||
</PopoverItem>
|
||||
|
||||
<PopoverItem
|
||||
v-for="obj in getReportableObjects({ track, album, artist, playlist, account, channel })"
|
||||
:key="obj.target.type + obj.target.id"
|
||||
icon="bi-exclamation-triangle-fill"
|
||||
@click.stop.prevent="report(obj)"
|
||||
>
|
||||
{{ obj.label }}
|
||||
</PopoverItem>
|
||||
</template>
|
||||
</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>
|
||||
</template>
|
||||
</Button>
|
||||
</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 { useStore } from '~/store'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
|
||||
import time from '~/utils/time'
|
||||
|
@ -14,6 +15,8 @@ import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
|||
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
|
||||
import PlayerControls from './PlayerControls.vue'
|
||||
import VolumeControl from './VolumeControl.vue'
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
|
||||
const {
|
||||
LoopingMode,
|
||||
|
@ -43,14 +46,26 @@ const {
|
|||
} = useQueue()
|
||||
|
||||
const store = useStore()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const toggleMobilePlayer = () => {
|
||||
store.commit('ui/queueFocused', ['queue', 'player'].includes(store.state.ui.queueFocused as string) ? null : 'player')
|
||||
/** Toggle between null and 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
|
||||
onKeyboardShortcut('e', toggleMobilePlayer)
|
||||
onKeyboardShortcut('e', togglePlayer)
|
||||
onKeyboardShortcut('p', () => { isPlaying.value = !isPlaying.value })
|
||||
onKeyboardShortcut('s', shuffle)
|
||||
onKeyboardShortcut('q', clear)
|
||||
|
@ -84,10 +99,6 @@ const labels = computed(() => ({
|
|||
addArtistContentFilter: t('components.audio.Player.label.addArtistContentFilter')
|
||||
}))
|
||||
|
||||
const switchTab = () => {
|
||||
store.commit('ui/queueFocused', store.state.ui.queueFocused === 'player' ? 'queue' : 'player')
|
||||
}
|
||||
|
||||
const progressBar = ref()
|
||||
const touchProgress = (event: MouseEvent) => {
|
||||
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')
|
||||
})
|
||||
|
||||
const hideArtist = () => {
|
||||
if (currentTrack.value.artistId !== -1 && currentTrack.value.artistCredit) {
|
||||
return store.dispatch('moderation/hide', {
|
||||
type: 'artist',
|
||||
target: {
|
||||
id: currentTrack.value.artistCredit[0].artist.id,
|
||||
name: currentTrack.value.artistCredit[0].artist.name
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
// TODO: check if still useful for filtering
|
||||
// const hideArtist = () => {
|
||||
// if (currentTrack.value.artistId !== -1 && currentTrack.value.artistCredit) {
|
||||
// return store.dispatch('moderation/hide', {
|
||||
// type: 'artist',
|
||||
// target: {
|
||||
// id: currentTrack.value.artistCredit[0].artist.id,
|
||||
// name: currentTrack.value.artistCredit[0].artist.name
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -135,7 +147,7 @@ const hideArtist = () => {
|
|||
/>
|
||||
<div
|
||||
class="ui inverted segment fixed-controls"
|
||||
@click.prevent.stop="toggleMobilePlayer"
|
||||
@click.prevent.stop="togglePlayer"
|
||||
>
|
||||
<div
|
||||
ref="progressBar"
|
||||
|
@ -156,12 +168,13 @@ const hideArtist = () => {
|
|||
<div class="controls track-controls queue-not-focused desktop-and-up">
|
||||
<div
|
||||
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
|
||||
ref="cover"
|
||||
v-lazy="store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
||||
alt=""
|
||||
:src="$store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
|
@ -170,7 +183,7 @@ const hideArtist = () => {
|
|||
>
|
||||
<strong>
|
||||
<router-link
|
||||
class="small header discrete link track"
|
||||
class="header discrete link track"
|
||||
:to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}"
|
||||
@click.stop.prevent=""
|
||||
>
|
||||
|
@ -184,11 +197,11 @@ const hideArtist = () => {
|
|||
:key="ac.artist.id"
|
||||
>
|
||||
<router-link
|
||||
class="discrete link"
|
||||
class="small discrete link"
|
||||
:to="{name: 'library.artists.detail', params: {id: ac.artist.id }}"
|
||||
@click.stop.prevent=""
|
||||
>
|
||||
{{ ac.credit ?? $t('components.audio.Player.meta.unknownArtist') }}
|
||||
{{ ac.credit ?? t('components.audio.Player.meta.unknownArtist') }}
|
||||
</router-link>
|
||||
<span>{{ ac.joinphrase }}</span>
|
||||
</template>
|
||||
|
@ -196,11 +209,11 @@ const hideArtist = () => {
|
|||
<template v-if="currentTrack.albumId !== -1">
|
||||
<span class="middle slash symbol" />
|
||||
<router-link
|
||||
class="discrete link"
|
||||
class="small discrete link"
|
||||
:to="{name: 'library.albums.detail', params: {id: currentTrack.albumId }}"
|
||||
@click.stop.prevent=""
|
||||
>
|
||||
{{ currentTrack.albumTitle ?? $t('components.audio.Player.meta.unknownAlbum') }}
|
||||
{{ currentTrack.albumTitle ?? t('components.audio.Player.meta.unknownAlbum') }}
|
||||
</router-link>
|
||||
</template>
|
||||
</div>
|
||||
|
@ -208,51 +221,57 @@ const hideArtist = () => {
|
|||
</div>
|
||||
<div class="controls track-controls queue-not-focused desktop-and-below">
|
||||
<div class="ui tiny image">
|
||||
<!-- TODO: Use smaller covers -->
|
||||
<img
|
||||
ref="cover"
|
||||
v-lazy="store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
||||
alt=""
|
||||
:src="$store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
||||
>
|
||||
</div>
|
||||
<div class="middle aligned content ellipsis">
|
||||
<strong>
|
||||
{{ currentTrack.title }}
|
||||
</strong>
|
||||
<div class="meta">
|
||||
<Layout
|
||||
flex
|
||||
no-gap
|
||||
class="meta"
|
||||
>
|
||||
<div
|
||||
v-for="ac in currentTrack.artistCredit"
|
||||
:key="ac.artist.id"
|
||||
>
|
||||
{{ ac.credit ?? $t('components.audio.Player.meta.unknownArtist') }}
|
||||
{{ ac.credit ?? t('components.audio.Player.meta.unknownArtist') }}
|
||||
<span>{{ ac.joinphrase }}</span>
|
||||
</div>
|
||||
<template v-if="currentTrack.albumId !== -1">
|
||||
<span class="middle slash symbol" />
|
||||
{{ currentTrack.albumTitle ?? $t('components.audio.Player.meta.unknownAlbum') }}
|
||||
{{ currentTrack.albumTitle ?? t('components.audio.Player.meta.unknownAlbum') }}
|
||||
</template>
|
||||
</div>
|
||||
</Layout>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="$store.state.auth.authenticated"
|
||||
v-if="store.state.auth.authenticated"
|
||||
class="controls desktop-and-up fluid align-right"
|
||||
>
|
||||
<track-favorite-icon
|
||||
class="control white"
|
||||
ghost
|
||||
:track="currentTrack"
|
||||
/>
|
||||
<track-playlist-icon
|
||||
class="control white"
|
||||
ghost
|
||||
:track="currentTrack"
|
||||
/>
|
||||
<button
|
||||
:class="['ui', 'really', 'basic', 'circular', 'icon', 'button', 'control']"
|
||||
<!-- <Button
|
||||
round
|
||||
ghost
|
||||
icon="bi-eye-slash"
|
||||
:aria-label="labels.addArtistContentFilter"
|
||||
:title="labels.addArtistContentFilter"
|
||||
@click="hideArtist"
|
||||
>
|
||||
<i :class="['eye slash outline', 'basic', 'icon']" />
|
||||
</button>
|
||||
</Button> -->
|
||||
</div>
|
||||
<player-controls class="controls queue-not-focused" />
|
||||
<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="group">
|
||||
<volume-control class="expandable" />
|
||||
<button
|
||||
class="circular control button"
|
||||
<Button
|
||||
:class="{ looping: looping !== LoopingMode.None }"
|
||||
:title="loopingTitle"
|
||||
ghost
|
||||
round
|
||||
:aria-label="loopingTitle"
|
||||
:disabled="!currentTrack"
|
||||
:icon="looping === LoopingMode.LoopTrack ? 'bi-repeat-1' : 'bi-repeat'"
|
||||
@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
|
||||
class="circular control button"
|
||||
<Button
|
||||
round
|
||||
ghost
|
||||
:class="{ shuffling: isShuffled }"
|
||||
:disabled="queue.length === 0"
|
||||
:title="labels.shuffle"
|
||||
:aria-label="labels.shuffle"
|
||||
icon="bi-shuffle"
|
||||
@click.prevent.stop="shuffle()"
|
||||
>
|
||||
<i :class="['ui', 'random', { disabled: queue.length === 0, shuffling: isShuffled }, 'icon']" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- TODO: Remove fake responsive elements -->
|
||||
<div class="group">
|
||||
<div class="fake-dropdown">
|
||||
<button
|
||||
class="position circular control button desktop-and-up"
|
||||
<Button
|
||||
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">
|
||||
<template #index>
|
||||
{{ currentIndex + 1 }}
|
||||
|
@ -323,12 +332,11 @@ const hideArtist = () => {
|
|||
{{ queue.length }}
|
||||
</template>
|
||||
</i18n-t>
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
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">
|
||||
<template #index>
|
||||
{{ currentIndex + 1 }}
|
||||
|
@ -337,46 +345,35 @@ const hideArtist = () => {
|
|||
{{ queue.length }}
|
||||
</template>
|
||||
</i18n-t>
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<button
|
||||
v-if="$store.state.ui.queueFocused"
|
||||
class="circular control button close-control desktop-and-up"
|
||||
@click.stop="toggleMobilePlayer"
|
||||
>
|
||||
<i class="large down angle icon" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="circular control button desktop-and-up"
|
||||
@click.stop="toggleMobilePlayer"
|
||||
>
|
||||
<i class="large up angle icon" />
|
||||
</button>
|
||||
<button
|
||||
v-if="$store.state.ui.queueFocused === 'player'"
|
||||
class="circular control button close-control desktop-and-below"
|
||||
<Button
|
||||
ghost
|
||||
:class="['desktop-and-up', { 'close-control': store.state.ui.queueFocused }]"
|
||||
:icon="store.state.ui.queueFocused ? 'bi-chevron-down' : 'bi-chevron-up'"
|
||||
:aria-pressed="store.state.ui.queueFocused ? true : undefined"
|
||||
@click.stop="togglePlayer"
|
||||
/>
|
||||
<Button
|
||||
ghost
|
||||
:class="['desktop-and-below', { 'close-control': store.state.ui.queueFocused === 'player' }]"
|
||||
:icon="store.state.ui.queueFocused === 'queue' ? 'bi-chevron-down' : 'bi-chevron-up'"
|
||||
:aria-pressed="store.state.ui.queueFocused ? true : undefined"
|
||||
@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>
|
||||
<button
|
||||
class="circular control button close-control desktop-and-below"
|
||||
@click.stop="$store.commit('ui/queueFocused', null)"
|
||||
>
|
||||
<i class="x icon" />
|
||||
</button>
|
||||
<Button
|
||||
class="close-control desktop-and-below"
|
||||
icon="bi-x"
|
||||
@click.stop="store.commit('ui/queueFocused', null)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
|
|
|
@ -5,7 +5,10 @@ import { computed } from 'vue'
|
|||
import { usePlayer } from '~/composables/audio/player'
|
||||
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 { t } = useI18n()
|
||||
|
@ -19,40 +22,33 @@ const labels = computed(() => ({
|
|||
|
||||
<template>
|
||||
<div class="player-controls">
|
||||
<button
|
||||
<Button
|
||||
:title="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()"
|
||||
>
|
||||
<i :class="['ui', 'large', 'backward step', 'icon']" />
|
||||
</button>
|
||||
<button
|
||||
v-if="!isPlaying"
|
||||
:title="labels.play"
|
||||
:aria-label="labels.play"
|
||||
class="circular button control"
|
||||
@click.prevent.stop="isPlaying = true"
|
||||
>
|
||||
<i :class="['ui', 'big', 'play', {'disabled': !currentTrack}, 'icon']" />
|
||||
</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
|
||||
/>
|
||||
<Button
|
||||
:title="isPlaying ? labels.pause : labels.play"
|
||||
round
|
||||
ghost
|
||||
:aria-label="isPlaying ? labels.pause : labels.play"
|
||||
:class="['control', isPlaying ? 'pause' : 'play', 'large']"
|
||||
:icon="isPlaying ? 'bi-pause-fill' : 'bi-play-fill'"
|
||||
@click.prevent.stop="isPlaying = !isPlaying"
|
||||
/>
|
||||
<Button
|
||||
:title="labels.next"
|
||||
:aria-label="labels.next"
|
||||
round
|
||||
ghost
|
||||
:disabled="!hasNext"
|
||||
class="circular button control"
|
||||
class="control"
|
||||
icon="bi-skip-forward-fill"
|
||||
@click.prevent.stop="playNext()"
|
||||
>
|
||||
<i :class="['ui', 'large', {'disabled': !hasNext}, 'forward step', 'icon']" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -6,8 +6,8 @@ import { ref, computed, reactive, watch, onMounted } from 'vue'
|
|||
import { refDebounced } from '@vueuse/core'
|
||||
|
||||
import axios from 'axios'
|
||||
import AlbumCard from '~/components/audio/album/Card.vue'
|
||||
import ArtistCard from '~/components/audio/artist/Card.vue'
|
||||
import AlbumCard from '~/components/album/Card.vue'
|
||||
import ArtistCard from '~/components/artist/Card.vue'
|
||||
|
||||
import useErrorHandler from '~/composables/useErrorHandler'
|
||||
import useLogger from '~/composables/useLogger'
|
||||
|
@ -73,7 +73,7 @@ const labels = computed(() => ({
|
|||
<template>
|
||||
<div>
|
||||
<h2>
|
||||
{{ $t('components.audio.Search.header.search') }}
|
||||
{{ t('components.audio.Search.header.search') }}
|
||||
</h2>
|
||||
<div :class="['ui', {'loading': isLoading }, 'search']">
|
||||
<div class="ui icon big input">
|
||||
|
@ -89,7 +89,7 @@ const labels = computed(() => ({
|
|||
</div>
|
||||
<template v-if="query.length > 0">
|
||||
<h3 class="ui title">
|
||||
{{ $t('components.audio.Search.header.artists') }}
|
||||
{{ t('components.audio.Search.header.artists') }}
|
||||
</h3>
|
||||
<div v-if="results.artists.length > 0">
|
||||
<div class="ui cards">
|
||||
|
@ -101,12 +101,12 @@ const labels = computed(() => ({
|
|||
</div>
|
||||
</div>
|
||||
<p v-else>
|
||||
{{ $t('components.audio.Search.empty.noArtists') }}
|
||||
{{ t('components.audio.Search.empty.noArtists') }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-if="query.length > 0">
|
||||
<h3 class="ui title">
|
||||
{{ $t('components.audio.Search.header.albums') }}
|
||||
{{ t('components.audio.Search.header.albums') }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="results.albums.length > 0"
|
||||
|
@ -124,7 +124,7 @@ const labels = computed(() => ({
|
|||
</div>
|
||||
</div>
|
||||
<p v-else>
|
||||
{{ $t('components.audio.Search.empty.noAlbums') }}
|
||||
{{ t('components.audio.Search.empty.noAlbums') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
|
|
@ -1,49 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import type { Artist, Track, Album, Tag } from '~/types'
|
||||
import type { RouteRecordName, RouteLocationNamedRaw } from 'vue-router'
|
||||
|
||||
import jQuery from 'jquery'
|
||||
import { trim } from 'lodash-es'
|
||||
import { useFocus, useCurrentElement } from '@vueuse/core'
|
||||
import { useFocus } from '@vueuse/core'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useStore } from '~/store'
|
||||
import { generateTrackCreditString } from '~/utils/utils'
|
||||
|
||||
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 { focused } = useFocus(search)
|
||||
onKeyboardShortcut(['shift', 'f'], () => (focused.value = true), true)
|
||||
|
@ -60,12 +23,11 @@ const labels = computed(() => ({
|
|||
}))
|
||||
|
||||
const router = useRouter()
|
||||
const store = useStore()
|
||||
const el = useCurrentElement()
|
||||
const query = ref()
|
||||
|
||||
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…
|
||||
return router.push(`/search?q=${query.value}&type=artists`)
|
||||
|
@ -75,168 +37,109 @@ const 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(() => {
|
||||
jQuery(el.value).search({
|
||||
type: 'category',
|
||||
minCharacters: 3,
|
||||
showNoResults: true,
|
||||
error: {
|
||||
// @ts-expect-error Semantic is broken
|
||||
noResultsHeader: t('components.audio.SearchBar.header.noResults'),
|
||||
noResults: t('components.audio.SearchBar.empty.noResults')
|
||||
},
|
||||
// TODO: Find out what jQuery version supports `search`
|
||||
// jQuery(el.value).search({
|
||||
// type: 'category',
|
||||
// minCharacters: 3,
|
||||
// showNoResults: true,
|
||||
// error: {
|
||||
// // @ts-expect-error Semantic is broken
|
||||
// noResultsHeader: t('components.audio.SearchBar.header.noResults'),
|
||||
// noResults: t('components.audio.SearchBar.empty.noResults')
|
||||
// },
|
||||
|
||||
onSelect (result, response) {
|
||||
jQuery(el.value).search('set value', query.value)
|
||||
router.push(result.routerUrl)
|
||||
jQuery(el.value).search('hide results')
|
||||
return false
|
||||
},
|
||||
onSearchQuery (value) {
|
||||
// query.value = value
|
||||
emit('search')
|
||||
},
|
||||
apiSettings: {
|
||||
url: store.getters['instance/absoluteUrl']('api/v1/search?query={query}'),
|
||||
beforeXHR: function (xhrObject) {
|
||||
if (!store.state.auth.authenticated) {
|
||||
return xhrObject
|
||||
}
|
||||
// onSelect (result, response) {
|
||||
// jQuery(el.value).search('set value', query.value)
|
||||
// router.push(result.routerUrl)
|
||||
// jQuery(el.value).search('hide results')
|
||||
// return false
|
||||
// },
|
||||
// onSearchQuery (value) {
|
||||
// // query.value = value
|
||||
// emit('search')
|
||||
// },
|
||||
// apiSettings: {
|
||||
// url: store.getters['instance/absoluteUrl']('api/v1/search?query={query}'),
|
||||
// beforeXHR: function (xhrObject) {
|
||||
// if (!store.state.auth.authenticated) {
|
||||
// return xhrObject
|
||||
// }
|
||||
|
||||
if (store.state.auth.oauth.accessToken) {
|
||||
xhrObject.setRequestHeader('Authorization', store.getters['auth/header'])
|
||||
}
|
||||
// if (store.state.auth.oauth.accessToken) {
|
||||
// xhrObject.setRequestHeader('Authorization', store.getters['auth/header'])
|
||||
// }
|
||||
|
||||
return xhrObject
|
||||
},
|
||||
onResponse: function (initialResponse) {
|
||||
const id = objectId.value
|
||||
const results: Partial<Record<CategoryCode, Results>> = {}
|
||||
// return xhrObject
|
||||
// },
|
||||
// onResponse: function (initialResponse) {
|
||||
// const id = objectId.value
|
||||
// const results: Partial<Record<CategoryCode, Results>> = {}
|
||||
|
||||
let resultsEmpty = true
|
||||
for (const category of categories.value) {
|
||||
results[category.code] = {
|
||||
name: category.name,
|
||||
results: []
|
||||
}
|
||||
// let resultsEmpty = true
|
||||
// for (const category of categories.value) {
|
||||
// results[category.code] = {
|
||||
// name: category.name,
|
||||
// results: []
|
||||
// }
|
||||
|
||||
if (category.code === 'federation' && id) {
|
||||
resultsEmpty = false
|
||||
results[category.code]?.results.push({
|
||||
title: t('components.audio.SearchBar.link.fediverse'),
|
||||
routerUrl: {
|
||||
name: 'search',
|
||||
query: { id }
|
||||
}
|
||||
})
|
||||
}
|
||||
// if (category.code === 'federation' && id) {
|
||||
// resultsEmpty = false
|
||||
// results[category.code]?.results.push({
|
||||
// title: t('components.audio.SearchBar.link.fediverse'),
|
||||
// routerUrl: {
|
||||
// name: 'search',
|
||||
// query: { id }
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
if (category.code === 'podcasts' && id) {
|
||||
resultsEmpty = false
|
||||
results[category.code]?.results.push({
|
||||
title: t('components.audio.SearchBar.link.rss'),
|
||||
routerUrl: {
|
||||
name: 'search',
|
||||
query: { id, type: 'rss' }
|
||||
}
|
||||
})
|
||||
}
|
||||
// if (category.code === 'podcasts' && id) {
|
||||
// resultsEmpty = false
|
||||
// results[category.code]?.results.push({
|
||||
// title: t('components.audio.SearchBar.link.rss'),
|
||||
// routerUrl: {
|
||||
// name: 'search',
|
||||
// query: { id, type: 'rss' }
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
if (category.code === 'more') {
|
||||
results[category.code]?.results.push({
|
||||
title: t('components.audio.SearchBar.link.more'),
|
||||
routerUrl: {
|
||||
name: 'search',
|
||||
query: { type: 'artists', q: query.value }
|
||||
}
|
||||
})
|
||||
}
|
||||
// if (category.code === 'more') {
|
||||
// results[category.code]?.results.push({
|
||||
// title: t('components.audio.SearchBar.link.more'),
|
||||
// routerUrl: {
|
||||
// name: 'search',
|
||||
// query: { type: 'artists', q: query.value }
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
if (isCategoryGuard(category)) {
|
||||
for (const result of initialResponse[category.code]) {
|
||||
resultsEmpty = false
|
||||
const id = category.getId(result)
|
||||
results[category.code]?.results.push({
|
||||
title: category.getTitle(result),
|
||||
id,
|
||||
routerUrl: {
|
||||
name: category.route,
|
||||
params: { id }
|
||||
},
|
||||
description: category.getDescription(result)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// if (isCategoryGuard(category)) {
|
||||
// for (const result of initialResponse[category.code]) {
|
||||
// resultsEmpty = false
|
||||
// const id = category.getId(result)
|
||||
// results[category.code]?.results.push({
|
||||
// title: category.getTitle(result),
|
||||
// id,
|
||||
// routerUrl: {
|
||||
// name: category.route,
|
||||
// params: { id }
|
||||
// },
|
||||
// description: category.getDescription(result)
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
return {
|
||||
results: resultsEmpty
|
||||
? {}
|
||||
: results
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
// return {
|
||||
// results: resultsEmpty
|
||||
// ? {}
|
||||
// : results
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
@ -4,6 +4,8 @@ import { useTimeoutFn } from '@vueuse/core'
|
|||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
|
||||
const { volume, mute } = usePlayer()
|
||||
const expanded = ref(false)
|
||||
|
||||
|
@ -32,8 +34,10 @@ const scroll = (event: WheelEvent) => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="circular control button"
|
||||
<Button
|
||||
round
|
||||
ghost
|
||||
square
|
||||
:class="['component-volume-control', {'expanded': expanded}]"
|
||||
@click.prevent.stop=""
|
||||
@mouseover="handleOver"
|
||||
|
@ -47,7 +51,7 @@ const scroll = (event: WheelEvent) => {
|
|||
:aria-label="labels.unmute"
|
||||
@click.prevent.stop="mute"
|
||||
>
|
||||
<i class="volume off icon" />
|
||||
<i class="bi bi-volume-mute-fill" />
|
||||
</span>
|
||||
<span
|
||||
v-else-if="volume < 0.5"
|
||||
|
@ -56,7 +60,7 @@ const scroll = (event: WheelEvent) => {
|
|||
:aria-label="labels.mute"
|
||||
@click.prevent.stop="mute"
|
||||
>
|
||||
<i class="volume down icon" />
|
||||
<i class="bi bi-volume-down-fill" />
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
|
@ -65,7 +69,7 @@ const scroll = (event: WheelEvent) => {
|
|||
:aria-label="labels.mute"
|
||||
@click.prevent.stop="mute"
|
||||
>
|
||||
<i class="volume up icon" />
|
||||
<i class="bi bi-volume-up-fill" />
|
||||
</span>
|
||||
<div class="popup">
|
||||
<label
|
||||
|
@ -81,5 +85,5 @@ const scroll = (event: WheelEvent) => {
|
|||
max="1"
|
||||
>
|
||||
</div>
|
||||
</button>
|
||||
</Button>
|
||||
</template>
|
||||
|
|
|
@ -1,83 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import type { Album } from '~/types'
|
||||
|
||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||
import { momentFormat } from '~/utils/filters'
|
||||
import { computed } from 'vue'
|
||||
import { useStore } from '~/store'
|
||||
import AlbumCard from '~/components/album/Card.vue'
|
||||
|
||||
interface Props {
|
||||
album: Album
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const store = useStore()
|
||||
defineProps<Props>()
|
||||
|
||||
const imageUrl = computed(() => props.album.cover?.urls.original
|
||||
? store.getters['instance/absoluteUrl'](props.album.cover.urls.medium_square_crop)
|
||||
: null
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card app-card component-album-card">
|
||||
<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>
|
||||
<AlbumCard :album="album" />
|
||||
</template>
|
||||
|
|
|
@ -1,20 +1,24 @@
|
|||
<script setup lang="ts">
|
||||
import type { Artist } from '~/types'
|
||||
|
||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||
import TagsList from '~/components/tags/List.vue'
|
||||
import { computed } from 'vue'
|
||||
import { useStore } from '~/store'
|
||||
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 {
|
||||
artist: Artist
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
|
@ -37,7 +41,7 @@ const imageUrl = computed(() => cover.value?.urls.original
|
|||
>
|
||||
<play-button
|
||||
: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']"
|
||||
:artist="artist"
|
||||
/>
|
||||
|
@ -53,7 +57,7 @@ const imageUrl = computed(() => cover.value?.urls.original
|
|||
</router-link>
|
||||
</strong>
|
||||
|
||||
<tags-list
|
||||
<TagsList
|
||||
label-classes="tiny"
|
||||
:truncate-size="20"
|
||||
:limit="2"
|
||||
|
@ -63,15 +67,15 @@ const imageUrl = computed(() => cover.value?.urls.original
|
|||
</div>
|
||||
<div class="extra content">
|
||||
<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 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>
|
||||
<play-button
|
||||
class="right floated basic icon"
|
||||
: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']"
|
||||
:artist="artist"
|
||||
/>
|
||||
|
|
|
@ -7,6 +7,7 @@ import { useI18n } from 'vue-i18n'
|
|||
|
||||
import { usePlayer } from '~/composables/audio/player'
|
||||
import { useQueue } from '~/composables/audio/queue'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
import usePlayOptions from '~/composables/audio/usePlayOptions'
|
||||
|
||||
|
@ -54,6 +55,8 @@ const { isPlaying } = usePlayer()
|
|||
const { activateTrack } = usePlayOptions(props)
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useStore()
|
||||
|
||||
const actionsButtonLabel = computed(() => t('components.audio.podcast.MobileRow.button.actions'))
|
||||
</script>
|
||||
|
||||
|
@ -71,13 +74,13 @@ const actionsButtonLabel = computed(() => t('components.audio.podcast.MobileRow.
|
|||
>
|
||||
<img
|
||||
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=""
|
||||
class="ui artist-track mini image"
|
||||
>
|
||||
<img
|
||||
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=""
|
||||
class="ui artist-track mini image"
|
||||
>
|
||||
|
@ -136,7 +139,7 @@ const actionsButtonLabel = computed(() => t('components.audio.podcast.MobileRow.
|
|||
</p>
|
||||
</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="[
|
||||
'meta',
|
||||
'right',
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
|
||||
import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
|
||||
// import type { Track } from '~/types'
|
||||
|
||||
import { useStore } from '~/store'
|
||||
import { useRouter } from 'vue-router'
|
||||
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 usePlayOptions from '~/composables/audio/usePlayOptions'
|
||||
import useReport from '~/composables/moderation/useReport'
|
||||
|
@ -55,11 +55,13 @@ const show = useVModel(props, 'show', emit)
|
|||
|
||||
const { report, getReportableObjects } = useReport()
|
||||
const { enqueue, enqueueNext } = usePlayOptions(props)
|
||||
|
||||
const store = useStore()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isFavorite = computed(() => store.getters['favorites/isFavorite'](props.track.id))
|
||||
|
||||
const { t } = useI18n()
|
||||
const favoriteButton = computed(() => isFavorite.value
|
||||
? t('components.audio.podcast.Modal.button.removeFromFavorites')
|
||||
: t('components.audio.podcast.Modal.button.addToFavorites')
|
||||
|
@ -90,18 +92,19 @@ const labels = computed(() => ({
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<semantic-modal
|
||||
<Modal
|
||||
ref="modal"
|
||||
v-model:show="show"
|
||||
v-model="show"
|
||||
:title="track.title"
|
||||
:scrolling="true"
|
||||
:additional-classes="['scrolling-track-options']"
|
||||
class="scrolling-track-options"
|
||||
>
|
||||
<div class="header">
|
||||
<template #topright>
|
||||
<div class="ui large centered rounded image">
|
||||
<img
|
||||
v-if="track.album && track.album.cover && track.album.cover.urls.original"
|
||||
v-lazy="
|
||||
$store.getters['instance/absoluteUrl'](
|
||||
store.getters['instance/absoluteUrl'](
|
||||
track.album.cover.urls.medium_square_crop
|
||||
)
|
||||
"
|
||||
|
@ -111,7 +114,7 @@ const labels = computed(() => ({
|
|||
<img
|
||||
v-else-if="track.cover"
|
||||
v-lazy="
|
||||
$store.getters['instance/absoluteUrl'](
|
||||
store.getters['instance/absoluteUrl'](
|
||||
track.cover.urls.medium_square_crop
|
||||
)
|
||||
"
|
||||
|
@ -133,18 +136,15 @@ const labels = computed(() => ({
|
|||
src="../../../assets/audio/default-cover.png"
|
||||
>
|
||||
</div>
|
||||
<h3 class="track-modal-title">
|
||||
{{ track.title }}
|
||||
</h3>
|
||||
<h4 class="track-modal-subtitle">
|
||||
{{ generateTrackCreditString(track) }}
|
||||
</h4>
|
||||
</div>
|
||||
</template>
|
||||
<div class="ui hidden divider" />
|
||||
<div class="content">
|
||||
<div class="ui one column unstackable grid">
|
||||
<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"
|
||||
>
|
||||
<div
|
||||
|
@ -152,7 +152,7 @@ const labels = computed(() => ({
|
|||
class="column"
|
||||
role="button"
|
||||
:aria-label="favoriteButton"
|
||||
@click.stop="$store.dispatch('favorites/toggle', track.id)"
|
||||
@click.stop="store.dispatch('favorites/toggle', track.id)"
|
||||
>
|
||||
<i
|
||||
:class="[
|
||||
|
@ -175,7 +175,7 @@ const labels = computed(() => ({
|
|||
:aria-label="labels.addToQueue"
|
||||
@click.stop.prevent="
|
||||
enqueue();
|
||||
modal.closeModal();
|
||||
show=false
|
||||
"
|
||||
>
|
||||
<i class="plus icon track-modal list-icon" />
|
||||
|
@ -189,7 +189,7 @@ const labels = computed(() => ({
|
|||
:aria-label="labels.playNext"
|
||||
@click.stop.prevent="
|
||||
enqueueNext(true);
|
||||
modal.closeModal();
|
||||
show=false
|
||||
"
|
||||
>
|
||||
<i class="step forward icon track-modal list-icon" />
|
||||
|
@ -202,11 +202,11 @@ const labels = computed(() => ({
|
|||
role="button"
|
||||
:aria-label="labels.startRadio"
|
||||
@click.stop.prevent="
|
||||
$store.dispatch('radios/start', {
|
||||
store.dispatch('radios/start', {
|
||||
type: 'similar',
|
||||
objectId: track.id,
|
||||
});
|
||||
modal.closeModal();
|
||||
show=false
|
||||
"
|
||||
>
|
||||
<i class="rss icon track-modal list-icon" />
|
||||
|
@ -218,7 +218,7 @@ const labels = computed(() => ({
|
|||
class="column"
|
||||
role="button"
|
||||
: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" />
|
||||
<span class="track-modal list-item">{{
|
||||
|
@ -236,7 +236,7 @@ const labels = computed(() => ({
|
|||
role="button"
|
||||
:aria-label="albumDetailsButton"
|
||||
@click.prevent.exact="
|
||||
$router.push({
|
||||
router.push({
|
||||
name: 'library.albums.detail',
|
||||
params: { id: track.album?.id },
|
||||
})
|
||||
|
@ -258,7 +258,7 @@ const labels = computed(() => ({
|
|||
class="column"
|
||||
role="button"
|
||||
: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" />
|
||||
<span class="track-modal list-item">{{ ac.artist.name }}</span>
|
||||
|
@ -271,7 +271,7 @@ const labels = computed(() => ({
|
|||
role="button"
|
||||
:aria-label="trackDetailsButton"
|
||||
@click.prevent.exact="
|
||||
$router.push({
|
||||
router.push({
|
||||
name: 'library.tracks.detail',
|
||||
params: { id: track.id },
|
||||
})
|
||||
|
@ -298,5 +298,5 @@ const labels = computed(() => ({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</semantic-modal>
|
||||
</Modal>
|
||||
</template>
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import type { Track, Album, Playlist, Library, Channel, Actor, Cover, ArtistCredit } from '~/types'
|
||||
import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
|
||||
import { getArtistCoverUrl } from '~/utils/utils'
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useQueue } from '~/composables/audio/queue'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
|
@ -48,6 +50,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
account: null
|
||||
})
|
||||
|
||||
const store = useStore()
|
||||
|
||||
const description = ref('')
|
||||
const renderedDescription = useMarkdown(description)
|
||||
|
||||
|
@ -83,13 +87,25 @@ await fetchData()
|
|||
>
|
||||
<img
|
||||
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=""
|
||||
class="ui artist-track mini image"
|
||||
>
|
||||
<img
|
||||
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=""
|
||||
class="ui artist-track mini image"
|
||||
>
|
||||
|
@ -120,10 +136,10 @@ await fetchData()
|
|||
class="meta right floated column"
|
||||
>
|
||||
<play-button
|
||||
id="playmenu"
|
||||
class="play-button basic icon"
|
||||
:dropdown-only="true"
|
||||
:is-playable="track.is_playable"
|
||||
discrete
|
||||
:dropdown-icon-classes="[
|
||||
'ellipsis',
|
||||
'vertical',
|
||||
|
|
|
@ -3,7 +3,7 @@ import type { Track } from '~/types'
|
|||
|
||||
import PodcastRow from '~/components/audio/podcast/Row.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 {
|
||||
tracks: Track[]
|
||||
|
@ -61,11 +61,9 @@ const { page } = defineModels<{ page: number, }>()
|
|||
v-if="paginateResults"
|
||||
class="ui center aligned basic segment desktop-and-up"
|
||||
>
|
||||
<pagination
|
||||
v-bind="$attrs"
|
||||
v-model:current="page"
|
||||
:total="total"
|
||||
:paginate-by="paginateBy"
|
||||
<Pagination
|
||||
v-model:page="page"
|
||||
:pages="Math.ceil((total || 0)/paginateBy)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -90,12 +88,10 @@ const { page } = defineModels<{ page: number, }>()
|
|||
v-if="paginateResults"
|
||||
class="ui center aligned basic segment tablet-and-below"
|
||||
>
|
||||
<pagination
|
||||
<Pagination
|
||||
v-if="paginateResults"
|
||||
v-bind="$attrs"
|
||||
v-model:current="page"
|
||||
:total="total"
|
||||
:compact="true"
|
||||
v-model:page="page"
|
||||
:pages="Math.ceil((total || 0)/paginateBy)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -7,12 +7,13 @@ import { useI18n } from 'vue-i18n'
|
|||
|
||||
import { usePlayer } from '~/composables/audio/player'
|
||||
import { useQueue } from '~/composables/audio/queue'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
import usePlayOptions from '~/composables/audio/usePlayOptions'
|
||||
|
||||
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
||||
import TrackModal from '~/components/audio/track/Modal.vue'
|
||||
import { generateTrackCreditString, getArtistCoverUrl } from '~/utils/utils'
|
||||
import { generateTrackCreditString } from '~/utils/utils'
|
||||
|
||||
interface Props extends PlayOptionsProps {
|
||||
track: Track
|
||||
|
@ -54,6 +55,8 @@ const { isPlaying } = usePlayer()
|
|||
const { activateTrack } = usePlayOptions(props)
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useStore()
|
||||
|
||||
const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.button.actions'))
|
||||
</script>
|
||||
|
||||
|
@ -70,20 +73,14 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
|
|||
@click.prevent.exact="activateTrack(track, index)"
|
||||
>
|
||||
<img
|
||||
v-if="track.album?.cover?.urls.original"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)"
|
||||
v-if="track.cover"
|
||||
v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.small_square_crop)"
|
||||
alt=""
|
||||
class="ui artist-track mini image"
|
||||
>
|
||||
<img
|
||||
v-else-if="track.cover"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_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)"
|
||||
v-else-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"
|
||||
>
|
||||
|
@ -113,13 +110,13 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
|
|||
{{ generateTrackCreditString(track) }}
|
||||
<span class="middle middledot symbol" />
|
||||
<human-duration
|
||||
v-if="track.uploads[0] && track.uploads[0].duration"
|
||||
:duration="track.uploads[0].duration"
|
||||
v-if="track.uploads?.[0]?.duration"
|
||||
:duration="track.uploads[0]?.duration"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="$store.state.auth.authenticated"
|
||||
v-if="store.state.auth.authenticated"
|
||||
:class="[
|
||||
'meta',
|
||||
'right',
|
||||
|
|
|
@ -1,16 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
|
||||
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 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 { generateTrackCreditString, getArtistCoverUrl } from '~/utils/utils'
|
||||
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
|
||||
interface Events {
|
||||
(e: 'update:show', value: boolean): void
|
||||
}
|
||||
|
@ -59,6 +61,8 @@ const store = useStore()
|
|||
const isFavorite = computed(() => store.getters['favorites/isFavorite'](props.track.id))
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const favoriteButton = computed(() => isFavorite.value
|
||||
? t('components.audio.track.Modal.button.removeFromFavorites')
|
||||
: t('components.audio.track.Modal.button.addToFavorites')
|
||||
|
@ -89,23 +93,24 @@ const labels = computed(() => ({
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<semantic-modal
|
||||
<Modal
|
||||
ref="modal"
|
||||
v-model:show="show"
|
||||
v-model="show"
|
||||
:title="track.title"
|
||||
:scrolling="true"
|
||||
:additional-classes="['scrolling-track-options']"
|
||||
class="scrolling-track-options"
|
||||
>
|
||||
<div class="header">
|
||||
<div class="ui large centered rounded image">
|
||||
<img
|
||||
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=""
|
||||
class="ui centered image"
|
||||
>
|
||||
<img
|
||||
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=""
|
||||
class="ui centered image"
|
||||
>
|
||||
|
@ -122,9 +127,6 @@ const labels = computed(() => ({
|
|||
src="../../../assets/audio/default-cover.png"
|
||||
>
|
||||
</div>
|
||||
<h3 class="track-modal-title">
|
||||
{{ track.title }}
|
||||
</h3>
|
||||
<h4 class="track-modal-subtitle">
|
||||
{{ generateTrackCreditString(track) }}
|
||||
</h4>
|
||||
|
@ -133,7 +135,7 @@ const labels = computed(() => ({
|
|||
<div class="content">
|
||||
<div class="ui one column unstackable grid">
|
||||
<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"
|
||||
>
|
||||
<div
|
||||
|
@ -141,7 +143,7 @@ const labels = computed(() => ({
|
|||
class="column"
|
||||
role="button"
|
||||
: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' ]" />
|
||||
<span class="track-modal list-item">{{ favoriteButton }}</span>
|
||||
|
@ -152,7 +154,7 @@ const labels = computed(() => ({
|
|||
class="column"
|
||||
role="button"
|
||||
:aria-label="labels.addToQueue"
|
||||
@click.stop.prevent="enqueue(); modal.closeModal()"
|
||||
@click.stop.prevent="enqueue(); show = false"
|
||||
>
|
||||
<i class="plus icon track-modal list-icon" />
|
||||
<span class="track-modal list-item">{{ labels.addToQueue }}</span>
|
||||
|
@ -163,7 +165,7 @@ const labels = computed(() => ({
|
|||
class="column"
|
||||
role="button"
|
||||
: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" />
|
||||
<span class="track-modal list-item">{{ labels.playNext }}</span>
|
||||
|
@ -174,7 +176,7 @@ const labels = computed(() => ({
|
|||
class="column"
|
||||
role="button"
|
||||
: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" />
|
||||
<span class="track-modal list-item">{{ labels.startRadio }}</span>
|
||||
|
@ -185,7 +187,7 @@ const labels = computed(() => ({
|
|||
class="column"
|
||||
role="button"
|
||||
: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" />
|
||||
<span class="track-modal list-item">
|
||||
|
@ -202,7 +204,7 @@ const labels = computed(() => ({
|
|||
class="column"
|
||||
role="button"
|
||||
: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" />
|
||||
<span class="track-modal list-item">{{ albumDetailsButton }}</span>
|
||||
|
@ -218,7 +220,7 @@ const labels = computed(() => ({
|
|||
class="column"
|
||||
role="button"
|
||||
: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" />
|
||||
<span class="track-modal list-item">{{ ac.credit }}</span>
|
||||
|
@ -230,7 +232,7 @@ const labels = computed(() => ({
|
|||
class="column"
|
||||
role="button"
|
||||
: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" />
|
||||
<span class="track-modal list-item">{{ trackDetailsButton }}</span>
|
||||
|
@ -250,5 +252,5 @@ const labels = computed(() => ({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</semantic-modal>
|
||||
</Modal>
|
||||
</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>
|
||||
</template>
|
||||
|
|
|
@ -6,11 +6,16 @@ import { computed, ref } from 'vue'
|
|||
|
||||
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 PlayIndicator from '~/components/audio/track/PlayIndicator.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 {
|
||||
track: Track
|
||||
|
@ -60,11 +65,14 @@ const hover = ref(false)
|
|||
|
||||
<template>
|
||||
<div
|
||||
:class="[{ active }, 'track-row row']"
|
||||
:class="[{ active }, 'track-row row', $style.row]"
|
||||
style="display: contents;"
|
||||
@dblclick="activateTrack(track, index)"
|
||||
@mousemove="hover = true"
|
||||
@mouseout="hover = false"
|
||||
>
|
||||
<!-- 1. column: Play button or track position -->
|
||||
|
||||
<div
|
||||
class="actions one wide left floated column"
|
||||
role="button"
|
||||
|
@ -78,32 +86,29 @@ const hover = ref(false)
|
|||
!hover
|
||||
"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
v-else-if="
|
||||
!isPlaying &&
|
||||
active &&
|
||||
!hover
|
||||
"
|
||||
class="ui really tiny basic icon button play-button paused"
|
||||
>
|
||||
<i class="play icon" />
|
||||
</button>
|
||||
<button
|
||||
ghost
|
||||
icon="bi-play-fill"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="
|
||||
isPlaying &&
|
||||
active &&
|
||||
hover
|
||||
"
|
||||
class="ui really tiny basic icon button play-button"
|
||||
>
|
||||
<i class="pause icon" />
|
||||
</button>
|
||||
<button
|
||||
ghost
|
||||
icon="bi-pause-fill"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="hover"
|
||||
class="ui really tiny basic icon button play-button"
|
||||
>
|
||||
<i class="play icon" />
|
||||
</button>
|
||||
ghost
|
||||
icon="bi-play-fill"
|
||||
/>
|
||||
<span
|
||||
v-else-if="showPosition"
|
||||
class="track-position"
|
||||
|
@ -111,40 +116,35 @@ const hover = ref(false)
|
|||
{{ `${track.position}`.padStart(2, '0') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showArt"
|
||||
class="image left floated column"
|
||||
role="button"
|
||||
@click.prevent.exact="activateTrack(track, index)"
|
||||
>
|
||||
<img
|
||||
v-if="track.album?.cover?.urls.original"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)"
|
||||
alt=""
|
||||
class="ui artist-track mini image"
|
||||
v-if="showArt && track.cover?.urls.original"
|
||||
v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.small_square_crop)"
|
||||
:alt="track.title"
|
||||
class="track_image"
|
||||
>
|
||||
<img
|
||||
v-else-if="track.cover?.urls.original"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)"
|
||||
v-else-if="showArt && 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"
|
||||
class="track_image"
|
||||
>
|
||||
<img
|
||||
v-else-if="track.artist_credit?.length && track.artist_credit[0].artist.cover?.urls.original"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](track.artist_credit[0].artist.cover.urls.medium_square_crop) "
|
||||
v-else-if="showArt"
|
||||
alt=""
|
||||
class="ui artist-track mini image"
|
||||
>
|
||||
<img
|
||||
v-else
|
||||
alt=""
|
||||
class="ui artist-track mini image"
|
||||
class="track_image"
|
||||
src="../../../assets/audio/default-cover.png"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
tabindex="0"
|
||||
class="content ellipsis left floated column"
|
||||
class="content ellipsis column left floated column"
|
||||
>
|
||||
<a
|
||||
@click="activateTrack(track, index)"
|
||||
|
@ -152,22 +152,23 @@ const hover = ref(false)
|
|||
{{ track.title }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showAlbum"
|
||||
class="content ellipsis left floated column"
|
||||
>
|
||||
<router-link
|
||||
v-if="showAlbum"
|
||||
:to="{ name: 'library.albums.detail', params: { id: track.album?.id } }"
|
||||
>
|
||||
{{ track.album?.title }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showArtist"
|
||||
class="content ellipsis left floated column"
|
||||
>
|
||||
<template
|
||||
v-for="ac in track.artist_credit"
|
||||
v-for="ac in (showArtist ? track.artist_credit : [])"
|
||||
:key="ac.artist.id"
|
||||
>
|
||||
<router-link
|
||||
|
@ -182,41 +183,49 @@ const hover = ref(false)
|
|||
<span>{{ ac.joinphrase }}</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$store.state.auth.authenticated"
|
||||
class="meta right floated column"
|
||||
>
|
||||
<track-favorite-icon
|
||||
class="tiny"
|
||||
:border="false"
|
||||
v-if="store.state.auth.authenticated"
|
||||
ghost
|
||||
:track="track"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showDuration"
|
||||
class="meta right floated column"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="displayActions"
|
||||
class="meta right floated column"
|
||||
>
|
||||
<play-button
|
||||
id="playmenu"
|
||||
class="play-button basic icon"
|
||||
<PlayButton
|
||||
:dropdown-only="true"
|
||||
:is-playable="track.is_playable"
|
||||
:dropdown-icon-classes="[
|
||||
'ellipsis',
|
||||
'vertical',
|
||||
'large really discrete',
|
||||
]"
|
||||
:track="track"
|
||||
class="ui floating dropdown"
|
||||
ghost
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</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 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 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'
|
||||
|
||||
|
@ -62,7 +66,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
filters: () => ({}),
|
||||
nextUrl: null,
|
||||
|
||||
paginateResults: true,
|
||||
paginateResults: false,
|
||||
total: 0,
|
||||
page: 1,
|
||||
paginateBy: 25,
|
||||
|
@ -83,11 +87,15 @@ const allTracks = computed(() => {
|
|||
: tracks
|
||||
})
|
||||
|
||||
const paginateResults = computed(() => props.paginateResults && allTracks.value.length < props.paginateBy)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const labels = computed(() => ({
|
||||
title: t('components.audio.track.Table.table.header.title'),
|
||||
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)
|
||||
|
@ -140,11 +148,16 @@ const updatePage = (page: number) => {
|
|||
<template>
|
||||
<div>
|
||||
<!-- Show the search bar if search is true -->
|
||||
<inline-search-bar
|
||||
|
||||
<Input
|
||||
v-if="search"
|
||||
v-model="query"
|
||||
search
|
||||
autofocus
|
||||
:placeholder="labels.searchPlaceholder"
|
||||
@search="performSearch"
|
||||
/>
|
||||
<Spacer v-if="search" />
|
||||
|
||||
<!-- Add a header if needed -->
|
||||
|
||||
|
@ -161,66 +174,41 @@ const updatePage = (page: number) => {
|
|||
@refresh="fetchData()"
|
||||
/>
|
||||
</slot>
|
||||
<div v-else>
|
||||
<div
|
||||
:class="['track-table', 'ui', 'unstackable', 'grid', 'tablet-and-up']"
|
||||
>
|
||||
<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
|
||||
v-else
|
||||
class="actions left floated column"
|
||||
/>
|
||||
<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>
|
||||
</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 on screens > 768px wide -->
|
||||
<!-- TODO: Make responsive to parent container instead of screen -->
|
||||
|
||||
<div
|
||||
v-else
|
||||
:class="['track-table', 'ui', 'unstackable', 'grid', 'tablet-and-up']"
|
||||
>
|
||||
<Loader v-if="isLoading" />
|
||||
|
||||
<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
|
||||
v-for="(track, index) in allTracks"
|
||||
|
@ -236,29 +224,25 @@ const updatePage = (page: number) => {
|
|||
:show-duration="showDuration"
|
||||
:is-podcast="isPodcast"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="tracks && paginateResults"
|
||||
class="ui center aligned basic segment desktop-and-up"
|
||||
>
|
||||
<pagination
|
||||
:total="totalTracks"
|
||||
:current="tracks !== undefined ? page : currentPage"
|
||||
:paginate-by="paginateBy"
|
||||
@update:current="updatePage"
|
||||
/>
|
||||
</div>
|
||||
</Table>
|
||||
|
||||
<!-- Pagination -->
|
||||
|
||||
<Pagination
|
||||
v-if="paginateResults"
|
||||
:pages="paginateBy"
|
||||
:page="page"
|
||||
@update:current="updatePage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Under 768px screen width -->
|
||||
<!-- TODO: Make responsive to parent container instead of screen -->
|
||||
|
||||
<div
|
||||
:class="['track-table', 'ui', 'unstackable', 'grid', 'tablet-and-below']"
|
||||
>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="ui inverted active dimmer"
|
||||
>
|
||||
<div class="ui loader" />
|
||||
</div>
|
||||
<Loader v-if="isLoading" />
|
||||
|
||||
<!-- For each item, build a row -->
|
||||
|
||||
|
@ -275,19 +259,19 @@ const updatePage = (page: number) => {
|
|||
:is-album="isAlbum"
|
||||
:is-podcast="isPodcast"
|
||||
/>
|
||||
<div
|
||||
v-if="tracks && paginateResults && totalTracks > paginateBy"
|
||||
class="ui center aligned basic segment tablet-and-below"
|
||||
>
|
||||
<pagination
|
||||
v-if="paginateResults && totalTracks > paginateBy"
|
||||
:paginate-by="paginateBy"
|
||||
:total="totalTracks"
|
||||
:current="tracks !== undefined ? page : currentPage"
|
||||
:compact="true"
|
||||
@update:current="updatePage"
|
||||
/>
|
||||
</div>
|
||||
<Pagination
|
||||
v-if="paginateResults"
|
||||
:pages="paginateBy"
|
||||
:page="page"
|
||||
@update:current="updatePage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
[table-header] > label {
|
||||
padding-left: 4px;
|
||||
align-self: center !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,20 +1,27 @@
|
|||
<script setup lang="ts">
|
||||
import type { Track, Listening } from '~/types'
|
||||
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { ref, reactive, watch, onMounted } from 'vue'
|
||||
import { useStore } from '~/store'
|
||||
import { clone } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getArtistCoverUrl } from '~/utils/utils'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
import usePage from '~/composables/navigation/usePage'
|
||||
import useWebSocketHandler from '~/composables/useWebSocketHandler'
|
||||
|
||||
import PlayButton from '~/components/audio/PlayButton.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 { getArtistCoverUrl } from '~/utils/utils'
|
||||
|
||||
interface Events {
|
||||
(e: 'count', count: number): void
|
||||
}
|
||||
|
@ -23,55 +30,61 @@ interface Props {
|
|||
filters: Record<string, string | boolean>
|
||||
url: string
|
||||
isActivity?: boolean
|
||||
showCount?: boolean
|
||||
limit?: number
|
||||
itemClasses?: string
|
||||
websocketHandlers?: string[]
|
||||
title?: string
|
||||
}
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isActivity: true,
|
||||
showCount: false,
|
||||
limit: 5,
|
||||
limit: 9,
|
||||
itemClasses: '',
|
||||
websocketHandlers: () => []
|
||||
websocketHandlers: () => [],
|
||||
title: undefined
|
||||
})
|
||||
|
||||
const store = useStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const objects = reactive([] as Listening[])
|
||||
const count = ref(0)
|
||||
const nextPage = ref<string | null>(null)
|
||||
const page = usePage()
|
||||
|
||||
const isLoading = ref(false)
|
||||
|
||||
const fetchData = async (url = props.url) => {
|
||||
isLoading.value = true
|
||||
|
||||
const params = {
|
||||
...clone(props.filters),
|
||||
page_size: props.limit
|
||||
page: page.value,
|
||||
page_size: props.limit ?? 9
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(url, { params })
|
||||
nextPage.value = response.data.next
|
||||
count.value = response.data.count
|
||||
|
||||
const newObjects = !props.isActivity
|
||||
? response.data.results.map((track: Track) => ({ track }))
|
||||
: response.data.results
|
||||
|
||||
objects.push(...newObjects)
|
||||
objects.splice(0, objects.length, ...newObjects)
|
||||
} catch (error) {
|
||||
useErrorHandler(error as Error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(fetchData, 1000)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => store.state.moderation.lastUpdate,
|
||||
[() => store.state.moderation.lastUpdate, page],
|
||||
() => fetchData(),
|
||||
{ immediate: true }
|
||||
)
|
||||
|
@ -79,150 +92,249 @@ watch(
|
|||
watch(count, (to) => emit('count', to))
|
||||
|
||||
watch(() => props.websocketHandlers.includes('Listen'), (to) => {
|
||||
useWebSocketHandler('Listen', (event) => {
|
||||
// TODO (wvffle): Add reactivity to recently listened / favorited / added (#1316, #1534)
|
||||
// count.value += 1
|
||||
if (to) {
|
||||
useWebSocketHandler('Listen', (event: unknown) => {
|
||||
// Handle WebSocket events for "Listen"
|
||||
|
||||
// objects.unshift(event as Listening)
|
||||
// objects.pop()
|
||||
})
|
||||
})
|
||||
// Add the event to `objects` reactively
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="component-track-widget">
|
||||
<h3 v-if="!!$slots.title">
|
||||
<slot name="title" />
|
||||
<span
|
||||
v-if="showCount"
|
||||
class="ui tiny circular label"
|
||||
>{{ count }}</span>
|
||||
</h3>
|
||||
<div
|
||||
v-if="count > 0"
|
||||
class="ui divided unstackable items"
|
||||
<Section
|
||||
align-left
|
||||
:h2="title"
|
||||
:columns-per-item="4"
|
||||
>
|
||||
<Loader
|
||||
v-if="isLoading"
|
||||
style="grid-column: 1 / -1;"
|
||||
/>
|
||||
<Alert
|
||||
v-if="!isLoading && count === 0"
|
||||
style="grid-column: 1 / -1;"
|
||||
blue
|
||||
align-items="center"
|
||||
>
|
||||
<div
|
||||
v-for="object in objects"
|
||||
:key="object.id"
|
||||
:class="['item', itemClasses]"
|
||||
>
|
||||
<div class="ui tiny image">
|
||||
<img
|
||||
v-if="object.track.album && object.track.album.cover"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](object.track.album.cover.urls.medium_square_crop)"
|
||||
alt=""
|
||||
>
|
||||
<img
|
||||
v-else-if="object.track.cover"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](object.track.cover.urls.medium_square_crop)"
|
||||
alt=""
|
||||
>
|
||||
<img
|
||||
v-else-if="object.track.artist_credit && object.track.artist_credit.length > 0"
|
||||
v-lazy="getArtistCoverUrl(object.track.artist_credit)"
|
||||
alt=""
|
||||
>
|
||||
<img
|
||||
v-else
|
||||
alt=""
|
||||
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"
|
||||
<h4>
|
||||
<i class="bi bi-search" />
|
||||
{{ t('components.audio.track.Widget.empty.noResults') }}
|
||||
</h4>
|
||||
</Alert>
|
||||
<!-- TODO: Use activity.vue -->
|
||||
<div
|
||||
v-for="object in (count > 0 ? objects : [])"
|
||||
:key="object.id"
|
||||
class="funkwhale activity"
|
||||
:class="['item', itemClasses]"
|
||||
>
|
||||
<div class="activity-image">
|
||||
<img
|
||||
v-if="object.track.album && object.track.album.cover"
|
||||
v-lazy="store.getters['instance/absoluteUrl'](object.track.album.cover.urls.small_square_crop)"
|
||||
alt=""
|
||||
>
|
||||
<img
|
||||
v-else-if="object.track.cover"
|
||||
v-lazy="store.getters['instance/absoluteUrl'](object.track.cover.urls.small_square_crop)"
|
||||
alt=""
|
||||
>
|
||||
<img
|
||||
v-else-if="object.track.artist_credit && object.track.artist_credit.length > 1"
|
||||
v-lazy="getArtistCoverUrl(object.track.artist_credit)"
|
||||
alt=""
|
||||
>
|
||||
<i
|
||||
v-else
|
||||
class="bi bi-vinyl-fill"
|
||||
/>
|
||||
<!-- TODO: Add Playbutton overlay -->
|
||||
</div>
|
||||
<div class="activity-content">
|
||||
<router-link
|
||||
class="funkwhale link artist"
|
||||
:to="{name: 'library.tracks.detail', params: {id: object.track.id}}"
|
||||
>
|
||||
<Heading
|
||||
:h3="object.track.title"
|
||||
title
|
||||
/>
|
||||
</router-link>
|
||||
<Spacer :size="2" />
|
||||
<div
|
||||
v-if="object.track.artist_credit"
|
||||
class="funkwhale link artist"
|
||||
>
|
||||
<span
|
||||
v-for="ac in object.track.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 v-if="ac.joinphrase">{{ ac.joinphrase }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="middle aligned content">
|
||||
<div class="ui unstackable grid">
|
||||
<div class="thirteen wide stretched column">
|
||||
<div class="ellipsis">
|
||||
<router-link :to="{name: 'library.tracks.detail', params: {id: object.track.id}}">
|
||||
{{ object.track.title }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
v-if="object.track.artist_credit"
|
||||
class="meta ellipsis"
|
||||
>
|
||||
<span
|
||||
v-for="ac in object.track.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 v-if="ac.joinphrase">{{ ac.joinphrase }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<tags-list
|
||||
label-classes="tiny"
|
||||
:truncate-size="20"
|
||||
:limit="2"
|
||||
:show-more="false"
|
||||
:tags="object.track.tags"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="isActivity"
|
||||
class="extra"
|
||||
>
|
||||
<router-link
|
||||
class="left floated"
|
||||
:to="{name: 'profile.overview', params: {username: object.actor.name}}"
|
||||
>
|
||||
<span class="at symbol" />{{ object.actor.name }}
|
||||
</router-link>
|
||||
<span class="right floated"><human-date :date="object.creation_date" /></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="one wide stretched column">
|
||||
<play-button
|
||||
class="basic icon"
|
||||
:account="object.actor"
|
||||
:dropdown-only="true"
|
||||
:dropdown-icon-classes="['ellipsis', 'vertical', 'large really discrete']"
|
||||
:track="object.track"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Spacer :size="8" />
|
||||
<TagsList
|
||||
label-classes="tiny"
|
||||
:truncate-size="20"
|
||||
:limit="2"
|
||||
:show-more="false"
|
||||
:tags="object.track.tags"
|
||||
/>
|
||||
<Spacer :size="4" />
|
||||
<div
|
||||
v-if="isActivity"
|
||||
class="extra"
|
||||
>
|
||||
<router-link
|
||||
class="funkwhale link user"
|
||||
:to="{name: 'profile.overview', params: {username: object.actor.name}}"
|
||||
>
|
||||
<span class="at symbol" />{{ object.actor.name }}
|
||||
</router-link>
|
||||
<span class="right floated"><human-date :date="object.creation_date" /></span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="ui inverted active dimmer"
|
||||
>
|
||||
<div class="ui loader" />
|
||||
</div>
|
||||
<play-button
|
||||
:account="object.actor"
|
||||
:dropdown-only="true"
|
||||
:track="object.track"
|
||||
square-small
|
||||
/>
|
||||
</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>
|
||||
<Pagination
|
||||
v-if="page && count > props.limit"
|
||||
v-model:page="page"
|
||||
:pages="Math.ceil((count || 0) / props.limit)"
|
||||
style="grid-column: 1 / -1;"
|
||||
/>
|
||||
</Section>
|
||||
</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({
|
||||
loadRandomPreset
|
||||
})
|
||||
|
||||
let autoPresetInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function toggleAutoPreset() {
|
||||
if (autoPresetInterval) {
|
||||
clearInterval(autoPresetInterval)
|
||||
autoPresetInterval = null
|
||||
} else {
|
||||
autoPresetInterval = setInterval(() => {
|
||||
loadRandomPreset()
|
||||
}, 23000)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -36,6 +49,7 @@ defineExpose({
|
|||
<canvas
|
||||
ref="canvas"
|
||||
@click="loadRandomPreset()"
|
||||
@dblclick="toggleAutoPreset()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -60,7 +60,7 @@ store.state.auth.applicationSecret = undefined
|
|||
<template>
|
||||
<main
|
||||
v-title="labels.title"
|
||||
class="main pusher"
|
||||
class="main"
|
||||
>
|
||||
<div class="ui vertical stripe segment">
|
||||
<section class="ui text container">
|
||||
|
@ -72,17 +72,17 @@ store.state.auth.applicationSecret = undefined
|
|||
</div>
|
||||
<template v-else>
|
||||
<router-link :to="{name: 'settings'}">
|
||||
{{ $t('components.auth.ApplicationEdit.link.settings') }}
|
||||
{{ t('components.auth.ApplicationEdit.link.settings') }}
|
||||
</router-link>
|
||||
<h2 class="ui header">
|
||||
{{ $t('components.auth.ApplicationEdit.header.appDetails') }}
|
||||
{{ t('components.auth.ApplicationEdit.header.appDetails') }}
|
||||
</h2>
|
||||
<div class="ui form">
|
||||
<p>
|
||||
{{ $t('components.auth.ApplicationEdit.help.appDetails') }}
|
||||
{{ t('components.auth.ApplicationEdit.help.appDetails') }}
|
||||
</p>
|
||||
<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
|
||||
id="copy-id"
|
||||
:value="application.client_id"
|
||||
|
@ -94,14 +94,14 @@ store.state.auth.applicationSecret = undefined
|
|||
>
|
||||
<div class="ui small warning message">
|
||||
<h3 class="header">
|
||||
{{ $t('components.auth.ApplicationEdit.header.appSecretWarning') }}
|
||||
{{ t('components.auth.ApplicationEdit.header.appSecretWarning') }}
|
||||
</h3>
|
||||
<p>
|
||||
{{ $t('components.auth.ApplicationEdit.message.appSecretWarning') }}
|
||||
{{ t('components.auth.ApplicationEdit.message.appSecretWarning') }}
|
||||
</p>
|
||||
</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
|
||||
id="copy-secret"
|
||||
:value="secret"
|
||||
|
@ -111,7 +111,7 @@ store.state.auth.applicationSecret = undefined
|
|||
v-if="application.token != undefined"
|
||||
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
|
||||
id="copy-secret"
|
||||
:value="application.token"
|
||||
|
@ -121,12 +121,12 @@ store.state.auth.applicationSecret = undefined
|
|||
@click.prevent="refreshToken"
|
||||
>
|
||||
<i class="refresh icon" />
|
||||
{{ $t('components.auth.ApplicationEdit.button.regenerateToken') }}
|
||||
{{ t('components.auth.ApplicationEdit.button.regenerateToken') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="ui header">
|
||||
{{ $t('components.auth.ApplicationEdit.header.editApp') }}
|
||||
{{ t('components.auth.ApplicationEdit.header.editApp') }}
|
||||
</h2>
|
||||
<application-form
|
||||
:app="application"
|
||||
|
|
|
@ -7,6 +7,8 @@ import { computedEager } from '@vueuse/core'
|
|||
import { useI18n } from 'vue-i18n'
|
||||
import { uniq } from 'lodash-es'
|
||||
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
|
||||
import useScopes from '~/composables/auth/useScopes'
|
||||
|
||||
interface Events {
|
||||
|
@ -120,7 +122,7 @@ const allScopes = computed(() => {
|
|||
class="ui negative message"
|
||||
>
|
||||
<h4 class="header">
|
||||
{{ $t('components.auth.ApplicationForm.header.failure') }}
|
||||
{{ t('components.auth.ApplicationForm.header.failure') }}
|
||||
</h4>
|
||||
<ul class="list">
|
||||
<li
|
||||
|
@ -132,7 +134,7 @@ const allScopes = computed(() => {
|
|||
</ul>
|
||||
</div>
|
||||
<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
|
||||
id="application-name"
|
||||
v-model="fields.name"
|
||||
|
@ -142,7 +144,7 @@ const allScopes = computed(() => {
|
|||
>
|
||||
</div>
|
||||
<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
|
||||
id="redirect-uris"
|
||||
v-model="fields.redirect_uris"
|
||||
|
@ -150,13 +152,13 @@ const allScopes = computed(() => {
|
|||
type="text"
|
||||
>
|
||||
<p class="help">
|
||||
{{ $t('components.auth.ApplicationForm.help.redirectUri') }}
|
||||
{{ t('components.auth.ApplicationForm.help.redirectUri') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ui field">
|
||||
<label>{{ $t('components.auth.ApplicationForm.label.scopes.label') }}</label>
|
||||
<label>{{ t('components.auth.ApplicationForm.label.scopes.label') }}</label>
|
||||
<p>
|
||||
{{ $t('components.auth.ApplicationForm.label.scopes.description') }}
|
||||
{{ t('components.auth.ApplicationForm.label.scopes.description') }}
|
||||
</p>
|
||||
<div class="ui stackable two column grid">
|
||||
<div
|
||||
|
@ -198,16 +200,16 @@ const allScopes = computed(() => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
:class="['ui', {'loading': isLoading}, 'success', 'button']"
|
||||
<Button
|
||||
:is-loading="isLoading"
|
||||
type="submit"
|
||||
>
|
||||
<span v-if="app !== null">
|
||||
{{ $t('components.auth.ApplicationForm.button.update') }}
|
||||
{{ t('components.auth.ApplicationForm.button.update') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('components.auth.ApplicationForm.button.create') }}
|
||||
{{ t('components.auth.ApplicationForm.button.create') }}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</template>
|
||||
|
|
|
@ -53,12 +53,12 @@ const created = (application: Application) => {
|
|||
<template>
|
||||
<main
|
||||
v-title="labels.title"
|
||||
class="main pusher"
|
||||
class="main"
|
||||
>
|
||||
<div class="ui vertical stripe segment">
|
||||
<section class="ui text container">
|
||||
<router-link :to="{name: 'settings'}">
|
||||
{{ $t('components.auth.ApplicationNew.link.settings') }}
|
||||
{{ t('components.auth.ApplicationNew.link.settings') }}
|
||||
</router-link>
|
||||
<h2 class="ui header">
|
||||
{{ labels.title }}
|
||||
|
|
|
@ -9,6 +9,8 @@ import { ref, computed } from 'vue'
|
|||
import useSharedLabels from '~/composables/locale/useSharedLabels'
|
||||
import useScopes from '~/composables/auth/useScopes'
|
||||
import useFormData from '~/composables/useFormData'
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
|
||||
interface Props {
|
||||
clientId: string
|
||||
|
@ -112,31 +114,30 @@ whenever(() => props.clientId, fetchApplication, { immediate: true })
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
<layout
|
||||
v-title="labels.title"
|
||||
class="main pusher"
|
||||
main
|
||||
class="main"
|
||||
>
|
||||
<section class="ui vertical stripe segment">
|
||||
<div class="ui small text container">
|
||||
<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>
|
||||
<div
|
||||
<Alert
|
||||
v-if="errors.length > 0"
|
||||
red
|
||||
role="alert"
|
||||
class="ui negative message"
|
||||
>
|
||||
<h4
|
||||
v-if="application"
|
||||
class="header"
|
||||
>
|
||||
{{ $t('components.auth.Authorize.header.authorizeFailure') }}
|
||||
{{ t('components.auth.Authorize.header.authorizeFailure') }}
|
||||
</h4>
|
||||
<h4
|
||||
v-else
|
||||
class="header"
|
||||
>
|
||||
{{ $t('components.auth.Authorize.header.fetchFailure') }}
|
||||
{{ t('components.auth.Authorize.header.fetchFailure') }}
|
||||
</h4>
|
||||
<ul class="list">
|
||||
<li
|
||||
|
@ -146,20 +147,18 @@ whenever(() => props.clientId, fetchApplication, { immediate: true })
|
|||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
</Alert>
|
||||
<Loader
|
||||
v-if="isLoading"
|
||||
class="ui inverted active dimmer"
|
||||
>
|
||||
<div class="ui loader" />
|
||||
</div>
|
||||
/>
|
||||
<form
|
||||
v-else-if="application && !code"
|
||||
:class="['ui', {loading: isLoading}, 'form']"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<h3>
|
||||
{{ $t('components.auth.Authorize.header.access', {app_name: application.name}) }}
|
||||
{{ t('components.auth.Authorize.header.access', {app_name: application.name}) }}
|
||||
</h3>
|
||||
|
||||
<h4
|
||||
|
@ -171,23 +170,23 @@ whenever(() => props.clientId, fetchApplication, { immediate: true })
|
|||
v-if="topic.write && !topic.read"
|
||||
:class="['ui', 'basic', 'right floated', 'tiny', 'vertically-spaced component-label label']"
|
||||
>
|
||||
<i class="pencil icon" />
|
||||
{{ $t('components.auth.Authorize.header.writeOnly') }}
|
||||
<i class="bi bi-pencil" />
|
||||
{{ t('components.auth.Authorize.header.writeOnly') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="!topic.write && topic.read"
|
||||
: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
|
||||
v-else-if="topic.write && topic.read"
|
||||
:class="['ui', 'basic', 'right floated', 'tiny', 'vertically-spaced component-label label']"
|
||||
>
|
||||
<i class="pencil icon" />
|
||||
{{ $t('components.auth.Authorize.header.allScopes') }}
|
||||
<i class="bi bi-pencil" />
|
||||
{{ t('components.auth.Authorize.header.allScopes') }}
|
||||
</span>
|
||||
<i :class="[topic.icon, 'icon']" />
|
||||
<i :class="[topic.icon, 'bi']" />
|
||||
<div class="content">
|
||||
{{ topic.label }}
|
||||
<div class="sub header">
|
||||
|
@ -196,7 +195,7 @@ whenever(() => props.clientId, fetchApplication, { immediate: true })
|
|||
</div>
|
||||
</h4>
|
||||
<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
|
||||
v-for="(unknownscope, key) in unknownRequestedScopes"
|
||||
:key="key"
|
||||
|
@ -204,17 +203,16 @@ whenever(() => props.clientId, fetchApplication, { immediate: true })
|
|||
<li>{{ unknownscope }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button
|
||||
class="ui success labeled icon button"
|
||||
<Button
|
||||
icon="bi-unlock"
|
||||
type="submit"
|
||||
>
|
||||
<i class="lock open icon" />
|
||||
{{ $t('components.auth.Authorize.button.authorize', { app: application.name }) }}
|
||||
</button>
|
||||
{{ t('components.auth.Authorize.button.authorize', { app: application.name }) }}
|
||||
</Button>
|
||||
<p
|
||||
v-if="redirectUri === 'urn:ietf:wg:oauth:2.0:oob'"
|
||||
>
|
||||
{{ $t('components.auth.Authorize.help.copyCode') }}
|
||||
{{ t('components.auth.Authorize.help.copyCode') }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
|
@ -225,10 +223,10 @@ whenever(() => props.clientId, fetchApplication, { immediate: true })
|
|||
</p>
|
||||
</form>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</layout>
|
||||
</template>
|
||||
|
|
|
@ -2,12 +2,16 @@
|
|||
import type { BackendError } from '~/types'
|
||||
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 { useI18n } from 'vue-i18n'
|
||||
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 {
|
||||
next?: RouteLocationRaw
|
||||
|
@ -44,12 +48,6 @@ const labels = computed(() => ({
|
|||
usernamePlaceholder: t('components.auth.LoginForm.placeholder.username')
|
||||
}))
|
||||
|
||||
const username = ref()
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
username.value?.focus()
|
||||
})
|
||||
|
||||
const isLoading = ref(false)
|
||||
const errors = ref([] as string[])
|
||||
const submit = async () => {
|
||||
|
@ -77,82 +75,97 @@ const submit = async () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="ui form"
|
||||
<Layout
|
||||
form
|
||||
stack
|
||||
style="max-width: 600px"
|
||||
@submit.prevent="submit()"
|
||||
>
|
||||
<div
|
||||
<Alert
|
||||
v-if="errors.length > 0"
|
||||
role="alert"
|
||||
class="ui negative message"
|
||||
red
|
||||
>
|
||||
<h4 class="header">
|
||||
{{ $t('components.auth.LoginForm.header.loginFailure') }}
|
||||
{{ t('components.auth.LoginForm.header.loginFailure') }}
|
||||
</h4>
|
||||
<ul class="list">
|
||||
<li
|
||||
v-if="errors[0] == 'invalid_credentials' && $store.state.instance.settings.moderation.signup_approval_enabled.value"
|
||||
<component
|
||||
:is="errors.length>1 ? 'ul' : 'div'"
|
||||
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] }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<template v-if="domain === $store.getters['instance/domain']">
|
||||
<div class="field">
|
||||
<label for="username-field">
|
||||
{{ $t('components.auth.LoginForm.label.username') }}
|
||||
</component>
|
||||
</component>
|
||||
</Alert>
|
||||
<Spacer />
|
||||
<template v-if="domain === store.getters['instance/domain']">
|
||||
<Input
|
||||
id="username-field"
|
||||
ref="username"
|
||||
v-model="credentials.username"
|
||||
autocomplete="username"
|
||||
required
|
||||
name="username"
|
||||
type="text"
|
||||
autofocus
|
||||
:placeholder="labels.usernamePlaceholder"
|
||||
>
|
||||
<template #label>
|
||||
{{ 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') }}
|
||||
{{ t('components.auth.LoginForm.link.createAccount') }}
|
||||
</router-link>
|
||||
</template>
|
||||
</label>
|
||||
<input
|
||||
id="username-field"
|
||||
ref="username"
|
||||
v-model="credentials.username"
|
||||
required
|
||||
name="username"
|
||||
type="text"
|
||||
autofocus
|
||||
:placeholder="labels.usernamePlaceholder"
|
||||
>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password-field">
|
||||
{{ $t('components.auth.LoginForm.label.password') }}
|
||||
</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" />
|
||||
<router-link
|
||||
tabindex="1"
|
||||
:to="{ name: 'auth.password-reset', query: { email: credentials.username } }"
|
||||
>
|
||||
{{ $t('components.auth.LoginForm.link.resetPassword') }}
|
||||
{{ t('components.auth.LoginForm.link.resetPassword') }}
|
||||
</router-link>
|
||||
</label>
|
||||
<password-input
|
||||
v-model="credentials.password"
|
||||
field-id="password-field"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Input>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>
|
||||
{{ $t('components.auth.LoginForm.message.redirect', { domain: $store.getters['instance/domain'] }) }}
|
||||
{{ t('components.auth.LoginForm.message.redirect', { domain: store.getters['instance/domain'] }) }}
|
||||
</p>
|
||||
</template>
|
||||
<button
|
||||
:class="['ui', { 'loading': isLoading }, 'right', 'floated', buttonClasses, 'button']"
|
||||
<Button
|
||||
solid
|
||||
primary
|
||||
type="submit"
|
||||
>
|
||||
{{ $t('components.auth.LoginForm.button.login') }}
|
||||
</button>
|
||||
</form>
|
||||
{{ t('components.auth.LoginForm.button.login') }}
|
||||
</Button>
|
||||
</Layout>
|
||||
</template>
|
||||
|
|
|
@ -1,8 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
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 labels = computed(() => ({
|
||||
title: t('components.auth.Logout.title')
|
||||
}))
|
||||
|
@ -11,40 +19,43 @@ const labels = computed(() => ({
|
|||
<template>
|
||||
<main
|
||||
v-title="labels.title"
|
||||
class="main pusher"
|
||||
class="main"
|
||||
>
|
||||
<section class="ui vertical stripe segment">
|
||||
<div
|
||||
v-if="$store.state.auth.authenticated"
|
||||
class="ui small text container"
|
||||
<div
|
||||
v-if="store.state.auth.authenticated"
|
||||
class="ui small text container"
|
||||
>
|
||||
<h2>
|
||||
{{ t('components.auth.Logout.header.confirm') }}
|
||||
</h2>
|
||||
<p>
|
||||
{{ t('components.auth.Logout.message.loggedIn', { username: store.state.auth.username }) }}
|
||||
</p>
|
||||
<Spacer />
|
||||
<Button
|
||||
solid
|
||||
primary
|
||||
@click="store.dispatch('auth/logout')"
|
||||
>
|
||||
<h2>
|
||||
{{ $t('components.auth.Logout.header.confirm') }}
|
||||
</h2>
|
||||
<p>
|
||||
{{ $t('components.auth.Logout.message.loggedIn', { username: $store.state.auth.username }) }}
|
||||
</p>
|
||||
<button
|
||||
class="ui button"
|
||||
@click="$store.dispatch('auth/logout')"
|
||||
>
|
||||
{{ $t('components.auth.Logout.button.logout') }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="ui small text container"
|
||||
>
|
||||
<h2>
|
||||
{{ $t('components.auth.Logout.header.unauthenticated') }}
|
||||
</h2>
|
||||
<router-link
|
||||
{{ t('components.auth.Logout.button.logout') }}
|
||||
</Button>
|
||||
</div>
|
||||
<Alert
|
||||
v-else
|
||||
yellow
|
||||
>
|
||||
<h2>
|
||||
{{ t('components.auth.Logout.header.unauthenticated') }}
|
||||
</h2>
|
||||
<template #actions>
|
||||
<Link
|
||||
solid
|
||||
primary
|
||||
to="/login"
|
||||
class="ui button"
|
||||
>
|
||||
{{ $t('components.auth.Logout.link.login') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
{{ t('components.auth.Logout.link.login') }}
|
||||
</Link>
|
||||
</template>
|
||||
</Alert>
|
||||
</main>
|
||||
</template>
|
||||
|
|
|
@ -5,12 +5,21 @@ import axios from 'axios'
|
|||
import { clone } from 'lodash-es'
|
||||
import useMarkdown, { useMarkdownRaw } from '~/composables/useMarkdown'
|
||||
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 {
|
||||
plugin: Plugin
|
||||
libraries: Library[]
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const description = useMarkdown(() => props.plugin.description ?? '')
|
||||
|
@ -53,8 +62,9 @@ const submitAndScan = async () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
:class="['ui segment form', {loading: isLoading}]"
|
||||
<Layout
|
||||
form
|
||||
:class="['ui form', {loading: isLoading}]"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<h3>{{ plugin.label }}</h3>
|
||||
|
@ -63,23 +73,20 @@ const submitAndScan = async () => {
|
|||
:html="description"
|
||||
/>
|
||||
<template v-if="plugin.homepage">
|
||||
<div class="ui small hidden divider" />
|
||||
<a
|
||||
:href="plugin.homepage"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="external icon" />
|
||||
{{ $t('components.auth.Plugin.link.documentation') }}
|
||||
{{ t('components.auth.Plugin.link.documentation') }}
|
||||
</a>
|
||||
</template>
|
||||
<div class="ui clearing hidden divider" />
|
||||
<div
|
||||
<Alert
|
||||
v-if="errors.length > 0"
|
||||
role="alert"
|
||||
class="ui negative message"
|
||||
red
|
||||
>
|
||||
<h4 class="header">
|
||||
{{ $t('components.auth.Plugin.header.failure') }}
|
||||
{{ t('components.auth.Plugin.header.failure') }}
|
||||
</h4>
|
||||
<ul class="list">
|
||||
<li
|
||||
|
@ -89,23 +96,19 @@ const submitAndScan = async () => {
|
|||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Alert>
|
||||
<div class="field">
|
||||
<div class="ui toggle checkbox">
|
||||
<input
|
||||
:id="`${plugin.name}-enabled`"
|
||||
v-model="enabled"
|
||||
type="checkbox"
|
||||
>
|
||||
<label :for="`${plugin.name}-enabled`">{{ $t('components.auth.Plugin.label.pluginEnabled') }}</label>
|
||||
</div>
|
||||
<Toggle
|
||||
v-model="enabled"
|
||||
big
|
||||
:label="t('components.auth.Plugin.label.pluginEnabled')"
|
||||
/>
|
||||
</div>
|
||||
<div class="ui clearing hidden divider" />
|
||||
<div
|
||||
v-if="plugin.source"
|
||||
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
|
||||
id="plugin-library"
|
||||
v-model="values['library']"
|
||||
|
@ -119,7 +122,7 @@ const submitAndScan = async () => {
|
|||
</option>
|
||||
</select>
|
||||
<div>
|
||||
{{ $t('components.auth.Plugin.description.library') }}
|
||||
{{ t('components.auth.Plugin.description.library') }}
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="(plugin.conf?.length ?? 0) > 0">
|
||||
|
@ -131,12 +134,12 @@ const submitAndScan = async () => {
|
|||
v-if="field.type === 'text'"
|
||||
class="field"
|
||||
>
|
||||
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
|
||||
<input
|
||||
<Input
|
||||
:id="`plugin-${field.name}`"
|
||||
v-model="values[field.name]"
|
||||
:label="field.label || field.name"
|
||||
type="text"
|
||||
>
|
||||
/>
|
||||
<sanitized-html
|
||||
v-if="field.help"
|
||||
:html="useMarkdownRaw(field.help)"
|
||||
|
@ -146,10 +149,10 @@ const submitAndScan = async () => {
|
|||
v-if="field.type === 'long_text'"
|
||||
class="field"
|
||||
>
|
||||
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
|
||||
<textarea
|
||||
:id="`plugin-${field.name}`"
|
||||
v-model="values[field.name]"
|
||||
:label="field.label || field.name"
|
||||
type="text"
|
||||
rows="5"
|
||||
/>
|
||||
|
@ -162,12 +165,12 @@ const submitAndScan = async () => {
|
|||
v-if="field.type === 'url'"
|
||||
class="field"
|
||||
>
|
||||
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
|
||||
<input
|
||||
<Input
|
||||
:id="`plugin-${field.name}`"
|
||||
v-model="values[field.name]"
|
||||
:label="field.label || field.name"
|
||||
type="url"
|
||||
>
|
||||
/>
|
||||
<sanitized-html
|
||||
v-if="field.help"
|
||||
:html="useMarkdownRaw(field.help)"
|
||||
|
@ -177,12 +180,12 @@ const submitAndScan = async () => {
|
|||
v-if="field.type === 'password'"
|
||||
class="field"
|
||||
>
|
||||
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
|
||||
<input
|
||||
<Input
|
||||
:id="`plugin-${field.name}`"
|
||||
v-model="values[field.name]"
|
||||
type="password"
|
||||
>
|
||||
:label="field.label || field.name"
|
||||
password
|
||||
/>
|
||||
<sanitized-html
|
||||
v-if="field.help"
|
||||
:html="useMarkdownRaw(field.help)"
|
||||
|
@ -192,30 +195,27 @@ const submitAndScan = async () => {
|
|||
v-if="field.type === 'boolean'"
|
||||
class="field"
|
||||
>
|
||||
<div class="ui toggle checkbox">
|
||||
<input
|
||||
:id="`plugin-${field.name}`"
|
||||
v-model="values[field.name]"
|
||||
type="checkbox"
|
||||
>
|
||||
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
|
||||
</div>
|
||||
<Toggle
|
||||
v-model="values[field.name]"
|
||||
:label="field.label || field.name"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']"
|
||||
primary
|
||||
:class="[{'loading': isLoading}]"
|
||||
>
|
||||
{{ $t('components.auth.Plugin.button.save') }}
|
||||
</button>
|
||||
<button
|
||||
{{ t('components.auth.Plugin.button.save') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="plugin.source"
|
||||
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']"
|
||||
primary
|
||||
:class="[{'loading': isLoading}]"
|
||||
@click.prevent="submitAndScan"
|
||||
>
|
||||
{{ $t('components.auth.Plugin.button.scan') }}
|
||||
</button>
|
||||
<div class="ui clearing hidden divider" />
|
||||
</form>
|
||||
{{ t('components.auth.Plugin.button.scan') }}
|
||||
</Button>
|
||||
</Layout>
|
||||
</template>
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -9,7 +9,12 @@ import { useStore } from '~/store'
|
|||
import axios from 'axios'
|
||||
|
||||
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'
|
||||
|
||||
interface Props {
|
||||
|
@ -86,54 +91,59 @@ fetchInstanceSettings()
|
|||
|
||||
<template>
|
||||
<div v-if="submitted">
|
||||
<div class="ui success message">
|
||||
<p v-if="signupRequiresApproval">
|
||||
{{ $t('components.auth.SignupForm.message.awaitingReview') }}
|
||||
</p>
|
||||
<p v-else>
|
||||
{{ $t('components.auth.SignupForm.message.accountCreated') }}
|
||||
</p>
|
||||
</div>
|
||||
<Alert
|
||||
v-if="signupRequiresApproval"
|
||||
yellow
|
||||
>
|
||||
{{ t('components.auth.SignupForm.message.awaitingReview') }}
|
||||
</Alert>
|
||||
<Alert
|
||||
v-else
|
||||
green
|
||||
>
|
||||
{{ t('components.auth.SignupForm.message.accountCreated') }}
|
||||
</Alert>
|
||||
<h2>
|
||||
{{ $t('components.auth.SignupForm.header.login') }}
|
||||
{{ t('components.auth.SignupForm.header.login') }}
|
||||
</h2>
|
||||
<login-form
|
||||
style="max-width: 600px"
|
||||
button-classes="basic success"
|
||||
:show-signup="false"
|
||||
/>
|
||||
</div>
|
||||
<form
|
||||
<Layout
|
||||
v-else
|
||||
:class="['ui', {'loading': isLoadingInstanceSetting}, 'form']"
|
||||
form
|
||||
stack
|
||||
style="max-width: 600px"
|
||||
@submit.prevent="submit()"
|
||||
>
|
||||
<p
|
||||
v-if="!$store.state.instance.settings.users.registration_enabled.value"
|
||||
class="ui message"
|
||||
<Alert
|
||||
v-if="!store.state.instance.settings.users.registration_enabled.value"
|
||||
red
|
||||
>
|
||||
{{ $t('components.auth.SignupForm.message.registrationClosed') }}
|
||||
</p>
|
||||
<p
|
||||
{{ t('components.auth.SignupForm.message.registrationClosed') }}
|
||||
</Alert>
|
||||
<Alert
|
||||
v-else-if="signupRequiresApproval"
|
||||
class="ui message"
|
||||
yellow
|
||||
>
|
||||
{{ $t('components.auth.SignupForm.message.requiresReview') }}
|
||||
</p>
|
||||
{{ t('components.auth.SignupForm.message.requiresReview') }}
|
||||
</Alert>
|
||||
<template v-if="formCustomization?.help_text">
|
||||
<rendered-description
|
||||
:content="formCustomization.help_text"
|
||||
:fetch-html="fetchDescriptionHtml"
|
||||
:permissive="true"
|
||||
/>
|
||||
<div class="ui hidden divider" />
|
||||
</template>
|
||||
<div
|
||||
<Alert
|
||||
v-if="errors.length > 0"
|
||||
role="alert"
|
||||
class="ui negative message"
|
||||
red
|
||||
>
|
||||
<h4 class="header">
|
||||
{{ $t('components.auth.SignupForm.header.signupFailure') }}
|
||||
{{ t('components.auth.SignupForm.header.signupFailure') }}
|
||||
</h4>
|
||||
<ul class="list">
|
||||
<li
|
||||
|
@ -143,81 +153,79 @@ fetchInstanceSettings()
|
|||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="required field">
|
||||
<label for="username-field">{{ $t('components.auth.SignupForm.label.username') }}</label>
|
||||
<input
|
||||
id="username-field"
|
||||
ref="username"
|
||||
v-model="payload.username"
|
||||
name="username"
|
||||
required
|
||||
</Alert>
|
||||
<Input
|
||||
id="username-field"
|
||||
ref="username"
|
||||
v-model="payload.username"
|
||||
:label="t('components.auth.SignupForm.label.username')"
|
||||
name="username"
|
||||
required
|
||||
type="text"
|
||||
autofocus
|
||||
:placeholder="labels.usernamePlaceholder"
|
||||
/>
|
||||
<Input
|
||||
id="email-field"
|
||||
ref="email"
|
||||
v-model="payload.email"
|
||||
:label="t('components.auth.SignupForm.label.email')"
|
||||
autocomplete="email"
|
||||
name="email"
|
||||
required
|
||||
type="email"
|
||||
:placeholder="labels.emailPlaceholder"
|
||||
/>
|
||||
<Input
|
||||
v-model="payload.password1"
|
||||
password
|
||||
autocomplete="new-password"
|
||||
:label="t('components.auth.SignupForm.label.password')"
|
||||
field-id="password-field"
|
||||
/>
|
||||
<Input
|
||||
v-if="!store.state.instance.settings.users.registration_enabled.value && payload.invitation"
|
||||
id="invitation-code"
|
||||
v-model="payload.invitation"
|
||||
:label="t('components.auth.SignupForm.label.invitation')"
|
||||
required
|
||||
type="text"
|
||||
name="invitation"
|
||||
:placeholder="labels.placeholder"
|
||||
/>
|
||||
<div
|
||||
v-for="(field, idx) in
|
||||
( signupRequiresApproval && formCustomization && (formCustomization.fields.length ?? 0) > 0
|
||||
? formCustomization.fields
|
||||
: []
|
||||
)"
|
||||
:key="idx"
|
||||
:class="[{required: field.required}, 'field']"
|
||||
>
|
||||
<!-- TODO: as string is probably leading to issues with editform. -->
|
||||
<Textarea
|
||||
v-if="field.input_type === 'long_text'"
|
||||
:id="`custom-field-${idx}`"
|
||||
v-model="payload.request_fields[field.label] as string"
|
||||
:label="field.label"
|
||||
:required="field.required || undefined"
|
||||
rows="5"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
:id="`custom-field-${idx}`"
|
||||
v-model="payload.request_fields[field.label] as string"
|
||||
:label="field.label"
|
||||
type="text"
|
||||
autofocus
|
||||
:placeholder="labels.usernamePlaceholder"
|
||||
>
|
||||
</div>
|
||||
<div class="required field">
|
||||
<label for="email-field">{{ $t('components.auth.SignupForm.label.email') }}</label>
|
||||
<input
|
||||
id="email-field"
|
||||
ref="email"
|
||||
v-model="payload.email"
|
||||
name="email"
|
||||
required
|
||||
type="email"
|
||||
:placeholder="labels.emailPlaceholder"
|
||||
>
|
||||
</div>
|
||||
<div class="required field">
|
||||
<label for="password-field">{{ $t('components.auth.SignupForm.label.password') }}</label>
|
||||
<password-input
|
||||
v-model="payload.password1"
|
||||
field-id="password-field"
|
||||
:required="field.required"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
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"
|
||||
v-model="payload.invitation"
|
||||
required
|
||||
type="text"
|
||||
name="invitation"
|
||||
:placeholder="labels.placeholder"
|
||||
>
|
||||
</div>
|
||||
<template v-if="signupRequiresApproval && (formCustomization?.fields.length ?? 0) > 0">
|
||||
<div
|
||||
v-for="(field, idx) in formCustomization?.fields"
|
||||
:key="idx"
|
||||
:class="[{required: field.required}, 'field']"
|
||||
>
|
||||
<label :for="`custom-field-${idx}`">{{ field.label }}</label>
|
||||
<textarea
|
||||
v-if="field.input_type === 'long_text'"
|
||||
:id="`custom-field-${idx}`"
|
||||
v-model="payload.request_fields[field.label]"
|
||||
:required="field.required"
|
||||
rows="5"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
:id="`custom-field-${idx}`"
|
||||
v-model="payload.request_fields[field.label]"
|
||||
type="text"
|
||||
:required="field.required"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<button
|
||||
:class="['ui', buttonClasses, {'loading': isLoading}, ' right floated button']"
|
||||
<Button
|
||||
primary
|
||||
auto
|
||||
type="submit"
|
||||
>
|
||||
{{ $t('components.auth.SignupForm.button.create') }}
|
||||
</button>
|
||||
</form>
|
||||
{{ t('components.auth.SignupForm.button.create') }}
|
||||
</Button>
|
||||
</Layout>
|
||||
</template>
|
||||
|
|
|
@ -6,7 +6,11 @@ import { computed, ref } from 'vue'
|
|||
import { useStore } from '~/store'
|
||||
import axios from 'axios'
|
||||
|
||||
import DangerousButton from '~/components/common/DangerousButton.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 store = useStore()
|
||||
|
@ -82,43 +86,43 @@ fetchToken()
|
|||
@submit.prevent="requestNewToken()"
|
||||
>
|
||||
<h2>
|
||||
{{ $t('components.auth.SubsonicTokenForm.header.subsonic') }}
|
||||
{{ t('components.auth.SubsonicTokenForm.header.subsonic') }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="!subsonicEnabled"
|
||||
class="ui message"
|
||||
>
|
||||
{{ $t('components.auth.SubsonicTokenForm.message.unavailable') }}
|
||||
{{ t('components.auth.SubsonicTokenForm.message.unavailable') }}
|
||||
</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>
|
||||
{{ $t('components.auth.SubsonicTokenForm.description.subsonic.paragraph3') }}
|
||||
{{ t('components.auth.SubsonicTokenForm.description.subsonic.paragraph3') }}
|
||||
</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://docs.funkwhale.audio/users/apps.html#subsonic-compatible-clients"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('components.auth.SubsonicTokenForm.link.apps') }}
|
||||
{{ t('components.auth.SubsonicTokenForm.link.apps') }}
|
||||
</a>
|
||||
</p>
|
||||
<div
|
||||
<Alert
|
||||
v-if="success"
|
||||
class="ui positive message"
|
||||
green
|
||||
>
|
||||
<h4 class="header">
|
||||
{{ successMessage }}
|
||||
</h4>
|
||||
</div>
|
||||
<div
|
||||
</Alert>
|
||||
<Alert
|
||||
v-if="subsonicEnabled && errors.length > 0"
|
||||
red
|
||||
role="alert"
|
||||
class="ui negative message"
|
||||
>
|
||||
<h4 class="header">
|
||||
{{ $t('components.auth.SubsonicTokenForm.header.error') }}
|
||||
{{ t('components.auth.SubsonicTokenForm.header.error') }}
|
||||
</h4>
|
||||
<ul class="list">
|
||||
<li
|
||||
|
@ -128,7 +132,7 @@ fetchToken()
|
|||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Alert>
|
||||
<template v-if="subsonicEnabled">
|
||||
<div
|
||||
v-if="token"
|
||||
|
@ -147,58 +151,42 @@ fetchToken()
|
|||
:default-show="showToken"
|
||||
/>
|
||||
</div>
|
||||
<dangerous-button
|
||||
<DangerousButton
|
||||
v-if="token"
|
||||
:class="['ui', {'loading': isLoading}, 'button']"
|
||||
:is-loading="isLoading"
|
||||
:action="requestNewToken"
|
||||
:title="t('components.auth.SubsonicTokenForm.modal.newPassword.header')"
|
||||
>
|
||||
{{ $t('components.auth.SubsonicTokenForm.button.newPassword') }}
|
||||
<template #modal-header>
|
||||
<p>
|
||||
{{ $t('components.auth.SubsonicTokenForm.modal.newPassword.header') }}
|
||||
</p>
|
||||
</template>
|
||||
{{ t('components.auth.SubsonicTokenForm.button.newPassword') }}
|
||||
<template #modal-content>
|
||||
<p>
|
||||
{{ $t('components.auth.SubsonicTokenForm.modal.newPassword.content.warning') }}
|
||||
</p>
|
||||
{{ t('components.auth.SubsonicTokenForm.modal.newPassword.content.warning') }}
|
||||
</template>
|
||||
<template #modal-confirm>
|
||||
<div>
|
||||
{{ $t('components.auth.SubsonicTokenForm.button.confirmNewPassword') }}
|
||||
</div>
|
||||
{{ t('components.auth.SubsonicTokenForm.button.confirmNewPassword') }}
|
||||
</template>
|
||||
</dangerous-button>
|
||||
<button
|
||||
</DangerousButton>
|
||||
<Button
|
||||
v-else
|
||||
color=""
|
||||
:class="['ui', {'loading': isLoading}, 'button']"
|
||||
primary
|
||||
:is-loading="isLoading"
|
||||
@click="requestNewToken"
|
||||
>
|
||||
{{ $t('components.auth.SubsonicTokenForm.button.confirmNewPassword') }}
|
||||
</button>
|
||||
<dangerous-button
|
||||
{{ t('components.auth.SubsonicTokenForm.button.confirmNewPassword') }}
|
||||
</Button>
|
||||
<DangerousButton
|
||||
v-if="token"
|
||||
:class="['ui', {'loading': isLoading}, 'warning', 'button']"
|
||||
:is-loading="isLoading"
|
||||
:action="disable"
|
||||
:title="t('components.auth.SubsonicTokenForm.modal.disableSubsonic.header')"
|
||||
>
|
||||
{{ $t('components.auth.SubsonicTokenForm.button.disable') }}
|
||||
<template #modal-header>
|
||||
<p>
|
||||
{{ $t('components.auth.SubsonicTokenForm.modal.disableSubsonic.header') }}
|
||||
</p>
|
||||
</template>
|
||||
{{ t('components.auth.SubsonicTokenForm.button.disable') }}
|
||||
<template #modal-content>
|
||||
<p>
|
||||
{{ $t('components.auth.SubsonicTokenForm.modal.disableSubsonic.content.warning') }}
|
||||
</p>
|
||||
{{ t('components.auth.SubsonicTokenForm.modal.disableSubsonic.content.warning') }}
|
||||
</template>
|
||||
<template #modal-confirm>
|
||||
<div>
|
||||
{{ $t('components.auth.SubsonicTokenForm.button.confirmDisable') }}
|
||||
</div>
|
||||
{{ t('components.auth.SubsonicTokenForm.button.confirmDisable') }}
|
||||
</template>
|
||||
</dangerous-button>
|
||||
</DangerousButton>
|
||||
</template>
|
||||
</form>
|
||||
</template>
|
||||
|
|
|
@ -2,20 +2,24 @@
|
|||
import type { BackendError, Channel } from '~/types'
|
||||
|
||||
import { computed, watch, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
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 {
|
||||
(e: 'submittable', value: boolean): void
|
||||
(e: 'loading', value: boolean): void
|
||||
(e: 'created'): void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
channel: Channel
|
||||
}
|
||||
const channel = defineModel<Channel>({ required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const title = ref('')
|
||||
|
||||
|
@ -28,7 +32,7 @@ const submit = async () => {
|
|||
try {
|
||||
await axios.post('albums/', {
|
||||
title: title.value,
|
||||
artist: props.channel.artist?.id
|
||||
artist: channel.value.artist?.id
|
||||
})
|
||||
|
||||
emit('created')
|
||||
|
@ -49,17 +53,17 @@ defineExpose({
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
<Layout
|
||||
form
|
||||
:class="['ui', {loading: isLoading}, 'form']"
|
||||
@submit.stop.prevent
|
||||
>
|
||||
<div
|
||||
<Alert
|
||||
v-if="errors.length > 0"
|
||||
role="alert"
|
||||
class="ui negative message"
|
||||
red
|
||||
>
|
||||
<h4 class="header">
|
||||
{{ $t('components.channels.AlbumForm.header.error') }}
|
||||
{{ t('components.channels.AlbumForm.header.error') }}
|
||||
</h4>
|
||||
<ul class="list">
|
||||
<li
|
||||
|
@ -69,15 +73,13 @@ defineExpose({
|
|||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Alert>
|
||||
<div class="ui required field">
|
||||
<label for="album-title">
|
||||
{{ $t('components.channels.AlbumForm.label.albumTitle') }}
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
v-model="title"
|
||||
type="text"
|
||||
>
|
||||
:label="t('components.channels.AlbumForm.label.albumTitle')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Layout>
|
||||
</template>
|
||||
|
|
|
@ -1,68 +1,105 @@
|
|||
<script setup lang="ts">
|
||||
import type { Channel } from '~/types'
|
||||
import SemanticModal from '~/components/semantic/Modal.vue'
|
||||
import ChannelAlbumForm from '~/components/channels/AlbumForm.vue'
|
||||
import { watch, ref } from 'vue'
|
||||
import type { Channel, BackendError } from '~/types'
|
||||
|
||||
interface Events {
|
||||
(e: 'created'): void
|
||||
}
|
||||
import axios from 'axios'
|
||||
|
||||
interface Props {
|
||||
channel: Channel
|
||||
}
|
||||
import { watch, computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useModal } from '~/ui/composables/useModal.ts'
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
defineProps<Props>()
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
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 submittable = ref(false)
|
||||
const show = ref(false)
|
||||
const submittable = computed(() => newAlbumTitle.value.length > 0)
|
||||
const errors = ref<string[]>([])
|
||||
|
||||
watch(show, () => {
|
||||
const isOpen = useModal('album').isOpen
|
||||
|
||||
watch(isOpen, () => {
|
||||
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({
|
||||
show
|
||||
submit
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<semantic-modal
|
||||
v-model:show="show"
|
||||
<Modal
|
||||
v-model="isOpen"
|
||||
:title="channel?.artist?.content_category === 'podcast' ? t('components.channels.AlbumModal.header.newSeries') : t('components.channels.AlbumModal.header.newAlbum')"
|
||||
class="small"
|
||||
:cancel="t('components.channels.AlbumModal.button.cancel')"
|
||||
>
|
||||
<h4 class="header">
|
||||
<span v-if="channel.content_category === 'podcast'">
|
||||
{{ $t('components.channels.AlbumModal.header.newSeries') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('components.channels.AlbumModal.header.newAlbum') }}
|
||||
</span>
|
||||
</h4>
|
||||
<div class="scrolling content">
|
||||
<channel-album-form
|
||||
ref="albumForm"
|
||||
:channel="channel"
|
||||
@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()"
|
||||
<template #alert>
|
||||
<Alert
|
||||
v-if="errors?.length > 0"
|
||||
red
|
||||
>
|
||||
{{ $t('components.channels.AlbumModal.button.create') }}
|
||||
</button>
|
||||
</div>
|
||||
</semantic-modal>
|
||||
<h4 class="header">
|
||||
{{ t('components.channels.AlbumForm.header.error') }}
|
||||
</h4>
|
||||
<ul class="list">
|
||||
<li
|
||||
v-for="(error, key) in errors"
|
||||
:key="key"
|
||||
>
|
||||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
</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>
|
||||
|
|
|
@ -3,7 +3,16 @@ import type { Album, Channel } from '~/types'
|
|||
|
||||
import axios from 'axios'
|
||||
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 {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
|
@ -21,6 +30,11 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
})
|
||||
|
||||
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[]>([])
|
||||
|
||||
|
@ -45,33 +59,43 @@ watch(() => props.channel, fetchData, { immediate: true })
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<label for="album-dropdown">
|
||||
<span v-if="channel && channel.artist && channel.artist.content_category === 'podcast'">
|
||||
{{ $t('components.channels.AlbumSelect.label.series') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('components.channels.AlbumSelect.label.album') }}
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
id="album-dropdown"
|
||||
v-model="value"
|
||||
class="ui search normal dropdown"
|
||||
<label for="album-dropdown">
|
||||
<span v-if="channel && channel.artist && channel.artist.content_category === 'podcast'">
|
||||
{{ t('components.channels.AlbumSelect.label.series') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ t('components.channels.AlbumSelect.label.album') }}
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
id="album-dropdown"
|
||||
v-model="value"
|
||||
class="ui search normal dropdown"
|
||||
>
|
||||
<option value="">
|
||||
{{ t('components.channels.AlbumSelect.option.none') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="album in albums"
|
||||
:key="album.id"
|
||||
:value="album.id"
|
||||
>
|
||||
<option value="">
|
||||
{{ $t('components.channels.AlbumSelect.option.none') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="album in albums"
|
||||
:key="album.id"
|
||||
:value="album.id"
|
||||
>
|
||||
{{ album.title }}
|
||||
<span>
|
||||
{{ $t('components.channels.AlbumSelect.meta.tracks', album.tracks_count) }}
|
||||
</span>
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
{{ album.title }}
|
||||
{{ t('components.channels.AlbumSelect.meta.tracks', album.tracks_count) }}
|
||||
</option>
|
||||
</select>
|
||||
<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>
|
||||
|
|
|
@ -4,6 +4,7 @@ import type { License } from '~/types'
|
|||
import { computed, reactive, ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface Events {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
|
@ -13,6 +14,8 @@ interface Props {
|
|||
modelValue: string | null
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: null
|
||||
|
@ -55,7 +58,7 @@ fetchLicenses()
|
|||
<template>
|
||||
<div>
|
||||
<label for="license-dropdown">
|
||||
{{ $t('components.channels.LicenseSelect.label.license') }}
|
||||
{{ t('components.channels.LicenseSelect.label.license') }}
|
||||
</label>
|
||||
<select
|
||||
id="license-dropdown"
|
||||
|
@ -63,7 +66,7 @@ fetchLicenses()
|
|||
class="ui search normal dropdown"
|
||||
>
|
||||
<option value="">
|
||||
{{ $t('components.channels.LicenseSelect.option.none') }}
|
||||
{{ t('components.channels.LicenseSelect.option.none') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="l in featuredLicenses"
|
||||
|
@ -84,7 +87,7 @@ fetchLicenses()
|
|||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{{ $t('components.channels.LicenseSelect.link.license') }}
|
||||
{{ t('components.channels.LicenseSelect.link.license') }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
@ -4,8 +4,12 @@ import type { Channel } from '~/types'
|
|||
import { useI18n } from 'vue-i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useStore } from '~/store'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import LoginModal from '~/components/common/LoginModal.vue'
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
interface Events {
|
||||
(e: 'unsubscribed'): void
|
||||
|
@ -43,28 +47,29 @@ const loginModal = ref()
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
v-if="$store.state.auth.authenticated"
|
||||
:class="['ui', 'pink', {'inverted': isSubscribed}, {'favorited': isSubscribed}, 'icon', 'labeled', 'button']"
|
||||
<Button
|
||||
v-if="store.state.auth.authenticated"
|
||||
:class="['pink', {'favorited': isSubscribed}]"
|
||||
outline
|
||||
:aria-pressed="isSubscribed || undefined"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<i class="heart icon" />
|
||||
{{ title }}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
:class="['ui', 'pink', 'icon', 'labeled', 'button']"
|
||||
outline
|
||||
icon="bi-heart"
|
||||
@click="loginModal.show = true"
|
||||
>
|
||||
<i class="heart icon" />
|
||||
{{ title }}
|
||||
<login-modal
|
||||
ref="loginModal"
|
||||
class="small"
|
||||
:next-route="$route.fullPath"
|
||||
:next-route="route.fullPath"
|
||||
:message="message.authMessage"
|
||||
:cover="channel.artist?.cover!"
|
||||
@created="loginModal.show = false"
|
||||
/>
|
||||
</button>
|
||||
</Button>
|
||||
</template>
|
||||
|
|
|
@ -3,13 +3,13 @@ import type { BackendError, Channel, Upload, Track } from '~/types'
|
|||
import type { VueUploadItem } from 'vue-upload-component'
|
||||
|
||||
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 { useI18n } from 'vue-i18n'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
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 FileUploadWidget from '~/components/library/FileUploadWidget.vue'
|
||||
|
@ -18,13 +18,19 @@ import AlbumSelect from '~/components/channels/AlbumSelect.vue'
|
|||
|
||||
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 {
|
||||
(e: 'status', status: UploadStatus): void
|
||||
(e: 'step', step: 1 | 2 | 3): void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
channel?: Channel | null
|
||||
channel: Channel | null,
|
||||
filter: 'podcast' | 'music' | undefined,
|
||||
}
|
||||
|
||||
interface QuotaStatus {
|
||||
|
@ -57,8 +63,12 @@ const store = useStore()
|
|||
|
||||
const errors = ref([] as string[])
|
||||
|
||||
const values = reactive({
|
||||
channel: props.channel?.uuid ?? null,
|
||||
const values = reactive<{
|
||||
channelUuid: string | null; // Channel UUID
|
||||
license: string | null;
|
||||
album: string | null;
|
||||
}>({
|
||||
channelUuid: props.channel?.uuid ?? null,
|
||||
license: null,
|
||||
album: null
|
||||
})
|
||||
|
@ -68,29 +78,91 @@ const files = ref([] as VueUploadItem[])
|
|||
//
|
||||
// Channels
|
||||
//
|
||||
const availableChannels = reactive({
|
||||
channels: [] as Channel[],
|
||||
count: 0,
|
||||
loading: false
|
||||
})
|
||||
const availableChannels = ref<Channel[] | null>(null)
|
||||
|
||||
/*
|
||||
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 () => {
|
||||
availableChannels.loading = true
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await axios.get('channels/', { params: { scope: 'me' } })
|
||||
availableChannels.channels = response.data.results
|
||||
availableChannels.count = response.data.count
|
||||
const response = await axios.get<paths['/api/v2/channels/']['get']['responses']['200']['content']['application/json']>(
|
||||
'channels/',
|
||||
{ params: { scope: 'me' } }
|
||||
)
|
||||
|
||||
availableChannels.value = response.data.results.filter(channel =>
|
||||
props.filter === undefined
|
||||
? true
|
||||
: channel.artist?.content_category === props.filter
|
||||
)
|
||||
|
||||
} catch (error) {
|
||||
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
|
||||
//
|
||||
const quotaStatus = ref()
|
||||
|
@ -125,13 +197,13 @@ const remainingSpace = computed(() => Math.max(
|
|||
//
|
||||
const includeDraftUploads = ref()
|
||||
const draftUploads = ref([] as Upload[])
|
||||
whenever(() => values.channel !== null, async () => {
|
||||
whenever(() => values.channelUuid !== null, async () => {
|
||||
files.value = []
|
||||
draftUploads.value = []
|
||||
|
||||
try {
|
||||
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[]
|
||||
|
@ -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
|
||||
//
|
||||
|
@ -213,6 +279,13 @@ const uploadedFilesById = computed(() => uploadedFiles.value.reduce((acc: Record
|
|||
//
|
||||
// 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 }
|
||||
const uploadImportData = reactive({} as Record<string, Metadata>)
|
||||
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) {
|
||||
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()
|
||||
fetchQuota()
|
||||
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
watch(selectedUploadId, async (_, from) => {
|
||||
if (from) {
|
||||
await patchUpload(from, { import_metadata: uploadImportData[from] })
|
||||
}
|
||||
|
@ -400,23 +410,28 @@ const labels = computed(() => ({
|
|||
editTitle: t('components.channels.UploadForm.button.edit')
|
||||
}))
|
||||
|
||||
const isLoading = ref(false)
|
||||
const publish = async () => {
|
||||
isLoading.value = true
|
||||
|
||||
errors.value = []
|
||||
|
||||
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',
|
||||
objects: uploadedFiles.value.map((file) => file.response?.uuid)
|
||||
})
|
||||
|
||||
// Tell the store that the uploaded files are pending import
|
||||
store.commit('channels/publish', {
|
||||
uploads: uploadedFiles.value.map((file) => ({ ...file.response, import_status: 'pending' })),
|
||||
channel: selectedChannel.value
|
||||
})
|
||||
} catch (error) {
|
||||
// TODO: Use inferred error type instead of typecasting
|
||||
errors.value = (error as BackendError).backendErrors
|
||||
}
|
||||
|
||||
|
@ -424,23 +439,24 @@ const publish = async () => {
|
|||
}
|
||||
|
||||
defineExpose({
|
||||
step,
|
||||
publish
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
:class="['ui', { loading: availableChannels.loading }, 'form component-file-upload']"
|
||||
<Layout
|
||||
form
|
||||
gap-8
|
||||
:class="['ui', { loading: isLoading }, 'form component-file-upload']"
|
||||
@submit.stop.prevent
|
||||
>
|
||||
<div
|
||||
<!-- Error message -->
|
||||
<Alert
|
||||
v-if="errors.length > 0"
|
||||
role="alert"
|
||||
class="ui negative message"
|
||||
red
|
||||
>
|
||||
<h4 class="header">
|
||||
{{ $t('components.channels.UploadForm.header.error') }}
|
||||
{{ t('components.channels.UploadForm.header.error') }}
|
||||
</h4>
|
||||
<ul class="list">
|
||||
<li
|
||||
|
@ -450,182 +466,199 @@ defineExpose({
|
|||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div :class="['ui', 'required', {hidden: step > 1}, 'field']">
|
||||
<label for="channel-dropdown">
|
||||
{{ $t('components.channels.UploadForm.label.channel') }}
|
||||
</label>
|
||||
<div
|
||||
id="channel-dropdown"
|
||||
class="ui search normal selection dropdown"
|
||||
</Alert>
|
||||
|
||||
<!-- Select Album and License -->
|
||||
|
||||
<div :class="['ui', 'required', 'field']">
|
||||
<label
|
||||
v-if="availableChannels !== null && availableChannels.length === 1"
|
||||
>
|
||||
<div class="text" />
|
||||
<i class="dropdown icon" />
|
||||
</div>
|
||||
{{ `${t('components.channels.UploadForm.label.channel')}: ${selectedChannel?.artist.name}` }}
|
||||
</label>
|
||||
<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>
|
||||
<album-select
|
||||
v-if="selectedChannel !== null"
|
||||
v-model.number="values.album"
|
||||
:channel="selectedChannel"
|
||||
:class="['ui', {hidden: step > 1}, 'field']"
|
||||
/>
|
||||
<license-select
|
||||
v-model="values.license"
|
||||
:class="['ui', {hidden: step > 1}, 'field']"
|
||||
/>
|
||||
<div :class="['ui', {hidden: step > 1}, 'message']">
|
||||
<div>
|
||||
<license-select
|
||||
v-if="values.license !== null"
|
||||
v-model="values.license"
|
||||
:class="['ui', 'field']"
|
||||
/>
|
||||
<div class="content">
|
||||
<p>
|
||||
<i class="copyright icon" />
|
||||
{{ $t('components.channels.UploadForm.help.license') }}
|
||||
{{ t('components.channels.UploadForm.help.license') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="step === 2 || step === 3">
|
||||
<div
|
||||
v-if="remainingSpace === 0"
|
||||
role="alert"
|
||||
class="ui warning message"
|
||||
|
||||
<!-- Files to upload -->
|
||||
<template v-if="remainingSpace === 0">
|
||||
<Alert
|
||||
red
|
||||
>
|
||||
<div class="content">
|
||||
<p>
|
||||
<i class="warning icon" />
|
||||
{{ $t('components.channels.UploadForm.warning.quota') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="step === 2 && draftUploads?.length > 0 && includeDraftUploads === undefined"
|
||||
class="ui visible info message"
|
||||
<i class="bi bi-exclamation-triangle" />
|
||||
{{ t('components.channels.UploadForm.warning.quota') }}
|
||||
</Alert>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Alert
|
||||
v-if="draftUploads?.length > 0 && includeDraftUploads === undefined"
|
||||
blue
|
||||
>
|
||||
<p>
|
||||
<i class="bi bi-circle-clockwise" />
|
||||
{{ t('components.channels.UploadForm.message.pending') }}
|
||||
</p>
|
||||
<Button
|
||||
@click.stop.prevent="includeDraftUploads = false"
|
||||
>
|
||||
<p>
|
||||
<i class="redo icon" />
|
||||
{{ $t('components.channels.UploadForm.message.pending') }}
|
||||
</p>
|
||||
<button
|
||||
class="ui basic button"
|
||||
@click.stop.prevent="includeDraftUploads = false"
|
||||
>
|
||||
{{ $t('components.channels.UploadForm.button.ignore') }}
|
||||
</button>
|
||||
<button
|
||||
class="ui basic button"
|
||||
@click.stop.prevent="includeDraftUploads = true"
|
||||
>
|
||||
{{ $t('components.channels.UploadForm.button.resume') }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="uploadedFiles.length > 0"
|
||||
:class="[{hidden: step === 3}]"
|
||||
{{ t('components.channels.UploadForm.button.ignore') }}
|
||||
</Button>
|
||||
<Button
|
||||
@click.stop.prevent="includeDraftUploads = true"
|
||||
>
|
||||
<div
|
||||
v-for="file in uploadedFiles"
|
||||
:key="file.id"
|
||||
class="channel-file"
|
||||
>
|
||||
<div class="content">
|
||||
<div
|
||||
v-if="file.response?.uuid"
|
||||
role="button"
|
||||
class="ui basic icon button"
|
||||
:title="labels.editTitle"
|
||||
@click.stop.prevent="selectedUploadId = file.response?.uuid"
|
||||
>
|
||||
<i class="pencil icon" />
|
||||
</div>
|
||||
<div
|
||||
v-if="file.error"
|
||||
class="ui basic danger icon label"
|
||||
:title="file.error.toString()"
|
||||
@click.stop.prevent="selectedUploadId = file.response?.uuid"
|
||||
>
|
||||
<i class="warning sign icon" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="file.active && !file.response"
|
||||
class="ui active slow inline loader"
|
||||
/>
|
||||
</div>
|
||||
<h4 class="ui header">
|
||||
<template v-if="file.metadata.title">
|
||||
{{ file.metadata.title }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ file.name }}
|
||||
</template>
|
||||
<div class="sub header">
|
||||
<template v-if="file.response?.uuid">
|
||||
{{ humanSize(file.size ?? 0) }}
|
||||
<template v-if="file.response.duration">
|
||||
<span class="middle middledot symbol" />
|
||||
<human-duration :duration="file.response.duration" />
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="file.active">
|
||||
{{ $t('components.channels.UploadForm.status.uploading') }}
|
||||
</span>
|
||||
<span v-else-if="file.error">
|
||||
{{ $t('components.channels.UploadForm.status.errored') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('components.channels.UploadForm.status.pending') }}
|
||||
</span>
|
||||
<span class="middle middledot symbol" />
|
||||
{{ humanSize(file.size ?? 0) }}
|
||||
<span class="middle middledot symbol" />
|
||||
{{ parseFloat(file.progress ?? '0') }}
|
||||
<span class="percent symbol" />
|
||||
</template>
|
||||
<span class="middle middledot symbol" />
|
||||
<a @click.stop.prevent="remove(file)">
|
||||
{{ $t('components.channels.UploadForm.button.remove') }}
|
||||
</a>
|
||||
<template v-if="file.error">
|
||||
<span class="middle middledot symbol" />
|
||||
<a @click.stop.prevent="retry(file)">
|
||||
{{ $t('components.channels.UploadForm.button.retry') }}
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<upload-metadata-form
|
||||
v-if="selectedUpload"
|
||||
v-model:values="uploadImportData[selectedUploadId]"
|
||||
:upload="selectedUpload"
|
||||
/>
|
||||
{{ t('components.channels.UploadForm.button.resume') }}
|
||||
</Button>
|
||||
</Alert>
|
||||
<Alert
|
||||
v-if="uploadedFiles.length > 0"
|
||||
v-bind="{[ uploadedFiles.some(file=>file.error) ? 'red' : 'green' ]:true}"
|
||||
>
|
||||
<div
|
||||
v-if="step === 2"
|
||||
class="ui message"
|
||||
v-for="file in uploadedFiles"
|
||||
:key="file.id"
|
||||
class="channel-file"
|
||||
>
|
||||
<div class="content">
|
||||
<p>
|
||||
<i class="info icon" />
|
||||
{{ $t('components.channels.UploadForm.description.extensions', {extensions: $store.state.ui.supportedExtensions.join(', ')}) }}
|
||||
</p>
|
||||
<Button
|
||||
v-if="file.response?.uuid"
|
||||
icon="bi-pencil-fill"
|
||||
class="ui basic icon button"
|
||||
:title="labels.editTitle"
|
||||
@click.stop.prevent="selectedUploadId = file.response?.uuid"
|
||||
/>
|
||||
<div
|
||||
v-if="file.error"
|
||||
class="ui basic danger icon label"
|
||||
:title="file.error.toString()"
|
||||
@click.stop.prevent="selectedUploadId = file.response?.uuid"
|
||||
>
|
||||
<i class="bi bi-exclamation-triangle-fill" />
|
||||
</div>
|
||||
<Loader v-else-if="file.active && !file.response" />
|
||||
</div>
|
||||
<h4 class="ui header">
|
||||
<template v-if="file.metadata.title">
|
||||
{{ file.metadata.title }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ file.name }}
|
||||
</template>
|
||||
<div class="sub header">
|
||||
<template v-if="file.response?.uuid">
|
||||
{{ humanSize(file.size ?? 0) }}
|
||||
<template v-if="file.response.duration">
|
||||
<span class="middle middledot symbol" />
|
||||
<human-duration :duration="file.response.duration" />
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="file.active">
|
||||
{{ t('components.channels.UploadForm.status.uploading') }}
|
||||
</span>
|
||||
<span v-else-if="file.error">
|
||||
{{ t('components.channels.UploadForm.status.errored') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ t('components.channels.UploadForm.status.pending') }}
|
||||
</span>
|
||||
<span class="middle middledot symbol" />
|
||||
{{ humanSize(file.size ?? 0) }}
|
||||
<span class="middle middledot symbol" />
|
||||
{{ parseFloat(file.progress ?? '0') }}
|
||||
<span class="percent symbol" />
|
||||
</template>
|
||||
<span class="middle middledot symbol" />
|
||||
<a @click.stop.prevent="remove(file)">
|
||||
{{ t('components.channels.UploadForm.button.remove') }}
|
||||
</a>
|
||||
<template v-if="file.error">
|
||||
<span class="middle middledot symbol" />
|
||||
<a @click.stop.prevent="retry(file)">
|
||||
{{ t('components.channels.UploadForm.button.retry') }}
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</h4>
|
||||
</div>
|
||||
<file-upload-widget
|
||||
ref="upload"
|
||||
v-model="files"
|
||||
:class="['ui', 'icon', 'basic', 'button', 'channels', {hidden: step === 3}]"
|
||||
:data="baseImportMetadata"
|
||||
@input-file="beforeFileUpload"
|
||||
>
|
||||
<div>
|
||||
<i class="upload icon" />
|
||||
{{ $t('components.channels.UploadForm.message.dragAndDrop') }}
|
||||
</div>
|
||||
<div class="ui very small divider" />
|
||||
<div>
|
||||
{{ $t('components.channels.UploadForm.label.openBrowser') }}
|
||||
</div>
|
||||
</file-upload-widget>
|
||||
<div class="ui hidden divider" />
|
||||
</template>
|
||||
</Alert>
|
||||
</template>
|
||||
</form>
|
||||
<upload-metadata-form
|
||||
v-if="selectedUpload"
|
||||
v-model:values="uploadImportData[selectedUploadId]"
|
||||
:upload="selectedUpload"
|
||||
/>
|
||||
<Alert
|
||||
blue
|
||||
class="ui message"
|
||||
>
|
||||
<Layout
|
||||
flex
|
||||
gap-8
|
||||
>
|
||||
<i class="bi bi-info-circle-fill" />
|
||||
{{ t('components.channels.UploadForm.description.extensions', {extensions: store.state.ui.supportedExtensions.join(', ')}) }}
|
||||
</Layout>
|
||||
</Alert>
|
||||
<FileUploadWidget
|
||||
v-if="selectedChannel && selectedChannel.uuid"
|
||||
ref="upload"
|
||||
v-model="files"
|
||||
:class="['ui', 'button', 'channels']"
|
||||
:channel="selectedChannel.uuid"
|
||||
:data="baseImportMetadata"
|
||||
@input-file="beforeFileUpload"
|
||||
>
|
||||
<div>
|
||||
<i class="bi bi-upload" />
|
||||
{{ t('components.channels.UploadForm.message.dragAndDrop') }}
|
||||
</div>
|
||||
<div class="ui very small divider" />
|
||||
<Button
|
||||
primary
|
||||
icon="bi-folder2-open"
|
||||
>
|
||||
{{ t('components.channels.UploadForm.label.openBrowser') }}
|
||||
</Button>
|
||||
<Spacer
|
||||
class="divider"
|
||||
:size="32"
|
||||
/>
|
||||
</FileUploadWidget>
|
||||
</Layout>
|
||||
</template>
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
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 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 }
|
||||
interface Events {
|
||||
(e: 'update:values', values: Values): void
|
||||
|
@ -16,15 +19,31 @@ interface Props {
|
|||
values: Partial<Values> | null
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
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>({
|
||||
position: 0,
|
||||
description: '',
|
||||
title: '',
|
||||
tags: [],
|
||||
tags: tags.value.currents.map(tag => tag.label),
|
||||
cover: null,
|
||||
...(props.values ?? props.upload.import_metadata ?? {})
|
||||
})
|
||||
|
@ -34,54 +53,45 @@ watch(newValues, (values) => emit('update:values', values), { immediate: true })
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['ui', {loading: isLoading}, 'form']">
|
||||
<div class="ui required field">
|
||||
<label for="upload-title">
|
||||
{{ $t('components.channels.UploadMetadataForm.label.title') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="newValues.title"
|
||||
type="text"
|
||||
>
|
||||
</div>
|
||||
<Layout
|
||||
form
|
||||
:class="[{loading: isLoading}]"
|
||||
>
|
||||
<Input
|
||||
v-model="newValues.title"
|
||||
:label="t('components.channels.UploadMetadataForm.label.title')"
|
||||
required
|
||||
/>
|
||||
<attachment-input
|
||||
v-model="newValues.cover"
|
||||
@delete="newValues.cover = ''"
|
||||
>
|
||||
{{ $t('components.channels.UploadMetadataForm.label.image') }}
|
||||
{{ t('components.channels.UploadMetadataForm.label.image') }}
|
||||
</attachment-input>
|
||||
<div class="ui small hidden divider" />
|
||||
<div class="ui two fields">
|
||||
<div class="ui field">
|
||||
<label for="upload-tags">
|
||||
{{ $t('components.channels.UploadMetadataForm.label.tags') }}
|
||||
</label>
|
||||
<tags-selector
|
||||
id="upload-tags"
|
||||
v-model="newValues.tags"
|
||||
:required="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="ui field">
|
||||
<label for="upload-position">
|
||||
{{ $t('components.channels.UploadMetadataForm.label.position') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="newValues.position"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui field">
|
||||
<label for="upload-description">
|
||||
{{ $t('components.channels.UploadMetadataForm.label.description') }}
|
||||
</label>
|
||||
<content-form
|
||||
v-model="newValues.description"
|
||||
field-id="upload-description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label for="upload-tags">
|
||||
{{ t('components.channels.UploadMetadataForm.label.tags') }}
|
||||
</label>
|
||||
<!-- TODO: Make Tags work -->
|
||||
<Pills
|
||||
:get="(v) => { tags = v }"
|
||||
:set="() => tags"
|
||||
label="Custom Tags"
|
||||
cancel="Cancel"
|
||||
/>
|
||||
<Spacer />
|
||||
<Input
|
||||
v-model="newValues.position"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
:label="t('components.channels.UploadMetadataForm.label.position')"
|
||||
/>
|
||||
<label for="upload-description">
|
||||
{{ t('components.channels.UploadMetadataForm.label.description') }}
|
||||
</label>
|
||||
<content-form
|
||||
v-model="newValues.description"
|
||||
field-id="upload-description"
|
||||
/>
|
||||
</Layout>
|
||||
</template>
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
<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 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 { useRouter } from 'vue-router'
|
||||
import { useStore } from '~/store'
|
||||
|
@ -15,6 +18,8 @@ const update = (value: boolean) => store.commit('channels/showUploadModal', { sh
|
|||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { filter } = defineProps<{ filter: 'podcast' | 'music' }>()
|
||||
|
||||
const uploadForm = ref()
|
||||
|
||||
const statusData = ref()
|
||||
|
@ -45,31 +50,30 @@ const statusInfo = computed(() => {
|
|||
|
||||
const step = ref(1)
|
||||
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>
|
||||
|
||||
<template>
|
||||
<semantic-modal
|
||||
v-model:show="$store.state.channels.showUploadModal"
|
||||
<Modal
|
||||
v-model="store.state.channels.showUploadModal"
|
||||
:title="title"
|
||||
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">
|
||||
<channel-upload-form
|
||||
ref="uploadForm"
|
||||
:channel="$store.state.channels.uploadModalConfig.channel ?? null"
|
||||
:filter="filter"
|
||||
:channel="store.state.channels.uploadModalConfig.channel ?? null"
|
||||
@step="step = $event"
|
||||
@loading="isLoading = $event"
|
||||
@status="statusData = $event"
|
||||
|
@ -82,74 +86,74 @@ const isLoading = ref(false)
|
|||
</template>
|
||||
<div class="ui very small hidden divider" />
|
||||
<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>
|
||||
</div>
|
||||
<div class="ui hidden clearing divider mobile-only" />
|
||||
<button
|
||||
<Button
|
||||
v-if="step === 1"
|
||||
class="ui basic cancel button"
|
||||
>
|
||||
{{ $t('components.channels.UploadModal.button.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="step < 3"
|
||||
class="ui basic button"
|
||||
@click.stop.prevent="uploadForm.step -= 1"
|
||||
>
|
||||
{{ $t('components.channels.UploadModal.button.previous') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="step === 3"
|
||||
class="ui basic button"
|
||||
@click.stop.prevent="uploadForm.step -= 1"
|
||||
>
|
||||
{{ $t('components.channels.UploadModal.button.update') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="step === 1"
|
||||
class="ui primary button"
|
||||
@click.stop.prevent="uploadForm.step += 1"
|
||||
>
|
||||
{{ $t('components.channels.UploadModal.button.next') }}
|
||||
</button>
|
||||
<div
|
||||
v-if="step === 2"
|
||||
class="ui primary buttons"
|
||||
>
|
||||
<button
|
||||
:class="['ui', 'primary button', {loading: isLoading}]"
|
||||
type="submit"
|
||||
:disabled="!statusData?.canSubmit || undefined"
|
||||
@click.prevent.stop="uploadForm.publish"
|
||||
>
|
||||
{{ $t('components.channels.UploadModal.button.publish') }}
|
||||
</button>
|
||||
<button
|
||||
ref="dropdown"
|
||||
v-dropdown
|
||||
class="ui floating dropdown icon button"
|
||||
:disabled="!statusData?.canSubmit || undefined"
|
||||
>
|
||||
<i class="dropdown icon" />
|
||||
<div class="menu">
|
||||
<div
|
||||
role="button"
|
||||
class="basic item"
|
||||
@click="update(false)"
|
||||
>
|
||||
{{ $t('components.channels.UploadModal.button.finishLater') }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="step === 4"
|
||||
class="ui basic cancel button"
|
||||
color="secondary"
|
||||
variant="outline"
|
||||
@click="update(false)"
|
||||
>
|
||||
{{ $t('components.channels.UploadModal.button.close') }}
|
||||
</button>
|
||||
{{ t('components.channels.UploadModal.button.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="step < 3"
|
||||
color="secondary"
|
||||
variant="outline"
|
||||
@click.stop.prevent="uploadForm.step -= 1"
|
||||
>
|
||||
{{ t('components.channels.UploadModal.button.previous') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="step === 3"
|
||||
color="secondary"
|
||||
@click.stop.prevent="uploadForm.step -= 1"
|
||||
>
|
||||
{{ t('components.channels.UploadModal.button.update') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="step === 1"
|
||||
color="secondary"
|
||||
@click.stop.prevent="uploadForm.step += 1"
|
||||
>
|
||||
{{ t('components.channels.UploadModal.button.next') }}
|
||||
</Button>
|
||||
<div class="ui primary buttons">
|
||||
<Button
|
||||
:is-loading="isLoading"
|
||||
type="submit"
|
||||
:disabled="!statusData?.canSubmit"
|
||||
@click.prevent.stop="uploadForm.publish"
|
||||
>
|
||||
{{ t('components.channels.UploadModal.button.publish') }}
|
||||
</Button>
|
||||
|
||||
<Popover v-model="open">
|
||||
<template #default="{ toggleOpen }">
|
||||
<Button
|
||||
color="primary"
|
||||
icon="bi-chevron-down"
|
||||
:disabled="!statusData?.canSubmit"
|
||||
@click.stop="toggleOpen()"
|
||||
/>
|
||||
</template>
|
||||
<template #items>
|
||||
<PopoverItem @click="update(false)">
|
||||
{{ t('components.channels.UploadModal.button.finishLater') }}
|
||||
</PopoverItem>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="step === 4"
|
||||
color="secondary"
|
||||
@click="update(false)"
|
||||
>
|
||||
{{ t('components.channels.UploadModal.button.close') }}
|
||||
</Button>
|
||||
</div>
|
||||
</semantic-modal>
|
||||
</Modal>
|
||||
</template>
|
||||
|
|
|
@ -1,9 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
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 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'
|
||||
|
||||
interface Action {
|
||||
|
@ -11,7 +18,7 @@ interface Action {
|
|||
label: string
|
||||
isDangerous?: boolean
|
||||
allowAll?: boolean
|
||||
confirmColor?: string
|
||||
confirmColor?: 'success' | 'danger'
|
||||
confirmationMessage?: string
|
||||
filterChackable?: (item: any) => boolean
|
||||
}
|
||||
|
@ -54,6 +61,13 @@ const checkable = computed(() => {
|
|||
.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 => {
|
||||
return props.customObjects.find(custom => custom[props.idField] === object[props.idField])
|
||||
?? object
|
||||
|
@ -154,198 +168,220 @@ const launchAction = async () => {
|
|||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="table-wrapper component-action-table">
|
||||
<table class="ui compact very basic unstackable table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="1000">
|
||||
<div
|
||||
v-if="refreshable"
|
||||
class="right floated"
|
||||
>
|
||||
<span v-if="needsRefresh">
|
||||
{{ $t('components.common.ActionTable.message.needsRefresh') }}
|
||||
</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">
|
||||
<!-- TODO (wvffle): Check if we don't have to migrate to v-model -->
|
||||
<input
|
||||
type="checkbox"
|
||||
:aria-label="labels.selectAllItems"
|
||||
:disabled="checkable.length === 0"
|
||||
:checked="checkable.length > 0 && checked.length === checkable.length"
|
||||
@change="toggleCheckAll"
|
||||
>
|
||||
</div>
|
||||
</th>
|
||||
<slot name="header-cells" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody v-if="objectsData.count > 0">
|
||||
<tr
|
||||
v-for="(obj, index) in objects"
|
||||
:key="index"
|
||||
>
|
||||
<td
|
||||
v-if="actions.length > 0"
|
||||
class="collapsing"
|
||||
>
|
||||
<Table
|
||||
v-if="objectsData.count > 0"
|
||||
:grid-template-columns="gridColumns"
|
||||
class="ui compact very basic unstackable table"
|
||||
>
|
||||
<template #header>
|
||||
<label v-if="actions.length > 0">
|
||||
<div class="ui checkbox">
|
||||
<!-- TODO (wvffle): Check if we don't have to migrate to v-model -->
|
||||
<input
|
||||
type="checkbox"
|
||||
:aria-label="labels.selectItem"
|
||||
:disabled="checkable.indexOf(obj[idField]) === -1"
|
||||
:checked="checked.indexOf(obj[idField]) > -1"
|
||||
@click="toggleCheck($event, obj[idField], index)"
|
||||
:aria-label="labels.selectAllItems"
|
||||
:disabled="checkable.length === 0"
|
||||
:checked="checkable.length > 0 && checked.length === checkable.length"
|
||||
@change="toggleCheckAll"
|
||||
>
|
||||
</td>
|
||||
<slot
|
||||
name="row-cells"
|
||||
:obj="obj"
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</label>
|
||||
<slot name="header-cells" />
|
||||
</template>
|
||||
|
||||
<div
|
||||
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"
|
||||
:key="index"
|
||||
>
|
||||
<label
|
||||
v-if="actions.length > 0"
|
||||
class="collapsing"
|
||||
>
|
||||
<!-- TODO (wvffle): Check if we don't have to migrate to v-model -->
|
||||
<input
|
||||
type="checkbox"
|
||||
:aria-label="labels.selectItem"
|
||||
:disabled="checkable.indexOf(obj[idField]) === -1"
|
||||
:checked="checked.indexOf(obj[idField]) > -1"
|
||||
@click="toggleCheck($event, obj[idField], index)"
|
||||
>
|
||||
</label>
|
||||
<slot
|
||||
name="row-cells"
|
||||
:obj="obj"
|
||||
/>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -5,7 +5,7 @@ import { hashCode, intToRGB } from '~/utils/color'
|
|||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
actor: Actor
|
||||
actor: { full_username : string; preferred_username?:string; icon?:Actor['icon'] }
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
@ -18,12 +18,29 @@ const defaultAvatarStyle = computed(() => ({ backgroundColor: `#${actorColor.val
|
|||
<img
|
||||
v-if="actor.icon && actor.icon.urls.original"
|
||||
alt=""
|
||||
:src="actor.icon.urls.medium_square_crop"
|
||||
class="ui avatar circular image"
|
||||
:src="actor.icon.urls.small_square_crop"
|
||||
class="ui tiny avatar circular image"
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
:style="defaultAvatarStyle"
|
||||
class="ui avatar circular label"
|
||||
>{{ actor.preferred_username?.[0] || "" }}</span>
|
||||
class="ui tiny avatar circular label"
|
||||
>
|
||||
{{ actor.preferred_username?.[0] || "" }}
|
||||
</span>
|
||||
</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">
|
||||
import type { Actor } from '~/types'
|
||||
import type { components } from '~/generated/types'
|
||||
|
||||
import { toRefs } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import { truncate } from '~/utils/filters'
|
||||
|
||||
import Pill from '~/components/ui/Pill.vue'
|
||||
|
||||
interface Props {
|
||||
actor: Actor
|
||||
actor: Actor | components['schemas']['APIActor']
|
||||
avatar?: boolean
|
||||
admin?: boolean
|
||||
displayName?: boolean
|
||||
truncateLength?: number
|
||||
discrete?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
avatar: true,
|
||||
admin: false,
|
||||
displayName: false,
|
||||
truncateLength: 30
|
||||
truncateLength: 30,
|
||||
discrete: false
|
||||
})
|
||||
|
||||
const { displayName, actor, truncateLength, admin, avatar } = toRefs(props)
|
||||
|
@ -27,7 +32,7 @@ const repr = computed(() => {
|
|||
? actor.value.preferred_username
|
||||
: actor.value.full_username
|
||||
|
||||
return truncate(name, truncateLength.value)
|
||||
return truncate(name || '', truncateLength.value)
|
||||
})
|
||||
|
||||
const url = computed(() => {
|
||||
|
@ -35,15 +40,15 @@ const url = computed(() => {
|
|||
return { name: 'manage.moderation.accounts.detail', params: { id: actor.value.full_username } }
|
||||
}
|
||||
|
||||
if (actor.value.is_local) {
|
||||
return { name: 'profile.overview', params: { username: actor.value.preferred_username } }
|
||||
if (actor.value?.is_local) {
|
||||
return { name: 'profile.overview', params: { username: actor.value?.preferred_username } }
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'profile.full.overview',
|
||||
params: {
|
||||
username: actor.value.preferred_username,
|
||||
domain: actor.value.domain
|
||||
username: actor.value?.preferred_username,
|
||||
domain: actor.value?.domain
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -52,13 +57,28 @@ const url = computed(() => {
|
|||
<template>
|
||||
<router-link
|
||||
:to="url"
|
||||
:title="actor.full_username"
|
||||
class="username"
|
||||
@click.stop.prevent=""
|
||||
>
|
||||
<actor-avatar
|
||||
v-if="avatar"
|
||||
:actor="actor"
|
||||
/>
|
||||
<span> </span>
|
||||
<slot>{{ repr }}</slot>
|
||||
<Pill>
|
||||
<template #image>
|
||||
<actor-avatar
|
||||
v-if="avatar"
|
||||
:actor="actor"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="bi bi-person-circle"
|
||||
style="font-size: 24px;"
|
||||
/>
|
||||
</template>
|
||||
{{ repr }}
|
||||
</Pill>
|
||||
</router-link>
|
||||
</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 { reactive, ref, watch } from 'vue'
|
||||
import { useStore } from '~/store'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import useFormData from '~/composables/useFormData'
|
||||
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
import Alert from '~/components/ui/Alert.vue'
|
||||
|
||||
interface Events {
|
||||
(e: 'update:modelValue', value: string | null): void
|
||||
(e: 'delete'): void
|
||||
|
@ -20,6 +24,8 @@ interface Props {
|
|||
initialValue?: string | undefined
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
imageClass: '',
|
||||
|
@ -101,13 +107,13 @@ const getAttachmentUrl = (uuid: string) => {
|
|||
|
||||
<template>
|
||||
<div class="ui form">
|
||||
<div
|
||||
<Alert
|
||||
v-if="errors.length > 0"
|
||||
red
|
||||
role="alert"
|
||||
class="ui negative message"
|
||||
>
|
||||
<h4 class="header">
|
||||
{{ $t('components.common.AttachmentInput.header.failure') }}
|
||||
{{ t('components.common.AttachmentInput.header.failure') }}
|
||||
</h4>
|
||||
<ul class="list">
|
||||
<li
|
||||
|
@ -117,7 +123,7 @@ const getAttachmentUrl = (uuid: string) => {
|
|||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Alert>
|
||||
<div class="ui field">
|
||||
<span id="avatarLabel">
|
||||
<slot />
|
||||
|
@ -144,7 +150,7 @@ const getAttachmentUrl = (uuid: string) => {
|
|||
<div class="eleven wide column">
|
||||
<div class="file-input">
|
||||
<label :for="attachmentId">
|
||||
{{ $t('components.common.AttachmentInput.label.upload') }}
|
||||
{{ t('components.common.AttachmentInput.label.upload') }}
|
||||
</label>
|
||||
<input
|
||||
:id="attachmentId"
|
||||
|
@ -159,21 +165,22 @@ const getAttachmentUrl = (uuid: string) => {
|
|||
</div>
|
||||
<div class="ui very small hidden divider" />
|
||||
<p>
|
||||
{{ $t('components.common.AttachmentInput.help.upload') }}
|
||||
{{ t('components.common.AttachmentInput.help.upload') }}
|
||||
</p>
|
||||
<button
|
||||
<Button
|
||||
v-if="value"
|
||||
class="ui basic tiny button"
|
||||
destructive
|
||||
icon="bi-trash"
|
||||
@click.stop.prevent="remove(value as string)"
|
||||
>
|
||||
{{ $t('components.common.AttachmentInput.button.remove') }}
|
||||
</button>
|
||||
{{ t('components.common.AttachmentInput.button.remove') }}
|
||||
</Button>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="ui active inverted dimmer"
|
||||
>
|
||||
<div class="ui indeterminate text loader">
|
||||
{{ $t('components.common.AttachmentInput.loader.uploading') }}
|
||||
{{ t('components.common.AttachmentInput.loader.uploading') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface Events {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
|
@ -9,6 +10,8 @@ interface Props {
|
|||
modelValue: boolean
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
const props = defineProps<Props>()
|
||||
const value = useVModel(props, 'modelValue', emit)
|
||||
|
@ -21,10 +24,10 @@ const value = useVModel(props, 'modelValue', emit)
|
|||
@click.prevent="value = !value"
|
||||
>
|
||||
<span v-if="value">
|
||||
{{ $t('components.common.CollapseLink.button.expand') }}
|
||||
{{ t('components.common.CollapseLink.button.expand') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('components.common.CollapseLink.button.collapse') }}
|
||||
{{ t('components.common.CollapseLink.button.collapse') }}
|
||||
</span>
|
||||
<i :class="[{ down: !value, right: value }, 'angle', 'icon']" />
|
||||
</a>
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import axios from 'axios'
|
||||
import { useVModel, watchDebounced, useTextareaAutosize, syncRef } from '@vueuse/core'
|
||||
import { ref, computed, watchEffect, onMounted, nextTick, watch } from 'vue'
|
||||
import { useVModel, useTextareaAutosize, syncRef } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import useLogger from '~/composables/useLogger'
|
||||
import Textarea from '~/components/ui/Textarea.vue'
|
||||
|
||||
interface Events {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
|
@ -28,119 +27,34 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
required: false
|
||||
})
|
||||
|
||||
const logger = useLogger()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { textarea, input } = useTextareaAutosize()
|
||||
const value = useVModel(props, 'modelValue', emit)
|
||||
syncRef(value, input)
|
||||
|
||||
const isPreviewing = ref(false)
|
||||
const preview = ref()
|
||||
const isLoadingPreview = ref(false)
|
||||
|
||||
const labels = computed(() => ({
|
||||
placeholder: props.placeholder ?? t('components.common.ContentForm.placeholder.input')
|
||||
}))
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="content-form ui segments">
|
||||
<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"
|
||||
v-model="value"
|
||||
:required="required"
|
||||
:placeholder="labels.placeholder"
|
||||
/>
|
||||
</div>
|
||||
<div class="ui very small hidden divider" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="ui bottom attached segment">
|
||||
<span
|
||||
v-if="charLimit"
|
||||
:class="['right', 'floated', {'ui danger text': remainingChars < 0}]"
|
||||
>
|
||||
{{ remainingChars }}
|
||||
</span>
|
||||
<p>
|
||||
{{ $t('components.common.ContentForm.help.markdown') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
ref="textarea"
|
||||
v-model="value"
|
||||
:required="required || undefined"
|
||||
:placeholder="labels.placeholder"
|
||||
:autofocus="autofocus || undefined"
|
||||
/>
|
||||
<span
|
||||
v-if="charLimit"
|
||||
:class="['right', 'floated', {'ui danger text': remainingChars < 0}]"
|
||||
>
|
||||
{{ remainingChars }}
|
||||
</span>
|
||||
<p>
|
||||
{{ t('components.common.ContentForm.help.markdown') }}
|
||||
</p>
|
||||
</template>
|
||||
|
|
|
@ -1,43 +1,79 @@
|
|||
<script setup lang="ts">
|
||||
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 {
|
||||
value: string
|
||||
buttonClasses?: string
|
||||
id?: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
buttonClasses: 'accent',
|
||||
id: 'copy-input'
|
||||
id: 'copy-input',
|
||||
label: 'label'
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { value } = toRefs(props)
|
||||
const { copy, isSupported: canCopy, copied } = useClipboard({ source: value, copiedDuring: 5000 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ui fluid action input component-copy-input">
|
||||
<p
|
||||
v-if="copied"
|
||||
class="message"
|
||||
>
|
||||
{{ $t('components.common.CopyInput.message.success') }}
|
||||
</p>
|
||||
<input
|
||||
:id="id"
|
||||
:value="value"
|
||||
:name="id"
|
||||
type="text"
|
||||
readonly
|
||||
>
|
||||
<button
|
||||
:class="['ui', buttonClasses, 'right', 'labeled', 'icon', 'button']"
|
||||
:disabled="!canCopy || undefined"
|
||||
@click="copy()"
|
||||
>
|
||||
<i class="copy icon" />
|
||||
{{ $t('components.common.CopyInput.button.copy') }}
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="copied"
|
||||
class="message"
|
||||
>
|
||||
{{ t('components.common.CopyInput.message.success') }}
|
||||
</p>
|
||||
<Input
|
||||
:id="id"
|
||||
v-model="value"
|
||||
readonly
|
||||
:name="id"
|
||||
type="text"
|
||||
:label="label"
|
||||
>
|
||||
<template #input-right>
|
||||
<Button
|
||||
:class="['ui', buttonClasses, 'input-right']"
|
||||
min-content
|
||||
secondary
|
||||
:disabled="!canCopy || undefined"
|
||||
@click="copy()"
|
||||
>
|
||||
<i class="bi bi-copy" />
|
||||
{{ t('components.common.CopyInput.button.copy') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Input>
|
||||
</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">
|
||||
import SemanticModal from '~/components/semantic/Modal.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 {
|
||||
(e: 'confirm'): void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
action?: () => void
|
||||
disabled?: boolean
|
||||
confirmColor?: 'danger' | 'success'
|
||||
}
|
||||
// Note that properties such as [disabled] and 'destructive' | 'primary' are inherited.
|
||||
const props = defineProps<{
|
||||
title?: string
|
||||
action?:() => void,
|
||||
confirmColor?:'success' | 'danger',
|
||||
popoverItem?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
action: () => undefined,
|
||||
disabled: false,
|
||||
confirmColor: 'danger'
|
||||
})
|
||||
|
||||
const showModal = ref(false)
|
||||
|
||||
|
@ -29,40 +32,35 @@ const confirm = () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:class="[{disabled: disabled}]"
|
||||
:disabled="disabled"
|
||||
<component
|
||||
:is="props.popoverItem ? PopoverItem : Button"
|
||||
destructive
|
||||
v-bind="$attrs"
|
||||
@click.prevent.stop="showModal = true"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<semantic-modal
|
||||
v-model:show="showModal"
|
||||
class="small"
|
||||
<Modal
|
||||
v-model="showModal"
|
||||
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="description">
|
||||
<slot name="modal-content" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="ui basic cancel button">
|
||||
{{ $t('components.common.DangerousButton.button.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
:class="['ui', 'confirm', confirmColor, 'button']"
|
||||
<template #actions>
|
||||
<Button
|
||||
v-bind="{[{success: 'primary', danger: 'destructive'}[confirmColor || 'danger']]: true}"
|
||||
@click="confirm"
|
||||
>
|
||||
<slot name="modal-confirm">
|
||||
{{ $t('components.common.DangerousButton.button.confirm') }}
|
||||
{{ t('components.common.DangerousButton.button.confirm') }}
|
||||
</slot>
|
||||
</button>
|
||||
</div>
|
||||
</semantic-modal>
|
||||
</button>
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</component>
|
||||
</template>
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import moment from 'moment'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface Props {
|
||||
seconds?: number
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
seconds: 0
|
||||
})
|
||||
|
@ -19,10 +22,10 @@ const duration = computed(() => {
|
|||
<template>
|
||||
<span>
|
||||
<span v-if="duration.hours > 0">
|
||||
{{ $t('components.common.Duration.meta.hours', duration) }}
|
||||
{{ t('components.common.Duration.meta.hours', duration) }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('components.common.Duration.meta.minutes', duration) }}
|
||||
{{ t('components.common.Duration.meta.minutes', duration) }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
<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 {
|
||||
(e: 'refresh'): void
|
||||
}
|
||||
|
@ -7,6 +13,8 @@ interface Props {
|
|||
refresh?: boolean
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
withDefaults(defineProps<Props>(), {
|
||||
refresh: false
|
||||
|
@ -14,24 +22,28 @@ withDefaults(defineProps<Props>(), {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ui small placeholder segment component-placeholder component-empty-state">
|
||||
<Alert
|
||||
blue
|
||||
align-items="center"
|
||||
>
|
||||
<h4 class="ui header">
|
||||
<div class="content">
|
||||
<slot name="title">
|
||||
<i class="search icon" />
|
||||
{{ $t('components.common.EmptyState.header.noResults') }}
|
||||
<i class="bi bi-search" />
|
||||
{{ t('components.common.EmptyState.header.noResults') }}
|
||||
</slot>
|
||||
</div>
|
||||
</h4>
|
||||
<div class="inline center aligned text">
|
||||
<slot />
|
||||
<button
|
||||
<Spacer :size="16" />
|
||||
<Button
|
||||
v-if="refresh"
|
||||
class="ui button"
|
||||
primary
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
{{ $t('components.common.EmptyState.button.refresh') }}
|
||||
</button>
|
||||
{{ t('components.common.EmptyState.button.refresh') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
</template>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useToggle } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface Props {
|
||||
content: string
|
||||
|
@ -11,6 +12,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
length: 150
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const [expanded, toggleExpanded] = useToggle(false)
|
||||
const truncated = computed(() => props.content.slice(0, props.length))
|
||||
</script>
|
||||
|
@ -27,10 +30,10 @@ const truncated = computed(() => props.content.slice(0, props.length))
|
|||
>
|
||||
<br>
|
||||
<span v-if="expanded">
|
||||
{{ $t('components.common.ExpandableDiv.button.less') }}
|
||||
{{ t('components.common.ExpandableDiv.button.less') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('components.common.ExpandableDiv.button.more') }}
|
||||
{{ t('components.common.ExpandableDiv.button.more') }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
@ -25,8 +25,15 @@ const realDate = useTimeAgo(date)
|
|||
>
|
||||
<i
|
||||
v-if="props.icon"
|
||||
class="outline clock icon"
|
||||
class="bi bi-clock"
|
||||
/>
|
||||
{{ realDate }}
|
||||
</time>
|
||||
</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 { useI18n } from 'vue-i18n'
|
||||
|
||||
import Input from '~/components/ui/Input.vue'
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
|
||||
interface Events {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'search', query: string): void
|
||||
|
@ -25,16 +28,11 @@ const labels = computed(() => ({
|
|||
searchPlaceholder: t('components.common.InlineSearchBar.placeholder.search'),
|
||||
clear: t('components.common.InlineSearchBar.button.clear')
|
||||
}))
|
||||
|
||||
const search = () => {
|
||||
value.value = ''
|
||||
emit('search', value.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="ui inline form"
|
||||
<Layout
|
||||
form
|
||||
@submit.stop.prevent="emit('search', value)"
|
||||
>
|
||||
<div :class="['ui', 'action', {icon: value}, 'input']">
|
||||
|
@ -42,27 +40,16 @@ const search = () => {
|
|||
for="search-query"
|
||||
class="hidden"
|
||||
>
|
||||
{{ $t('components.common.InlineSearchBar.label.search') }}
|
||||
{{ t('components.common.InlineSearchBar.label.search') }}
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
id="search-query"
|
||||
v-model="value"
|
||||
search
|
||||
name="search-query"
|
||||
type="text"
|
||||
: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>
|
||||
</form>
|
||||
</Layout>
|
||||
</template>
|
||||
|
|
|
@ -2,9 +2,13 @@
|
|||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import type { Cover } from '~/types'
|
||||
|
||||
import SemanticModal from '~/components/semantic/Modal.vue'
|
||||
import { ref, computed } from 'vue'
|
||||
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 {
|
||||
nextRoute: RouteLocationRaw
|
||||
|
@ -14,6 +18,8 @@ interface Props {
|
|||
|
||||
defineProps<Props>()
|
||||
|
||||
const store = useStore()
|
||||
|
||||
const show = ref(false)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
@ -26,10 +32,10 @@ const labels = computed(() => ({
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<semantic-modal v-model:show="show">
|
||||
<h4 class="header">
|
||||
{{ labels.header }}
|
||||
</h4>
|
||||
<Modal
|
||||
v-model="show"
|
||||
:title="labels.header"
|
||||
>
|
||||
<div
|
||||
v-if="cover"
|
||||
class="image content"
|
||||
|
@ -57,22 +63,21 @@ const labels = computed(() => ({
|
|||
{{ message }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<router-link
|
||||
<template #actions>
|
||||
<Spacer grow />
|
||||
<Link
|
||||
:to="{path: '/login', query: { next: nextRoute as string }}"
|
||||
class="ui labeled icon button"
|
||||
icon="bi-key-fill"
|
||||
>
|
||||
<i class="key icon" />
|
||||
{{ labels.login }}
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="$store.state.instance.settings.users.registration_enabled.value"
|
||||
</Link>
|
||||
<Link
|
||||
v-if="store.state.instance.settings.users.registration_enabled.value"
|
||||
:to="{path: '/signup'}"
|
||||
class="ui labeled icon button"
|
||||
icon="bi-person-fill"
|
||||
>
|
||||
<i class="user icon" />
|
||||
{{ labels.signup }}
|
||||
</router-link>
|
||||
</div>
|
||||
</semantic-modal>
|
||||
</Link>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
|
|
@ -1,35 +1,49 @@
|
|||
<script setup lang="ts">
|
||||
import $ from 'jquery'
|
||||
import { onMounted } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useStore } from '~/store'
|
||||
import Alert from '~/components/ui/Alert.vue'
|
||||
|
||||
interface Message {
|
||||
content: string
|
||||
key: string
|
||||
color?: 'blue' | 'red' | 'purple' | 'green' | 'yellow'
|
||||
error?: boolean | string
|
||||
date?: Date
|
||||
}
|
||||
|
||||
const props = defineProps<{ message: Message }>()
|
||||
|
||||
const isVisible = ref(true)
|
||||
const store = useStore()
|
||||
onMounted(() => {
|
||||
const params = {
|
||||
context: '#app',
|
||||
message: props.message.content,
|
||||
showProgress: 'top',
|
||||
position: 'bottom right',
|
||||
progressUp: true,
|
||||
onRemove () {
|
||||
store.commit('ui/removeMessage', props.message.key)
|
||||
},
|
||||
...props.message
|
||||
|
||||
const messageColor = computed(() => {
|
||||
if (props.message.color) {
|
||||
return props.message.color
|
||||
}
|
||||
|
||||
// @ts-expect-error fomantic ui
|
||||
$('body').toast(params)
|
||||
$('.ui.toast.visible').last().attr('role', 'alert')
|
||||
if (props.message.error || props.message.content?.toLowerCase().includes('error')) {
|
||||
return 'red'
|
||||
}
|
||||
|
||||
return 'blue'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
isVisible.value = false
|
||||
store.commit('ui/removeMessage', props.message.key)
|
||||
}, 5000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div />
|
||||
<Transition name="fade">
|
||||
<Alert
|
||||
v-if="isVisible"
|
||||
role="alert"
|
||||
:[messageColor]="true"
|
||||
class="is-notification"
|
||||
>
|
||||
{{ message.content }}
|
||||
</Alert>
|
||||
</Transition>
|
||||
</template>
|
||||
|
|
|
@ -3,10 +3,15 @@ import type { BackendError } from '~/types'
|
|||
|
||||
import { ref, computed } from 'vue'
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import axios from 'axios'
|
||||
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 {
|
||||
(e: 'updated', data: unknown): void
|
||||
}
|
||||
|
@ -19,8 +24,11 @@ interface Props {
|
|||
fetchHtml?: boolean
|
||||
permissive?: boolean
|
||||
truncateLength?: number
|
||||
moreLink?: boolean
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
content: null,
|
||||
|
@ -29,7 +37,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
canUpdate: true,
|
||||
fetchHtml: false,
|
||||
permissive: false,
|
||||
truncateLength: 500
|
||||
truncateLength: 200,
|
||||
moreLink: true
|
||||
})
|
||||
|
||||
const preview = ref('')
|
||||
|
@ -46,6 +55,8 @@ const truncatedHtml = computed(() => clip(props.content?.html ?? '', props.trunc
|
|||
}))
|
||||
|
||||
const showMore = ref(false)
|
||||
|
||||
// Truncated or full Html or an empty string
|
||||
const html = computed(() => props.fetchHtml
|
||||
? preview.value
|
||||
: props.truncateLength > 0 && !showMore.value
|
||||
|
@ -81,80 +92,102 @@ const submit = async () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<template v-if="content && !isUpdating">
|
||||
<sanitized-html :html="html" />
|
||||
<template v-if="isTruncated">
|
||||
<div class="ui small hidden divider" />
|
||||
<a
|
||||
v-if="showMore === false"
|
||||
href=""
|
||||
@click.stop.prevent="showMore = true"
|
||||
>
|
||||
{{ $t('components.common.RenderedDescription.button.more') }}
|
||||
</a>
|
||||
<a
|
||||
v-else
|
||||
href=""
|
||||
@click.stop.prevent="showMore = false"
|
||||
>
|
||||
{{ $t('components.common.RenderedDescription.button.less') }}
|
||||
</a>
|
||||
</template>
|
||||
</template>
|
||||
<p v-else-if="!isUpdating">
|
||||
{{ $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>
|
||||
</template>
|
||||
<form
|
||||
v-if="isUpdating"
|
||||
class="ui form"
|
||||
@submit.prevent="submit()"
|
||||
>
|
||||
<div
|
||||
v-if="errors.length > 0"
|
||||
role="alert"
|
||||
class="ui negative message"
|
||||
>
|
||||
<h4 class="header">
|
||||
{{ $t('components.common.RenderedDescription.header.failure') }}
|
||||
</h4>
|
||||
<ul class="list">
|
||||
<li
|
||||
v-for="(error, key) in errors"
|
||||
:key="key"
|
||||
>
|
||||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<content-form
|
||||
v-model="text"
|
||||
:autofocus="true"
|
||||
/>
|
||||
<Layout
|
||||
v-if="content && !isUpdating"
|
||||
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">
|
||||
<a
|
||||
class="left floated"
|
||||
@click.prevent="isUpdating = false"
|
||||
v-if="showMore === false && props.moreLink !== false"
|
||||
class="more"
|
||||
style="align-self: flex-end; color: var(--fw-primary);"
|
||||
href=""
|
||||
@click.stop.prevent="showMore = true"
|
||||
>
|
||||
{{ $t('components.common.RenderedDescription.button.cancel') }}
|
||||
{{ t('components.common.RenderedDescription.button.more') }}
|
||||
</a>
|
||||
<button
|
||||
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']"
|
||||
type="submit"
|
||||
:disabled="isLoading"
|
||||
<a
|
||||
v-else-if="props.moreLink !== false"
|
||||
class="more"
|
||||
style="align-self: center; color: var(--fw-primary);"
|
||||
href=""
|
||||
@click.stop.prevent="showMore = false"
|
||||
>
|
||||
{{ $t('components.common.RenderedDescription.button.update') }}
|
||||
</button>
|
||||
<div class="ui clearing hidden divider" />
|
||||
</form>
|
||||
</div>
|
||||
{{ t('components.common.RenderedDescription.button.less') }}
|
||||
</a>
|
||||
</template>
|
||||
</Layout>
|
||||
<span v-else-if="!isUpdating">
|
||||
{{ t('components.common.RenderedDescription.empty.noDescription') }}
|
||||
</span>
|
||||
|
||||
<!-- [DISABLED] Display an edit form -->
|
||||
<!-- TODO: Check if we want to revive in-situ editing here -->
|
||||
|
||||
<form
|
||||
v-if="isUpdating"
|
||||
@submit.prevent="submit()"
|
||||
>
|
||||
<Alert
|
||||
v-if="errors.length > 0"
|
||||
red
|
||||
title="{{ t('components.common.RenderedDescription.header.failure') }}"
|
||||
role="alert"
|
||||
>
|
||||
<ul class="list">
|
||||
<li
|
||||
v-for="(error, key) in errors"
|
||||
:key="key"
|
||||
>
|
||||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
</Alert>
|
||||
<content-form
|
||||
v-model="text"
|
||||
:autofocus="true"
|
||||
/>
|
||||
<Button
|
||||
class="left floated"
|
||||
solid
|
||||
secondary
|
||||
@click.prevent="isUpdating = false"
|
||||
>
|
||||
{{ t('components.common.RenderedDescription.button.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']"
|
||||
type="submit"
|
||||
:disabled="isLoading"
|
||||
solid
|
||||
primary
|
||||
>
|
||||
{{ t('components.common.RenderedDescription.button.update') }}
|
||||
</Button>
|
||||
</form>
|
||||
</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"
|
||||
>
|
||||
<slot>
|
||||
<i class="question circle icon" />
|
||||
<i class="bi bi-question-circle" />
|
||||
</slot>
|
||||
</component>
|
||||
<slot v-else />
|
||||
|
|
|
@ -3,14 +3,23 @@ import type { User } from '~/types'
|
|||
|
||||
import { hashCode, intToRGB } from '~/utils/color'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
import Link from '~/components/ui/Link.vue'
|
||||
|
||||
interface Props {
|
||||
user: User
|
||||
avatar?: boolean
|
||||
discrete?: boolean
|
||||
}
|
||||
|
||||
const store = useStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
avatar: true
|
||||
avatar: true,
|
||||
discrete: false
|
||||
})
|
||||
|
||||
const userColor = computed(() => intToRGB(hashCode(props.user.username + props.user.id)))
|
||||
|
@ -18,21 +27,29 @@ const defaultAvatarStyle = computed(() => ({ backgroundColor: `#${userColor.valu
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<span class="component-user-link">
|
||||
<Link
|
||||
:to="user"
|
||||
:title="user.full_username"
|
||||
class="username"
|
||||
:solid="!discrete"
|
||||
:round="!discrete"
|
||||
>
|
||||
<template v-if="avatar">
|
||||
<img
|
||||
v-if="user.avatar && user.avatar.urls.medium_square_crop"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](user.avatar.urls.medium_square_crop)"
|
||||
class="ui tiny circular avatar"
|
||||
v-if="user.avatar && user.avatar.urls.small_square_crop"
|
||||
v-lazy="store.getters['instance/absoluteUrl'](user.avatar.urls.small_square_crop)"
|
||||
class="ui avatar tiny circular image"
|
||||
alt=""
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
:style="defaultAvatarStyle"
|
||||
class="ui circular label"
|
||||
>{{ user.username[0] }}</span>
|
||||
class="ui tiny avatar circular label"
|
||||
>
|
||||
{{ user.username[0] }}
|
||||
</span>
|
||||
|
||||
</template>
|
||||
{{ $t('components.common.UserLink.link.username', {username: user.username}) }}
|
||||
</span>
|
||||
{{ t('components.common.UserLink.link.username', {username: user.username}) }}
|
||||
</Link>
|
||||
</template>
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
import { SUPPORTED_LOCALES, setI18nLanguage } from '~/init/locale'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed } from 'vue'
|
||||
import { useStore } from '~/store'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import useThemeList from '~/composables/useThemeList'
|
||||
import useTheme from '~/composables/useTheme'
|
||||
|
@ -10,9 +12,13 @@ interface Events {
|
|||
(e: 'show:shortcuts-modal'): void
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const store = useStore()
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const themes = useThemeList()
|
||||
const { theme } = useTheme()
|
||||
|
||||
|
@ -48,7 +54,7 @@ const labels = computed(() => ({
|
|||
<a
|
||||
v-for="(language, key) in SUPPORTED_LOCALES"
|
||||
:key="key"
|
||||
:class="[{'active': $i18n.locale === key},'item']"
|
||||
:class="[{'active': locale === key},'item']"
|
||||
:value="key"
|
||||
@click="setI18nLanguage(key)"
|
||||
>{{ language }}</a>
|
||||
|
@ -74,27 +80,27 @@ const labels = computed(() => ({
|
|||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="$store.state.auth.authenticated">
|
||||
<template v-if="store.state.auth.authenticated">
|
||||
<div class="divider" />
|
||||
<router-link
|
||||
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" />
|
||||
{{ labels.profile }}
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="$store.state.auth.authenticated"
|
||||
v-if="store.state.auth.authenticated"
|
||||
class="item"
|
||||
:to="{name: 'notifications'}"
|
||||
>
|
||||
<i class="bell icon" />
|
||||
<div
|
||||
v-if="$store.state.ui.notifications.inbox > 0"
|
||||
v-if="store.state.ui.notifications.inbox > 0"
|
||||
:title="labels.notifications"
|
||||
:class="['ui', 'circular', 'mini', 'right floated', 'accent', 'label']"
|
||||
>
|
||||
{{ $store.state.ui.notifications.inbox }}
|
||||
{{ store.state.ui.notifications.inbox }}
|
||||
</div>
|
||||
{{ labels.notifications }}
|
||||
</router-link>
|
||||
|
@ -155,14 +161,14 @@ const labels = computed(() => ({
|
|||
{{ labels.shortcuts }}
|
||||
</a>
|
||||
<router-link
|
||||
v-if="$route.path != '/about'"
|
||||
v-if="route.path != '/about'"
|
||||
class="item"
|
||||
:to="{ name: 'about' }"
|
||||
>
|
||||
<i class="question circle outline icon" />
|
||||
{{ labels.about }}
|
||||
</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" />
|
||||
<router-link
|
||||
class="item"
|
||||
|
@ -173,7 +179,7 @@ const labels = computed(() => ({
|
|||
{{ labels.logout }}
|
||||
</router-link>
|
||||
</template>
|
||||
<template v-if="!$store.state.auth.authenticated">
|
||||
<template v-if="!store.state.auth.authenticated">
|
||||
<div class="divider" />
|
||||
<router-link
|
||||
class="item"
|
||||
|
@ -183,7 +189,7 @@ const labels = computed(() => ({
|
|||
{{ labels.login }}
|
||||
</router-link>
|
||||
</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
|
||||
class="item"
|
||||
:to="{ name: 'signup' }"
|
||||
|
|
|
@ -8,6 +8,8 @@ import useTheme from '~/composables/useTheme'
|
|||
import { useVModel } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useStore } from '~/store'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { SUPPORTED_LOCALES } from '~/init/locale'
|
||||
|
||||
|
@ -21,6 +23,9 @@ interface Props {
|
|||
show: boolean
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const store = useStore()
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
|
@ -62,18 +67,18 @@ const locale = computed(() => SUPPORTED_LOCALES[i18nLocale.value as SupportedLan
|
|||
:fullscreen="false"
|
||||
>
|
||||
<div
|
||||
v-if="$store.state.auth.authenticated"
|
||||
v-if="store.state.auth.authenticated"
|
||||
class="header"
|
||||
>
|
||||
<img
|
||||
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-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)"
|
||||
alt=""
|
||||
class="ui centered small circular image"
|
||||
>
|
||||
<actor-avatar
|
||||
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">
|
||||
{{ labels.header }}
|
||||
|
@ -124,12 +129,12 @@ const locale = computed(() => SUPPORTED_LOCALES[i18nLocale.value as SupportedLan
|
|||
</div>
|
||||
</div>
|
||||
<div class="ui divider" />
|
||||
<template v-if="$store.state.auth.authenticated">
|
||||
<template v-if="store.state.auth.authenticated">
|
||||
<div class="row">
|
||||
<div
|
||||
class="column"
|
||||
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" />
|
||||
<span class="user-modal list-item">{{ labels.profile }}</span>
|
||||
|
@ -137,7 +142,7 @@ const locale = computed(() => SUPPORTED_LOCALES[i18nLocale.value as SupportedLan
|
|||
</div>
|
||||
<div class="row">
|
||||
<router-link
|
||||
v-if="$store.state.auth.authenticated"
|
||||
v-if="store.state.auth.authenticated"
|
||||
v-slot="{ navigate }"
|
||||
custom
|
||||
:to="{ name: 'notifications' }"
|
||||
|
@ -212,7 +217,7 @@ const locale = computed(() => SUPPORTED_LOCALES[i18nLocale.value as SupportedLan
|
|||
<div class="ui divider" />
|
||||
|
||||
<router-link
|
||||
v-if="$store.state.auth.authenticated"
|
||||
v-if="store.state.auth.authenticated"
|
||||
v-slot="{ navigate }"
|
||||
custom
|
||||
:to="{ name: 'logout' }"
|
||||
|
@ -244,7 +249,7 @@ const locale = computed(() => SUPPORTED_LOCALES[i18nLocale.value as SupportedLan
|
|||
</div>
|
||||
</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 }"
|
||||
custom
|
||||
:to="{ name: 'signup' }"
|
||||
|
|
|
@ -10,11 +10,16 @@ import { sortedUniq } from 'lodash-es'
|
|||
import { useStore } from '~/store'
|
||||
|
||||
import axios from 'axios'
|
||||
import $ from 'jquery'
|
||||
|
||||
import TrackTable from '~/components/audio/track/Table.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 useOrdering from '~/composables/navigation/useOrdering'
|
||||
|
@ -58,15 +63,15 @@ const fetchFavorites = async () => {
|
|||
isLoading.value = true
|
||||
|
||||
const params = {
|
||||
favorites: 'true',
|
||||
page: page.value,
|
||||
page_size: paginateBy.value,
|
||||
ordering: orderingString.value
|
||||
ordering: orderingString.value,
|
||||
scope: store.state.auth.fullUsername
|
||||
}
|
||||
|
||||
const measureLoading = logger.time('Loading user favorites')
|
||||
try {
|
||||
const response = await axios.get('tracks/', { params })
|
||||
const response = await axios.get('favorites/tracks/', { params })
|
||||
|
||||
results.length = 0
|
||||
results.push(...response.data.results)
|
||||
|
@ -86,16 +91,20 @@ const fetchFavorites = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
watch(page, fetchFavorites)
|
||||
fetchFavorites()
|
||||
onMounted(() => {
|
||||
fetchFavorites()
|
||||
})
|
||||
|
||||
watch([() => paginateBy, page],
|
||||
() => fetchFavorites(),
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onOrderingUpdate(() => {
|
||||
page.value = 1
|
||||
fetchFavorites()
|
||||
})
|
||||
|
||||
onMounted(() => $('.ui.dropdown').dropdown())
|
||||
|
||||
const { t } = useI18n()
|
||||
const labels = computed(() => ({
|
||||
title: t('components.favorites.List.title')
|
||||
|
@ -105,119 +114,140 @@ const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value]
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
<Layout
|
||||
v-title="labels.title"
|
||||
class="main pusher"
|
||||
main
|
||||
stack
|
||||
no-gap
|
||||
align-left
|
||||
>
|
||||
<section class="ui vertical center aligned stripe segment">
|
||||
<div :class="['ui', { 'active': isLoading }, 'inverted', 'dimmer']">
|
||||
<div class="ui text loader">
|
||||
{{ $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" />
|
||||
{{ $t('components.favorites.List.header.favorites', $store.state.favorites.count) }}
|
||||
</h2>
|
||||
<radio-button
|
||||
v-if="$store.state.favorites.count > 0"
|
||||
type="favorites"
|
||||
/>
|
||||
</section>
|
||||
<section
|
||||
v-if="$store.state.favorites.count > 0"
|
||||
class="ui vertical stripe segment"
|
||||
<Header
|
||||
page-heading
|
||||
:h1="labels.title"
|
||||
>
|
||||
<div :class="['ui', { 'loading': isLoading }, 'form']">
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<label for="favorites-ordering">
|
||||
{{ $t('components.favorites.List.ordering.label') }}
|
||||
</label>
|
||||
<select
|
||||
id="favorites-ordering"
|
||||
v-model="ordering"
|
||||
class="ui dropdown"
|
||||
<template #action>
|
||||
<RadioButton
|
||||
v-if="store.state.favorites.count > 0"
|
||||
type="favorites"
|
||||
/>
|
||||
</template>
|
||||
</Header>
|
||||
|
||||
<Loader v-if="isLoading" />
|
||||
<Layout
|
||||
v-if="store.state.favorites.count > 0"
|
||||
form
|
||||
stack
|
||||
:class="['ui', { 'loading': isLoading }, 'form']"
|
||||
>
|
||||
<Spacer :size="16" />
|
||||
<Layout
|
||||
flex
|
||||
style="justify-content: flex-end;"
|
||||
>
|
||||
<Layout
|
||||
stack
|
||||
no-gap
|
||||
label
|
||||
for="favorites-ordering"
|
||||
>
|
||||
<span class="label">
|
||||
{{ t('components.favorites.List.ordering.label') }}
|
||||
</span>
|
||||
<select
|
||||
id="favorites-ordering"
|
||||
v-model="ordering"
|
||||
class="dropdown"
|
||||
>
|
||||
<option
|
||||
v-for="option in orderingOptions"
|
||||
:key="option[0]"
|
||||
:value="option[0]"
|
||||
>
|
||||
<option
|
||||
v-for="option in orderingOptions"
|
||||
:key="option[0]"
|
||||
:value="option[0]"
|
||||
>
|
||||
{{ sharedLabels.filters[option[1]] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="favorites-ordering-direction">
|
||||
{{ $t('components.favorites.List.ordering.direction.label') }}
|
||||
</label>
|
||||
<select
|
||||
id="favorites-ordering-direction"
|
||||
v-model="orderingDirection"
|
||||
class="ui dropdown"
|
||||
{{ sharedLabels.filters[option[1]] }}
|
||||
</option>
|
||||
</select>
|
||||
</Layout>
|
||||
<Layout
|
||||
stack
|
||||
no-gap
|
||||
label
|
||||
for="favorites-ordering-direction"
|
||||
>
|
||||
<span class="label">
|
||||
{{ t('components.favorites.List.ordering.direction.label') }}
|
||||
</span>
|
||||
<select
|
||||
id="favorites-ordering-direction"
|
||||
v-model="orderingDirection"
|
||||
class="dropdown"
|
||||
>
|
||||
<option value="+">
|
||||
{{ t('components.favorites.List.ordering.direction.ascending') }}
|
||||
</option>
|
||||
<option value="-">
|
||||
{{ t('components.favorites.List.ordering.direction.descending') }}
|
||||
</option>
|
||||
</select>
|
||||
</Layout>
|
||||
<Layout
|
||||
stack
|
||||
no-gap
|
||||
label
|
||||
for="favorites-results"
|
||||
>
|
||||
<span class="label">
|
||||
{{ t('components.favorites.List.pagination.results') }}
|
||||
</span>
|
||||
<select
|
||||
id="favorites-results"
|
||||
v-model="paginateBy"
|
||||
class="dropdown"
|
||||
>
|
||||
<option
|
||||
v-for="opt in paginateOptions"
|
||||
:key="opt"
|
||||
:value="opt"
|
||||
>
|
||||
<option value="+">
|
||||
{{ $t('components.favorites.List.ordering.direction.ascending') }}
|
||||
</option>
|
||||
<option value="-">
|
||||
{{ $t('components.favorites.List.ordering.direction.descending') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="favorites-results">
|
||||
{{ $t('components.favorites.List.pagination.results') }}
|
||||
</label>
|
||||
<select
|
||||
id="favorites-results"
|
||||
v-model="paginateBy"
|
||||
class="ui dropdown"
|
||||
>
|
||||
<option
|
||||
v-for="opt in paginateOptions"
|
||||
:key="opt"
|
||||
:value="opt"
|
||||
>
|
||||
{{ opt }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<track-table
|
||||
{{ opt }}
|
||||
</option>
|
||||
</select>
|
||||
</Layout>
|
||||
</Layout>
|
||||
<Pagination
|
||||
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"
|
||||
:search="true"
|
||||
:show-artist="true"
|
||||
:show-album="true"
|
||||
:tracks="results"
|
||||
/>
|
||||
<div class="ui center aligned basic segment">
|
||||
<pagination
|
||||
v-if="results && count > paginateBy"
|
||||
v-model:current="page"
|
||||
:paginate-by="paginateBy"
|
||||
:total="count"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
v-else
|
||||
class="ui placeholder segment"
|
||||
</Layout>
|
||||
<Alert
|
||||
v-else-if="!isLoading"
|
||||
blue
|
||||
align-items="center"
|
||||
>
|
||||
<div class="ui icon header">
|
||||
<i class="broken heart icon" />
|
||||
{{ $t('components.favorites.List.empty.noFavorites') }}
|
||||
</div>
|
||||
<router-link
|
||||
:to="'/library'"
|
||||
class="ui success labeled icon button"
|
||||
<i
|
||||
class="bi bi-heartbreak-fill"
|
||||
style="font-size: 100px;"
|
||||
/>
|
||||
<Spacer />
|
||||
{{ t('components.favorites.List.empty.noFavorites') }}
|
||||
<Spacer :size="32" />
|
||||
<Link
|
||||
to="/library"
|
||||
solid
|
||||
primary
|
||||
icon="bi-headphones"
|
||||
>
|
||||
<i class="headphones icon" />
|
||||
{{ $t('components.favorites.List.link.library') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</main>
|
||||
{{ t('components.favorites.List.link.library') }}
|
||||
</Link>
|
||||
</Alert>
|
||||
</Layout>
|
||||
</template>
|
||||
|
|
|
@ -6,16 +6,20 @@ import { useI18n } from 'vue-i18n'
|
|||
import { useStore } from '~/store'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
|
||||
interface Props {
|
||||
track?: QueueTrack | Track
|
||||
button?: boolean
|
||||
border?: boolean
|
||||
ghost?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
track: () => ({} as Track),
|
||||
button: false,
|
||||
border: false
|
||||
border: false,
|
||||
ghost: false
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
@ -29,26 +33,27 @@ const title = computed(() => isFavorite.value
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
<Button
|
||||
v-if="button"
|
||||
:icon="isFavorite ? 'bi-heart-fill' : 'bi-heart'"
|
||||
: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">
|
||||
{{ $t('components.favorites.TrackFavoriteIcon.label.inFavorites') }}
|
||||
{{ t('components.favorites.TrackFavoriteIcon.label.inFavorites') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('components.favorites.TrackFavoriteIcon.button.add') }}
|
||||
{{ t('components.favorites.TrackFavoriteIcon.button.add') }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
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']"
|
||||
:aria-label="title"
|
||||
:title="title"
|
||||
@click.stop="$store.dispatch('favorites/toggle', track.id)"
|
||||
>
|
||||
<i :class="['heart', {'pink': isFavorite}, 'basic', 'icon']" />
|
||||
</button>
|
||||
@click.stop="store.dispatch('favorites/toggle', track.id)"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
@ -2,10 +2,17 @@
|
|||
import type { BackendError } from '~/types'
|
||||
|
||||
import axios from 'axios'
|
||||
import SemanticModal from '~/components/semantic/Modal.vue'
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Events {
|
||||
(e: 'refresh'): void
|
||||
}
|
||||
|
@ -68,20 +75,22 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="button"
|
||||
<Button
|
||||
secondary
|
||||
icon="bi-arrow-clockwise"
|
||||
:loading="isLoading"
|
||||
low-height
|
||||
@click="fetch"
|
||||
>
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
<semantic-modal
|
||||
v-model:show="showModal"
|
||||
<Modal
|
||||
v-model="showModal"
|
||||
:title="t('components.federation.FetchButton.header.refresh')"
|
||||
class="small"
|
||||
:cancel="t('components.federation.FetchButton.button.close')"
|
||||
>
|
||||
<h3 class="header">
|
||||
{{ $t('components.federation.FetchButton.header.refresh') }}
|
||||
</h3>
|
||||
<div class="scrolling content">
|
||||
<template v-if="data && data.status != 'pending'">
|
||||
<div
|
||||
|
@ -89,10 +98,10 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
|
|||
class="ui message"
|
||||
>
|
||||
<h4 class="header">
|
||||
{{ $t('components.federation.FetchButton.header.skipped') }}
|
||||
{{ t('components.federation.FetchButton.header.skipped') }}
|
||||
</h4>
|
||||
<p>
|
||||
{{ $t('components.federation.FetchButton.description.skipped') }}
|
||||
{{ t('components.federation.FetchButton.description.skipped') }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
@ -100,10 +109,10 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
|
|||
class="ui success message"
|
||||
>
|
||||
<h4 class="header">
|
||||
{{ $t('components.federation.FetchButton.header.success') }}
|
||||
{{ t('components.federation.FetchButton.header.success') }}
|
||||
</h4>
|
||||
<p>
|
||||
{{ $t('components.federation.FetchButton.description.success') }}
|
||||
{{ t('components.federation.FetchButton.description.success') }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
@ -111,16 +120,16 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
|
|||
class="ui error message"
|
||||
>
|
||||
<h4 class="header">
|
||||
{{ $t('components.federation.FetchButton.header.failure') }}
|
||||
{{ t('components.federation.FetchButton.header.failure') }}
|
||||
</h4>
|
||||
<p>
|
||||
{{ $t('components.federation.FetchButton.description.failure') }}
|
||||
{{ t('components.federation.FetchButton.description.failure') }}
|
||||
</p>
|
||||
<table class="ui very basic collapsing celled table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
{{ $t('components.federation.FetchButton.table.error.label.type') }}
|
||||
{{ t('components.federation.FetchButton.table.error.label.type') }}
|
||||
</td>
|
||||
<td>
|
||||
{{ data.detail.error_code }}
|
||||
|
@ -128,32 +137,32 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
|
|||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
{{ $t('components.federation.FetchButton.table.error.label.detail') }}
|
||||
{{ t('components.federation.FetchButton.table.error.label.detail') }}
|
||||
</td>
|
||||
<td>
|
||||
<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 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 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 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 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 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 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 v-else>
|
||||
{{ $t('components.federation.FetchButton.table.error.value.unknownError') }}
|
||||
{{ t('components.federation.FetchButton.table.error.value.unknownError') }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -166,7 +175,7 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
|
|||
class="ui active inverted dimmer"
|
||||
>
|
||||
<div class="ui text loader">
|
||||
{{ $t('components.federation.FetchButton.loader.fetchRequest') }}
|
||||
{{ t('components.federation.FetchButton.loader.fetchRequest') }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
@ -174,7 +183,7 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
|
|||
class="ui active inverted dimmer"
|
||||
>
|
||||
<div class="ui text loader">
|
||||
{{ $t('components.federation.FetchButton.loader.awaitingResult') }}
|
||||
{{ t('components.federation.FetchButton.loader.awaitingResult') }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
@ -183,7 +192,7 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
|
|||
class="ui negative message"
|
||||
>
|
||||
<h4 class="header">
|
||||
{{ $t('components.federation.FetchButton.header.saveFailure') }}
|
||||
{{ t('components.federation.FetchButton.header.saveFailure') }}
|
||||
</h4>
|
||||
<ul class="list">
|
||||
<li
|
||||
|
@ -200,25 +209,21 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
|
|||
class="ui warning message"
|
||||
>
|
||||
<h4 class="header">
|
||||
{{ $t('components.federation.FetchButton.header.pending') }}
|
||||
{{ t('components.federation.FetchButton.header.pending') }}
|
||||
</h4>
|
||||
<p>
|
||||
{{ $t('components.federation.FetchButton.description.pending') }}
|
||||
{{ t('components.federation.FetchButton.description.pending') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="ui basic cancel button">
|
||||
{{ $t('components.federation.FetchButton.button.close') }}
|
||||
</button>
|
||||
<button
|
||||
<Button
|
||||
v-if="data && data.status === 'finished'"
|
||||
class="ui confirm success button"
|
||||
@click.prevent="showModal = false; emit('refresh')"
|
||||
>
|
||||
{{ $t('components.federation.FetchButton.button.reload') }}
|
||||
</button>
|
||||
{{ t('components.federation.FetchButton.button.reload') }}
|
||||
</Button>
|
||||
</div>
|
||||
</semantic-modal>
|
||||
</div>
|
||||
</Modal>
|
||||
</Button>
|
||||
</template>
|
||||
|
|
|
@ -1,11 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
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 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'
|
||||
|
||||
|
@ -15,8 +22,12 @@ interface Events {
|
|||
|
||||
interface Props {
|
||||
url: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useStore()
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
|
@ -29,12 +40,12 @@ const fetchData = async (url = props.url) => {
|
|||
try {
|
||||
const response = await axios.get(url, {
|
||||
params: {
|
||||
page_size: 6
|
||||
page_size: 3
|
||||
}
|
||||
})
|
||||
|
||||
nextPage.value = response.data.next
|
||||
libraries.push(...response.data.results)
|
||||
libraries.splice(0, libraries.length, ...response.data.results)
|
||||
emit('loaded', libraries)
|
||||
} catch (error) {
|
||||
useErrorHandler(error as Error)
|
||||
|
@ -43,55 +54,49 @@ const fetchData = async (url = props.url) => {
|
|||
isLoading.value = false
|
||||
}
|
||||
|
||||
fetchData()
|
||||
onMounted(() => {
|
||||
setTimeout(fetchData, 1000)
|
||||
})
|
||||
|
||||
watch(() => props.url, () => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrapper">
|
||||
<h3
|
||||
v-if="!!$slots.title"
|
||||
class="ui header"
|
||||
>
|
||||
<slot name="title" />
|
||||
</h3>
|
||||
<p
|
||||
v-if="!isLoading && libraries.length > 0"
|
||||
class="ui subtitle"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
<p
|
||||
<Section
|
||||
align-left
|
||||
:h2="title"
|
||||
:columns-per-item="3"
|
||||
>
|
||||
<Loader
|
||||
v-if="isLoading"
|
||||
style="grid-column: 1 / -1;"
|
||||
/>
|
||||
<Alert
|
||||
v-if="!isLoading && libraries.length === 0"
|
||||
class="ui subtitle"
|
||||
blue
|
||||
style="grid-column: 1 / -1;"
|
||||
>
|
||||
{{ $t('components.federation.LibraryWidget.empty.noMatch') }}
|
||||
</p>
|
||||
<div class="ui hidden divider" />
|
||||
<div class="ui cards">
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="ui inverted active dimmer"
|
||||
>
|
||||
<div class="ui loader" />
|
||||
</div>
|
||||
<library-card
|
||||
v-for="library in libraries"
|
||||
:key="library.uuid"
|
||||
:display-scan="false"
|
||||
:display-follow="$store.state.auth.authenticated && library.actor.full_username != $store.state.auth.fullUsername"
|
||||
:initial-library="library"
|
||||
:display-copy-fid="true"
|
||||
/>
|
||||
</div>
|
||||
{{ t('components.federation.LibraryWidget.empty.noMatch') }}
|
||||
</Alert>
|
||||
<library-card
|
||||
v-for="library in libraries"
|
||||
:key="library.uuid"
|
||||
:display-scan="false"
|
||||
:display-follow="store.state.auth.authenticated && library.actor.full_username != store.state.auth.fullUsername"
|
||||
:initial-library="library"
|
||||
:display-copy-fid="true"
|
||||
/>
|
||||
<template v-if="nextPage">
|
||||
<div class="ui hidden divider" />
|
||||
<button
|
||||
<Spacer />
|
||||
<Button
|
||||
v-if="nextPage"
|
||||
:class="['ui', 'basic', 'button']"
|
||||
primary
|
||||
@click="fetchData(nextPage)"
|
||||
>
|
||||
{{ $t('components.federation.LibraryWidget.button.showMore') }}
|
||||
</button>
|
||||
{{ t('components.federation.LibraryWidget.button.showMore') }}
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</Section>
|
||||
</template>
|
||||
|
|
|
@ -1,16 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import type { Actor } from '~/types'
|
||||
import type { components } from '~/generated/types'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Events {
|
||||
(e: 'unfollowed'): void
|
||||
(e: 'followed'): void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
actor: Actor
|
||||
actor: components['schemas']['FullActor']
|
||||
}
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
|
@ -33,19 +38,20 @@ const toggle = () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
<Button
|
||||
secondary
|
||||
:class="['ui', 'pink', {'inverted': isApproved || isPending}, {'favorited': isApproved}, 'icon', 'labeled', 'button']"
|
||||
:icon="isPending ? 'bi-heart' : 'bi-heart-fill'"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<i class="heart icon" />
|
||||
<span v-if="isApproved">
|
||||
{{ $t('components.audio.LibraryFollowButton.button.unfollow') }}
|
||||
{{ t('components.audio.LibraryFollowButton.button.unfollow') }}
|
||||
</span>
|
||||
<span v-else-if="isPending">
|
||||
{{ $t('components.audio.LibraryFollowButton.button.cancel') }}
|
||||
{{ t('components.audio.LibraryFollowButton.button.cancel') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('components.audio.LibraryFollowButton.button.follow') }}
|
||||
{{ t('components.audio.LibraryFollowButton.button.follow') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useClipboard, useVModel } from '@vueuse/core'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
import Input from '~/components/ui/Input.vue'
|
||||
|
||||
interface Events {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}
|
||||
|
@ -23,16 +25,12 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
|
||||
const value = useVModel(props, 'modelValue', emit)
|
||||
|
||||
const showPassword = ref(props.defaultShow)
|
||||
|
||||
const { t } = useI18n()
|
||||
const labels = computed(() => ({
|
||||
title: t('components.forms.PasswordInput.title'),
|
||||
copy: t('components.forms.PasswordInput.button.copy')
|
||||
}))
|
||||
|
||||
const passwordInputType = computed(() => showPassword.value ? 'text' : 'password')
|
||||
|
||||
const store = useStore()
|
||||
const { isSupported: canCopy, copy } = useClipboard({ source: value })
|
||||
const copyPassword = () => {
|
||||
|
@ -45,30 +43,38 @@ const copyPassword = () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ui fluid action input">
|
||||
<input
|
||||
<div>
|
||||
<Input
|
||||
:id="fieldId"
|
||||
v-model="value"
|
||||
password
|
||||
required
|
||||
name="password"
|
||||
:type="passwordInputType"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:title="labels.title"
|
||||
class="ui icon button"
|
||||
@click.prevent="showPassword = !showPassword"
|
||||
>
|
||||
<i class="eye icon" />
|
||||
</button>
|
||||
<button
|
||||
v-if="copyButton && canCopy"
|
||||
type="button"
|
||||
class="ui icon button"
|
||||
:title="labels.copy"
|
||||
@click.prevent="copyPassword"
|
||||
>
|
||||
<i class="copy icon" />
|
||||
</button>
|
||||
<template #input-right>
|
||||
<button
|
||||
v-if="copyButton && canCopy"
|
||||
role="switch"
|
||||
type="button"
|
||||
class="input-right copy"
|
||||
:title="labels.copy"
|
||||
@click.prevent="copyPassword"
|
||||
>
|
||||
<i class="bi bi-copy" />
|
||||
</button>
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
</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 { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { sum } from 'lodash-es'
|
||||
import { useStore } from '~/store'
|
||||
import { useQueue } from '~/composables/audio/queue'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
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 TagsList from '~/components/tags/List.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 useLogger from '~/composables/useLogger'
|
||||
|
@ -22,9 +31,11 @@ interface Events {
|
|||
}
|
||||
|
||||
interface Props {
|
||||
id: number
|
||||
id: number | string
|
||||
}
|
||||
|
||||
const store = useStore()
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
|
@ -51,10 +62,16 @@ const publicLibraries = computed(() => libraries.value?.filter(library => librar
|
|||
const logger = useLogger()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
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 fetchData = async () => {
|
||||
isLoading.value = true
|
||||
|
@ -63,12 +80,10 @@ const fetchData = async () => {
|
|||
|
||||
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}/`)
|
||||
|
||||
artist.value = artistResponse.data
|
||||
if (artist.value?.channel) {
|
||||
artist.value.channel.artist = artist.value
|
||||
}
|
||||
|
||||
object.value = albumResponse.data
|
||||
if (object.value) {
|
||||
|
@ -116,6 +131,8 @@ const fetchTracks = async () => {
|
|||
watch(() => props.id, fetchData, { immediate: true })
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const remove = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
|
@ -131,227 +148,154 @@ const remove = async () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
v-title="labels.title"
|
||||
class="ui vertical segment"
|
||||
>
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" />
|
||||
</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
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<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
|
||||
v-if="artistCredit"
|
||||
:artist-credit="artistCredit"
|
||||
/>
|
||||
</header>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="ui center aligned text padded basic segment"
|
||||
>
|
||||
<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 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">
|
||||
{{ momentFormat(new Date(object.release_date ?? '1970-01-01'), 'Y') }}
|
||||
<span class="middle middledot symbol" />
|
||||
</template>
|
||||
<template v-if="totalTracks > 0">
|
||||
<span v-if="isSerie">
|
||||
{{ $t('components.library.AlbumBase.meta.episodes', totalTracks) }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('components.library.AlbumBase.meta.tracks', totalTracks) }}
|
||||
</span>
|
||||
<span class="middle middledot symbol" />
|
||||
</template>
|
||||
<human-duration
|
||||
v-if="totalDuration > 0"
|
||||
:duration="totalDuration"
|
||||
/>
|
||||
<div class="ui small hidden divider" />
|
||||
<play-button
|
||||
class="vibrant"
|
||||
:album="object"
|
||||
:is-playable="object.is_playable"
|
||||
/>
|
||||
<div class="ui horizontal hidden 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 v-if="(object.tags && object.tags.length > 0) || object.description || $store.state.auth.authenticated && object.is_local">
|
||||
<div class="ui small hidden divider" />
|
||||
<div class="ui divider" />
|
||||
<div class="ui small hidden divider" />
|
||||
<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
|
||||
v-if="object"
|
||||
:key="$route.fullPath"
|
||||
:paginate-by="paginateBy"
|
||||
:total-tracks="totalTracks"
|
||||
:is-serie="isSerie"
|
||||
:artist-credit="artistCredit"
|
||||
:object="object"
|
||||
:is-loading-tracks="isLoadingTracks"
|
||||
object-type="album"
|
||||
@libraries-loaded="libraries = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<Loader
|
||||
v-if="isLoading"
|
||||
v-title="labels.title"
|
||||
/>
|
||||
<Header
|
||||
v-if="object"
|
||||
:h1="object.title"
|
||||
page-heading
|
||||
>
|
||||
<template #image>
|
||||
<img
|
||||
v-if="object.cover && object.cover.urls.original"
|
||||
v-lazy="store.getters['instance/absoluteUrl'](object.cover.urls.large_square_crop)"
|
||||
:alt="object.title"
|
||||
class="channel-image"
|
||||
>
|
||||
<img
|
||||
v-else
|
||||
alt=""
|
||||
class="channel-image"
|
||||
src="../../assets/audio/default-cover.png"
|
||||
>
|
||||
</template>
|
||||
</main>
|
||||
<artist-credit-label
|
||||
v-if="artistCredit"
|
||||
:artist-credit="artistCredit"
|
||||
/>
|
||||
<!-- Metadata: -->
|
||||
<Layout
|
||||
gap-4
|
||||
class="meta"
|
||||
>
|
||||
<Layout
|
||||
flex
|
||||
gap-4
|
||||
>
|
||||
<template v-if="object.release_date">
|
||||
{{ momentFormat(new Date(object.release_date ?? '1970-01-01'), 'Y') }}
|
||||
<i class="bi bi-dot" />
|
||||
</template>
|
||||
<template v-if="totalTracks > 0">
|
||||
<span v-if="isSerie">
|
||||
{{ t('components.library.AlbumBase.meta.episodes', totalTracks) }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ t('components.library.AlbumBase.meta.tracks', totalTracks) }}
|
||||
</span>
|
||||
</template>
|
||||
<i
|
||||
v-if="totalDuration > 0"
|
||||
class="bi bi-dot"
|
||||
/>
|
||||
<human-duration
|
||||
v-if="totalDuration > 0"
|
||||
:duration="totalDuration"
|
||||
/>
|
||||
<!--TODO: License -->
|
||||
</Layout>
|
||||
</Layout>
|
||||
<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"
|
||||
/>
|
||||
<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
|
||||
:object="object"
|
||||
:public-libraries="publicLibraries"
|
||||
:is-loading="isLoading"
|
||||
:is-album="isAlbum"
|
||||
:is-serie="isSerie"
|
||||
:is-channel="isChannel"
|
||||
:artist-credit="artistCredit"
|
||||
@remove="remove"
|
||||
/>
|
||||
</Layout>
|
||||
</Header>
|
||||
|
||||
<div style="flex 1;">
|
||||
<router-view
|
||||
v-if="object"
|
||||
:key="route.fullPath"
|
||||
:paginate-by="paginateBy"
|
||||
:total-tracks="totalTracks"
|
||||
:is-serie="isSerie"
|
||||
:artist-credit="artistCredit"
|
||||
:object="object"
|
||||
:is-loading-tracks="isLoadingTracks"
|
||||
object-type="album"
|
||||
@libraries-loaded="libraries = $event"
|
||||
/>
|
||||
</div>
|
||||
</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 TrackTable from '~/components/audio/track/Table.vue'
|
||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||
import TagsList from '~/components/tags/List.vue'
|
||||
import Pagination from '~/components/vui/Pagination.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 {
|
||||
(e: 'libraries-loaded', libraries: Library[]): void
|
||||
|
@ -24,26 +29,25 @@ interface Props {
|
|||
|
||||
const emit = defineEmits<Events>()
|
||||
const props = defineProps<Props>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const getDiscKey = (disc: Track[]) => disc?.map(track => track.id).join('|') ?? ''
|
||||
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)
|
||||
return acc
|
||||
}, new Set()).size)
|
||||
|
||||
const discs = computed(() => props.object.tracks
|
||||
.reduce((acc: Track[][], track: Track) => {
|
||||
const discNumber = track.disc_number - (props.object.tracks[0]?.disc_number ?? 1)
|
||||
acc[discNumber].push(track)
|
||||
return acc
|
||||
}, Array(discCount.value).fill(undefined).map(() => []))
|
||||
)
|
||||
const discs = computed(() => props.object?.tracks?.reduce((acc: Track[][], track: Track) => {
|
||||
const discNumber = track.disc_number - (props.object?.tracks?.[0]?.disc_number ?? 1)
|
||||
acc[discNumber].push(track)
|
||||
return acc
|
||||
}, 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) => {
|
||||
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)
|
||||
return acc
|
||||
}, Array(discCount.value).fill(undefined).map(() => []))
|
||||
|
@ -54,38 +58,45 @@ const paginatedDiscs = computed(() => props.object.tracks.slice(props.paginateBy
|
|||
<template>
|
||||
<div
|
||||
v-if="!isLoadingTracks"
|
||||
class="ui vertical segment"
|
||||
>
|
||||
<h2 class="ui header">
|
||||
<span v-if="isSerie">
|
||||
{{ $t('components.library.AlbumDetail.header.episodes') }}
|
||||
{{ t('components.library.AlbumDetail.header.episodes') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('components.library.AlbumDetail.header.tracks') }}
|
||||
{{ t('components.library.AlbumDetail.header.tracks') }}
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<channel-entries
|
||||
v-if="artistCredit && artistCredit[0].artist.channel && isSerie"
|
||||
:default-cover="null"
|
||||
:is-podcast="isSerie"
|
||||
: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-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
|
||||
v-for="tracks, index in paginatedDiscs"
|
||||
:key="index + getDiscKey(tracks)"
|
||||
>
|
||||
<template v-if="tracks.length > 0">
|
||||
<div class="ui hidden divider" />
|
||||
<play-button
|
||||
<PlayButton
|
||||
class="right floated mini inverted vibrant"
|
||||
:tracks="discs[index]"
|
||||
:tracks="discs ? discs[index] : []"
|
||||
/>
|
||||
<h3>
|
||||
{{ $t('components.library.AlbumDetail.meta.volume', {number: tracks[0].disc_number}) }}
|
||||
{{ t('components.library.AlbumDetail.meta.volume', { number: tracks[0].disc_number }) }}
|
||||
</h3>
|
||||
<track-table
|
||||
:is-album="true"
|
||||
|
@ -99,7 +110,7 @@ const paginatedDiscs = computed(() => props.object.tracks.slice(props.paginateBy
|
|||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-else-if="object.tracks">
|
||||
<track-table
|
||||
:is-album="true"
|
||||
:tracks="object.tracks"
|
||||
|
@ -124,14 +135,13 @@ const paginatedDiscs = computed(() => props.object.tracks.slice(props.paginateBy
|
|||
</template>
|
||||
|
||||
<template v-if="artistCredit && !artistCredit[0]?.artist.channel && !isSerie">
|
||||
<h2>
|
||||
{{ $t('components.library.AlbumDetail.header.libraries') }}
|
||||
</h2>
|
||||
<Spacer />
|
||||
<library-widget
|
||||
:url="'albums/' + object.id + '/libraries/'"
|
||||
:title="t('components.library.AlbumDetail.header.libraries')"
|
||||
@loaded="emit('libraries-loaded', $event)"
|
||||
>
|
||||
{{ $t('components.library.AlbumDetail.description.libraries') }}
|
||||
{{ t('components.library.AlbumDetail.description.libraries') }}
|
||||
</library-widget>
|
||||
</template>
|
||||
</div>
|
||||
|
|
|
@ -3,17 +3,18 @@ import type { Album, ArtistCredit, Library } from '~/types'
|
|||
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
import { getDomain } from '~/utils'
|
||||
|
||||
import useReport from '~/composables/moderation/useReport'
|
||||
|
||||
import EmbedWizard from '~/components/audio/EmbedWizard.vue'
|
||||
import SemanticModal from '~/components/semantic/Modal.vue'
|
||||
|
||||
interface Events {
|
||||
(e: 'remove'): void
|
||||
}
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
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 {
|
||||
isLoading: boolean
|
||||
|
@ -23,9 +24,10 @@ interface Props {
|
|||
isAlbum: boolean
|
||||
isChannel: boolean
|
||||
isSerie: boolean
|
||||
|
||||
}
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
const store = useStore()
|
||||
const props = defineProps<Props>()
|
||||
const { report, getReportableObjects } = useReport()
|
||||
|
||||
|
@ -38,147 +40,131 @@ const labels = computed(() => ({
|
|||
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 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>
|
||||
|
||||
<template>
|
||||
<span>
|
||||
|
||||
<semantic-modal
|
||||
<Modal
|
||||
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="description">
|
||||
<embed-wizard
|
||||
:id="object.id"
|
||||
type="album"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="ui basic deny button">
|
||||
{{ $t('components.library.AlbumDropdown.button.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</semantic-modal>
|
||||
<button
|
||||
v-dropdown="{direction: 'downward'}"
|
||||
class="ui floating dropdown circular icon basic button"
|
||||
:title="labels.more"
|
||||
>
|
||||
<i class="ellipsis vertical 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.AlbumDropdown.link.domain') }}
|
||||
</a>
|
||||
</Modal>
|
||||
<Popover v-model="open">
|
||||
<template #default="{ toggleOpen }">
|
||||
<OptionsButton
|
||||
:title="labels.more"
|
||||
is-square-small
|
||||
@click="toggleOpen()"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<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"
|
||||
role="button"
|
||||
class="basic item"
|
||||
icon="bi-code"
|
||||
@click="showEmbedModal = !showEmbedModal"
|
||||
>
|
||||
<i class="code icon" />
|
||||
{{ $t('components.library.AlbumDropdown.button.embed') }}
|
||||
</div>
|
||||
<a
|
||||
{{ t('components.library.AlbumDropdown.button.embed') }}
|
||||
</PopoverItem>
|
||||
|
||||
<PopoverItem
|
||||
v-if="isAlbum && musicbrainzUrl"
|
||||
:href="musicbrainzUrl"
|
||||
:to="musicbrainzUrl"
|
||||
icon="bi-box-arrow-up-right"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
class="basic item"
|
||||
>
|
||||
<i class="external icon" />
|
||||
{{ $t('components.library.AlbumDropdown.link.musicbrainz') }}
|
||||
</a>
|
||||
<a
|
||||
{{ t('components.library.AlbumDropdown.link.musicbrainz') }}
|
||||
</PopoverItem>
|
||||
|
||||
<PopoverItem
|
||||
v-if="!isChannel && isAlbum"
|
||||
:href="discogsUrl"
|
||||
:to="discogsUrl"
|
||||
icon="bi-box-arrow-up-right"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
class="basic item"
|
||||
>
|
||||
<i class="external icon" />
|
||||
{{ $t('components.library.AlbumDropdown.link.discogs') }}
|
||||
</a>
|
||||
<router-link
|
||||
{{ t('components.library.AlbumDropdown.link.discogs') }}
|
||||
</PopoverItem>
|
||||
|
||||
<PopoverItem
|
||||
v-if="object.is_local"
|
||||
:to="{name: 'library.albums.edit', params: {id: object.id }}"
|
||||
class="basic item"
|
||||
:to="{ name: 'library.albums.edit', params: { id: object.id } }"
|
||||
icon="bi-pencil"
|
||||
>
|
||||
<i class="edit icon" />
|
||||
{{ $t('components.library.AlbumDropdown.button.edit') }}
|
||||
</router-link>
|
||||
<dangerous-button
|
||||
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']"
|
||||
@confirm="remove()"
|
||||
>
|
||||
<i class="ui trash icon" />
|
||||
{{ $t('components.library.AlbumDropdown.button.delete') }}
|
||||
<template #modal-header>
|
||||
<p>
|
||||
{{ $t('components.library.AlbumDropdown.modal.delete.header') }}
|
||||
</p>
|
||||
</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})"
|
||||
{{ t('components.library.AlbumDropdown.button.edit') }}
|
||||
</PopoverItem>
|
||||
|
||||
<hr>
|
||||
|
||||
<PopoverItem
|
||||
v-for="obj in getReportableObjects({
|
||||
album: object
|
||||
/*
|
||||
|
||||
TODO: The type of the following field has changed to number.
|
||||
Find out if we want to load the corresponding channel instead.
|
||||
|
||||
, channel: artistCredit[0]?.artist.channel
|
||||
*/
|
||||
})"
|
||||
:key="obj.target.type + obj.target.id"
|
||||
role="button"
|
||||
class="basic item"
|
||||
@click.stop.prevent="report(obj)"
|
||||
icon="bi-flag"
|
||||
@click="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.albums.detail', params: {id: object.id}}"
|
||||
{{ obj.label }}
|
||||
</PopoverItem>
|
||||
|
||||
<hr>
|
||||
|
||||
<PopoverItem
|
||||
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') }}
|
||||
</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/album/${object.id}`)"
|
||||
{{ t('components.library.AlbumDropdown.link.moderation') }}
|
||||
</PopoverItem>
|
||||
|
||||
<PopoverItem
|
||||
v-if="store.state.auth.profile?.is_superuser"
|
||||
:to="store.getters['instance/absoluteUrl'](`/api/admin/music/album/${object.id}`)"
|
||||
icon="bi-wrench"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<i class="wrench icon" />
|
||||
{{ $t('components.library.AlbumDropdown.link.django') }}
|
||||
</a>
|
||||
</div>
|
||||
</button>
|
||||
{{ t('components.library.AlbumDropdown.link.django') }}
|
||||
</PopoverItem>
|
||||
</template>
|
||||
</Popover>
|
||||
</span>
|
||||
</template>
|
||||
|
|
|
@ -3,6 +3,7 @@ import type { EditObjectType } from '~/composables/moderation/useEditConfigs'
|
|||
import type { Album, Library, Actor } from '~/types'
|
||||
|
||||
import { useStore } from '~/store'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EditForm from '~/components/library/EditForm.vue'
|
||||
|
||||
|
@ -14,32 +15,29 @@ interface Props {
|
|||
|
||||
defineProps<Props>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useStore()
|
||||
const canEdit = store.state.auth.availablePermissions.library
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="ui vertical stripe segment">
|
||||
<div class="ui text container">
|
||||
<h2>
|
||||
<span v-if="canEdit">
|
||||
{{ $t('components.library.AlbumEdit.header.edit') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('components.library.AlbumEdit.header.suggest') }}
|
||||
</span>
|
||||
</h2>
|
||||
<div
|
||||
v-if="!object.is_local"
|
||||
class="ui message"
|
||||
>
|
||||
{{ $t('components.library.AlbumEdit.message.remote') }}
|
||||
</div>
|
||||
<edit-form
|
||||
v-else
|
||||
:object-type="objectType"
|
||||
:object="object"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<h2>
|
||||
<span v-if="canEdit">
|
||||
{{ t('components.library.AlbumEdit.header.edit') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ t('components.library.AlbumEdit.header.suggest') }}
|
||||
</span>
|
||||
</h2>
|
||||
<div
|
||||
v-if="!object.is_local"
|
||||
class="ui message"
|
||||
>
|
||||
{{ t('components.library.AlbumEdit.message.remote') }}
|
||||
</div>
|
||||
<edit-form
|
||||
v-else
|
||||
:object-type="objectType"
|
||||
:object="object"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
@ -1,22 +1,30 @@
|
|||
<script setup lang="ts">
|
||||
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 { OrderingField } from '~/store/ui'
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouteQuery } from '@vueuse/router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { syncRef } from '@vueuse/core'
|
||||
import { sortedUniq } from 'lodash-es'
|
||||
import { useStore } from '~/store'
|
||||
import { useModal } from '~/ui/composables/useModal.ts'
|
||||
|
||||
import axios from 'axios'
|
||||
import $ from 'jquery'
|
||||
|
||||
import TagsSelector from '~/components/library/TagsSelector.vue'
|
||||
import AlbumCard from '~/components/audio/album/Card.vue'
|
||||
import Pagination from '~/components/vui/Pagination.vue'
|
||||
import Pagination from '~/components/ui/Pagination.vue'
|
||||
import Card from '~/components/ui/Card.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 useOrdering from '~/composables/navigation/useOrdering'
|
||||
|
@ -38,13 +46,13 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
|
||||
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 query = ref(q.value)
|
||||
const query = ref(q.value ?? '')
|
||||
syncRef(q, query, { direction: 'ltr' })
|
||||
|
||||
const result = ref<BackendResponse<Album>>()
|
||||
const result = ref<PaginatedAlbumList>()
|
||||
|
||||
const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [
|
||||
['creation_date', 'creation_date'],
|
||||
|
@ -60,21 +68,22 @@ const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirectio
|
|||
const isLoading = ref(false)
|
||||
const fetchData = async () => {
|
||||
isLoading.value = true
|
||||
const params = {
|
||||
const params : operations['get_album_fetches_2']['parameters']['query'] = {
|
||||
scope: props.scope,
|
||||
page: page.value,
|
||||
page_size: paginateBy.value,
|
||||
q: query.value,
|
||||
// @ts-expect-error TODO: add strict types to useOrdering
|
||||
ordering: orderingString.value,
|
||||
playable: 'true',
|
||||
playable: true,
|
||||
tag: tags.value,
|
||||
include_channels: 'true',
|
||||
include_channels: true,
|
||||
content_category: 'music'
|
||||
}
|
||||
|
||||
const measureLoading = logger.time('Fetching albums')
|
||||
try {
|
||||
const response = await axios.get('albums/', {
|
||||
const response = await axios.get<PaginatedAlbumList>('albums/', {
|
||||
params,
|
||||
paramsSerializer: {
|
||||
indexes: null
|
||||
|
@ -93,7 +102,7 @@ const fetchData = async () => {
|
|||
|
||||
const store = useStore()
|
||||
watch(() => store.state.moderation.lastUpdate, fetchData)
|
||||
watch([page, tags, q, () => props.scope], fetchData)
|
||||
watch([page, tags, q, ordering, orderingDirection, () => props.scope], fetchData)
|
||||
fetchData()
|
||||
|
||||
const search = () => {
|
||||
|
@ -106,148 +115,165 @@ onOrderingUpdate(() => {
|
|||
fetchData()
|
||||
})
|
||||
|
||||
onMounted(() => $('.ui.dropdown').dropdown())
|
||||
|
||||
const { t } = useI18n()
|
||||
const labels = computed(() => ({
|
||||
searchPlaceholder: t('components.library.Albums.placeholder.search'),
|
||||
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>
|
||||
|
||||
<template>
|
||||
<main v-title="labels.title">
|
||||
<section class="ui vertical stripe segment">
|
||||
<h2 class="ui header">
|
||||
{{ $t('components.library.Albums.header.browse') }}
|
||||
</h2>
|
||||
<form
|
||||
:class="['ui', {'loading': isLoading}, 'form']"
|
||||
@submit.prevent="search"
|
||||
<Layout
|
||||
v-title="labels.title"
|
||||
stack
|
||||
main
|
||||
>
|
||||
<Header
|
||||
page-heading
|
||||
:h1="t('components.library.Albums.header.browse')"
|
||||
/>
|
||||
<Layout
|
||||
form
|
||||
flex
|
||||
:class="['ui', {'loading': isLoading}, 'form']"
|
||||
@submit.prevent="search"
|
||||
>
|
||||
<Input
|
||||
id="album-search"
|
||||
v-model="query"
|
||||
search
|
||||
name="search"
|
||||
:label="t('components.library.Albums.label.search')"
|
||||
autofocus
|
||||
: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"
|
||||
>
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<label for="albums-search">
|
||||
{{ $t('components.library.Albums.label.search') }}
|
||||
</label>
|
||||
<div class="ui action input">
|
||||
<input
|
||||
id="albums-search"
|
||||
v-model="query"
|
||||
type="text"
|
||||
name="search"
|
||||
:placeholder="labels.searchPlaceholder"
|
||||
>
|
||||
<button
|
||||
class="ui icon button"
|
||||
type="submit"
|
||||
: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
|
||||
id="album-ordering"
|
||||
v-model="ordering"
|
||||
class="ui dropdown"
|
||||
>
|
||||
<option
|
||||
v-for="(option, key) in orderingOptions"
|
||||
:key="key"
|
||||
:value="option[0]"
|
||||
>
|
||||
{{ sharedLabels.filters[option[1]] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="album-ordering-direction">{{ $t('components.library.Albums.ordering.direction.label') }}</label>
|
||||
<select
|
||||
id="album-ordering-direction"
|
||||
v-model="orderingDirection"
|
||||
class="ui dropdown"
|
||||
>
|
||||
<option value="+">
|
||||
{{ $t('components.library.Albums.ordering.direction.ascending') }}
|
||||
</option>
|
||||
<option value="-">
|
||||
{{ $t('components.library.Albums.ordering.direction.descending') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="album-results">{{ $t('components.library.Albums.pagination.results') }}</label>
|
||||
<select
|
||||
id="album-results"
|
||||
v-model="paginateBy"
|
||||
class="ui dropdown"
|
||||
>
|
||||
<option
|
||||
v-for="opt in paginateOptions"
|
||||
:key="opt"
|
||||
:value="opt"
|
||||
>
|
||||
{{ opt }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="ui hidden divider" />
|
||||
<div
|
||||
v-if="result"
|
||||
transition-duration="0"
|
||||
item-selector=".column"
|
||||
percent-position="true"
|
||||
stagger="0"
|
||||
class=""
|
||||
>
|
||||
<div
|
||||
v-if="result.results.length > 0"
|
||||
class="ui app-cards cards"
|
||||
<span class="label">
|
||||
{{ t('components.library.Albums.ordering.label') }}
|
||||
</span>
|
||||
<select
|
||||
id="album-ordering"
|
||||
v-model="ordering"
|
||||
class="dropdown"
|
||||
>
|
||||
<album-card
|
||||
v-for="album in result.results"
|
||||
:key="album.id"
|
||||
:album="album"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="ui placeholder segment sixteen wide column"
|
||||
style="text-align: center; display: flex; align-items: center"
|
||||
>
|
||||
<div class="ui icon header">
|
||||
<i class="compact disc icon" />
|
||||
{{ $t('components.library.Albums.empty.noResults') }}
|
||||
</div>
|
||||
<router-link
|
||||
v-if="$store.state.auth.authenticated"
|
||||
:to="{name: 'content.index'}"
|
||||
class="ui success button labeled icon"
|
||||
<option
|
||||
v-for="(option, key) in orderingOptions"
|
||||
:key="key"
|
||||
:value="option[0]"
|
||||
>
|
||||
<i class="upload icon" />
|
||||
{{ $t('components.library.Albums.link.addMusic') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</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>
|
||||
</section>
|
||||
</main>
|
||||
{{ sharedLabels.filters[option[1]] }}
|
||||
</option>
|
||||
</select>
|
||||
</Layout>
|
||||
<Layout
|
||||
stack
|
||||
no-gap
|
||||
label
|
||||
for="album-ordering-direction"
|
||||
>
|
||||
<span class="label">
|
||||
{{ t('components.library.Albums.ordering.direction.label') }}
|
||||
</span>
|
||||
<select
|
||||
id="album-ordering-direction"
|
||||
v-model="orderingDirection"
|
||||
class="dropdown"
|
||||
>
|
||||
<option value="+">
|
||||
{{ t('components.library.Albums.ordering.direction.ascending') }}
|
||||
</option>
|
||||
<option value="-">
|
||||
{{ t('components.library.Albums.ordering.direction.descending') }}
|
||||
</option>
|
||||
</select>
|
||||
</Layout>
|
||||
<Layout
|
||||
stack
|
||||
no-gap
|
||||
label
|
||||
for="album-results"
|
||||
>
|
||||
<span class="label">
|
||||
{{ t('components.library.Albums.pagination.results') }}
|
||||
</span>
|
||||
<select
|
||||
id="album-results"
|
||||
v-model="paginateBy"
|
||||
class="dropdown"
|
||||
>
|
||||
<option
|
||||
v-for="opt in paginateOptions"
|
||||
:key="opt"
|
||||
:value="opt"
|
||||
>
|
||||
{{ opt }}
|
||||
</option>
|
||||
</select>
|
||||
</Layout>
|
||||
</Layout>
|
||||
<Loader v-if="isLoading" />
|
||||
<Pagination
|
||||
v-if="page && result && result.count > paginateBy"
|
||||
v-model:page="page"
|
||||
:pages="Math.ceil((result.count || 0)/paginateBy)"
|
||||
/>
|
||||
<Layout
|
||||
v-if="result && result.results.length > 0"
|
||||
grid
|
||||
style="display:flex; flex-wrap:wrap; gap: 32px; margin-top:32px;"
|
||||
>
|
||||
<AlbumCard
|
||||
v-for="album in result.results"
|
||||
:key="album.id"
|
||||
:album="album"
|
||||
/>
|
||||
</Layout>
|
||||
<Layout
|
||||
v-else-if="result && result.results.length === 0"
|
||||
stack
|
||||
>
|
||||
<Alert blue>
|
||||
<i class="bi bi-disc" />
|
||||
{{ t('components.library.Albums.empty.noResults') }}
|
||||
</Alert>
|
||||
<Card
|
||||
v-if="store.state.auth.authenticated"
|
||||
:title="t('components.library.Albums.link.addMusic')"
|
||||
solid
|
||||
small
|
||||
primary
|
||||
style="text-align: center;"
|
||||
:to="useModal('upload').to"
|
||||
>
|
||||
<template #image>
|
||||
<i
|
||||
class="bi bi-upload"
|
||||
style="font-size: 100px; position: relative; top: 50px;"
|
||||
/>
|
||||
</template>
|
||||
</Card>
|
||||
</Layout>
|
||||
<Spacer grow />
|
||||
<Pagination
|
||||
v-if="page && result && result.count > paginateBy"
|
||||
v-model:page="page"
|
||||
:pages="Math.ceil((result.count || 0)/paginateBy)"
|
||||
/>
|
||||
</Layout>
|
||||
</template>
|
||||
|
|
|
@ -1,25 +1,36 @@
|
|||
<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 { 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 { useStore } from '~/store'
|
||||
|
||||
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 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 {
|
||||
id: number
|
||||
id: number | string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
@ -36,26 +47,50 @@ const nextTracksUrl = ref(null)
|
|||
const totalAlbums = ref(0)
|
||||
const totalTracks = ref(0)
|
||||
|
||||
const dropdown = ref()
|
||||
|
||||
const logger = useLogger()
|
||||
const store = useStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
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 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 publicLibraries = computed(() => libraries.value?.filter(library => library.privacy_level === 'everyone') ?? [])
|
||||
const cover = computed(() => object.value?.cover?.urls.original
|
||||
? object.value.cover
|
||||
: object.value?.albums.find(album => album.cover?.urls.original)?.cover
|
||||
)
|
||||
const headerStyle = computed(() => cover.value?.urls.original
|
||||
? { backgroundImage: `url(${store.getters['instance/absoluteUrl'](cover.value.urls.original)})` }
|
||||
: ''
|
||||
)
|
||||
|
||||
// TODO: This is cover logic. We use it a lot. Should all go into a single, smart, parametrised function.
|
||||
// Something like `useCover.ts`!
|
||||
const cover = computed(() => {
|
||||
const artistCover = object.value?.cover
|
||||
|
||||
// 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 labels = computed(() => ({
|
||||
|
@ -91,196 +126,249 @@ const fetchData = async () => {
|
|||
isLoading.value = false
|
||||
}
|
||||
|
||||
const totalDuration = computed(() => sum((tracks.value ?? []).map(track => track.uploads[0]?.duration ?? 0)))
|
||||
|
||||
watch(() => props.id, fetchData, { immediate: true })
|
||||
|
||||
const isOpen = useModal('artist-description').isOpen
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main v-title="labels.title">
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="ui vertical segment"
|
||||
>
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" />
|
||||
</div>
|
||||
<template v-if="object && !isLoading">
|
||||
<section
|
||||
v-title="object.name"
|
||||
:class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
|
||||
:style="headerStyle"
|
||||
<Loader v-if="isLoading" />
|
||||
<Header
|
||||
v-if="object && !isLoading"
|
||||
v-title="labels.title"
|
||||
:h1="object.name"
|
||||
page-heading
|
||||
>
|
||||
<template #image>
|
||||
<img
|
||||
v-lazy="cover.urls.large_square_crop"
|
||||
:alt="object.name"
|
||||
class="channel-image"
|
||||
>
|
||||
<div class="segment-content">
|
||||
<h2 class="ui center aligned icon header">
|
||||
<i class="circular inverted users violet icon" />
|
||||
<div class="content">
|
||||
{{ object.name }}
|
||||
<div
|
||||
v-if="albums"
|
||||
class="sub header"
|
||||
>
|
||||
{{ $t('components.library.ArtistBase.meta.tracks', totalTracks) }}
|
||||
{{ $t('components.library.ArtistBase.meta.albums', totalAlbums) }}
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
<tags-list
|
||||
v-if="object.tags && object.tags.length > 0"
|
||||
:tags="object.tags"
|
||||
/>
|
||||
<div class="ui hidden divider" />
|
||||
<div class="header-buttons">
|
||||
<div class="ui buttons">
|
||||
<radio-button
|
||||
type="artist"
|
||||
:object-id="object.id"
|
||||
/>
|
||||
</div>
|
||||
<div class="ui buttons">
|
||||
<play-button
|
||||
:is-playable="isPlayable"
|
||||
class="vibrant"
|
||||
:artist="object"
|
||||
>
|
||||
{{ $t('components.library.ArtistBase.button.play') }}
|
||||
</play-button>
|
||||
</div>
|
||||
|
||||
<semantic-modal
|
||||
v-if="publicLibraries.length > 0"
|
||||
v-model:show="showEmbedModal"
|
||||
>
|
||||
<h4 class="header">
|
||||
{{ $t('components.library.ArtistBase.modal.embed.header') }}
|
||||
</h4>
|
||||
<div class="scrolling content">
|
||||
<div class="description">
|
||||
<embed-wizard
|
||||
:id="object.id"
|
||||
type="artist"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="ui deny button">
|
||||
{{ $t('components.library.ArtistBase.button.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</semantic-modal>
|
||||
<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
|
||||
:key="$route.fullPath"
|
||||
:tracks="tracks"
|
||||
:next-tracks-url="nextTracksUrl"
|
||||
:next-albums-url="nextAlbumsUrl"
|
||||
:albums="albums"
|
||||
:is-loading-albums="isLoading"
|
||||
:object="object"
|
||||
object-type="artist"
|
||||
@libraries-loaded="libraries = $event"
|
||||
/>
|
||||
</template>
|
||||
</main>
|
||||
<Layout
|
||||
flex
|
||||
class="meta"
|
||||
no-gap
|
||||
>
|
||||
<div
|
||||
v-if="albums"
|
||||
>
|
||||
{{ t('components.library.ArtistBase.meta.tracks', totalTracks) }}
|
||||
{{ t('components.library.ArtistBase.meta.albums', totalAlbums) }}
|
||||
</div>
|
||||
<div v-if="totalDuration > 0">
|
||||
<i class="bi bi-dot" />
|
||||
<human-duration
|
||||
v-if="totalDuration > 0"
|
||||
:duration="totalDuration"
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
<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
|
||||
type="artist"
|
||||
:object-id="object.id"
|
||||
low-height
|
||||
/>
|
||||
<Spacer grow />
|
||||
<Popover>
|
||||
<template #default="{ toggleOpen }">
|
||||
<OptionsButton
|
||||
is-square-small
|
||||
@click="toggleOpen"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #items>
|
||||
<PopoverItem
|
||||
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')"
|
||||
>
|
||||
<embed-wizard
|
||||
:id="object.id"
|
||||
type="artist"
|
||||
/>
|
||||
<template #actions>
|
||||
<Button secondary>
|
||||
{{ t('components.library.ArtistBase.button.cancel') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</Header>
|
||||
<hr>
|
||||
<router-view
|
||||
:key="route.fullPath"
|
||||
:tracks="tracks"
|
||||
:next-tracks-url="nextTracksUrl"
|
||||
:next-albums-url="nextAlbumsUrl"
|
||||
:albums="albums"
|
||||
:is-loading-albums="isLoading"
|
||||
:object="object"
|
||||
object-type="artist"
|
||||
@libraries-loaded="libraries = $event"
|
||||
/>
|
||||
</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 { useStore } from '~/store'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
import LibraryWidget from '~/components/federation/LibraryWidget.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'
|
||||
|
||||
|
@ -26,6 +35,8 @@ interface Props {
|
|||
nextAlbumsUrl?: string | null
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
nextTracksUrl: null,
|
||||
|
@ -57,87 +68,82 @@ const loadMoreAlbums = async () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="object">
|
||||
<div
|
||||
<Layout
|
||||
v-if="object"
|
||||
stack
|
||||
>
|
||||
<TagsList
|
||||
v-if="object.tags && object.tags.length > 0"
|
||||
style="margin-top: -16px;"
|
||||
:tags="object.tags"
|
||||
/>
|
||||
<Alert
|
||||
v-if="contentFilter"
|
||||
class="ui small text container"
|
||||
blue
|
||||
>
|
||||
<div class="ui hidden divider" />
|
||||
<div class="ui message">
|
||||
<p>
|
||||
{{ $t('components.library.ArtistDetail.message.filter') }}
|
||||
</p>
|
||||
<router-link
|
||||
class="right floated"
|
||||
:to="{name: 'settings'}"
|
||||
>
|
||||
{{ $t('components.library.ArtistDetail.link.filter') }}
|
||||
</router-link>
|
||||
<button
|
||||
class="ui basic tiny button"
|
||||
@click="$store.dispatch('moderation/deleteContentFilter', contentFilter.uuid)"
|
||||
>
|
||||
{{ $t('components.library.ArtistDetail.button.filter') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<section
|
||||
v-if="isLoadingAlbums"
|
||||
class="ui vertical stripe segment"
|
||||
>
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" />
|
||||
</section>
|
||||
<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">
|
||||
<p>
|
||||
{{ t('components.library.ArtistDetail.message.filter') }}
|
||||
</p>
|
||||
<Link
|
||||
class="right floated"
|
||||
:to="{name: 'settings'}"
|
||||
>
|
||||
{{ t('components.library.ArtistDetail.link.filter') }}
|
||||
</Link>
|
||||
<Button
|
||||
class="tiny"
|
||||
@click="store.dispatch('moderation/deleteContentFilter', contentFilter.uuid)"
|
||||
>
|
||||
{{ t('components.library.ArtistDetail.button.filter') }}
|
||||
</Button>
|
||||
</Alert>
|
||||
<Loader v-if="isLoadingAlbums" />
|
||||
<template v-else-if="albums && albums.length > 0">
|
||||
<Heading
|
||||
:h2="t('components.library.ArtistDetail.header.album')"
|
||||
section-heading
|
||||
/>
|
||||
<Layout flex>
|
||||
<album-card
|
||||
v-for="album in allAlbums"
|
||||
:key="album.id"
|
||||
:album="album"
|
||||
/>
|
||||
</div>
|
||||
<div class="ui hidden divider" />
|
||||
<button
|
||||
v-if="loadMoreAlbumsUrl !== null"
|
||||
:class="['ui', {loading: isLoadingMoreAlbums}, 'button']"
|
||||
@click="loadMoreAlbums()"
|
||||
>
|
||||
{{ $t('components.library.ArtistDetail.button.more') }}
|
||||
</button>
|
||||
</section>
|
||||
<section
|
||||
v-if="tracks.length > 0"
|
||||
class="ui vertical stripe segment"
|
||||
>
|
||||
<track-table
|
||||
<Spacer
|
||||
h
|
||||
grow
|
||||
/>
|
||||
<Button
|
||||
v-if="loadMoreAlbumsUrl !== null"
|
||||
primary
|
||||
:is-loading="isLoadingMoreAlbums"
|
||||
@click="loadMoreAlbums()"
|
||||
>
|
||||
{{ t('components.library.ArtistDetail.button.more') }}
|
||||
</Button>
|
||||
</Layout>
|
||||
</template>
|
||||
<template v-if="tracks.length > 0">
|
||||
<Heading
|
||||
:h2="t('components.library.ArtistDetail.header.track')"
|
||||
section-heading
|
||||
/>
|
||||
<TrackTable
|
||||
:is-artist="true"
|
||||
:show-position="false"
|
||||
:track-only="true"
|
||||
:tracks="tracks.slice(0,5)"
|
||||
>
|
||||
<template #header>
|
||||
<h2>
|
||||
{{ $t('components.library.ArtistDetail.header.track') }}
|
||||
</h2>
|
||||
<div class="ui hidden divider" />
|
||||
</template>
|
||||
</track-table>
|
||||
</section>
|
||||
<section class="ui vertical stripe segment">
|
||||
<h2>
|
||||
{{ $t('components.library.ArtistDetail.header.library') }}
|
||||
</h2>
|
||||
<library-widget
|
||||
:url="'artists/' + object.id + '/libraries/'"
|
||||
@loaded="emit('libraries-loaded', $event)"
|
||||
>
|
||||
{{ $t('components.library.ArtistDetail.description.library') }}
|
||||
</library-widget>
|
||||
</section>
|
||||
</div>
|
||||
/>
|
||||
</template>
|
||||
<Heading
|
||||
:h2="t('components.library.ArtistDetail.header.library')"
|
||||
section-heading
|
||||
/>
|
||||
<LibraryWidget
|
||||
:url="'artists/' + object.id + '/libraries/'"
|
||||
@loaded="emit('libraries-loaded', $event)"
|
||||
>
|
||||
{{ t('components.library.ArtistDetail.description.library') }}
|
||||
</LibraryWidget>
|
||||
</Layout>
|
||||
</template>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue