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:
jon r 2025-04-18 11:08:13 +02:00
parent aa79610a22
commit ae6ac1f624
274 changed files with 31600 additions and 15802 deletions

View File

@ -0,0 +1 @@
Improve visuals & layout (#2091)

View File

@ -5,8 +5,16 @@ import { get } from 'lodash-es'
import { humanSize } from '~/utils/filters' import { humanSize } from '~/utils/filters'
import { computed } from 'vue' import { computed } from 'vue'
import type { components } from '~/generated/types.ts'
import SignupForm from '~/components/auth/SignupForm.vue' import SignupForm from '~/components/auth/SignupForm.vue'
import LogoText from '~/components/LogoText.vue' import LogoText from '~/components/LogoText.vue'
import useMarkdown from '~/composables/useMarkdown'
import Link from '~/components/ui/Link.vue'
import Card from '~/components/ui/Card.vue'
import Button from '~/components/ui/Button.vue'
import Layout from '~/components/ui/Layout.vue'
const store = useStore() const store = useStore()
const nodeinfo = computed(() => store.state.instance.nodeinfo) const nodeinfo = computed(() => store.state.instance.nodeinfo)
@ -16,7 +24,8 @@ const labels = computed(() => ({
title: t('components.About.title') title: t('components.About.title')
})) }))
const podName = computed(() => get(nodeinfo.value, 'metadata.nodeName') ?? 'Funkwhale') const podName = computed(() => (n => n === '' ? 'No name' : n ?? 'Funkwhale')(get(nodeinfo.value, 'metadata.nodeName')))
const banner = computed(() => get(nodeinfo.value, 'metadata.banner')) const banner = computed(() => get(nodeinfo.value, 'metadata.banner'))
const shortDescription = computed(() => get(nodeinfo.value, 'metadata.shortDescription')) const shortDescription = computed(() => get(nodeinfo.value, 'metadata.shortDescription'))
@ -28,10 +37,22 @@ const stats = computed(() => {
return null return null
} }
return { users, hours } const info = nodeinfo.value ?? {} as components['schemas']['NodeInfo21']
const data = {
users: info.usage.users.activeMonth || null,
hours: info.metadata.content.local.hoursOfContent || null,
artists: info.metadata.content.local.artists || null,
albums: info.metadata.content.local.releases || null,
tracks: info.metadata.content.local.recordings || null,
listenings: info.metadata.usage?.listenings.total || null
}
return { users, hours, data }
}) })
const openRegistrations = computed(() => get(nodeinfo.value, 'openRegistrations')) const openRegistrations = computed(() => get(nodeinfo.value, 'openRegistrations'))
const defaultUploadQuota = computed(() => humanSize(get(nodeinfo.value, 'metadata.defaultUploadQuota', 0) * 1000 * 1000)) const defaultUploadQuota = computed(() => humanSize(get(nodeinfo.value, 'metadata.defaultUploadQuota', 0) * 1000 * 1000))
const headerStyle = computed(() => { const headerStyle = computed(() => {
@ -43,64 +64,76 @@ const headerStyle = computed(() => {
backgroundImage: `url(${store.getters['instance/absoluteUrl'](banner.value)})` backgroundImage: `url(${store.getters['instance/absoluteUrl'](banner.value)})`
} }
}) })
const longDescription = useMarkdown(() => get(nodeinfo.value, 'metadata.longDescription', ''))
const rules = useMarkdown(() => get(nodeinfo.value, 'metadata.rules', ''))
const terms = useMarkdown(() => get(nodeinfo.value, 'metadata.terms', ''))
const contactEmail = computed(() => get(nodeinfo.value, 'metadata.contactEmail'))
const anonymousCanListen = computed(() => {
const features = get(nodeinfo.value, 'metadata.metadata.feature', []) as string[]
const hasAnonymousCanListen = features.includes('anonymousCanListen')
return hasAnonymousCanListen
})
const allowListEnabled = computed(() => get(nodeinfo.value, 'metadata.allowList.enabled'))
const version = computed(() => get(nodeinfo.value, 'software.version'))
const federationEnabled = computed(() => {
const features = get(nodeinfo.value, 'metadata.metadata.feature', []) as string[]
const hasAnonymousCanListen = features.includes('federation')
return hasAnonymousCanListen
})
</script> </script>
<template> <template>
<main <Layout
v-title="labels.title" v-title="labels.title"
class="main pusher page-about" stack
main
style="align-items: center;"
>
<!-- About funkwhale -->
<Link
to="/"
width="full"
align-text="stretch"
style="width:min(480px, 100%)"
> >
<div class="ui container">
<div class="ui horizontally fitted basic stripe segment">
<div class="ui horizontally fitted basic very padded segment">
<div class="ui center aligned text container">
<div class="ui text container">
<div class="ui equal width compact stackable grid">
<div class="column" />
<div class="ten wide column">
<div class="ui vertically fitted basic segment">
<router-link to="/">
<logo-text /> <logo-text />
</router-link> </Link>
</div>
</div>
<div class="column" />
</div>
<h2 class="header"> <h2 class="header">
{{ $t('components.About.header.funkwhale') }} {{ t('components.About.header.funkwhale') }}
</h2> </h2>
<p> <p>
{{ $t('components.About.description.funkwhale') }} {{ t('components.About.description.funkwhale') }}
</p> </p>
</div>
</div> <Layout
</div> flex
<div class="ui hidden divider" /> style="justify-content: center;"
<div class="ui vertically fitted basic stripe segment"> >
<div class="ui two stackable cards"> <Card
<div class="ui card"> v-if="!store.state.auth.authenticated"
<div :title="t('components.About.header.signup')"
v-if="!$store.state.auth.authenticated" width="256px"
class="signup-form content"
> >
<h3 class="header">
{{ $t('components.About.header.signup') }}
</h3>
<template v-if="openRegistrations"> <template v-if="openRegistrations">
<p> <p>
{{ $t('components.About.description.signup') }} {{ t('components.About.description.signup') }}
</p> </p>
<p v-if="defaultUploadQuota"> <p v-if="defaultUploadQuota">
{{ $t('components.About.description.quota', {quota: defaultUploadQuota}) }} {{ t('components.About.description.quota', {quota: defaultUploadQuota}) }}
</p> </p>
<signup-form <signup-form
button-classes="success" button-classes="success"
:show-login="false" :show-login="true"
/> />
</template> </template>
<div v-else> <div v-else>
<p> <p>
{{ $t('components.About.help.closedRegistrations') }} {{ t('components.About.help.closedRegistrations') }}
</p> </p>
<a <a
@ -108,29 +141,143 @@ const headerStyle = computed(() => {
rel="noopener" rel="noopener"
href="https://funkwhale.audio/#get-started" href="https://funkwhale.audio/#get-started"
> >
{{ $t('components.About.link.findOtherPod') }} {{ t('components.About.link.findOtherPod') }}
&nbsp;<i class="external alternate icon" /> &nbsp;<i class="external alternate icon" />
</a> </a>
</div> </div>
</div>
<div <div
v-else v-if="!(store.state.auth.authenticated || openRegistrations)"
class="signup-form content" class="signup-form content"
> >
<h3 class="header"> <h3 class="header">
{{ $t('components.About.header.signup') }} {{ t('components.About.header.signup') }}
<div class="ui positive message"> <div class="ui positive message">
<div class="header"> <div class="header">
{{ $t('components.About.message.loggedIn') }} {{ t('components.About.message.loggedIn') }}
</div> </div>
<p> <p>
{{ $t('components.About.message.greeting', {username: $store.state.auth.username}) }} {{ t('components.About.message.greeting', {username: store.state.auth.username}) }}
</p> </p>
</div> </div>
</h3> </h3>
</div> </div>
</Card>
<Card
v-else
:title="t('components.About.message.greeting', {username: store.state.auth.username})"
width="256px"
>
<p v-if="defaultUploadQuota">
{{ t('components.About.description.quota', {quota: defaultUploadQuota}) }}
</p>
<template #action>
<Button
full
disabled
>
{{ t('components.About.message.loggedIn') }}
</Button>
</template>
</Card>
<Card
:title="podName"
width="256px"
>
<section
:class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
:style="headerStyle"
>
<h1>
<i class="music icon" />
</h1>
</section>
<div class="content pod-description">
<h3
id="description"
class="ui header"
>
{{ t('components.About.header.aboutPod') }}
</h3>
<div
v-if="shortDescription"
class="sub header"
>
{{ shortDescription }}
</div> </div>
<div class="ui card"> <p v-else>
{{ t('components.About.placeholder.noDescription') }}
</p>
<template v-if="stats">
<div class="statistics-container ui doubling grid">
<div class="two column row">
<div class="column">
<span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.users?.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br>
{{ stats.users ? t('components.About.stat.activeUsers', stats.users) : "" }}
</span>
</div>
<div class="column">
<span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.hours ? stats.hours.toLocaleString(store.state.ui.momentLocale) : "" }}</strong></span>
<br>
{{ stats.hours ? t('components.About.stat.hoursOfMusic', stats.hours) : "" }}
</span>
</div>
</div>
</div>
</template>
</div>
<template #action>
<Link
align-text="center"
to="/about/pod"
>
{{ t('components.About.link.learnMore') }}
</Link>
</template>
</Card>
</Layout>
<Layout
flex
style="justify-content: center;"
>
<Card
width="256px"
to="/"
:title="t('components.About.header.publicContent')"
icon="bi-box-arrow-up-right"
>
<!-- TODO: Link to Explore page? -->
{{ t('components.About.description.publicContent') }}
</Card>
<Card
width="256px"
:title="t('components.About.link.findOtherPod')"
to="https://funkwhale.audio/#get-started"
icon="bi-box-arrow-up-right"
>
{{ t('components.About.description.publicContent') }}
</Card>
<Card
width="256px"
:title="t('components.About.header.findApp')"
to="https://funkwhale.audio/apps"
icon="bi-box-arrow-up-right"
>
{{ t('components.About.description.findApp') }}
</Card>
</Layout>
<section <section
:class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
:style="headerStyle" :style="headerStyle"
@ -140,122 +287,331 @@ const headerStyle = computed(() => {
{{ podName }} {{ podName }}
</h1> </h1>
</section> </section>
<div class="content pod-description">
<h3 <!-- About Pod -->
id="description" <div class="about-pod-info-container">
<div class="about-pod-info-toc">
<div class="ui vertical pointing secondary menu">
<router-link
to="/about/pod"
class="item"
>
{{ t('components.AboutPod.link.about') }}
</router-link>
<router-link
to="/about/pod#rules"
class="item"
>
{{ t('components.AboutPod.link.rules') }}
</router-link>
<router-link
to="/about/pod#terms"
class="item"
>
{{ t('components.AboutPod.link.terms') }}
</router-link>
<router-link
to="/about/pod#features"
class="item"
>
{{ t('components.AboutPod.link.features') }}
</router-link>
<router-link
v-if="stats"
to="/about/pod#statistics"
class="item"
>
{{ t('components.AboutPod.link.statistics') }}
</router-link>
</div>
</div>
<div class="about-pod-info">
<h2
id="description about-this-pod"
class="ui header" class="ui header"
> >
{{ $t('components.About.header.aboutPod') }} {{ t('components.AboutPod.header.about') }}
</h3> </h2>
<div <sanitized-html
v-if="shortDescription" v-if="longDescription"
class="sub header" :html="longDescription"
> />
{{ shortDescription }}
</div>
<p v-else> <p v-else>
{{ $t('components.About.placeholder.noDescription') }} {{ t('components.AboutPod.placeholder.noDescription') }}
</p> </p>
<h3
id="rules"
class="ui header"
>
{{ t('components.AboutPod.header.rules') }}
</h3>
<sanitized-html
v-if="rules"
:html="rules"
/>
<p v-else>
{{ t('components.AboutPod.placeholder.noRules') }}
</p>
<h3
id="terms"
class="ui header"
>
{{ t('components.AboutPod.header.terms') }}
</h3>
<sanitized-html
v-if="terms"
:html="terms"
/>
<p v-else>
{{ t('components.AboutPod.placeholder.noTerms') }}
</p>
<h3
id="features"
class="header"
>
{{ t('components.AboutPod.header.features') }}
</h3>
<div class="features-container ui two column stackable grid">
<div class="column">
<table class="ui very basic table unstackable">
<tbody>
<tr>
<td>
{{ t('components.AboutPod.feature.version') }}
</td>
<td
v-if="version"
class="right aligned"
>
<span class="features-status ui text">
{{ version }}
</span>
</td>
<td
v-else
class="right aligned"
>
<span class="features-status ui text">
{{ t('components.AboutPod.notApplicable') }}
</span>
</td>
</tr>
<tr>
<td>
{{ t('components.AboutPod.feature.federation') }}
</td>
<td
v-if="federationEnabled"
class="right aligned"
>
<span class="features-status ui text">
<i class="check icon" />
{{ t('components.AboutPod.feature.status.enabled') }}
</span>
</td>
<td
v-else
class="right aligned"
>
<span class="features-status ui text">
<i class="x icon" />
{{ t('components.AboutPod.feature.status.disabled') }}
</span>
</td>
</tr>
<tr>
<td>
{{ t('components.AboutPod.feature.allowList') }}
</td>
<td
v-if="allowListEnabled"
class="right aligned"
>
<span class="features-status ui text">
<i class="check icon" />
{{ t('components.AboutPod.feature.status.enabled') }}
</span>
</td>
<td
v-else
class="right aligned"
>
<span class="features-status ui text">
<i class="x icon" />
{{ t('components.AboutPod.feature.status.disabled') }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="column">
<table class="ui very basic table unstackable">
<tbody>
<tr>
<td>
{{ t('components.AboutPod.feature.anonymousAccess') }}
</td>
<td
v-if="anonymousCanListen"
class="right aligned"
>
<span class="features-status ui text">
<i class="check icon" />
{{ t('components.AboutPod.feature.status.enabled') }}
</span>
</td>
<td
v-else
class="right aligned"
>
<span class="features-status ui text">
<i class="x icon" />
{{ t('components.AboutPod.feature.status.disabled') }}
</span>
</td>
</tr>
<tr>
<td>
{{ t('components.AboutPod.feature.registrations') }}
</td>
<td
v-if="openRegistrations"
class="right aligned"
>
<span class="features-status ui text">
<i class="check icon" />
{{ t('components.AboutPod.feature.status.open') }}
</span>
</td>
<td
v-else
class="right aligned"
>
<span class="features-status ui text">
<i class="x icon" />
{{ t('components.AboutPod.feature.status.closed') }}
</span>
</td>
</tr>
<tr>
<td>
{{ t('components.AboutPod.feature.quota') }}
</td>
<td
v-if="defaultUploadQuota"
class="right aligned"
>
<span class="features-status ui text">
{{ defaultUploadQuota }}
</span>
</td>
<td
v-else
class="right aligned"
>
<span class="features-status ui text">
{{ t('components.AboutPod.notApplicable') }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<template v-if="stats"> <template v-if="stats">
<div class="statistics-container ui doubling grid"> <h3
<div class="two column row"> id="statistics"
<div class="column"> class="header"
>
{{ t('components.AboutPod.header.statistics') }}
</h3>
<div class="statistics-container">
<div
v-if="stats.hours"
class="statistics-statistic"
>
<span class="statistics-figure ui text"> <span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.users.toLocaleString($store.state.ui.momentLocale) }}</strong></span> <span class="ui big text"><strong>{{ stats.hours.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br> <br>
{{ $t('components.About.stat.activeUsers', stats.users) }} {{ t('components.AboutPod.stat.hoursOfMusic', stats.hours) }}
</span> </span>
</div> </div>
<div class="column"> <div
v-if="stats.data.artists"
class="statistics-statistic"
>
<span class="statistics-figure ui text"> <span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.hours.toLocaleString($store.state.ui.momentLocale) }}</strong></span> <span class="ui big text"><strong>{{ stats.data.artists.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br> <br>
{{ $t('components.About.stat.hoursOfMusic', stats.hours) }} {{ t('components.AboutPod.stat.artistsCount', stats.data.artists) }}
</span> </span>
</div> </div>
<div
v-if="stats.data.albums"
class="statistics-statistic"
>
<span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.data.albums.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br>
{{ t('components.AboutPod.stat.albumsCount', stats.data.albums) }}
</span>
</div>
<div
v-if="stats.data.tracks"
class="statistics-statistic"
>
<span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.data.tracks.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br>
{{ t('components.AboutPod.stat.tracksCount', stats.data.tracks) }}
</span>
</div>
<div
v-if="stats.users"
class="statistics-statistic"
>
<span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.users.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br>
{{ t('components.AboutPod.stat.activeUsers', stats.users) }}
</span>
</div>
<div
v-if="stats.data.listenings"
class="statistics-statistic"
>
<span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.data.listenings.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br>
{{ t('components.AboutPod.stat.listeningsCount', stats.data.listenings) }}
</span>
</div> </div>
</div> </div>
</template> </template>
<router-link <template v-if="contactEmail">
to="/about/pod"
class="ui fluid basic secondary button"
>
{{ $t('components.About.link.learnMore') }}
</router-link>
</div>
</div>
</div>
<!-- TODO (wvffle): Remove style when migrate away from fomantic -->
<div
class="ui three stackable cards"
style="z-index: 1; position: relative;"
>
<router-link
to="/"
class="ui card"
>
<div class="content">
<h3 <h3
id="description" id="contact"
class="ui header" class="ui header"
> >
{{ $t('components.About.header.publicContent') }} {{ t('components.AboutPod.header.contact') }}
</h3> </h3>
<p>
{{ $t('components.About.description.publicContent') }}
</p>
</div>
</router-link>
<a <a
href="https://funkwhale.audio/#get-started" v-if="contactEmail"
class="ui card" :href="`mailto:${contactEmail}`"
target="_blank"
> >
<div class="content"> {{ t('components.AboutPod.message.contact', { contactEmail }) }}
<h3
id="description"
class="ui header"
>
{{ $t('components.About.link.findOtherPod') }}
&nbsp;<i class="external alternate icon" />
</h3>
<p>
{{ $t('components.About.description.publicContent') }}
</p>
</div>
</a> </a>
<a </template>
href="https://funkwhale.audio/apps"
class="ui card" <div class="ui hidden divider" />
target="_blank" </div>
> </div>
<div class="content"> </Layout>
<h3
id="description"
class="ui header"
>
{{ $t('components.About.header.findApp') }}
&nbsp;<i class="external alternate icon" />
</h3>
<p>
{{ $t('components.About.description.findApp') }}
</p>
</div>
</a>
</div>
<div class="ui fluid horizontally fitted basic clearing segment container">
<router-link
to="/about/pod"
class="ui right floated basic secondary button"
>
{{ $t('components.About.header.aboutPod') }}
<i class="icon arrow right" />
</router-link>
</div>
</div>
</div>
</div>
</main>
</template> </template>

View File

@ -5,7 +5,6 @@ import { get } from 'lodash-es'
import { computed } from 'vue' import { computed } from 'vue'
import useMarkdown from '~/composables/useMarkdown' import useMarkdown from '~/composables/useMarkdown'
import type { NodeInfo } from '~/store/instance'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const store = useStore() const store = useStore()
@ -39,24 +38,14 @@ const federationEnabled = computed(() => {
const onDesktop = computed(() => window.innerWidth > 800) const onDesktop = computed(() => window.innerWidth > 800)
const stats = computed(() => { const stats = computed(() => ({
const info = nodeinfo.value ?? {} as NodeInfo users: nodeinfo.value?.usage.users.activeMonth,
hours: nodeinfo.value?.metadata.content.local.hoursOfContent,
const data = { artists: nodeinfo.value?.metadata.content.local.artists,
users: get(info, 'usage.users.activeMonth', null), albums: nodeinfo.value?.metadata.content.local.releases, // TODO: Check where to get 'metadata.content.local.albums.total'
hours: get(info, 'metadata.content.local.hoursOfContent', null), tracks: nodeinfo.value?.metadata.content.local.recordings, // TODO: 'metadata.content.local.tracks.total'
artists: get(info, 'metadata.content.local.artists.total', null), listenings: nodeinfo.value?.metadata.usage?.listenings.total
albums: get(info, 'metadata.content.local.albums.total', null), }))
tracks: get(info, 'metadata.content.local.tracks.total', null),
listenings: get(info, 'metadata.usage.listenings.total', null)
}
if (data.users === null || data.artists === null) {
return data
}
return data
})
const headerStyle = computed(() => { const headerStyle = computed(() => {
if (!banner.value) { if (!banner.value) {
@ -72,7 +61,7 @@ const headerStyle = computed(() => {
<template> <template>
<main <main
v-title="labels.title" v-title="labels.title"
class="main pusher page-about" class="main page-about"
> >
<div <div
class="ui" class="ui"
@ -99,32 +88,32 @@ const headerStyle = computed(() => {
to="/about/pod" to="/about/pod"
class="item" class="item"
> >
{{ $t('components.AboutPod.link.about') }} {{ t('components.AboutPod.link.about') }}
</router-link> </router-link>
<router-link <router-link
to="/about/pod#rules" to="/about/pod#rules"
class="item" class="item"
> >
{{ $t('components.AboutPod.link.rules') }} {{ t('components.AboutPod.link.rules') }}
</router-link> </router-link>
<router-link <router-link
to="/about/pod#terms" to="/about/pod#terms"
class="item" class="item"
> >
{{ $t('components.AboutPod.link.terms') }} {{ t('components.AboutPod.link.terms') }}
</router-link> </router-link>
<router-link <router-link
to="/about/pod#features" to="/about/pod#features"
class="item" class="item"
> >
{{ $t('components.AboutPod.link.features') }} {{ t('components.AboutPod.link.features') }}
</router-link> </router-link>
<router-link <router-link
v-if="stats" v-if="stats"
to="/about/pod#statistics" to="/about/pod#statistics"
class="item" class="item"
> >
{{ $t('components.AboutPod.link.statistics') }} {{ t('components.AboutPod.link.statistics') }}
</router-link> </router-link>
</div> </div>
</div> </div>
@ -134,49 +123,49 @@ const headerStyle = computed(() => {
id="description about-this-pod" id="description about-this-pod"
class="ui header" class="ui header"
> >
{{ $t('components.AboutPod.header.about') }} {{ t('components.AboutPod.header.about') }}
</h2> </h2>
<sanitized-html <sanitized-html
v-if="longDescription" v-if="longDescription"
:html="longDescription" :html="longDescription"
/> />
<p v-else> <p v-else>
{{ $t('components.AboutPod.placeholder.noDescription') }} {{ t('components.AboutPod.placeholder.noDescription') }}
</p> </p>
<h3 <h3
id="rules" id="rules"
class="ui header" class="ui header"
> >
{{ $t('components.AboutPod.header.rules') }} {{ t('components.AboutPod.header.rules') }}
</h3> </h3>
<sanitized-html <sanitized-html
v-if="rules" v-if="rules"
:html="rules" :html="rules"
/> />
<p v-else> <p v-else>
{{ $t('components.AboutPod.placeholder.noRules') }} {{ t('components.AboutPod.placeholder.noRules') }}
</p> </p>
<h3 <h3
id="terms" id="terms"
class="ui header" class="ui header"
> >
{{ $t('components.AboutPod.header.terms') }} {{ t('components.AboutPod.header.terms') }}
</h3> </h3>
<sanitized-html <sanitized-html
v-if="terms" v-if="terms"
:html="terms" :html="terms"
/> />
<p v-else> <p v-else>
{{ $t('components.AboutPod.placeholder.noTerms') }} {{ t('components.AboutPod.placeholder.noTerms') }}
</p> </p>
<h3 <h3
id="features" id="features"
class="header" class="header"
> >
{{ $t('components.AboutPod.header.features') }} {{ t('components.AboutPod.header.features') }}
</h3> </h3>
<div class="features-container ui two column stackable grid"> <div class="features-container ui two column stackable grid">
<div class="column"> <div class="column">
@ -184,7 +173,7 @@ const headerStyle = computed(() => {
<tbody> <tbody>
<tr> <tr>
<td> <td>
{{ $t('components.AboutPod.feature.version') }} {{ t('components.AboutPod.feature.version') }}
</td> </td>
<td <td
v-if="version" v-if="version"
@ -199,13 +188,13 @@ const headerStyle = computed(() => {
class="right aligned" class="right aligned"
> >
<span class="features-status ui text"> <span class="features-status ui text">
{{ $t('components.AboutPod.notApplicable') }} {{ t('components.AboutPod.notApplicable') }}
</span> </span>
</td> </td>
</tr> </tr>
<tr> <tr>
<td> <td>
{{ $t('components.AboutPod.feature.federation') }} {{ t('components.AboutPod.feature.federation') }}
</td> </td>
<td <td
v-if="federationEnabled" v-if="federationEnabled"
@ -213,7 +202,7 @@ const headerStyle = computed(() => {
> >
<span class="features-status ui text"> <span class="features-status ui text">
<i class="check icon" /> <i class="check icon" />
{{ $t('components.AboutPod.feature.status.enabled') }} {{ t('components.AboutPod.feature.status.enabled') }}
</span> </span>
</td> </td>
<td <td
@ -222,13 +211,13 @@ const headerStyle = computed(() => {
> >
<span class="features-status ui text"> <span class="features-status ui text">
<i class="x icon" /> <i class="x icon" />
{{ $t('components.AboutPod.feature.status.disabled') }} {{ t('components.AboutPod.feature.status.disabled') }}
</span> </span>
</td> </td>
</tr> </tr>
<tr> <tr>
<td> <td>
{{ $t('components.AboutPod.feature.allowList') }} {{ t('components.AboutPod.feature.allowList') }}
</td> </td>
<td <td
v-if="allowListEnabled" v-if="allowListEnabled"
@ -236,7 +225,7 @@ const headerStyle = computed(() => {
> >
<span class="features-status ui text"> <span class="features-status ui text">
<i class="check icon" /> <i class="check icon" />
{{ $t('components.AboutPod.feature.status.enabled') }} {{ t('components.AboutPod.feature.status.enabled') }}
</span> </span>
</td> </td>
<td <td
@ -245,7 +234,7 @@ const headerStyle = computed(() => {
> >
<span class="features-status ui text"> <span class="features-status ui text">
<i class="x icon" /> <i class="x icon" />
{{ $t('components.AboutPod.feature.status.disabled') }} {{ t('components.AboutPod.feature.status.disabled') }}
</span> </span>
</td> </td>
</tr> </tr>
@ -257,7 +246,7 @@ const headerStyle = computed(() => {
<tbody> <tbody>
<tr> <tr>
<td> <td>
{{ $t('components.AboutPod.feature.anonymousAccess') }} {{ t('components.AboutPod.feature.anonymousAccess') }}
</td> </td>
<td <td
v-if="anonymousCanListen" v-if="anonymousCanListen"
@ -265,7 +254,7 @@ const headerStyle = computed(() => {
> >
<span class="features-status ui text"> <span class="features-status ui text">
<i class="check icon" /> <i class="check icon" />
{{ $t('components.AboutPod.feature.status.enabled') }} {{ t('components.AboutPod.feature.status.enabled') }}
</span> </span>
</td> </td>
<td <td
@ -274,13 +263,13 @@ const headerStyle = computed(() => {
> >
<span class="features-status ui text"> <span class="features-status ui text">
<i class="x icon" /> <i class="x icon" />
{{ $t('components.AboutPod.feature.status.disabled') }} {{ t('components.AboutPod.feature.status.disabled') }}
</span> </span>
</td> </td>
</tr> </tr>
<tr> <tr>
<td> <td>
{{ $t('components.AboutPod.feature.registrations') }} {{ t('components.AboutPod.feature.registrations') }}
</td> </td>
<td <td
v-if="openRegistrations" v-if="openRegistrations"
@ -288,7 +277,7 @@ const headerStyle = computed(() => {
> >
<span class="features-status ui text"> <span class="features-status ui text">
<i class="check icon" /> <i class="check icon" />
{{ $t('components.AboutPod.feature.status.open') }} {{ t('components.AboutPod.feature.status.open') }}
</span> </span>
</td> </td>
<td <td
@ -297,13 +286,13 @@ const headerStyle = computed(() => {
> >
<span class="features-status ui text"> <span class="features-status ui text">
<i class="x icon" /> <i class="x icon" />
{{ $t('components.AboutPod.feature.status.closed') }} {{ t('components.AboutPod.feature.status.closed') }}
</span> </span>
</td> </td>
</tr> </tr>
<tr> <tr>
<td> <td>
{{ $t('components.AboutPod.feature.quota') }} {{ t('components.AboutPod.feature.quota') }}
</td> </td>
<td <td
v-if="defaultUploadQuota" v-if="defaultUploadQuota"
@ -318,7 +307,7 @@ const headerStyle = computed(() => {
class="right aligned" class="right aligned"
> >
<span class="features-status ui text"> <span class="features-status ui text">
{{ $t('components.AboutPod.notApplicable') }} {{ t('components.AboutPod.notApplicable') }}
</span> </span>
</td> </td>
</tr> </tr>
@ -332,7 +321,7 @@ const headerStyle = computed(() => {
id="statistics" id="statistics"
class="header" class="header"
> >
{{ $t('components.AboutPod.header.statistics') }} {{ t('components.AboutPod.header.statistics') }}
</h3> </h3>
<div class="statistics-container"> <div class="statistics-container">
<div <div
@ -340,9 +329,9 @@ const headerStyle = computed(() => {
class="statistics-statistic" class="statistics-statistic"
> >
<span class="statistics-figure ui text"> <span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.hours.toLocaleString($store.state.ui.momentLocale) }}</strong></span> <span class="ui big text"><strong>{{ stats.hours?.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br> <br>
{{ $t('components.AboutPod.stat.hoursOfMusic', stats.hours) }} {{ t('components.AboutPod.stat.hoursOfMusic', stats.hours) }}
</span> </span>
</div> </div>
<div <div
@ -350,9 +339,9 @@ const headerStyle = computed(() => {
class="statistics-statistic" class="statistics-statistic"
> >
<span class="statistics-figure ui text"> <span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.artists.toLocaleString($store.state.ui.momentLocale) }}</strong></span> <span class="ui big text"><strong>{{ stats.artists?.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br> <br>
{{ $t('components.AboutPod.stat.artistsCount', stats.artists) }} {{ t('components.AboutPod.stat.artistsCount', stats.artists) }}
</span> </span>
</div> </div>
<div <div
@ -360,9 +349,9 @@ const headerStyle = computed(() => {
class="statistics-statistic" class="statistics-statistic"
> >
<span class="statistics-figure ui text"> <span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.albums.toLocaleString($store.state.ui.momentLocale) }}</strong></span> <span class="ui big text"><strong>{{ stats.albums?.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br> <br>
{{ $t('components.AboutPod.stat.albumsCount', stats.albums) }} {{ t('components.AboutPod.stat.albumsCount', stats.albums) }}
</span> </span>
</div> </div>
<div <div
@ -370,9 +359,9 @@ const headerStyle = computed(() => {
class="statistics-statistic" class="statistics-statistic"
> >
<span class="statistics-figure ui text"> <span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.tracks.toLocaleString($store.state.ui.momentLocale) }}</strong></span> <span class="ui big text"><strong>{{ stats.tracks?.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br> <br>
{{ $t('components.AboutPod.stat.tracksCount', stats.tracks) }} {{ t('components.AboutPod.stat.tracksCount', stats.tracks) }}
</span> </span>
</div> </div>
<div <div
@ -380,9 +369,9 @@ const headerStyle = computed(() => {
class="statistics-statistic" class="statistics-statistic"
> >
<span class="statistics-figure ui text"> <span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.users.toLocaleString($store.state.ui.momentLocale) }}</strong></span> <span class="ui big text"><strong>{{ stats.users.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br> <br>
{{ $t('components.AboutPod.stat.activeUsers', stats.users) }} {{ t('components.AboutPod.stat.activeUsers', stats.users) }}
</span> </span>
</div> </div>
<div <div
@ -390,9 +379,9 @@ const headerStyle = computed(() => {
class="statistics-statistic" class="statistics-statistic"
> >
<span class="statistics-figure ui text"> <span class="statistics-figure ui text">
<span class="ui big text"><strong>{{ stats.listenings.toLocaleString($store.state.ui.momentLocale) }}</strong></span> <span class="ui big text"><strong>{{ stats.listenings.toLocaleString(store.state.ui.momentLocale) }}</strong></span>
<br> <br>
{{ $t('components.AboutPod.stat.listeningsCount', stats.listenings) }} {{ t('components.AboutPod.stat.listeningsCount', stats.listenings) }}
</span> </span>
</div> </div>
</div> </div>
@ -403,13 +392,13 @@ const headerStyle = computed(() => {
id="contact" id="contact"
class="ui header" class="ui header"
> >
{{ $t('components.AboutPod.header.contact') }} {{ t('components.AboutPod.header.contact') }}
</h3> </h3>
<a <a
v-if="contactEmail" v-if="contactEmail"
:href="`mailto:${contactEmail}`" :href="`mailto:${contactEmail}`"
> >
{{ $t('components.AboutPod.message.contact', { contactEmail }) }} {{ t('components.AboutPod.message.contact', { contactEmail }) }}
</a> </a>
</template> </template>
@ -420,7 +409,7 @@ const headerStyle = computed(() => {
class="ui left floated basic secondary button" class="ui left floated basic secondary button"
> >
<i class="icon arrow left" /> <i class="icon arrow left" />
{{ $t('components.AboutPod.link.introduction') }} {{ t('components.AboutPod.link.introduction') }}
</router-link> </router-link>
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { get } from 'lodash-es' import { get } from 'lodash-es'
import AlbumWidget from '~/components/audio/album/Widget.vue' import AlbumWidget from '~/components/album/Widget.vue'
import ChannelsWidget from '~/components/audio/ChannelsWidget.vue' import ChannelsWidget from '~/components/audio/ChannelsWidget.vue'
import LoginForm from '~/components/auth/LoginForm.vue' import LoginForm from '~/components/auth/LoginForm.vue'
import SignupForm from '~/components/auth/SignupForm.vue' import SignupForm from '~/components/auth/SignupForm.vue'
@ -64,7 +64,7 @@ whenever(() => store.state.auth.authenticated, () => {
<template> <template>
<main <main
v-title="labels.title" v-title="labels.title"
class="main pusher page-home" class="main page-home"
> >
<section <section
:class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
@ -73,7 +73,7 @@ whenever(() => store.state.auth.authenticated, () => {
<div class="segment-content"> <div class="segment-content">
<h1 class="ui center aligned large header"> <h1 class="ui center aligned large header">
<span> <span>
{{ $t('components.Home.header.welcome', {podName: podName}) }} {{ t('components.Home.header.welcome', {podName: podName}) }}
</span> </span>
<div <div
v-if="shortDescription" v-if="shortDescription"
@ -88,7 +88,7 @@ whenever(() => store.state.auth.authenticated, () => {
<div class="ui stackable grid"> <div class="ui stackable grid">
<div class="ten wide column"> <div class="ten wide column">
<h2 class="header"> <h2 class="header">
{{ $t('components.Home.header.about') }} {{ t('components.Home.header.about') }}
</h2> </h2>
<div <div
id="pod" id="pod"
@ -97,7 +97,7 @@ whenever(() => store.state.auth.authenticated, () => {
<div class="ui stackable grid"> <div class="ui stackable grid">
<div class="eight wide column"> <div class="eight wide column">
<p v-if="!longDescription"> <p v-if="!longDescription">
{{ $t('components.Home.placeholder.noDescription') }} {{ t('components.Home.placeholder.noDescription') }}
</p> </p>
<template v-if="longDescription || rules"> <template v-if="longDescription || rules">
<sanitized-html <sanitized-html
@ -120,7 +120,7 @@ whenever(() => store.state.auth.authenticated, () => {
class="ui link" class="ui link"
:to="{name: 'about'}" :to="{name: 'about'}"
> >
{{ $t('components.Home.link.learnMore') }} {{ t('components.Home.link.learnMore') }}
</router-link> </router-link>
</div> </div>
</div> </div>
@ -135,7 +135,7 @@ whenever(() => store.state.auth.authenticated, () => {
class="ui link" class="ui link"
:to="{name: 'about', hash: '#rules'}" :to="{name: 'about', hash: '#rules'}"
> >
{{ $t('components.Home.link.rules') }} {{ t('components.Home.link.rules') }}
</router-link> </router-link>
</div> </div>
</div> </div>
@ -145,20 +145,20 @@ whenever(() => store.state.auth.authenticated, () => {
<div class="eight wide column"> <div class="eight wide column">
<template v-if="stats"> <template v-if="stats">
<h3 class="sub header"> <h3 class="sub header">
{{ $t('components.Home.header.statistics') }} {{ t('components.Home.header.statistics') }}
</h3> </h3>
<p> <p>
<i class="user icon" /> <i class="user icon" />
{{ $t('components.Home.stat.activeUsers', stats.users) }} {{ t('components.Home.stat.activeUsers', stats.users) }}
</p> </p>
<p> <p>
<i class="music icon" /> <i class="music icon" />
{{ $t('components.Home.stat.hoursOfMusic', stats.hours) }} {{ t('components.Home.stat.hoursOfMusic', stats.hours) }}
</p> </p>
</template> </template>
<template v-if="contactEmail"> <template v-if="contactEmail">
<h3 class="sub header"> <h3 class="sub header">
{{ $t('components.Home.header.contact') }} {{ t('components.Home.header.contact') }}
</h3> </h3>
<i class="at icon" /> <i class="at icon" />
<a :href="`mailto:${contactEmail}`">{{ contactEmail }}</a> <a :href="`mailto:${contactEmail}`">{{ contactEmail }}</a>
@ -181,13 +181,13 @@ whenever(() => store.state.auth.authenticated, () => {
<div class="ui stackable grid"> <div class="ui stackable grid">
<div class="four wide column"> <div class="four wide column">
<h3 class="header"> <h3 class="header">
{{ $t('components.Home.header.aboutFunkwhale') }} {{ t('components.Home.header.aboutFunkwhale') }}
</h3> </h3>
<p> <p>
{{ $t('components.Home.description.funkwhale.paragraph1') }} {{ t('components.Home.description.funkwhale.paragraph1') }}
</p> </p>
<p> <p>
{{ $t('components.Home.description.funkwhale.paragraph2') }} {{ t('components.Home.description.funkwhale.paragraph2') }}
</p> </p>
<a <a
target="_blank" target="_blank"
@ -195,12 +195,12 @@ whenever(() => store.state.auth.authenticated, () => {
href="https://funkwhale.audio" href="https://funkwhale.audio"
> >
<i class="external alternate icon" /> <i class="external alternate icon" />
{{ $t('components.Home.link.funkwhale') }} {{ t('components.Home.link.funkwhale') }}
</a> </a>
</div> </div>
<div class="four wide column"> <div class="four wide column">
<h3 class="header"> <h3 class="header">
{{ $t('components.Home.header.login') }} {{ t('components.Home.header.login') }}
</h3> </h3>
<login-form <login-form
button-classes="success" button-classes="success"
@ -210,14 +210,14 @@ whenever(() => store.state.auth.authenticated, () => {
</div> </div>
<div class="four wide column"> <div class="four wide column">
<h3 class="header"> <h3 class="header">
{{ $t('components.Home.header.signup') }} {{ t('components.Home.header.signup') }}
</h3> </h3>
<template v-if="openRegistrations"> <template v-if="openRegistrations">
<p> <p>
{{ $t('components.Home.description.signup') }} {{ t('components.Home.description.signup') }}
</p> </p>
<p v-if="defaultUploadQuota"> <p v-if="defaultUploadQuota">
{{ $t('components.Home.description.quota', { quota: humanSize(defaultUploadQuota * 1000 * 1000) }) }} {{ t('components.Home.description.quota', { quota: humanSize(defaultUploadQuota * 1000 * 1000) }) }}
</p> </p>
<signup-form <signup-form
button-classes="success" button-classes="success"
@ -226,7 +226,7 @@ whenever(() => store.state.auth.authenticated, () => {
</template> </template>
<div v-else> <div v-else>
<p> <p>
{{ $t('components.Home.help.registrationsClosed') }} {{ t('components.Home.help.registrationsClosed') }}
</p> </p>
<a <a
target="_blank" target="_blank"
@ -234,14 +234,14 @@ whenever(() => store.state.auth.authenticated, () => {
href="https://funkwhale.audio/#get-started" href="https://funkwhale.audio/#get-started"
> >
<i class="external alternate icon" /> <i class="external alternate icon" />
{{ $t('components.Home.link.findOtherPod') }} {{ t('components.Home.link.findOtherPod') }}
</a> </a>
</div> </div>
</div> </div>
<div class="four wide column"> <div class="four wide column">
<h3 class="header"> <h3 class="header">
{{ $t('components.Home.header.links') }} {{ t('components.Home.header.links') }}
</h3> </h3>
<div class="ui relaxed list"> <div class="ui relaxed list">
<div class="item"> <div class="item">
@ -252,10 +252,10 @@ whenever(() => store.state.auth.authenticated, () => {
class="header" class="header"
to="/library" to="/library"
> >
{{ $t('components.Home.link.publicContent.label') }} {{ t('components.Home.link.publicContent.label') }}
</router-link> </router-link>
<div class="description"> <div class="description">
{{ $t('components.Home.link.publicContent.description') }} {{ t('components.Home.link.publicContent.description') }}
</div> </div>
</div> </div>
</div> </div>
@ -268,10 +268,10 @@ whenever(() => store.state.auth.authenticated, () => {
target="_blank" target="_blank"
rel="noopener" rel="noopener"
> >
{{ $t('components.Home.link.mobileApps.label') }} {{ t('components.Home.link.mobileApps.label') }}
</a> </a>
<div class="description"> <div class="description">
{{ $t('components.Home.link.mobileApps.description') }} {{ t('components.Home.link.mobileApps.description') }}
</div> </div>
</div> </div>
</div> </div>
@ -284,10 +284,10 @@ whenever(() => store.state.auth.authenticated, () => {
target="_blank" target="_blank"
rel="noopener" rel="noopener"
> >
{{ $t('components.Home.link.userGuides.label') }} {{ t('components.Home.link.userGuides.label') }}
</a> </a>
<div class="description"> <div class="description">
{{ $t('components.Home.link.userGuides.description') }} {{ t('components.Home.link.userGuides.description') }}
</div> </div>
</div> </div>
</div> </div>
@ -304,16 +304,16 @@ whenever(() => store.state.auth.authenticated, () => {
:limit="10" :limit="10"
> >
<template #title> <template #title>
{{ $t('components.Home.header.newAlbums') }} {{ t('components.Home.header.newAlbums') }}
</template> </template>
<router-link to="/library"> <router-link to="/library">
{{ $t('components.Home.link.viewMore') }} {{ t('components.Home.link.viewMore') }}
<div class="ui hidden divider" /> <div class="ui hidden divider" />
</router-link> </router-link>
</album-widget> </album-widget>
<div class="ui hidden section divider" /> <div class="ui hidden section divider" />
<h3 class="ui header"> <h3 class="ui header">
{{ $t('components.Home.header.newChannels') }} {{ t('components.Home.header.newChannels') }}
</h3> </h3>
<channels-widget <channels-widget
:show-modification-date="true" :show-modification-date="true"

View File

@ -4,7 +4,7 @@ interface Props {
} }
withDefaults(defineProps<Props>(), { withDefaults(defineProps<Props>(), {
fill: '#222222' fill: 'var(--color)'
}) })
</script> </script>

View File

@ -12,7 +12,7 @@ const labels = computed(() => ({
<template> <template>
<main <main
class="main pusher" class="main"
:v-title="labels.title" :v-title="labels.title"
> >
<section class="ui vertical stripe segment"> <section class="ui vertical stripe segment">
@ -20,11 +20,11 @@ const labels = computed(() => ({
<h1 class="ui huge header"> <h1 class="ui huge header">
<i class="warning icon" /> <i class="warning icon" />
<div class="content"> <div class="content">
{{ $t('components.PageNotFound.header.pageNotFound') }} {{ t('components.PageNotFound.header.pageNotFound') }}
</div> </div>
</h1> </h1>
<p> <p>
{{ $t('components.PageNotFound.message.pageNotFound') }} {{ t('components.PageNotFound.message.pageNotFound') }}
</p> </p>
<a :href="path">{{ path }}</a> <a :href="path">{{ path }}</a>
<div class="ui hidden divider" /> <div class="ui hidden divider" />
@ -32,7 +32,7 @@ const labels = computed(() => ({
class="ui icon labeled right button" class="ui icon labeled right button"
to="/" to="/"
> >
{{ $t('components.PageNotFound.link.home') }} {{ t('components.PageNotFound.link.home') }}
<i class="right arrow icon" /> <i class="right arrow icon" />
</router-link> </router-link>
</div> </div>

View File

@ -5,7 +5,6 @@ import { whenever, watchDebounced, useCurrentElement, useScrollLock, useFullscre
import { nextTick, ref, computed, watchEffect, defineAsyncComponent } from 'vue' import { nextTick, ref, computed, watchEffect, defineAsyncComponent } from 'vue'
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap' import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useStore } from '~/store' import { useStore } from '~/store'
import { usePlayer } from '~/composables/audio/player' import { usePlayer } from '~/composables/audio/player'
@ -14,6 +13,8 @@ import { useQueue } from '~/composables/audio/queue'
import time from '~/utils/time' import time from '~/utils/time'
import { useI18n } from 'vue-i18n'
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue' import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue' import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
import PlayerControls from '~/components/audio/PlayerControls.vue' import PlayerControls from '~/components/audio/PlayerControls.vue'
@ -21,6 +22,12 @@ import PlayerControls from '~/components/audio/PlayerControls.vue'
import VirtualList from '~/components/vui/list/VirtualList.vue' import VirtualList from '~/components/vui/list/VirtualList.vue'
import QueueItem from '~/components/QueueItem.vue' import QueueItem from '~/components/QueueItem.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Link from '~/components/ui/Link.vue'
import Button from '~/components/ui/Button.vue'
import ArtistCreditLabel from '~/components/audio/ArtistCreditLabel.vue'
const MilkDrop = defineAsyncComponent(() => import('~/components/audio/visualizer/MilkDrop.vue')) const MilkDrop = defineAsyncComponent(() => import('~/components/audio/visualizer/MilkDrop.vue'))
const { const {
@ -173,7 +180,7 @@ if (!isWebGLSupported) {
<template> <template>
<section <section
class="main with-background component-queue" class="main opaque component-queue"
:aria-label="labels.queue" :aria-label="labels.queue"
> >
<div <div
@ -194,12 +201,12 @@ if (!isWebGLSupported) {
<img <img
v-if="fullscreen" v-if="fullscreen"
class="cover-shadow" class="cover-shadow"
:src="$store.getters['instance/absoluteUrl'](currentTrack.coverUrl)" :src="store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
> >
<img <img
ref="cover" ref="cover"
alt="" alt=""
:src="$store.getters['instance/absoluteUrl'](currentTrack.coverUrl)" :src="store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
> >
</template> </template>
<milk-drop <milk-drop
@ -212,47 +219,40 @@ if (!isWebGLSupported) {
v-if="!fullscreen || !idle" v-if="!fullscreen || !idle"
class="cover-buttons" class="cover-buttons"
> >
<tooltip :content="!isWebGLSupported && $t('components.Queue.message.webglUnsupported')"> <tooltip :content="!isWebGLSupported && t('components.Queue.message.webglUnsupported')">
<button <Button
v-if="coverType === CoverType.COVER_ART" v-if="coverType === CoverType.COVER_ART"
class="ui secondary button"
:aria-label="labels.showVisualizer" :aria-label="labels.showVisualizer"
:title="labels.showVisualizer" :title="labels.showVisualizer"
:disabled="!isWebGLSupported" :disabled="!isWebGLSupported"
icon="bi-display"
@click="coverType = CoverType.MILK_DROP" @click="coverType = CoverType.MILK_DROP"
> />
<i class="icon signal" /> <Button
</button>
<button
v-else-if="coverType === CoverType.MILK_DROP" v-else-if="coverType === CoverType.MILK_DROP"
class="ui secondary button"
:aria-label="labels.showCoverArt" :aria-label="labels.showCoverArt"
:title="labels.showCoverArt" :title="labels.showCoverArt"
:disabled="!isWebGLSupported" :disabled="!isWebGLSupported"
icon="bi-image-fill"
@click="coverType = CoverType.COVER_ART" @click="coverType = CoverType.COVER_ART"
> />
<i class="icon image outline" />
</button>
</tooltip> </tooltip>
<button <Button
v-if="!fullscreen" v-if="!fullscreen"
class="ui secondary button"
:aria-label="labels.fullscreen" :aria-label="labels.fullscreen"
:title="labels.fullscreen" :title="labels.fullscreen"
icon="bi-arrows-fullscreen"
@click="enter" @click="enter"
> />
<i class="icon expand" /> <Button
</button>
<button
v-else v-else
class="ui secondary button" secondary
:aria-label="labels.exitFullscreen" :aria-label="labels.exitFullscreen"
:title="labels.exitFullscreen" :title="labels.exitFullscreen"
icon="bi-fullscreen-exit"
@click="exit" @click="exit"
> />
<i class="icon compress" />
</button>
</div> </div>
</Transition> </Transition>
<Transition name="queue"> <Transition name="queue">
@ -267,65 +267,53 @@ if (!isWebGLSupported) {
v-for="ac in currentTrack.artistCredit" v-for="ac in currentTrack.artistCredit"
:key="ac.artist.id" :key="ac.artist.id"
> >
{{ ac.credit ?? $t('components.Queue.meta.unknownArtist') }} {{ ac.credit ?? t('components.Queue.meta.unknownArtist') }}
<span>{{ ac.joinphrase }}</span> <span>{{ ac.joinphrase }}</span>
</div> </div>
<span class="symbol hyphen middle" /> <span class="symbol hyphen middle" />
{{ currentTrack.albumTitle ?? $t('components.Queue.meta.unknownAlbum') }} {{ currentTrack.albumTitle ?? t('components.Queue.meta.unknownAlbum') }}
</h2> </h2>
</div> </div>
</Transition> </Transition>
</div> </div>
</div> </div>
<h1 class="ui header"> <h1 class="ui header">
<div class="content ellipsis"> <Link
<router-link class="track"
class="small header discrete link track"
:to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}" :to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}"
> >
{{ currentTrack.title }} {{ currentTrack.title }}
</router-link> </Link>
<div class="sub header ellipsis"> </h1>
<span> <h2>
<template
v-for="ac in currentTrack.artistCredit"
:key="ac.artist.id"
>
<router-link
class="discrete link"
:to="{name: 'library.artists.detail', params: {id: ac.artist.id }}"
@click.stop.prevent=""
>
{{ ac.credit ?? $t('components.Queue.meta.unknownArtist') }}
</router-link>
<span>{{ ac.joinphrase }}</span>
</template>
</span>
<template v-if="currentTrack.albumId !== -1"> <template v-if="currentTrack.albumId !== -1">
<span class="middle slash symbol" /> <Link
<router-link class="album"
class="discrete link album"
:to="{name: 'library.albums.detail', params: {id: currentTrack.albumId }}" :to="{name: 'library.albums.detail', params: {id: currentTrack.albumId }}"
> >
{{ currentTrack.albumTitle ?? $t('components.Queue.meta.unknownAlbum') }} {{ currentTrack.albumTitle ?? t('components.Queue.meta.unknownAlbum') }}
</router-link> </Link>
</template> </template>
</div> </h2>
</div> <span>
</h1> <ArtistCreditLabel
v-if="currentTrack.artistCredit"
:artist-credit="currentTrack.artistCredit"
/>
</span>
<div <div
v-if="currentTrack && errored" v-if="currentTrack && errored"
class="ui small warning message" class="ui small warning message"
> >
<h3 class="header"> <h3 class="header">
{{ $t('components.Queue.header.failure') }} {{ t('components.Queue.header.failure') }}
</h3> </h3>
<p v-if="hasNext && isPlaying"> <p v-if="hasNext && isPlaying">
{{ $t('components.Queue.message.automaticPlay') }} {{ t('components.Queue.message.automaticPlay') }}
<i class="loading spinner icon" /> <i class="loading spinner icon" />
</p> </p>
<p> <p>
{{ $t('components.Queue.warning.connectivity') }} {{ t('components.Queue.warning.connectivity') }}
</p> </p>
</div> </div>
<div <div
@ -333,32 +321,40 @@ if (!isWebGLSupported) {
class="ui small warning message" class="ui small warning message"
> >
<h3 class="header"> <h3 class="header">
{{ $t('components.Queue.header.noSources') }} {{ t('components.Queue.header.noSources') }}
</h3> </h3>
<p v-if="hasNext && isPlaying"> <p v-if="hasNext && isPlaying">
{{ $t('components.Queue.message.automaticPlay') }} {{ t('components.Queue.message.automaticPlay') }}
<i class="loading spinner icon" /> <i class="loading spinner icon" />
</p> </p>
</div> </div>
<div class="additional-controls desktop-and-below"> <Spacer
:size="16"
class="desktop-and-below"
/>
<Layout
flex
class="additional-controls desktop-and-below"
>
<track-favorite-icon <track-favorite-icon
v-if="$store.state.auth.authenticated" v-if="store.state.auth.authenticated"
:track="currentTrack" :track="currentTrack"
ghost
/> />
<track-playlist-icon <track-playlist-icon
v-if="$store.state.auth.authenticated" v-if="store.state.auth.authenticated"
:track="currentTrack" :track="currentTrack"
ghost
/> />
<button <Button
v-if="$store.state.auth.authenticated" v-if="store.state.auth.authenticated"
:class="['ui', 'really', 'basic', 'circular', 'icon', 'button']" ghost
icon="bi-eye-slash"
:aria-label="labels.addArtistContentFilter" :aria-label="labels.addArtistContentFilter"
:title="labels.addArtistContentFilter" :title="labels.addArtistContentFilter"
@click="hideArtist" @click="hideArtist"
> />
<i :class="['eye slash outline', 'basic', 'icon']" /> </Layout>
</button>
</div>
<div class="progress-wrapper"> <div class="progress-wrapper">
<div class="progress-area"> <div class="progress-area">
<div <div
@ -386,28 +382,33 @@ if (!isWebGLSupported) {
<span class="right floated timer total">{{ time.parse(Math.round(duration)) }}</span> <span class="right floated timer total">{{ time.parse(Math.round(duration)) }}</span>
</template> </template>
<template v-else> <template v-else>
<span class="left floated timer">{{ $t('components.Queue.meta.startTime') }}</span> <span class="left floated timer">{{ t('components.Queue.meta.startTime') }}</span>
<span class="right floated timer">{{ $t('components.Queue.meta.startTime') }}</span> <span class="right floated timer">{{ t('components.Queue.meta.startTime') }}</span>
</template> </template>
</div> </div>
</div> </div>
<player-controls class="desktop-and-below" /> <player-controls class="desktop-and-below queue-controls" />
</template> </template>
</div> </div>
<div id="queue"> <div id="queue">
<div class="ui basic clearing segment"> <div class="ui basic clearing segment">
<h2 class="ui header"> <h2 class="ui header">
<div class="content"> <div class="content">
<button <Button
v-t="'components.Queue.button.close'" ghost
class="ui right floated basic button" icon="bi-chevron-down"
@click="$store.commit('ui/queueFocused', null)" style="float: right; margin-right: 24px;"
@click="store.commit('ui/queueFocused', null)"
/> />
<button <Button
v-t="'components.Queue.button.clear'" red
class="ui right floated basic button danger" outline
icon="bi-trash-fill"
style="float: right; margin-right: 16px;"
@click="clear" @click="clear"
/> >
{{ t('components.Queue.button.clear') }}
</Button>
{{ labels.queue }} {{ labels.queue }}
<div class="sub header"> <div class="sub header">
<div> <div>
@ -420,7 +421,10 @@ if (!isWebGLSupported) {
</template> </template>
</i18n-t> </i18n-t>
<span class="middle pipe symbol" /> <span class="middle pipe symbol" />
<span v-t="'components.Queue.meta.end'" /> <span
v-t="'components.Queue.meta.end'"
style="margin-right: 8px;"
/>
<span :title="labels.duration"> <span :title="labels.duration">
{{ endsIn }} {{ endsIn }}
</span> </span>
@ -451,30 +455,31 @@ if (!isWebGLSupported) {
</template> </template>
<template #footer> <template #footer>
<div <div
v-if="$store.state.radios.populating" v-if="store.state.radios.populating"
class="radio-populating" class="radio-populating"
> >
<i class="loading spinner icon" /> <i class="loading spinner icon" />
{{ labels.populating }} {{ labels.populating }}
</div> </div>
<div <div
v-if="$store.state.radios.running" v-if="store.state.radios.running"
class="ui info message radio-message" class="ui info message radio-message"
> >
<div class="content"> <div class="content">
<h3 class="header"> <h3 class="header">
<i class="feed icon" /> <i class="bi bi-boombox-fill" />
{{ $t('components.Queue.header.radio') }} {{ t('components.Queue.header.radio') }}
</h3> </h3>
<p> <p>
{{ $t('components.Queue.message.radio') }} {{ t('components.Queue.message.radio') }}
</p> </p>
<button <Button
class="ui basic primary button" primary
@click="$store.dispatch('radios/stop')" icon="bi-stop-fill"
@click="store.dispatch('radios/stop')"
> >
{{ $t('components.Queue.button.stopRadio') }} {{ t('components.Queue.button.stopRadio') }}
</button> </Button>
</div> </div>
</div> </div>
</template> </template>

View File

@ -3,6 +3,11 @@ import type { QueueItemSource } from '~/types'
import time from '~/utils/time' import time from '~/utils/time'
import { generateTrackCreditStringFromQueue } from '~/utils/utils' import { generateTrackCreditStringFromQueue } from '~/utils/utils'
import { useStore } from '~/store'
import Button from '~/components/ui/Button.vue'
const store = useStore()
interface Events { interface Events {
(e: 'play', index: number): void (e: 'play', index: number): void
@ -24,7 +29,7 @@ defineProps<Props>()
tabindex="0" tabindex="0"
> >
<div class="handle"> <div class="handle">
<i class="grip lines icon" /> <i class="bi bi-list" />
</div> </div>
<div <div
class="image-cell" class="image-cell"
@ -37,7 +42,7 @@ defineProps<Props>()
> >
</div> </div>
<div @click="$emit('play', index)"> <div @click="$emit('play', index)">
<button <div
class="title reset ellipsis" class="title reset ellipsis"
:title="source.title" :title="source.title"
:aria-label="source.labels.selectTrack" :aria-label="source.labels.selectTrack"
@ -46,7 +51,7 @@ defineProps<Props>()
<span> <span>
{{ generateTrackCreditStringFromQueue(source) }} {{ generateTrackCreditStringFromQueue(source) }}
</span> </span>
</button> </div>
</div> </div>
<div class="duration-cell"> <div class="duration-cell">
<template v-if="source.sources.length > 0"> <template v-if="source.sources.length > 0">
@ -54,26 +59,28 @@ defineProps<Props>()
</template> </template>
</div> </div>
<div class="controls"> <div class="controls">
<button <Button
v-if="$store.state.auth.authenticated" v-if="store.state.auth.authenticated"
:aria-label="source.labels.favorite" :aria-label="source.labels.favorite"
:title="source.labels.favorite" :title="source.labels.favorite"
class="ui really basic circular icon button" :icon="store.getters['favorites/isFavorite'](source.id) ? 'bi-heart-fill' : 'bi-heart'"
@click.stop="$store.dispatch('favorites/toggle', source.id)" round
> ghost
<i square-small
:class="$store.getters['favorites/isFavorite'](source.id) ? 'pink' : ''" style="align-self: center;"
class="heart icon" :class="store.getters['favorites/isFavorite'](source.id) ? 'pink' : ''"
@click.stop="store.dispatch('favorites/toggle', source.id)"
/> />
</button> <Button
<button
:aria-label="source.labels.remove" :aria-label="source.labels.remove"
:title="source.labels.remove" :title="source.labels.remove"
class="ui really tiny basic circular icon button" icon="bi-x"
round
ghost
square-small
style="align-self: center;"
@click.stop="$emit('remove', index)" @click.stop="$emit('remove', index)"
> />
<i class="x icon" />
</button>
</div> </div>
</div> </div>
</template> </template>

View File

@ -8,6 +8,11 @@ import { useStore } from '~/store'
import axios from 'axios' import axios from 'axios'
import Layout from '~/components/ui/Layout.vue'
import Button from '~/components/ui/Button.vue'
import Input from '~/components/ui/Input.vue'
import Alert from '~/components/ui/Alert.vue'
import updateQueryString from '~/composables/updateQueryString' import updateQueryString from '~/composables/updateQueryString'
import useLogger from '~/composables/useLogger' import useLogger from '~/composables/useLogger'
@ -165,41 +170,42 @@ watch(() => props.initialId, () => {
</script> </script>
<template> <template>
<div <Layout
v-if="type === 'both'" v-if="type === 'both'"
class="two ui buttons" stack
> >
<button <Button
class="ui left floated labeled icon button" secondary
raised
split
round
icon="bi-rss-fill"
split-icon="bi-globe"
style="align-self: center;"
:split-title="t('components.RemoteSearchForm.button.fediverse')"
@click.prevent="type = 'rss'" @click.prevent="type = 'rss'"
@split-click.prevent="type = 'artists'"
> >
<i class="feed icon" /> {{ t('components.RemoteSearchForm.button.rss') }}
{{ $t('components.RemoteSearchForm.button.rss') }} </Button>
</button> </Layout>
<div class="or" /> <Layout
<button v-else
class="ui right floated right labeled icon button"
@click.prevent="type = 'artists'"
>
<i class="globe icon" />
{{ $t('components.RemoteSearchForm.button.fediverse') }}
</button>
</div>
<div v-else>
<form
id="remote-search" id="remote-search"
form
:class="['ui', {loading: isLoading}, 'form']" :class="['ui', {loading: isLoading}, 'form']"
@submit.stop.prevent="submit" @submit.stop.prevent="submit"
> >
<div <Alert
v-if="errors.length > 0" v-if="errors.length > 0"
red
role="alert" role="alert"
class="ui negative message" title="t('components.RemoteSearchForm.header.fetchFailed')"
>
<ul
v-if="errors.length > 1"
class="list"
> >
<h3 class="header">
{{ $t('components.RemoteSearchForm.header.fetchFailed') }}
</h3>
<ul class="list">
<li <li
v-for="(error, key) in errors" v-for="(error, key) in errors"
:key="key" :key="key"
@ -207,43 +213,42 @@ watch(() => props.initialId, () => {
{{ error }} {{ error }}
</li> </li>
</ul> </ul>
</div> <p v-else>
<div class="ui required field"> {{ errors[0] }}
<label for="object-id"> </p>
{{ labels.fieldLabel }} </Alert>
</label>
<p v-if="type === 'rss'"> <p v-if="type === 'rss'">
{{ $t('components.RemoteSearchForm.description.rss') }} {{ t('components.RemoteSearchForm.description.rss') }}
</p> </p>
<p v-else-if="type === 'artists'"> <p v-else-if="type === 'artists'">
{{ $t('components.RemoteSearchForm.description.fediverse') }} {{ t('components.RemoteSearchForm.description.fediverse') }}
</p> </p>
<input
<Input
id="object-id" id="object-id"
v-model="id" v-model="id"
type="text" type="text"
name="object-id" name="object-id"
:label="labels.fieldLabel"
:placeholder="labels.fieldPlaceholder" :placeholder="labels.fieldPlaceholder"
style="width: 100%;"
required required
> />
</div>
<button <Button
v-if="showSubmit" v-if="showSubmit"
primary
type="submit" type="submit"
:class="['ui', 'primary', {loading: isLoading}, 'button']" :class="{loading: isLoading}"
:disabled="isLoading || !id || id.length === 0" :disabled="isLoading || !id || id.length === 0"
> >
{{ $t('components.RemoteSearchForm.button.search') }} {{ t('components.RemoteSearchForm.button.search') }}
</button> </Button>
</form> </Layout>
<div <Alert
v-if="!isLoading && obj?.status === 'finished' && !redirectRoute" v-if="!isLoading && obj?.status === 'finished' && !redirectRoute"
role="alert" red
class="ui warning message"
> >
<p> {{ t('components.RemoteSearchForm.warning.unsupported') }}
{{ $t('components.RemoteSearchForm.warning.unsupported') }} </Alert>
</p>
</div>
</div>
</template> </template>

View File

@ -1,7 +1,13 @@
<script setup lang="ts">
import { useStore } from '~/store'
const store = useStore()
</script>
<template> <template>
<div class="ui toast-container"> <div class="ui toast-container">
<message <message
v-for="message in $store.state.ui.messages" v-for="message in store.state.ui.messages"
:key="message.key" :key="message.key"
:message="message" :message="message"
/> />

View File

@ -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 }&nbsp;<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>

View File

@ -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>

View File

@ -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>

View File

@ -6,6 +6,17 @@ import useFormData from '~/composables/useFormData'
import { ref, computed, reactive } from 'vue' import { ref, computed, reactive } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import useLogger from '~/composables/useLogger' import useLogger from '~/composables/useLogger'
import { useI18n } from 'vue-i18n'
import Section from '~/components/ui/Section.vue'
import Layout from '~/components/ui/Layout.vue'
import Toggle from '~/components/ui/Toggle.vue'
import Input from '~/components/ui/Input.vue'
import Alert from '~/components/ui/Alert.vue'
import Button from '~/components/ui/Button.vue'
import Spacer from '~/components/ui/Spacer.vue'
const { t } = useI18n()
interface Props { interface Props {
group: SettingsGroup group: SettingsGroup
@ -96,42 +107,24 @@ const save = async () => {
</script> </script>
<template> <template>
<!-- TODO: type the different values in `settings` (use generics) -->
<!-- eslint-disable vue/valid-v-model -->
<Section
align-left
:h2="group.label"
large-section-heading
>
<form <form
:id="group.id" :id="group.id"
class="ui form component-settings-group" class="ui form component-settings-group"
style="grid-column: 1 / -1;"
@submit.prevent="save" @submit.prevent="save"
> >
<div class="ui divider" /> <Spacer :size="16" />
<h3 class="ui header">
{{ group.label }}
</h3>
<div
v-if="errors.length > 0"
role="alert"
class="ui negative message"
>
<h4 class="header">
{{ $t('components.admin.SettingsGroup.header.error') }}
</h4>
<ul class="list">
<li
v-for="(error, key) in errors"
:key="key"
>
{{ error }}
</li>
</ul>
</div>
<div
v-if="result"
class="ui positive message"
>
{{ $t('components.admin.SettingsGroup.message.success') }}
</div>
<div <div
v-for="(setting, key) in settings" v-for="(setting, key) in settings"
:key="key" :key="key"
class="ui field" :class="[$style.field, 'ui', 'field']"
> >
<template v-if="setting.field.widget.class !== 'CheckboxInput'"> <template v-if="setting.field.widget.class !== 'CheckboxInput'">
<label :for="setting.identifier">{{ setting.verbose_name }}</label> <label :for="setting.identifier">{{ setting.verbose_name }}</label>
@ -144,60 +137,46 @@ const save = async () => {
v-bind="setting.fieldParams" v-bind="setting.fieldParams"
v-model="values[setting.identifier]" v-model="values[setting.identifier]"
/> />
<!-- eslint-disable vue/valid-v-model -->
<signup-form-builder <signup-form-builder
v-else-if="setting.fieldType === 'formBuilder'" v-else-if="setting.fieldType === 'formBuilder'"
v-model="values[setting.identifier] as Form" v-model="values[setting.identifier] as Form"
:signup-approval-enabled="!!values.moderation__signup_approval_enabled" :signup-approval-enabled="!!values.moderation__signup_approval_enabled"
/> />
<!-- eslint-enable vue/valid-v-model --> <Input
<input
v-else-if="setting.field.widget.class === 'PasswordInput'" v-else-if="setting.field.widget.class === 'PasswordInput'"
:id="setting.identifier" v-model="values[setting.identifier] as string"
v-model="values[setting.identifier]" password
:name="setting.identifier"
type="password" type="password"
class="ui input" class="ui input"
> />
<input <Input
v-else-if="setting.field.widget.class === 'TextInput'" v-else-if="setting.field.widget.class === 'TextInput'"
:id="setting.identifier" v-model="values[setting.identifier] as string"
v-model="values[setting.identifier]"
:name="setting.identifier"
type="text" type="text"
class="ui input" class="ui input"
> />
<input <Input
v-else-if="setting.field.class === 'IntegerField'" v-else-if="setting.field.class === 'IntegerField'"
:id="setting.identifier" v-model.number="values[setting.identifier] as number"
v-model.number="values[setting.identifier]"
:name="setting.identifier"
type="number" type="number"
class="ui input" class="ui input"
> />
<!-- eslint-disable vue/valid-v-model -->
<textarea <textarea
v-else-if="setting.field.widget.class === 'Textarea'" v-else-if="setting.field.widget.class === 'Textarea'"
:id="setting.identifier"
v-model="values[setting.identifier] as string" v-model="values[setting.identifier] as string"
:name="setting.identifier"
type="text" type="text"
class="ui input" class="ui input"
/> />
<!-- eslint-enable vue/valid-v-model --> <!-- eslint-enable vue/valid-v-model -->
<div <div
v-else-if="setting.field.widget.class === 'CheckboxInput'" v-else-if="setting.field.widget.class === 'CheckboxInput'"
class="ui toggle checkbox"
> >
<!-- eslint-disable vue/valid-v-model --> <Toggle
<input
:id="setting.identifier"
v-model="values[setting.identifier] as boolean" v-model="values[setting.identifier] as boolean"
:name="setting.identifier" big
type="checkbox" :label="setting.verbose_name"
> />
<!-- eslint-enable vue/valid-v-model --> <Spacer :size="8" />
<label :for="setting.identifier">{{ setting.verbose_name }}</label>
<p v-if="setting.help_text"> <p v-if="setting.help_text">
{{ setting.help_text }} {{ setting.help_text }}
</p> </p>
@ -208,6 +187,7 @@ const save = async () => {
v-model="values[setting.identifier]" v-model="values[setting.identifier]"
multiple multiple
class="ui search selection dropdown" class="ui search selection dropdown"
style="height: 150px;"
> >
<option <option
v-for="v in setting.additional_data?.choices" v-for="v in setting.additional_data?.choices"
@ -232,30 +212,75 @@ const save = async () => {
</option> </option>
</select> </select>
<div v-else-if="setting.field.widget.class === 'ImageWidget'"> <div v-else-if="setting.field.widget.class === 'ImageWidget'">
<input <!-- TODO: Implement image input -->
<!-- @vue-ignore -->
<Input
:id="setting.identifier" :id="setting.identifier"
:ref="setFileRef(setting.identifier)" :ref="setFileRef(setting.identifier)"
type="file" type="file"
> />
<div v-if="values[setting.identifier]"> <div v-if="values[setting.identifier]">
<div class="ui hidden divider" />
<h3 class="ui header"> <h3 class="ui header">
{{ $t('components.admin.SettingsGroup.header.image') }} {{ t('components.admin.SettingsGroup.header.image') }}
</h3> </h3>
<img <img
v-if="values[setting.identifier]" v-if="values[setting.identifier]"
class="ui image" class="ui image"
alt="" alt=""
:src="$store.getters['instance/absoluteUrl'](values[setting.identifier])" :src="store.getters['instance/absoluteUrl'](values[setting.identifier])"
> >
</div> </div>
</div> </div>
<Spacer />
</div> </div>
<button <Layout flex>
<Spacer grow />
<Button
type="submit" type="submit"
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'success', 'button']" :class="[{'loading': isLoading}]"
primary
> >
{{ $t('components.admin.SettingsGroup.button.save') }} {{ t('components.admin.SettingsGroup.button.save') }}
</button> </Button>
</Layout>
<Spacer />
<Alert
v-if="errors.length > 0"
red
>
<h4 class="header">
{{ t('components.admin.SettingsGroup.header.error', {label: group.label}) }}
</h4>
<ul class="list">
<li
v-for="(error, key) in errors"
:key="key"
>
{{ error }}
</li>
</ul>
</Alert>
<Alert
v-if="result"
green
>
{{ t('components.admin.SettingsGroup.message.success') }}
</Alert>
</form> </form>
</Section>
<hr :class="$style.separator">
<Spacer size-64 />
<!-- eslint-enable vue/valid-v-model -->
</template> </template>
<style module>
.field > div {
display: flex;
flex-direction: column;
}
.separator:last-of-type {
display: none;
}
</style>

View File

@ -2,6 +2,8 @@
import type { Form } from '~/types' import type { Form } from '~/types'
import SignupForm from '~/components/auth/SignupForm.vue' import SignupForm from '~/components/auth/SignupForm.vue'
import Button from '~/components/ui/Button.vue'
import { useVModel } from '@vueuse/core' import { useVModel } from '@vueuse/core'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@ -65,18 +67,20 @@ const move = (idx: number, increment: number) => {
<template> <template>
<div> <div>
<div class="ui top attached tabular menu"> <div class="ui top attached tabular menu">
<button <Button
color="primary"
:class="[{active: !isPreviewing}, 'item']" :class="[{active: !isPreviewing}, 'item']"
@click.stop.prevent="isPreviewing = false" @click.stop.prevent="isPreviewing = false"
> >
{{ $t('components.admin.SignupFormBuilder.button.edit') }} {{ t('components.admin.SignupFormBuilder.button.edit') }}
</button> </Button>
<button <Button
color="primary"
:class="[{active: isPreviewing}, 'item']" :class="[{active: isPreviewing}, 'item']"
@click.stop.prevent="isPreviewing = true" @click.stop.prevent="isPreviewing = true"
> >
{{ $t('components.admin.SignupFormBuilder.button.preview') }} {{ t('components.admin.SignupFormBuilder.button.preview') }}
</button> </Button>
</div> </div>
<div <div
v-if="isPreviewing" v-if="isPreviewing"
@ -95,10 +99,10 @@ const move = (idx: number, increment: number) => {
> >
<div class="field"> <div class="field">
<label for="help-text"> <label for="help-text">
{{ $t('components.admin.SignupFormBuilder.label.helpText') }} {{ t('components.admin.SignupFormBuilder.label.helpText') }}
</label> </label>
<p> <p>
{{ $t('components.admin.SignupFormBuilder.help.helpText') }} {{ t('components.admin.SignupFormBuilder.help.helpText') }}
</p> </p>
<content-form <content-form
v-if="value.help_text" v-if="value.help_text"
@ -109,24 +113,24 @@ const move = (idx: number, increment: number) => {
</div> </div>
<div class="field"> <div class="field">
<label> <label>
{{ $t('components.admin.SignupFormBuilder.label.additionalFields') }} {{ t('components.admin.SignupFormBuilder.label.additionalFields') }}
</label> </label>
<p> <p>
{{ $t('components.admin.SignupFormBuilder.help.additionalFields') }} {{ t('components.admin.SignupFormBuilder.help.additionalFields') }}
</p> </p>
<table v-if="value.fields?.length > 0"> <table v-if="value.fields?.length > 0">
<thead> <thead>
<tr> <tr>
<th> <th>
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.header.label') }} {{ t('components.admin.SignupFormBuilder.table.additionalFields.header.label') }}
</th> </th>
<th> <th>
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.header.type') }} {{ t('components.admin.SignupFormBuilder.table.additionalFields.header.type') }}
</th> </th>
<th> <th>
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.header.required') }} {{ t('components.admin.SignupFormBuilder.table.additionalFields.header.required') }}
</th> </th>
<th><span class="visually-hidden">{{ $t('components.admin.SignupFormBuilder.table.additionalFields.header.actions') }}</span></th> <th><span class="visually-hidden">{{ t('components.admin.SignupFormBuilder.table.additionalFields.header.actions') }}</span></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -144,20 +148,20 @@ const move = (idx: number, increment: number) => {
<td> <td>
<select v-model="field.input_type"> <select v-model="field.input_type">
<option value="short_text"> <option value="short_text">
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.type.short') }} {{ t('components.admin.SignupFormBuilder.table.additionalFields.type.short') }}
</option> </option>
<option value="long_text"> <option value="long_text">
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.type.long') }} {{ t('components.admin.SignupFormBuilder.table.additionalFields.type.long') }}
</option> </option>
</select> </select>
</td> </td>
<td> <td>
<select v-model="field.required"> <select v-model="field.required">
<option :value="true"> <option :value="true">
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.required.true') }} {{ t('components.admin.SignupFormBuilder.table.additionalFields.required.true') }}
</option> </option>
<option :value="false"> <option :value="false">
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.required.false') }} {{ t('components.admin.SignupFormBuilder.table.additionalFields.required.false') }}
</option> </option>
</select> </select>
</td> </td>
@ -187,13 +191,13 @@ const move = (idx: number, increment: number) => {
</tbody> </tbody>
</table> </table>
<div class="ui hidden divider" /> <div class="ui hidden divider" />
<button <Button
v-if="value.fields?.length < maxFields" v-if="value.fields?.length < maxFields"
class="ui basic button" color="primary"
@click.stop.prevent="addField" @click.stop.prevent="addField"
> >
{{ $t('components.admin.SignupFormBuilder.button.add') }} {{ t('components.admin.SignupFormBuilder.button.add') }}
</button> </Button>
</div> </div>
</div> </div>
<div class="ui hidden divider" /> <div class="ui hidden divider" />

View File

@ -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>

View File

@ -6,21 +6,27 @@ import { useStore } from '~/store'
import axios from 'axios' import axios from 'axios'
import AlbumCard from '~/components/audio/album/Card.vue' import usePage from '~/composables/navigation/usePage'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
import AlbumCard from '~/components/album/Card.vue'
import Section from '~/components/ui/Section.vue'
import Loader from '~/components/ui/Loader.vue'
import Pagination from '~/components/ui/Pagination.vue'
interface Props { interface Props {
filters: Record<string, string | boolean> filters: Record<string, string | boolean>
showCount?: boolean showCount?: boolean
search?: boolean search?: boolean
limit?: number limit?: number
title?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
showCount: false, showCount: false,
search: false, search: false,
limit: 12 limit: 12,
title: undefined
}) })
const store = useStore() const store = useStore()
@ -28,6 +34,7 @@ const store = useStore()
const query = ref('') const query = ref('')
const albums = reactive([] as Album[]) const albums = reactive([] as Album[])
const count = ref(0) const count = ref(0)
const page = usePage()
const nextPage = ref() const nextPage = ref()
const isLoading = ref(false) const isLoading = ref(false)
@ -38,13 +45,14 @@ const fetchData = async (url = 'albums/') => {
const params = { const params = {
q: query.value, q: query.value,
...props.filters, ...props.filters,
page: page.value,
page_size: props.limit page_size: props.limit
} }
const response = await axios.get(url, { params }) const response = await axios.get(url, { params })
nextPage.value = response.data.next nextPage.value = response.data.next
count.value = response.data.count count.value = response.data.count
albums.push(...response.data.results) albums.splice(0, albums.length, ...response.data.results)
} catch (error) { } catch (error) {
useErrorHandler(error as Error) useErrorHandler(error as Error)
} }
@ -52,68 +60,58 @@ const fetchData = async (url = 'albums/') => {
isLoading.value = false isLoading.value = false
} }
setTimeout(fetchData, 1000)
const performSearch = () => { const performSearch = () => {
albums.length = 0 albums.length = 0
fetchData() fetchData()
} }
watch( watch(
() => store.state.moderation.lastUpdate, [() => store.state.moderation.lastUpdate, page],
() => fetchData(), () => fetchData(),
{ immediate: true } { immediate: true }
) )
</script> </script>
<template> <template>
<div class="wrapper"> <Section
<h3 align-left
v-if="!!$slots.title" :h2="title"
class="ui header" :columns-per-item="1"
> >
<slot name="title" />
<span
v-if="showCount"
class="ui tiny circular label"
>{{ count }}</span>
</h3>
<slot />
<inline-search-bar <inline-search-bar
v-if="search" v-if="search"
v-model="query" v-model="query"
style="grid-column: 1 / -1;"
@search="performSearch" @search="performSearch"
/> />
<div class="ui hidden divider" /> <Loader
<div class="ui app-cards cards">
<div
v-if="isLoading" v-if="isLoading"
class="ui inverted active dimmer" style="grid-column: 1 / -1;"
> />
<div class="ui loader" /> <template v-if="!isLoading && albums.length > 0">
</div>
<album-card <album-card
v-for="album in albums" v-for="album in albums"
:key="album.id" :key="album.id"
:album="album" :album="album"
/> />
</div> </template>
<slot <slot
v-if="!isLoading && albums.length === 0" v-if="!isLoading && albums.length === 0"
name="empty-state" name="empty-state"
> >
<empty-state <empty-state
:refresh="true" :refresh="true"
style="grid-column: 1 / -1;"
@refresh="fetchData" @refresh="fetchData"
/> />
</slot> </slot>
<template v-if="nextPage"> <Pagination
<div class="ui hidden divider" /> v-if="page && albums && count > props.limit"
<button v-model:page="page"
v-if="nextPage" :pages="Math.ceil((count || 0) / props.limit)"
:class="['ui', 'basic', 'button']" style="grid-column: 1 / -1;"
@click="fetchData(nextPage)" />
> </Section>
{{ $t('components.audio.album.Widget.button.more') }}
</button>
</template>
</div>
</template> </template>

View File

@ -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>

View File

@ -1,26 +1,32 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Artist } from '~/types' import type { Artist } from '~/types'
import { reactive, ref, watch } from 'vue' import { reactive, ref, watch, onMounted } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import axios from 'axios' import axios from 'axios'
import ArtistCard from '~/components/audio/artist/Card.vue'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
import usePage from '~/composables/navigation/usePage'
import ArtistCard from '~/components/artist/Card.vue'
import Section from '~/components/ui/Section.vue'
import Pagination from '~/components/ui/Pagination.vue'
import Loader from '~/components/ui/Loader.vue'
interface Props { interface Props {
filters: Record<string, string | boolean> filters: Record<string, string | boolean>
search?: boolean search?: boolean
header?: boolean header?: boolean
limit?: number limit?: number
title?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
search: false, search: false,
header: true, header: true,
limit: 12 limit: 12,
title: undefined
}) })
const store = useStore() const store = useStore()
@ -28,6 +34,7 @@ const store = useStore()
const query = ref('') const query = ref('')
const artists = reactive([] as Artist[]) const artists = reactive([] as Artist[])
const count = ref(0) const count = ref(0)
const page = usePage()
const nextPage = ref() const nextPage = ref()
const isLoading = ref(false) const isLoading = ref(false)
@ -38,13 +45,14 @@ const fetchData = async (url = 'artists/') => {
const params = { const params = {
q: query.value, q: query.value,
...props.filters, ...props.filters,
page: page.value,
page_size: props.limit page_size: props.limit
} }
const response = await axios.get(url, { params }) const response = await axios.get(url, { params })
nextPage.value = response.data.next nextPage.value = response.data.next
count.value = response.data.count count.value = response.data.count
artists.push(...response.data.results) artists.splice(0, artists.length, ...response.data.results)
} catch (error) { } catch (error) {
useErrorHandler(error as Error) useErrorHandler(error as Error)
} }
@ -52,64 +60,58 @@ const fetchData = async (url = 'artists/') => {
isLoading.value = false isLoading.value = false
} }
onMounted(() => {
setTimeout(fetchData, 1000)
})
const performSearch = () => { const performSearch = () => {
artists.length = 0 artists.length = 0
fetchData() fetchData()
} }
watch( watch(
() => store.state.moderation.lastUpdate, [() => store.state.moderation.lastUpdate, page],
() => fetchData(), () => fetchData(),
{ immediate: true } { immediate: true }
) )
</script> </script>
<template> <template>
<div class="wrapper"> <Section
<h3 align-left
v-if="header" :columns-per-item="1"
class="ui header" :h2="title"
> >
<slot name="title" /> <Loader
<span class="ui tiny circular label">{{ count }}</span>
</h3>
<inline-search-bar
v-if="search"
v-model="query"
@search="performSearch"
/>
<div class="ui hidden divider" />
<div class="ui five app-cards cards">
<div
v-if="isLoading" v-if="isLoading"
class="ui inverted active dimmer" style="grid-column: 1 / -1;"
>
<div class="ui loader" />
</div>
<artist-card
v-for="artist in artists"
:key="artist.id"
:artist="artist"
/> />
</div>
<slot <slot
v-if="!isLoading && artists.length === 0" v-if="!isLoading && artists.length === 0"
name="empty-state" name="empty-state"
> >
<empty-state <empty-state
style="grid-column: 1 / -1;"
:refresh="true" :refresh="true"
@refresh="fetchData" @refresh="fetchData"
/> />
</slot> </slot>
<template v-if="nextPage"> <inline-search-bar
<div class="ui hidden divider" /> v-if="!isLoading && search"
<button v-model="query"
v-if="nextPage" style="grid-column: 1 / -1;"
:class="['ui', 'basic', 'button']" @search="performSearch"
@click="fetchData(nextPage)" />
> <artist-card
{{ $t('components.audio.artist.Widget.button.more') }} v-for="artist in artists"
</button> :key="artist.id"
</template> :artist="artist"
</div> />
<Pagination
v-if="page && artists && count > limit"
v-model:page="page"
style="grid-column: 1 / -1;"
:pages="Math.ceil((count || 0) / limit)"
/>
</Section>
</template> </template>

View File

@ -1,5 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ArtistCredit } from '~/types' import type { ArtistCredit } from '~/types'
import { useStore } from '~/store'
import Layout from '~/components/ui/Layout.vue'
import Pill from '~/components/ui/Pill.vue'
const store = useStore()
interface Props { interface Props {
artistCredit: ArtistCredit[] artistCredit: ArtistCredit[]
@ -7,6 +13,10 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
// TODO: Fix getRoute
// TODO: check if still needed:
/*
const getRoute = (ac: ArtistCredit) => { const getRoute = (ac: ArtistCredit) => {
return { return {
name: ac.artist.channel ? 'channels.detail' : 'library.artists.detail', name: ac.artist.channel ? 'channels.detail' : 'library.artists.detail',
@ -15,30 +25,47 @@ const getRoute = (ac: ArtistCredit) => {
} }
} }
} }
*/
</script> </script>
<template> <template>
<div class="artist-label ui image label"> <Layout
flex
gap-8
>
<template <template
v-for="ac in props.artistCredit" v-for="ac in props.artistCredit"
:key="ac.artist.id" :key="ac.artist.id"
> >
<router-link <router-link
:to="getRoute(ac)" :to="{name: 'library.artists.detail', params: {id: ac.artist.id }}"
class="username"
@click.stop.prevent=""
> >
<Pill>
<template #image>
<img <img
v-if="ac.index === 0 && ac.artist.cover && ac.artist.cover.urls.original" v-if="ac.artist.cover && ac.artist.cover.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](ac.artist.cover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](ac.artist.cover.urls.small_square_crop)"
alt="" :alt="ac.artist.name"
:class="[{circular: ac.artist.content_category != 'podcast'}]"
> >
<i <i
v-else-if="ac.index === 0" v-else
:class="[ac.artist.content_category != 'podcast' ? 'circular' : 'bordered', 'inverted violet users icon']" class="bi bi-person-circle"
style="font-size: 24px;"
/> />
</template>
{{ ac.credit }} {{ ac.credit }}
</Pill>
</router-link> </router-link>
<span>{{ ac.joinphrase }}</span> <span>{{ ac.joinphrase }}</span>
</template> </template>
</div> </Layout>
</template> </template>
<style lang="scss" scoped>
a.username {
text-decoration: none;
height: 25px;
}
</style>

View File

@ -2,6 +2,9 @@
import type { Artist } from '~/types' import type { Artist } from '~/types'
import { computed } from 'vue' import { computed } from 'vue'
import { useStore } from '~/store'
const store = useStore()
interface Props { interface Props {
artist: Artist artist: Artist
@ -10,7 +13,7 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
const route = computed(() => props.artist.channel const route = computed(() => props.artist.channel
? { name: 'channels.detail', params: { id: props.artist.channel.uuid } } ? { name: 'channels.detail', params: { id: props.artist.channel } }
: { name: 'library.artists.detail', params: { id: props.artist.id } } : { name: 'library.artists.detail', params: { id: props.artist.id } }
) )
</script> </script>
@ -22,7 +25,7 @@ const route = computed(() => props.artist.channel
> >
<img <img
v-if="artist.cover && artist.cover.urls.original" v-if="artist.cover && artist.cover.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](artist.cover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](artist.cover.urls.small_square_crop)"
alt="" alt=""
:class="[{circular: artist.content_category != 'podcast'}]" :class="[{circular: artist.content_category != 'podcast'}]"
> >

View File

@ -9,7 +9,9 @@ import { computed } from 'vue'
import moment from 'moment' import moment from 'moment'
import PlayButton from '~/components/audio/PlayButton.vue' import PlayButton from '~/components/audio/PlayButton.vue'
import TagsList from '~/components/tags/List.vue' import Card from '~/components/ui/Card.vue'
import Spacer from '~/components/ui/Spacer.vue'
import ActorLink from '~/components/common/ActorLink.vue'
interface Props { interface Props {
object: Channel object: Channel
@ -41,64 +43,92 @@ const updatedAgo = computed(() => moment(props.object.artist?.modification_date)
</script> </script>
<template> <template>
<div class="card app-card"> <Card
<div :title="object.artist?.name"
v-lazy:background-image="imageUrl"
:class="['ui', 'head-image', {'circular': object.artist?.content_category != 'podcast'}, {'padded': object.artist?.content_category === 'podcast'}, 'image', {'default-cover': !object.artist?.cover}]"
@click="$router.push({name: 'channels.detail', params: {id: urlId}})"
>
<play-button
:icon-only="true"
:is-playable="true"
:button-classes="['ui', 'circular', 'large', 'vibrant', 'icon', 'button']"
:artist="object.artist"
/>
</div>
<div class="content">
<strong>
<router-link
class="discrete link"
:to="{name: 'channels.detail', params: {id: urlId}}"
>
{{ object.artist?.name }}
</router-link>
</strong>
<div class="description">
<span
v-if="object.artist?.content_category === 'podcast'"
class="meta ellipsis"
>
{{ $t('components.audio.ChannelCard.meta.episodes', object.artist.tracks_count) }}
</span>
<span v-else>
{{ $t('components.audio.ChannelCard.meta.tracks', object.artist?.tracks_count ?? 0) }}
</span>
<tags-list
label-classes="tiny"
:truncate-size="20"
:limit="2"
:show-more="false"
:tags="object.artist?.tags ?? []" :tags="object.artist?.tags ?? []"
class="artist-card"
:to="{name: 'channels.detail', params: {id: urlId}}"
solid
small
>
<template #topright>
<PlayButton
icon-only
:artist="object.artist"
:is-playable="true"
/> />
</div> </template>
</div>
<div class="extra content"> <template #image>
<img
v-if="imageUrl"
v-lazy="imageUrl"
:alt="object.artist?.name"
:class="[object.artist?.content_category === 'podcast' ? 'podcast-image' : 'channel-image']"
>
<i
v-else
class="bi bi-person-circle"
style="font-size: 167px; margin: 16px;"
/>
</template>
<template #default>
<Spacer :size="8" />
<ActorLink
:actor="object.attributed_to"
discrete
/>
</template>
<template #footer>
<time <time
class="meta ellipsis"
:datetime="object.artist?.modification_date" :datetime="object.artist?.modification_date"
:title="updatedTitle" :title="updatedTitle"
> >
{{ updatedAgo }} {{ updatedAgo }}
</time> </time>
<play-button <i class="bi bi-dot" />
class="right floated basic icon" <span
v-if="object.artist?.content_category === 'podcast'"
>
{{ t('components.audio.ChannelCard.meta.episodes', object.artist.tracks_count) }}
</span>
<span v-else>
{{ t('components.audio.ChannelCard.meta.tracks', object.artist?.tracks_count ?? 0) }}
</span>
<Spacer
h
grow
/>
<PlayButton
:dropdown-only="true" :dropdown-only="true"
:is-playable="true" :is-playable="true"
:dropdown-icon-classes="['ellipsis', 'horizontal', 'large really discrete']"
:artist="object.artist" :artist="object.artist"
:channel="object" :channel="object"
:account="object.attributed_to" :account="object.attributed_to"
discrete
/> />
</div>
</div>
</template> </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>

View File

@ -3,11 +3,14 @@ import type { Cover, Track, BackendResponse, BackendError } from '~/types'
import { clone } from 'lodash-es' import { clone } from 'lodash-es'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import axios from 'axios' import axios from 'axios'
import PodcastTable from '~/components/audio/podcast/Table.vue' import PodcastTable from '~/components/audio/podcast/Table.vue'
import TrackTable from '~/components/audio/track/Table.vue' import TrackTable from '~/components/audio/track/Table.vue'
import Loader from '~/components/ui/Loader.vue'
interface Events { interface Events {
(e: 'fetched', data: BackendResponse<Track[]>): void (e: 'fetched', data: BackendResponse<Track[]>): void
} }
@ -18,6 +21,7 @@ interface Props {
defaultCover: Cover | null defaultCover: Cover | null
isPodcast: boolean isPodcast: boolean
} }
const { t } = useI18n()
const emit = defineEmits<Events>() const emit = defineEmits<Events>()
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@ -61,12 +65,7 @@ watch(page, fetchData, { immediate: true })
<template> <template>
<div> <div>
<slot /> <slot />
<div class="ui hidden divider" /> <Loader v-if="isLoading" />
<div
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div> </div>
<podcast-table <podcast-table
v-if="isPodcast" v-if="isPodcast"
@ -103,9 +102,8 @@ watch(page, fetchData, { immediate: true })
@refresh="fetchData()" @refresh="fetchData()"
> >
<p> <p>
{{ $t('components.audio.ChannelEntries.help.subscribe') }} {{ t('components.audio.ChannelEntries.help.subscribe') }}
</p> </p>
</empty-state> </empty-state>
</template> </template>
</div>
</template> </template>

View File

@ -5,10 +5,15 @@ import { computed } from 'vue'
import { usePlayer } from '~/composables/audio/player' import { usePlayer } from '~/composables/audio/player'
import { useQueue } from '~/composables/audio/queue' import { useQueue } from '~/composables/audio/queue'
import { useStore } from '~/store'
import { useRouter } from 'vue-router'
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue' import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import PlayButton from '~/components/audio/PlayButton.vue' import PlayButton from '~/components/audio/PlayButton.vue'
const store = useStore()
const router = useRouter()
interface Props { interface Props {
entry: Track entry: Track
defaultCover: Cover defaultCover: Cover
@ -37,30 +42,30 @@ const duration = computed(() => props.entry.uploads.find(upload => upload.durati
</div> </div>
<img <img
v-if="cover && cover.urls.original" v-if="cover && cover.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)"
alt="" alt=""
class="channel-image image" class="channel-image image"
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" @click="router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
> >
<img <img
v-else-if="entry.artist_credit?.[0].artist.content_category === 'podcast' && defaultCover != undefined" v-else-if="entry.artist_credit?.[0].artist.content_category === 'podcast' && defaultCover != undefined"
v-lazy="$store.getters['instance/absoluteUrl'](defaultCover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](defaultCover.urls.medium_square_crop)"
class="channel-image image" class="channel-image image"
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" @click="router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
> >
<img <img
v-else-if="entry.album && entry.album.cover && entry.album.cover.urls.original" v-else-if="entry.album && entry.album.cover && entry.album.cover.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](entry.album.cover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](entry.album.cover.urls.medium_square_crop)"
alt="" alt=""
class="channel-image image" class="channel-image image"
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" @click="router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
> >
<img <img
v-else v-else
alt="" alt=""
class="channel-image image" class="channel-image image"
src="../../assets/audio/default-cover.png" src="../../assets/audio/default-cover.png"
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" @click="router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
> >
<div class="ellipsis content"> <div class="ellipsis content">
<strong> <strong>
@ -78,7 +83,7 @@ const duration = computed(() => props.entry.uploads.find(upload => upload.durati
/> />
</div> </div>
<div class="meta"> <div class="meta">
<template v-if="$store.state.auth.authenticated && $store.getters['favorites/isFavorite'](entry.id)"> <template v-if="store.state.auth.authenticated && store.getters['favorites/isFavorite'](entry.id)">
<track-favorite-icon <track-favorite-icon
class="tiny" class="tiny"
:track="entry" :track="entry"

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ContentCategory, Channel, BackendError } from '~/types' import type { ContentCategory, Channel, BackendError } from '~/types'
import type { paths } from '~/generated/types'
import { slugify } from 'transliteration' import { slugify } from 'transliteration'
import { reactive, computed, ref, watchEffect, watch } from 'vue' import { reactive, computed, ref, watchEffect, watch } from 'vue'
@ -7,23 +8,27 @@ import { useI18n } from 'vue-i18n'
import axios from 'axios' import axios from 'axios'
import AttachmentInput from '~/components/common/AttachmentInput.vue' import AttachmentInput from '~/components/common/AttachmentInput.vue'
import TagsSelector from '~/components/library/TagsSelector.vue'
interface Events { import Layout from '~/components/ui/Layout.vue'
(e: 'category', contentCategory: ContentCategory): void import Alert from '~/components/ui/Alert.vue'
(e: 'submittable', value: boolean): void import Input from '~/components/ui/Input.vue'
(e: 'loading', value: boolean): void import Textarea from '~/components/ui/Textarea.vue'
(e: 'errored', errors: string[]): void import Pills from '~/components/ui/Pills.vue'
(e: 'created', channel: Channel): void
(e: 'updated', channel: Channel): void
}
interface Props { interface Props {
object?: Channel | null object?: Channel | null
step: number step?: number
} }
const emit = defineEmits<Events>() const emit = defineEmits<{
category: [contentCategory: ContentCategory]
submittable: [value: boolean]
loading: [value: boolean]
errored: [errors: string[]]
created: [channel: Channel]
updated: [channel: Channel]
}>()
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
object: null, object: null,
step: 1 step: 1
@ -38,9 +43,11 @@ const newValues = reactive({
description: props.object?.artist?.description?.text ?? '', description: props.object?.artist?.description?.text ?? '',
cover: props.object?.artist?.cover?.uuid ?? null, cover: props.object?.artist?.cover?.uuid ?? null,
content_category: props.object?.artist?.content_category ?? 'podcast', content_category: props.object?.artist?.content_category ?? 'podcast',
metadata: { ...(props.object?.metadata ?? {}) } metadata: { ...(props.object?.metadata ?? {}) } as Channel['metadata']
}) })
// If props has an object, then this form edits, else it creates
// TODO: rename to `process : 'creating' | 'editing'`
const creating = computed(() => props.object === null) const creating = computed(() => props.object === null)
const categoryChoices = computed(() => [ const categoryChoices = computed(() => [
{ {
@ -72,6 +79,8 @@ interface MetadataChoices {
const metadataChoices = ref({ itunes_category: null } as MetadataChoices) const metadataChoices = ref({ itunes_category: null } as MetadataChoices)
const itunesSubcategories = computed(() => { const itunesSubcategories = computed(() => {
for (const element of metadataChoices.value.itunes_category ?? []) { for (const element of metadataChoices.value.itunes_category ?? []) {
// TODO: Backend: Define schema for `metadata` field
// @ts-expect-error No types defined by backend schema for `metadata` field
if (element.value === newValues.metadata.itunes_category) { if (element.value === newValues.metadata.itunes_category) {
return element.children ?? [] return element.children ?? []
} }
@ -87,6 +96,7 @@ const labels = computed(() => ({
const submittable = computed(() => !!( const submittable = computed(() => !!(
newValues.content_category === 'podcast' newValues.content_category === 'podcast'
// @ts-expect-error No types defined by backend schema for `metadata` field
? newValues.name && newValues.username && newValues.metadata.itunes_category && newValues.metadata.language ? newValues.name && newValues.username && newValues.metadata.itunes_category && newValues.metadata.language
: newValues.name && newValues.username : newValues.name && newValues.username
)) ))
@ -97,13 +107,16 @@ watch(() => newValues.name, (name) => {
} }
}) })
// @ts-expect-error No types defined by backend schema for `metadata` field
watch(() => newValues.metadata.itunes_category, () => { watch(() => newValues.metadata.itunes_category, () => {
// @ts-expect-error No types defined by backend schema for `metadata` field
newValues.metadata.itunes_subcategory = null newValues.metadata.itunes_subcategory = null
}) })
const isLoading = ref(false) const isLoading = ref(false)
const errors = ref([] as string[]) const errors = ref([] as string[])
// @ts-expect-error Re-check emits
watchEffect(() => emit('category', newValues.content_category)) watchEffect(() => emit('category', newValues.content_category))
watchEffect(() => emit('loading', isLoading.value)) watchEffect(() => emit('loading', isLoading.value))
watchEffect(() => emit('submittable', submittable.value)) watchEffect(() => emit('submittable', submittable.value))
@ -111,8 +124,9 @@ watchEffect(() => emit('submittable', submittable.value))
// TODO (wvffle): Add loader / Use Suspense // TODO (wvffle): Add loader / Use Suspense
const fetchMetadataChoices = async () => { const fetchMetadataChoices = async () => {
try { try {
const response = await axios.get('channels/metadata-choices') const response = await axios.get<paths['/api/v2/channels/metadata-choices/']['get']['responses']['200']['content']['application/json']>('channels/metadata-choices/')
metadataChoices.value = response.data // TODO: Fix schema generation so we don't need to typecast here!
metadataChoices.value = response.data as unknown as MetadataChoices
} catch (error) { } catch (error) {
errors.value = (error as BackendError).backendErrors errors.value = (error as BackendError).backendErrors
} }
@ -155,17 +169,17 @@ defineExpose({
</script> </script>
<template> <template>
<form <Layout
form
class="ui form" class="ui form"
@submit.prevent.stop="submit" @submit.prevent.stop="submit"
> >
<div <Alert
v-if="errors.length > 0" v-if="errors.length > 0"
role="alert" red
class="ui negative message"
> >
<h4 class="header"> <h4 class="header">
{{ $t('components.audio.ChannelForm.header.error') }} {{ t('components.audio.ChannelForm.header.error') }}
</h4> </h4>
<ul class="list"> <ul class="list">
<li <li
@ -175,14 +189,14 @@ defineExpose({
{{ error }} {{ error }}
</li> </li>
</ul> </ul>
</div> </Alert>
<template v-if="metadataChoices"> <template v-if="metadataChoices">
<fieldset <fieldset
v-if="creating && step === 1" v-if="creating && step === 1"
class="ui grouped channel-type required field" class="ui grouped channel-type required field"
> >
<legend> <legend>
{{ $t('components.audio.ChannelForm.legend.purpose') }} {{ t('components.audio.ChannelForm.legend.purpose') }}
</legend> </legend>
<div class="ui hidden divider" /> <div class="ui hidden divider" />
<div class="field"> <div class="field">
@ -199,7 +213,7 @@ defineExpose({
:value="choice.value" :value="choice.value"
> >
<label :for="`category-${choice.value}`"> <label :for="`category-${choice.value}`">
<span :class="['right floated', 'placeholder', 'image', {circular: choice.value === 'music'}]" /> <span :class="['right floated', 'placeholder', 'image', 'shifted', {circular: choice.value === 'music'}]" />
<strong>{{ choice.label }}</strong> <strong>{{ choice.label }}</strong>
<div class="ui small hidden divider" /> <div class="ui small hidden divider" />
{{ choice.helpText }} {{ choice.helpText }}
@ -207,38 +221,30 @@ defineExpose({
</div> </div>
</div> </div>
</fieldset> </fieldset>
<template v-if="!creating || step === 2"> <template v-if="!creating || step === 2">
<div class="ui required field"> <div class="ui required field">
<label for="channel-name"> <Input
{{ $t('components.audio.ChannelForm.label.name') }}
</label>
<input
v-model="newValues.name" v-model="newValues.name"
type="text" type="text"
required required
:placeholder="labels.namePlaceholder" :placeholder="labels.namePlaceholder"
> :label="t('components.audio.ChannelForm.label.name')"
/>
</div> </div>
<div class="ui required field"> <div class="ui required field">
<label for="channel-username"> <Input
{{ $t('components.audio.ChannelForm.label.username') }}
</label>
<div class="ui left labeled input">
<div class="ui basic label">
<span class="at symbol" />
</div>
<input
v-model="newValues.username" v-model="newValues.username"
type="text" type="text"
:required="creating" :required="creating"
:disabled="!creating" :disabled="!creating"
:placeholder="labels.usernamePlaceholder" :placeholder="labels.usernamePlaceholder"
> :label="t('components.audio.ChannelForm.label.username')"
</div> />
<template v-if="creating"> <template v-if="creating">
<div class="ui small hidden divider" /> <div class="ui small hidden divider" />
<p> <p>
{{ $t('components.audio.ChannelForm.help.username') }} {{ t('components.audio.ChannelForm.help.username') }}
</p> </p>
</template> </template>
</div> </div>
@ -248,31 +254,25 @@ defineExpose({
:image-class="newValues.content_category === 'podcast' ? '' : 'circular'" :image-class="newValues.content_category === 'podcast' ? '' : 'circular'"
@delete="newValues.cover = null" @delete="newValues.cover = null"
> >
{{ $t('components.audio.ChannelForm.label.image') }} {{ t('components.audio.ChannelForm.label.image') }}
</attachment-input> </attachment-input>
</div> </div>
<div class="ui small hidden divider" /> <Pills
<div class="ui stackable grid row"> :get="model => { newValues.tags = model.currents.map(({ label }) => label) }"
<div class="ten wide column"> :set="model => ({
<div class="ui field"> currents: newValues.tags.map(tag => ({ type: 'custom' as const, label: tag })),
<label for="channel-tags"> others: [].map(tag => ({ type: 'custom' as const, label: tag }))
{{ $t('components.audio.ChannelForm.label.tags') }} })"
</label> :label="t('components.audio.ChannelForm.label.tags')"
<tags-selector
id="channel-tags"
v-model="newValues.tags"
:required="false"
/> />
</div>
</div>
<div <div
v-if="newValues.content_category === 'podcast'" v-if="newValues.content_category === 'podcast'"
class="six wide column"
> >
<div class="ui required field">
<label for="channel-language"> <label for="channel-language">
{{ $t('components.audio.ChannelForm.label.language') }} {{ t('components.audio.ChannelForm.label.language') }}
</label> </label>
<!-- @vue-ignore -->
<select <select
id="channel-language" id="channel-language"
v-model="newValues.metadata.language" v-model="newValues.metadata.language"
@ -289,23 +289,22 @@ defineExpose({
</option> </option>
</select> </select>
</div> </div>
</div>
</div>
<div class="ui small hidden divider" />
<div class="ui field"> <div class="ui field">
<label for="channel-name"> <Textarea
{{ $t('components.audio.ChannelForm.label.description') }} v-model="newValues.description"
</label> :label="t('components.audio.ChannelForm.label.description')"
<content-form v-model="newValues.description" /> initial-lines="3"
/>
</div> </div>
<div <template
v-if="newValues.content_category === 'podcast'" v-if="newValues.content_category === 'podcast'"
class="ui two fields"
> >
<div class="ui required field"> <div class="ui required field">
<label for="channel-itunes-category"> <label for="channel-itunes-category">
{{ $t('components.audio.ChannelForm.label.category') }} {{ t('components.audio.ChannelForm.label.category') }}
</label> </label>
<!-- @vue-ignore -->
<select <select
id="itunes-category" id="itunes-category"
v-model="newValues.metadata.itunes_category" v-model="newValues.metadata.itunes_category"
@ -324,8 +323,10 @@ defineExpose({
</div> </div>
<div class="ui field"> <div class="ui field">
<label for="channel-itunes-category"> <label for="channel-itunes-category">
{{ $t('components.audio.ChannelForm.label.subcategory') }} {{ t('components.audio.ChannelForm.label.subcategory') }}
</label> </label>
<!-- @vue-ignore -->
<select <select
id="itunes-category" id="itunes-category"
v-model="newValues.metadata.itunes_subcategory" v-model="newValues.metadata.itunes_subcategory"
@ -342,37 +343,37 @@ defineExpose({
</option> </option>
</select> </select>
</div> </div>
</div> </template>
<div <template
v-if="newValues.content_category === 'podcast'" v-if="newValues.content_category === 'podcast'"
class="ui two fields"
> >
<Alert blue>
<span>
<i class="bi bi-info-circle-fill" />
{{ t('components.audio.ChannelForm.help.podcastFields') }}
</span>
</Alert>
<div class="ui field"> <div class="ui field">
<label for="channel-itunes-email"> <!-- @vue-ignore -->
{{ $t('components.audio.ChannelForm.label.email') }} <Input
</label>
<input
id="channel-itunes-email" id="channel-itunes-email"
v-model="newValues.metadata.owner_email" v-model="newValues.metadata.owner_email"
name="channel-itunes-email" name="channel-itunes-email"
type="email" type="email"
> :label="t('components.audio.ChannelForm.label.email')"
/>
</div> </div>
<div class="ui field"> <div class="ui field">
<label for="channel-itunes-name"> <!-- @vue-ignore -->
{{ $t('components.audio.ChannelForm.label.owner') }} <Input
</label>
<input
id="channel-itunes-name" id="channel-itunes-name"
v-model="newValues.metadata.owner_name" v-model="newValues.metadata.owner_name"
name="channel-itunes-name" name="channel-itunes-name"
maxlength="255" maxlength="255"
> :label="t('components.audio.ChannelForm.label.owner')"
/>
</div> </div>
</div> </template>
<p>
{{ $t('components.audio.ChannelForm.help.podcastFields') }}
</p>
</template> </template>
</template> </template>
<div <div
@ -380,8 +381,8 @@ defineExpose({
class="ui active inverted dimmer" class="ui active inverted dimmer"
> >
<div class="ui text loader"> <div class="ui text loader">
{{ $t('components.audio.ChannelForm.loader.loading') }} {{ t('components.audio.ChannelForm.loader.loading') }}
</div> </div>
</div> </div>
</form> </Layout>
</template> </template>

View File

@ -1,72 +1,74 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Album } from '~/types' import { computed } from 'vue'
import { useStore } from '~/store'
import { useI18n } from 'vue-i18n'
import { momentFormat } from '~/utils/filters'
import defaultCover from '~/assets/audio/default-cover.png'
import PlayButton from '~/components/audio/PlayButton.vue' import PlayButton from '~/components/audio/PlayButton.vue'
import { computed } from 'vue' import Card from '~/components/ui/Card.vue'
import Spacer from '~/components/ui/Spacer.vue'
import { type Album } from '~/types'
interface Props { interface Props {
serie: Album serie: Album
} }
const { t } = useI18n()
const props = defineProps<Props>() const props = defineProps<Props>()
const cover = computed(() => props.serie?.cover ?? null) const { serie } = props
const store = useStore()
const imageUrl = computed(() => serie?.cover?.urls.original
? store.getters['instance/absoluteUrl'](serie.cover?.urls.medium_square_crop)
: defaultCover
)
</script> </script>
<template> <template>
<div class="channel-serie-card"> <Card
<div class="two-images"> :title="serie?.title"
<img :image="imageUrl"
v-if="cover && cover.urls.original" :tags="serie?.tags"
v-lazy="$store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)" :to="{name: 'library.albums.detail', params: {id: serie?.id}}"
alt="" small
class="channel-image"
@click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})"
> >
<img <template #topright>
v-else <PlayButton
alt="" icon-only
class="channel-image" :is-playable="serie?.is_playable"
src="../../assets/audio/default-cover.png"
@click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})"
>
<img
v-if="cover && cover.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)"
alt=""
class="channel-image"
@click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})"
>
<img
v-else
alt=""
class="channel-image"
src="../../assets/audio/default-cover.png"
@click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})"
>
</div>
<div class="content ellipsis">
<strong>
<router-link
class="discrete link"
:to="{name: 'library.albums.detail', params: {id: serie.id}}"
>
{{ serie.title }}
</router-link>
</strong>
<div class="description">
<span>
{{ $t('components.audio.ChannelSerieCard.meta.episodes', serie.tracks_count) }}
</span>
</div>
</div>
<div class="controls">
<play-button
:icon-only="true"
:is-playable="true"
:button-classes="['ui', 'circular', 'vibrant', 'icon', 'button']"
:album="serie" :album="serie"
/> />
</div>
</div>
</template> </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>

View File

@ -3,10 +3,14 @@ import type { BackendError, Album } from '~/types'
import { clone } from 'lodash-es' import { clone } from 'lodash-es'
import { ref, reactive } from 'vue' import { ref, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import axios from 'axios' import axios from 'axios'
import ChannelSerieCard from '~/components/audio/ChannelSerieCard.vue' import ChannelSerieCard from '~/components/audio/ChannelSerieCard.vue'
import AlbumCard from '~/components/audio/album/Card.vue' import AlbumCard from '~/components/audio/album/Card.vue'
import Layout from '~/components/ui/Layout.vue'
import Loader from '~/components/ui/Loader.vue'
import Button from '~/components/ui/Button.vue'
interface Props { interface Props {
filters: object filters: object
@ -14,6 +18,8 @@ interface Props {
limit?: number limit?: number
} }
const { t } = useI18n()
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
isPodcast: true, isPodcast: true,
limit: 5 limit: 5
@ -51,15 +57,9 @@ fetchData()
</script> </script>
<template> <template>
<div>
<slot /> <slot />
<div class="ui hidden divider" /> <Layout flex>
<div <Loader v-if="isLoading" />
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div>
<template v-if="isPodcast"> <template v-if="isPodcast">
<channel-serie-card <channel-serie-card
v-for="serie in albums" v-for="serie in albums"
@ -67,35 +67,31 @@ fetchData()
:serie="serie" :serie="serie"
/> />
</template> </template>
<div <template v-else>
v-else
class="ui app-cards cards"
>
<album-card <album-card
v-for="album in albums" v-for="album in albums"
:key="album.id" :key="album.id"
:album="album" :album="album"
/> />
</div> </template>
<template v-if="nextPage"> <template v-if="nextPage">
<div class="ui hidden divider" /> <Button
<button
v-if="nextPage" v-if="nextPage"
:class="['ui', 'basic', 'button']" secondary
@click="fetchData(nextPage)" @click="fetchData(nextPage)"
> >
{{ $t('components.audio.ChannelSeries.button.showMore') }} {{ t('components.audio.ChannelSeries.button.showMore') }}
</button> </Button>
</template> </template>
</Layout>
<template v-if="!isLoading && albums.length === 0"> <template v-if="!isLoading && albums.length === 0">
<empty-state <empty-state
:refresh="true" :refresh="true"
@refresh="fetchData()" @refresh="fetchData()"
> >
<p> <p>
{{ $t('components.audio.ChannelSeries.help.subscribe') }} {{ t('components.audio.ChannelSeries.help.subscribe') }}
</p> </p>
</empty-state> </empty-state>
</template> </template>
</div>
</template> </template>

View File

@ -1,47 +1,57 @@
<script setup lang="ts"> <script setup lang="ts">
import type { BackendError, BackendResponse, Channel } from '~/types' import type { BackendError, PaginatedChannelList } from '~/types'
import { type operations } from '~/generated/types.ts'
import { ref, reactive } from 'vue' import { ref, onMounted, watch } from 'vue'
import { clone } from 'lodash-es' import { clone } from 'lodash-es'
import axios from 'axios' import axios from 'axios'
import usePage from '~/composables/navigation/usePage'
import ChannelCard from '~/components/audio/ChannelCard.vue' import ChannelCard from '~/components/audio/ChannelCard.vue'
import Loader from '~/components/ui/Loader.vue'
import Section from '~/components/ui/Section.vue'
import Pagination from '~/components/ui/Pagination.vue'
interface Events { interface Events {
(e: 'fetched', channels: BackendResponse<Channel>): void (e: 'fetched', channels: PaginatedChannelList): void
} }
interface Props { interface Props {
filters: object filters: object
limit?: number limit?: number
title?: string
} }
const emit = defineEmits<Events>() const emit = defineEmits<Events>()
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
limit: 5 limit: 5,
title: undefined
}) })
const channels = reactive([] as Channel[]) const result = ref<PaginatedChannelList>()
const errors = ref([] as string[]) const errors = ref([] as string[])
const nextPage = ref() const nextPage = ref()
const page = usePage()
const count = ref(0) const count = ref(0)
const isLoading = ref(false) const isLoading = ref(false)
const fetchData = async (url = 'channels/') => { const fetchData = async (url = 'channels/') => {
isLoading.value = true isLoading.value = true
const params = { const params: operations['get_channels_2']['parameters']['query'] = {
...clone(props.filters), ...clone(props.filters),
page_size: props.limit, page: page.value,
include_channels: true page_size: props.limit
} }
try { try {
const response = await axios.get(url, { params }) const response = await axios.get<PaginatedChannelList>(url, { params })
nextPage.value = response.data.next nextPage.value = response.data.next
count.value = response.data.count count.value = response.data.count
channels.push(...response.data.results) result.value = response.data
emit('fetched', response.data) emit('fetched', response.data)
} catch (error) { } catch (error) {
errors.value = (error as BackendError).backendErrors errors.value = (error as BackendError).backendErrors
@ -50,41 +60,51 @@ const fetchData = async (url = 'channels/') => {
isLoading.value = false isLoading.value = false
} }
onMounted(() => {
fetchData() fetchData()
})
watch([() => props.filters, page],
() => fetchData(),
{ deep: true }
)
</script> </script>
<template> <template>
<div> <Section
<slot /> align-left
<div class="ui hidden divider" /> :columns-per-item="1"
<div class="ui app-cards cards"> :h2="title || undefined"
<div >
<Loader
v-if="isLoading" v-if="isLoading"
class="ui inverted active dimmer" style="grid-column: 1 / -1;"
>
<div class="ui loader" />
</div>
<channel-card
v-for="object in channels"
:key="object.uuid"
:object="object"
/> />
</div> <template
<template v-if="nextPage"> v-if="!isLoading && result?.count === 0"
<div class="ui hidden divider" />
<button
v-if="nextPage"
:class="['ui', 'basic', 'button']"
@click="fetchData(nextPage)"
> >
{{ $t('components.audio.ChannelsWidget.button.showMore') }}
</button>
</template>
<template v-if="!isLoading && channels.length === 0">
<empty-state <empty-state
:refresh="true" :refresh="true"
style="grid-column: 1 / -1;"
@refresh="fetchData('channels/')" @refresh="fetchData('channels/')"
/> />
</template> </template>
</div> <Pagination
v-if="page && result && count > limit && limit > 16"
v-model:page="page"
:pages="Math.ceil((count || 0) / limit)"
style="grid-column: 1 / -1;"
/>
<channel-card
v-for="channel in result?.results"
:key="channel.uuid"
:object="channel"
/>
<Pagination
v-if="page && result && count > limit"
v-model:page="page"
:pages="Math.ceil((count || 0) / limit)"
style="grid-column: 1 / -1;"
/>
</Section>
</template> </template>

View File

@ -3,14 +3,25 @@ import { get } from 'lodash-es'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import { useClipboard } from '@vueuse/core' import { useClipboard } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import Button from '~/components/ui/Button.vue'
import Alert from '~/components/ui/Alert.vue'
import Input from '~/components/ui/Input.vue'
import Spacer from '~/components/ui/Spacer.vue'
interface Props { interface Props {
type: string type: string
id: number id: number
} }
const { t } = useI18n()
const props = defineProps<Props>() const props = defineProps<Props>()
const width = ref(null)
// TODO: This used to be `null`. Is `0` correct?
const width = ref(0)
const height = ref(150) const height = ref(150)
const minHeight = ref(100) const minHeight = ref(100)
@ -51,69 +62,70 @@ const { copy, copied } = useClipboard({ source: embedCode })
> >
<p> <p>
<strong> <strong>
{{ $t('components.audio.EmbedWizard.warning.anonymous') }} {{ t('components.audio.EmbedWizard.warning.anonymous') }}
</strong> </strong>
</p> </p>
<p> <p>
{{ $t('components.audio.EmbedWizard.help.anonymous') }} {{ t('components.audio.EmbedWizard.help.anonymous') }}
</p> </p>
</div> </div>
<div class="ui form"> <div class="ui form">
<div class="two fields"> <div class="two fields">
<div class="field"> <div class="field">
<div class="field"> <div class="field">
<label for="embed-width">{{ $t('components.audio.EmbedWizard.label.width') }}</label> <label for="embed-width">{{ t('components.audio.EmbedWizard.label.width') }}</label>
<p> <p>
{{ $t('components.audio.EmbedWizard.help.width') }} {{ t('components.audio.EmbedWizard.help.width') }}
</p> </p>
<input <Input
id="embed-width" id="embed-width"
v-model.number="width" v-model.number="width"
type="number" type="number"
min="0" min="0"
step="10" step="10"
> />
</div> </div>
<template v-if="type != 'track'"> <template v-if="type != 'track'">
<br> <br>
<div class="field"> <div class="field">
<label for="embed-height">{{ $t('components.audio.EmbedWizard.label.height') }}</label> <label for="embed-height">{{ t('components.audio.EmbedWizard.label.height') }}</label>
<input <Input
id="embed-height" id="embed-height"
v-model="height" v-model="height"
type="number" type="number"
:min="minHeight" :min="minHeight"
max="1000" max="1000"
step="10" step="10"
> />
</div> </div>
</template> </template>
</div> </div>
<Spacer />
<div class="field"> <div class="field">
<button <Button
class="ui right accent labeled icon floated button" class="right floated"
icon="bi-copy"
secondary
@click="copy()" @click="copy()"
> >
<i class="copy icon" /> {{ t('components.audio.EmbedWizard.button.copy') }}
{{ $t('components.audio.EmbedWizard.button.copy') }} </Button>
</button> <label for="embed-width">{{ t('components.audio.EmbedWizard.label.embed') }}</label>
<label for="embed-width">{{ $t('components.audio.EmbedWizard.label.embed') }}</label>
<p> <p>
{{ $t('components.audio.EmbedWizard.help.embed') }} {{ t('components.audio.EmbedWizard.help.embed') }}
</p> </p>
<textarea <textarea
v-model="embedCode" v-model="embedCode"
rows="5" rows="3"
readonly readonly
style="width: 100%;"
/> />
<div class="ui right"> <Alert
<p
v-if="copied" v-if="copied"
class="message" green
> >
{{ $t('components.audio.EmbedWizard.message.copy') }} {{ t('components.audio.EmbedWizard.message.copy') }}
</p> </Alert>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -123,7 +135,7 @@ const { copy, copied } = useClipboard({ source: embedCode })
:href="iframeSrc" :href="iframeSrc"
target="_blank" target="_blank"
> >
{{ $t('components.audio.EmbedWizard.header.preview') }} {{ t('components.audio.EmbedWizard.header.preview') }}
</a> </a>
</h3> </h3>
<iframe <iframe

View File

@ -3,6 +3,7 @@ import type { Library } from '~/types'
import { computed } from 'vue' import { computed } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import { useI18n } from 'vue-i18n'
interface Events { interface Events {
(e: 'unfollowed'): void (e: 'unfollowed'): void
@ -13,6 +14,7 @@ interface Props {
library: Library library: Library
} }
const { t } = useI18n()
const emit = defineEmits<Events>() const emit = defineEmits<Events>()
const props = defineProps<Props>() const props = defineProps<Props>()
@ -39,13 +41,13 @@ const toggle = () => {
> >
<i class="heart icon" /> <i class="heart icon" />
<span v-if="isApproved"> <span v-if="isApproved">
{{ $t('components.audio.LibraryFollowButton.button.unfollow') }} {{ t('components.audio.LibraryFollowButton.button.unfollow') }}
</span> </span>
<span v-else-if="isPending"> <span v-else-if="isPending">
{{ $t('components.audio.LibraryFollowButton.button.cancel') }} {{ t('components.audio.LibraryFollowButton.button.cancel') }}
</span> </span>
<span v-else> <span v-else>
{{ $t('components.audio.LibraryFollowButton.button.follow') }} {{ t('components.audio.LibraryFollowButton.button.follow') }}
</span> </span>
</button> </button>
</template> </template>

View File

@ -1,15 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types' import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
import type { components } from '~/generated/types'
import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions' import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
import { ref, computed, onMounted } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import usePlayOptions from '~/composables/audio/usePlayOptions' import usePlayOptions from '~/composables/audio/usePlayOptions'
import useReport from '~/composables/moderation/useReport' import useReport from '~/composables/moderation/useReport'
import { useCurrentElement } from '@vueuse/core' import { useStore } from '~/store'
import { setupDropdown } from '~/utils/fomantic' import { useRouter, useRoute } from 'vue-router'
import Button from '~/components/ui/Button.vue'
import OptionsButton from '~/components/ui/button/Options.vue'
import Popover from '~/components/ui/Popover.vue'
import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
interface Props extends PlayOptionsProps { interface Props extends PlayOptionsProps {
split?: boolean
dropdownIconClasses?: string[] dropdownIconClasses?: string[]
playIconClass?: string playIconClass?: string
buttonClasses?: string[] buttonClasses?: string[]
@ -18,20 +25,22 @@ interface Props extends PlayOptionsProps {
iconOnly?: boolean iconOnly?: boolean
playing?: boolean playing?: boolean
paused?: boolean paused?: boolean
lowHeight?: boolean
// TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged // TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged
isPlayable?: boolean isPlayable?: boolean
tracks?: Track[] tracks?: Track[]
track?: Track | null track?: Track | null
artist?: Artist | null artist?: Artist | components["schemas"]["SimpleChannelArtist"] | components['schemas']['ArtistWithAlbums'] | null
album?: Album | null album?: Album | null
playlist?: Playlist | null playlist?: Playlist | null
library?: Library | null library?: Library | null
channel?: Channel | null channel?: Channel | null
account?: Actor | null account?: Actor | components['schemas']['APIActor'] | null
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
split: false,
tracks: () => [], tracks: () => [],
track: null, track: null,
artist: null, artist: null,
@ -40,17 +49,22 @@ const props = withDefaults(defineProps<Props>(), {
library: null, library: null,
channel: null, channel: null,
account: null, account: null,
dropdownIconClasses: () => ['dropdown'], dropdownIconClasses: () => ['bi-caret-down-fill'],
playIconClass: () => 'play icon', playIconClass: () => 'bi-play-fill',
buttonClasses: () => ['button'], buttonClasses: () => ['button'],
discrete: () => false, discrete: () => false,
dropdownOnly: () => false, dropdownOnly: () => false,
iconOnly: () => false, iconOnly: () => false,
isPlayable: () => false, isPlayable: () => false,
playing: () => false, playing: () => false,
paused: () => false paused: () => false,
lowHeight: () => false
}) })
// (1) Create a PlayButton
// Some of the props are meant for `usePlayOptions`!
// UsePlayOptions accepts the props from this component and returns the following things:
const { const {
playable, playable,
filterableArtist, filterableArtist,
@ -64,6 +78,10 @@ const {
const { report, getReportableObjects } = useReport() const { report, getReportableObjects } = useReport()
const { t } = useI18n() const { t } = useI18n()
const store = useStore()
const router = useRouter()
const route = useRoute()
const labels = computed(() => ({ const labels = computed(() => ({
playNow: t('components.audio.PlayButton.button.playNow'), playNow: t('components.audio.PlayButton.button.playNow'),
addToQueue: t('components.audio.PlayButton.button.addToQueue'), addToQueue: t('components.audio.PlayButton.button.addToQueue'),
@ -83,149 +101,163 @@ const labels = computed(() => ({
: t('components.audio.PlayButton.button.playTracks') : t('components.audio.PlayButton.button.playTracks')
})) }))
const title = computed(() => { const isOpen = ref(false)
if (playable.value) {
return t('components.audio.PlayButton.title.more')
}
if (props.track) {
return t('components.audio.PlayButton.title.unavailable')
}
return ''
})
const el = useCurrentElement()
const dropdown = ref()
onMounted(() => {
dropdown.value = setupDropdown('.ui.dropdown', el.value)
})
const openMenu = () => {
// little magic to ensure the menu is always visible in the viewport
// By default, try to display it on the right if there is enough room
const menu = dropdown.value.find('.menu')
if (menu.hasClass('visible')) return
const viewportOffset = menu.get(0)?.getBoundingClientRect() ?? { right: 0, left: 0 }
const viewportWidth = document.documentElement.clientWidth
const rightOverflow = viewportOffset.right - viewportWidth
const leftOverflow = -viewportOffset.left
menu.css({
cssText: rightOverflow > 0
? `left: ${-rightOverflow - 5}px !important;`
: `right: ${-leftOverflow + 5}px !important;`
})
}
</script> </script>
<template> <template>
<span <Popover
:title="title" v-if="split || (!iconOnly && dropdownOnly)"
:class="['ui', {'tiny': discrete, 'icon': !discrete, 'buttons': !dropdownOnly && !iconOnly}, 'play-button component-play-button']" v-model="isOpen"
> >
<button <OptionsButton
v-if="!dropdownOnly" v-if="dropdownOnly"
:disabled="!playable" v-bind="$attrs"
:is-ghost="discrete"
@click="isOpen = !isOpen"
/>
<Button
v-else
v-bind="{
disabled: !playable && !filterableArtist,
primary: playable,
split: true,
splitIcon: 'bi-caret-down-fill'
}"
:aria-label="labels.replacePlay" :aria-label="labels.replacePlay"
:class="[...buttonClasses, 'ui', {loading: isLoading, 'mini': discrete, disabled: !playable}]" :class="[...buttonClasses, 'play-button']"
:isloading="isLoading"
:dropdown-only="dropdownOnly"
:low-height="lowHeight || undefined"
style="align-self: start;"
@click.stop.prevent="replacePlay()" @click.stop.prevent="replacePlay()"
@split-click="isOpen = !isOpen"
> >
<template #main>
<i <i
v-if="playing" v-if="playing"
class="pause icon" class="bi bi-pause-fill"
/> />
<i <i
v-else v-else
:class="[playIconClass, 'icon']" :class="['bi', playIconClass]"
/> />
<template v-if="!discrete && !iconOnly">&nbsp;<slot>{{ $t('components.audio.PlayButton.button.discretePlay') }}</slot></template> <template v-if="!discrete && !iconOnly">
</button> &nbsp;<slot>{{ t('components.audio.PlayButton.button.discretePlay') }}</slot>
<button </template>
v-if="!discrete && !iconOnly" </template>
:class="['ui', {disabled: !playable && !filterableArtist}, 'floating', 'dropdown', {'icon': !dropdownOnly}, {'button': !dropdownOnly}]" </Button>
@click.stop.prevent="openMenu"
> <template #items>
<i <PopoverItem
:class="dropdownIconClasses.concat(['icon'])"
:title="title"
/>
<div class="menu">
<button
class="item basic"
:disabled="!playable" :disabled="!playable"
:title="labels.addToQueue" :title="labels.addToQueue"
icon="bi-plus"
@click.stop.prevent="enqueue" @click.stop.prevent="enqueue"
> >
<i class="plus icon" />{{ labels.addToQueue }} {{ labels.addToQueue }}
</button> </PopoverItem>
<button
class="item basic" <PopoverItem
:disabled="!playable" :disabled="!playable"
:title="labels.playNext" :title="labels.playNext"
icon="bi-skip-forward-fill"
@click.stop.prevent="enqueueNext()" @click.stop.prevent="enqueueNext()"
> >
<i class="step forward icon" />{{ labels.playNext }} {{ labels.playNext }}
</button> </PopoverItem>
<button
class="item basic" <PopoverItem
:disabled="!playable" :disabled="!playable"
:title="labels.playNow" :title="labels.playNow"
icon="bi-play-fill"
@click.stop.prevent="enqueueNext(true)" @click.stop.prevent="enqueueNext(true)"
> >
<i class="play icon" />{{ labels.playNow }} {{ labels.playNow }}
</button> </PopoverItem>
<button
<PopoverItem
v-if="track" v-if="track"
class="item basic"
:disabled="!playable" :disabled="!playable"
:title="labels.startRadio" :title="labels.startRadio"
@click.stop.prevent="$store.dispatch('radios/start', {type: 'similar', objectId: track?.id})" icon="bi-broadcast"
@click.stop.prevent="store.dispatch('radios/start', {type: 'similar', objectId: track?.id})"
> >
<i class="feed icon" />{{ labels.startRadio }} {{ labels.startRadio }}
</button> </PopoverItem>
<button
<PopoverItem
v-if="track" v-if="track"
class="item basic"
:disabled="!playable" :disabled="!playable"
@click.stop="$store.commit('playlists/chooseTrack', track)" icon="bi-list"
@click.stop="store.commit('playlists/chooseTrack', track)"
> >
<i class="list icon" />
{{ labels.addToPlaylist }} {{ labels.addToPlaylist }}
</button> </PopoverItem>
<button <PopoverItem
v-if="track && $route.name !== 'library.tracks.detail'" v-if="track && route.name !== 'library.tracks.detail'"
class="item basic" icon="bi-info-circle"
@click.stop.prevent="$router.push(`/library/tracks/${track?.id}/`)" @click.stop.prevent="router.push(`/library/tracks/${track?.id}/`)"
> >
<i class="info icon" />
<span v-if="track.artist_credit?.some(ac => ac.artist.content_category === 'podcast')"> <span v-if="track.artist_credit?.some(ac => ac.artist.content_category === 'podcast')">
{{ $t('components.audio.PlayButton.button.episodeDetails') }} {{ t('components.audio.PlayButton.button.episodeDetails') }}
</span> </span>
<span v-else> <span v-else>
{{ $t('components.audio.PlayButton.button.trackDetails') }} {{ t('components.audio.PlayButton.button.trackDetails') }}
</span> </span>
</button> </PopoverItem>
<div class="divider" />
<button <hr v-if="filterableArtist || Object.keys(getReportableObjects({ track, album, artist, playlist, account, channel })).length > 0">
<PopoverItem
v-if="filterableArtist" v-if="filterableArtist"
class="item basic"
:disabled="!filterableArtist" :disabled="!filterableArtist"
:title="labels.hideArtist" :title="labels.hideArtist"
icon="bi-eye-slash"
@click.stop.prevent="filterArtist" @click.stop.prevent="filterArtist"
> >
<i class="eye slash outline icon" />
{{ labels.hideArtist }} {{ labels.hideArtist }}
</button> </PopoverItem>
<button
<PopoverItem
v-for="obj in getReportableObjects({ track, album, artist, playlist, account, channel })" v-for="obj in getReportableObjects({ track, album, artist, playlist, account, channel })"
:key="obj.target.type + obj.target.id" :key="obj.target.type + obj.target.id"
class="item basic" icon="bi-exclamation-triangle-fill"
@click.stop.prevent="report(obj)" @click.stop.prevent="report(obj)"
> >
<i class="share icon" /> {{ obj.label }} {{ obj.label }}
</button> </PopoverItem>
</div> </template>
</button> </Popover>
<Button
v-else
v-bind="{
disabled: !playable,
primary: playable,
}"
:aria-label="labels.replacePlay"
:class="[...buttonClasses, 'play-button']"
:isloading="isLoading"
:square="iconOnly"
:icon="!playing ? playIconClass : 'bi-pause-fill'"
:round="iconOnly"
:primary="iconOnly && !discrete"
:ghost="discrete"
:low-height="lowHeight || undefined"
@click.stop.prevent="replacePlay()"
>
<template v-if="!discrete && !iconOnly">
<span>
{{ t('components.audio.PlayButton.button.discretePlay') }}
</span> </span>
</template> </template>
</Button>
</template>
<style lang="scss" scoped>
.funkwhale.split-button {
&.button {
gap: 0px;
padding: 0px;
}
}
</style>

View File

@ -6,6 +6,7 @@ import { useMouse, useWindowSize } from '@vueuse/core'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import onKeyboardShortcut from '~/composables/onKeyboardShortcut' import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
import time from '~/utils/time' import time from '~/utils/time'
@ -14,6 +15,8 @@ import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue' import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
import PlayerControls from './PlayerControls.vue' import PlayerControls from './PlayerControls.vue'
import VolumeControl from './VolumeControl.vue' import VolumeControl from './VolumeControl.vue'
import Layout from '~/components/ui/Layout.vue'
import Button from '~/components/ui/Button.vue'
const { const {
LoopingMode, LoopingMode,
@ -43,14 +46,26 @@ const {
} = useQueue() } = useQueue()
const store = useStore() const store = useStore()
const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const toggleMobilePlayer = () => { /** Toggle between null and player */
store.commit('ui/queueFocused', ['queue', 'player'].includes(store.state.ui.queueFocused as string) ? null : 'player') const togglePlayer = () => {
store.commit('ui/queueFocused',
store.state.ui.queueFocused === 'queue'
? null
: store.state.ui.queueFocused === 'player'
? null
: 'player'
)
}
const switchTab = () => {
store.commit('ui/queueFocused', store.state.ui.queueFocused === 'player' ? 'queue' : 'player')
} }
// Key binds // Key binds
onKeyboardShortcut('e', toggleMobilePlayer) onKeyboardShortcut('e', togglePlayer)
onKeyboardShortcut('p', () => { isPlaying.value = !isPlaying.value }) onKeyboardShortcut('p', () => { isPlaying.value = !isPlaying.value })
onKeyboardShortcut('s', shuffle) onKeyboardShortcut('s', shuffle)
onKeyboardShortcut('q', clear) onKeyboardShortcut('q', clear)
@ -84,10 +99,6 @@ const labels = computed(() => ({
addArtistContentFilter: t('components.audio.Player.label.addArtistContentFilter') addArtistContentFilter: t('components.audio.Player.label.addArtistContentFilter')
})) }))
const switchTab = () => {
store.commit('ui/queueFocused', store.state.ui.queueFocused === 'player' ? 'queue' : 'player')
}
const progressBar = ref() const progressBar = ref()
const touchProgress = (event: MouseEvent) => { const touchProgress = (event: MouseEvent) => {
const time = ((event.clientX - ((event.target as Element).closest('.progress')?.getBoundingClientRect().left ?? 0)) / progressBar.value.offsetWidth) * duration.value const time = ((event.clientX - ((event.target as Element).closest('.progress')?.getBoundingClientRect().left ?? 0)) / progressBar.value.offsetWidth) * duration.value
@ -108,17 +119,18 @@ const loopingTitle = computed(() => {
: t('components.audio.Player.label.loopingWholeQueue') : t('components.audio.Player.label.loopingWholeQueue')
}) })
const hideArtist = () => { // TODO: check if still useful for filtering
if (currentTrack.value.artistId !== -1 && currentTrack.value.artistCredit) { // const hideArtist = () => {
return store.dispatch('moderation/hide', { // if (currentTrack.value.artistId !== -1 && currentTrack.value.artistCredit) {
type: 'artist', // return store.dispatch('moderation/hide', {
target: { // type: 'artist',
id: currentTrack.value.artistCredit[0].artist.id, // target: {
name: currentTrack.value.artistCredit[0].artist.name // id: currentTrack.value.artistCredit[0].artist.id,
} // name: currentTrack.value.artistCredit[0].artist.name
}) // }
} // })
} // }
// }
</script> </script>
<template> <template>
@ -135,7 +147,7 @@ const hideArtist = () => {
/> />
<div <div
class="ui inverted segment fixed-controls" class="ui inverted segment fixed-controls"
@click.prevent.stop="toggleMobilePlayer" @click.prevent.stop="togglePlayer"
> >
<div <div
ref="progressBar" ref="progressBar"
@ -156,12 +168,13 @@ const hideArtist = () => {
<div class="controls track-controls queue-not-focused desktop-and-up"> <div class="controls track-controls queue-not-focused desktop-and-up">
<div <div
class="ui tiny image" class="ui tiny image"
@click.stop.prevent="$router.push({name: 'library.tracks.detail', params: {id: currentTrack.id }})" @click.stop.prevent="router.push({name: 'library.tracks.detail', params: {id: currentTrack.id }})"
> >
<!-- TODO: Use smaller covers -->
<img <img
ref="cover" ref="cover"
v-lazy="store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
alt="" alt=""
:src="$store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
> >
</div> </div>
<div <div
@ -170,7 +183,7 @@ const hideArtist = () => {
> >
<strong> <strong>
<router-link <router-link
class="small header discrete link track" class="header discrete link track"
:to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}" :to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}"
@click.stop.prevent="" @click.stop.prevent=""
> >
@ -184,11 +197,11 @@ const hideArtist = () => {
:key="ac.artist.id" :key="ac.artist.id"
> >
<router-link <router-link
class="discrete link" class="small discrete link"
:to="{name: 'library.artists.detail', params: {id: ac.artist.id }}" :to="{name: 'library.artists.detail', params: {id: ac.artist.id }}"
@click.stop.prevent="" @click.stop.prevent=""
> >
{{ ac.credit ?? $t('components.audio.Player.meta.unknownArtist') }} {{ ac.credit ?? t('components.audio.Player.meta.unknownArtist') }}
</router-link> </router-link>
<span>{{ ac.joinphrase }}</span> <span>{{ ac.joinphrase }}</span>
</template> </template>
@ -196,11 +209,11 @@ const hideArtist = () => {
<template v-if="currentTrack.albumId !== -1"> <template v-if="currentTrack.albumId !== -1">
<span class="middle slash symbol" /> <span class="middle slash symbol" />
<router-link <router-link
class="discrete link" class="small discrete link"
:to="{name: 'library.albums.detail', params: {id: currentTrack.albumId }}" :to="{name: 'library.albums.detail', params: {id: currentTrack.albumId }}"
@click.stop.prevent="" @click.stop.prevent=""
> >
{{ currentTrack.albumTitle ?? $t('components.audio.Player.meta.unknownAlbum') }} {{ currentTrack.albumTitle ?? t('components.audio.Player.meta.unknownAlbum') }}
</router-link> </router-link>
</template> </template>
</div> </div>
@ -208,51 +221,57 @@ const hideArtist = () => {
</div> </div>
<div class="controls track-controls queue-not-focused desktop-and-below"> <div class="controls track-controls queue-not-focused desktop-and-below">
<div class="ui tiny image"> <div class="ui tiny image">
<!-- TODO: Use smaller covers -->
<img <img
ref="cover" ref="cover"
v-lazy="store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
alt="" alt=""
:src="$store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
> >
</div> </div>
<div class="middle aligned content ellipsis"> <div class="middle aligned content ellipsis">
<strong> <strong>
{{ currentTrack.title }} {{ currentTrack.title }}
</strong> </strong>
<div class="meta"> <Layout
flex
no-gap
class="meta"
>
<div <div
v-for="ac in currentTrack.artistCredit" v-for="ac in currentTrack.artistCredit"
:key="ac.artist.id" :key="ac.artist.id"
> >
{{ ac.credit ?? $t('components.audio.Player.meta.unknownArtist') }} {{ ac.credit ?? t('components.audio.Player.meta.unknownArtist') }}
<span>{{ ac.joinphrase }}</span> <span>{{ ac.joinphrase }}</span>
</div> </div>
<template v-if="currentTrack.albumId !== -1"> <template v-if="currentTrack.albumId !== -1">
<span class="middle slash symbol" /> <span class="middle slash symbol" />
{{ currentTrack.albumTitle ?? $t('components.audio.Player.meta.unknownAlbum') }} {{ currentTrack.albumTitle ?? t('components.audio.Player.meta.unknownAlbum') }}
</template> </template>
</div> </Layout>
</div> </div>
</div> </div>
<div <div
v-if="$store.state.auth.authenticated" v-if="store.state.auth.authenticated"
class="controls desktop-and-up fluid align-right" class="controls desktop-and-up fluid align-right"
> >
<track-favorite-icon <track-favorite-icon
class="control white" ghost
:track="currentTrack" :track="currentTrack"
/> />
<track-playlist-icon <track-playlist-icon
class="control white" ghost
:track="currentTrack" :track="currentTrack"
/> />
<button <!-- <Button
:class="['ui', 'really', 'basic', 'circular', 'icon', 'button', 'control']" round
ghost
icon="bi-eye-slash"
:aria-label="labels.addArtistContentFilter" :aria-label="labels.addArtistContentFilter"
:title="labels.addArtistContentFilter" :title="labels.addArtistContentFilter"
@click="hideArtist" @click="hideArtist"
> >
<i :class="['eye slash outline', 'basic', 'icon']" /> </Button> -->
</button>
</div> </div>
<player-controls class="controls queue-not-focused" /> <player-controls class="controls queue-not-focused" />
<div class="controls progress-controls queue-not-focused tablet-and-up small align-left"> <div class="controls progress-controls queue-not-focused tablet-and-up small align-left">
@ -272,49 +291,39 @@ const hideArtist = () => {
<div class="controls queue-controls when-queue-focused align-right"> <div class="controls queue-controls when-queue-focused align-right">
<div class="group"> <div class="group">
<volume-control class="expandable" /> <volume-control class="expandable" />
<button <Button
class="circular control button"
:class="{ looping: looping !== LoopingMode.None }" :class="{ looping: looping !== LoopingMode.None }"
:title="loopingTitle" :title="loopingTitle"
ghost
round
:aria-label="loopingTitle" :aria-label="loopingTitle"
:disabled="!currentTrack" :disabled="!currentTrack"
:icon="looping === LoopingMode.LoopTrack ? 'bi-repeat-1' : 'bi-repeat'"
@click.prevent.stop="toggleLooping" @click.prevent.stop="toggleLooping"
>
<i class="repeat icon">
<span
v-if="looping !== LoopingMode.None"
class="ui circular tiny vibrant label"
>
<span
v-if="looping === LoopingMode.LoopTrack"
class="symbol single"
/> />
<span
v-else-if="looping === LoopingMode.LoopQueue"
class="infinity symbol"
/>
</span>
</i>
</button>
<button <Button
class="circular control button" round
ghost
:class="{ shuffling: isShuffled }"
:disabled="queue.length === 0" :disabled="queue.length === 0"
:title="labels.shuffle" :title="labels.shuffle"
:aria-label="labels.shuffle" :aria-label="labels.shuffle"
icon="bi-shuffle"
@click.prevent.stop="shuffle()" @click.prevent.stop="shuffle()"
> />
<i :class="['ui', 'random', { disabled: queue.length === 0, shuffling: isShuffled }, 'icon']" />
</button>
</div> </div>
<!-- TODO: Remove fake responsive elements -->
<div class="group"> <div class="group">
<div class="fake-dropdown"> <div class="fake-dropdown">
<button <Button
class="position circular control button desktop-and-up"
aria-expanded="true" aria-expanded="true"
@click.stop="toggleMobilePlayer" ghost
round
icon="bi-music-note-list"
@click.stop="togglePlayer"
> >
<i class="stream icon" />
<i18n-t keypath="components.audio.Player.meta.position"> <i18n-t keypath="components.audio.Player.meta.position">
<template #index> <template #index>
{{ currentIndex + 1 }} {{ currentIndex + 1 }}
@ -323,12 +332,11 @@ const hideArtist = () => {
{{ queue.length }} {{ queue.length }}
</template> </template>
</i18n-t> </i18n-t>
</button> </Button>
<button <Button
class="position circular control button desktop-and-below" class="position circular control button desktop-and-below"
@click.stop="switchTab" icon="bi-music-note-list"
> >
<i class="stream icon" />
<i18n-t keypath="components.audio.Player.meta.position"> <i18n-t keypath="components.audio.Player.meta.position">
<template #index> <template #index>
{{ currentIndex + 1 }} {{ currentIndex + 1 }}
@ -337,46 +345,35 @@ const hideArtist = () => {
{{ queue.length }} {{ queue.length }}
</template> </template>
</i18n-t> </i18n-t>
</button> </Button>
<button <Button
v-if="$store.state.ui.queueFocused" ghost
class="circular control button close-control desktop-and-up" :class="['desktop-and-up', { 'close-control': store.state.ui.queueFocused }]"
@click.stop="toggleMobilePlayer" :icon="store.state.ui.queueFocused ? 'bi-chevron-down' : 'bi-chevron-up'"
> :aria-pressed="store.state.ui.queueFocused ? true : undefined"
<i class="large down angle icon" /> @click.stop="togglePlayer"
</button> />
<button <Button
v-else ghost
class="circular control button desktop-and-up" :class="['desktop-and-below', { 'close-control': store.state.ui.queueFocused === 'player' }]"
@click.stop="toggleMobilePlayer" :icon="store.state.ui.queueFocused === 'queue' ? 'bi-chevron-down' : 'bi-chevron-up'"
> :aria-pressed="store.state.ui.queueFocused ? true : undefined"
<i class="large up angle icon" />
</button>
<button
v-if="$store.state.ui.queueFocused === 'player'"
class="circular control button close-control desktop-and-below"
@click.stop="switchTab" @click.stop="switchTab"
> />
<i class="large up angle icon" />
</button>
<button
v-if="$store.state.ui.queueFocused === 'queue'"
class="circular control button desktop-and-below"
@click.stop="switchTab"
>
<i class="large down angle icon" />
</button>
</div> </div>
<button <Button
class="circular control button close-control desktop-and-below" class="close-control desktop-and-below"
@click.stop="$store.commit('ui/queueFocused', null)" icon="bi-x"
> @click.stop="store.commit('ui/queueFocused', null)"
<i class="x icon" /> />
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
</template> </template>
<style lang="scss" scoped>
</style>

View File

@ -5,7 +5,10 @@ import { computed } from 'vue'
import { usePlayer } from '~/composables/audio/player' import { usePlayer } from '~/composables/audio/player'
import { useQueue } from '~/composables/audio/queue' import { useQueue } from '~/composables/audio/queue'
const { playPrevious, hasNext, playNext, currentTrack } = useQueue() import Button from '~/components/ui/Button.vue'
// TODO: Check if we want to use `currentTrack` from useQueue() in order to disable some icon. Or not.
const { playPrevious, hasNext, playNext } = useQueue()
const { isPlaying } = usePlayer() const { isPlaying } = usePlayer()
const { t } = useI18n() const { t } = useI18n()
@ -19,40 +22,33 @@ const labels = computed(() => ({
<template> <template>
<div class="player-controls"> <div class="player-controls">
<button <Button
:title="labels.previous" :title="labels.previous"
:aria-label="labels.previous" :aria-label="labels.previous"
class="circular button control tablet-and-up" round
ghost
class="control tablet-and-up"
icon="bi-skip-backward-fill"
@click.prevent.stop="playPrevious()" @click.prevent.stop="playPrevious()"
> />
<i :class="['ui', 'large', 'backward step', 'icon']" /> <Button
</button> :title="isPlaying ? labels.pause : labels.play"
<button round
v-if="!isPlaying" ghost
:title="labels.play" :aria-label="isPlaying ? labels.pause : labels.play"
:aria-label="labels.play" :class="['control', isPlaying ? 'pause' : 'play', 'large']"
class="circular button control" :icon="isPlaying ? 'bi-pause-fill' : 'bi-play-fill'"
@click.prevent.stop="isPlaying = true" @click.prevent.stop="isPlaying = !isPlaying"
> />
<i :class="['ui', 'big', 'play', {'disabled': !currentTrack}, 'icon']" /> <Button
</button>
<button
v-else
:title="labels.pause"
:aria-label="labels.pause"
class="circular button control"
@click.prevent.stop="isPlaying = false"
>
<i :class="['ui', 'big', 'pause', {'disabled': !currentTrack}, 'icon']" />
</button>
<button
:title="labels.next" :title="labels.next"
:aria-label="labels.next" :aria-label="labels.next"
round
ghost
:disabled="!hasNext" :disabled="!hasNext"
class="circular button control" class="control"
icon="bi-skip-forward-fill"
@click.prevent.stop="playNext()" @click.prevent.stop="playNext()"
> />
<i :class="['ui', 'large', {'disabled': !hasNext}, 'forward step', 'icon']" />
</button>
</div> </div>
</template> </template>

View File

@ -6,8 +6,8 @@ import { ref, computed, reactive, watch, onMounted } from 'vue'
import { refDebounced } from '@vueuse/core' import { refDebounced } from '@vueuse/core'
import axios from 'axios' import axios from 'axios'
import AlbumCard from '~/components/audio/album/Card.vue' import AlbumCard from '~/components/album/Card.vue'
import ArtistCard from '~/components/audio/artist/Card.vue' import ArtistCard from '~/components/artist/Card.vue'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
import useLogger from '~/composables/useLogger' import useLogger from '~/composables/useLogger'
@ -73,7 +73,7 @@ const labels = computed(() => ({
<template> <template>
<div> <div>
<h2> <h2>
{{ $t('components.audio.Search.header.search') }} {{ t('components.audio.Search.header.search') }}
</h2> </h2>
<div :class="['ui', {'loading': isLoading }, 'search']"> <div :class="['ui', {'loading': isLoading }, 'search']">
<div class="ui icon big input"> <div class="ui icon big input">
@ -89,7 +89,7 @@ const labels = computed(() => ({
</div> </div>
<template v-if="query.length > 0"> <template v-if="query.length > 0">
<h3 class="ui title"> <h3 class="ui title">
{{ $t('components.audio.Search.header.artists') }} {{ t('components.audio.Search.header.artists') }}
</h3> </h3>
<div v-if="results.artists.length > 0"> <div v-if="results.artists.length > 0">
<div class="ui cards"> <div class="ui cards">
@ -101,12 +101,12 @@ const labels = computed(() => ({
</div> </div>
</div> </div>
<p v-else> <p v-else>
{{ $t('components.audio.Search.empty.noArtists') }} {{ t('components.audio.Search.empty.noArtists') }}
</p> </p>
</template> </template>
<template v-if="query.length > 0"> <template v-if="query.length > 0">
<h3 class="ui title"> <h3 class="ui title">
{{ $t('components.audio.Search.header.albums') }} {{ t('components.audio.Search.header.albums') }}
</h3> </h3>
<div <div
v-if="results.albums.length > 0" v-if="results.albums.length > 0"
@ -124,7 +124,7 @@ const labels = computed(() => ({
</div> </div>
</div> </div>
<p v-else> <p v-else>
{{ $t('components.audio.Search.empty.noAlbums') }} {{ t('components.audio.Search.empty.noAlbums') }}
</p> </p>
</template> </template>
</div> </div>

View File

@ -1,49 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Artist, Track, Album, Tag } from '~/types'
import type { RouteRecordName, RouteLocationNamedRaw } from 'vue-router'
import jQuery from 'jquery' import { useFocus } from '@vueuse/core'
import { trim } from 'lodash-es'
import { useFocus, useCurrentElement } from '@vueuse/core'
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useStore } from '~/store'
import { generateTrackCreditString } from '~/utils/utils'
import onKeyboardShortcut from '~/composables/onKeyboardShortcut' import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
interface Events {
(e: 'search'): void
}
type CategoryCode = 'federation' | 'podcasts' | 'artists' | 'albums' | 'tracks' | 'tags' | 'more'
interface Category {
code: CategoryCode,
name: string,
route: RouteRecordName
getId: (obj: unknown) => number
getTitle: (obj: unknown) => string
getDescription: (obj: unknown) => string
}
type SimpleCategory = Partial<Category> & Pick<Category, 'code' | 'name'>
const isCategoryGuard = (object: Category | SimpleCategory): object is Category => typeof object.route === 'string'
interface Results {
name: string,
results: Result[]
}
interface Result {
title: string
id?: number
description?: string
routerUrl: RouteLocationNamedRaw
}
const emit = defineEmits<Events>()
const search = ref() const search = ref()
const { focused } = useFocus(search) const { focused } = useFocus(search)
onKeyboardShortcut(['shift', 'f'], () => (focused.value = true), true) onKeyboardShortcut(['shift', 'f'], () => (focused.value = true), true)
@ -60,12 +23,11 @@ const labels = computed(() => ({
})) }))
const router = useRouter() const router = useRouter()
const store = useStore()
const el = useCurrentElement()
const query = ref() const query = ref()
const enter = () => { const enter = () => {
jQuery(el.value).search('cancel query') // TODO: Find out what jQuery version supports `search`
// jQuery(el.value).search('cancel query')
// Cancel any API search request to backend // Cancel any API search request to backend
return router.push(`/search?q=${query.value}&type=artists`) return router.push(`/search?q=${query.value}&type=artists`)
@ -75,168 +37,109 @@ const blur = () => {
search.value.blur() search.value.blur()
} }
const categories = computed(() => [
{
code: 'federation',
name: t('components.audio.SearchBar.label.category.federation')
},
{
code: 'podcasts',
name: t('components.audio.SearchBar.label.category.podcasts')
},
{
code: 'artists',
route: 'library.artists.detail',
name: labels.value.artist,
getId: (obj: Artist) => obj.id,
getTitle: (obj: Artist) => obj.name,
getDescription: () => ''
},
{
code: 'albums',
route: 'library.albums.detail',
name: labels.value.album,
getId: (obj: Album) => obj.id,
getTitle: (obj: Album) => obj.title,
getDescription: (obj: Album) => generateTrackCreditString(obj)
},
{
code: 'tracks',
route: 'library.tracks.detail',
name: labels.value.track,
getId: (obj: Track) => obj.id,
getTitle: (obj: Track) => obj.title,
getDescription: (track: Track) => {
const album = track.album ?? null
return generateTrackCreditString(album) ?? generateTrackCreditString(track) ?? ''
}
},
{
code: 'tags',
route: 'library.tags.detail',
name: labels.value.tag,
getId: (obj: Tag) => obj.name,
getTitle: (obj: Tag) => `#${obj.name}`,
getDescription: (obj: Tag) => ''
},
{
code: 'more',
name: ''
}
] as (Category | SimpleCategory)[])
const objectId = computed(() => {
const trimmedQuery = trim(trim(query.value), '@')
if (trimmedQuery.startsWith('http://') || trimmedQuery.startsWith('https://') || trimmedQuery.includes('@')) {
return query.value
}
return null
})
onMounted(() => { onMounted(() => {
jQuery(el.value).search({ // TODO: Find out what jQuery version supports `search`
type: 'category', // jQuery(el.value).search({
minCharacters: 3, // type: 'category',
showNoResults: true, // minCharacters: 3,
error: { // showNoResults: true,
// @ts-expect-error Semantic is broken // error: {
noResultsHeader: t('components.audio.SearchBar.header.noResults'), // // @ts-expect-error Semantic is broken
noResults: t('components.audio.SearchBar.empty.noResults') // noResultsHeader: t('components.audio.SearchBar.header.noResults'),
}, // noResults: t('components.audio.SearchBar.empty.noResults')
// },
onSelect (result, response) { // onSelect (result, response) {
jQuery(el.value).search('set value', query.value) // jQuery(el.value).search('set value', query.value)
router.push(result.routerUrl) // router.push(result.routerUrl)
jQuery(el.value).search('hide results') // jQuery(el.value).search('hide results')
return false // return false
}, // },
onSearchQuery (value) { // onSearchQuery (value) {
// query.value = value // // query.value = value
emit('search') // emit('search')
}, // },
apiSettings: { // apiSettings: {
url: store.getters['instance/absoluteUrl']('api/v1/search?query={query}'), // url: store.getters['instance/absoluteUrl']('api/v1/search?query={query}'),
beforeXHR: function (xhrObject) { // beforeXHR: function (xhrObject) {
if (!store.state.auth.authenticated) { // if (!store.state.auth.authenticated) {
return xhrObject // return xhrObject
} // }
if (store.state.auth.oauth.accessToken) { // if (store.state.auth.oauth.accessToken) {
xhrObject.setRequestHeader('Authorization', store.getters['auth/header']) // xhrObject.setRequestHeader('Authorization', store.getters['auth/header'])
} // }
return xhrObject // return xhrObject
}, // },
onResponse: function (initialResponse) { // onResponse: function (initialResponse) {
const id = objectId.value // const id = objectId.value
const results: Partial<Record<CategoryCode, Results>> = {} // const results: Partial<Record<CategoryCode, Results>> = {}
let resultsEmpty = true // let resultsEmpty = true
for (const category of categories.value) { // for (const category of categories.value) {
results[category.code] = { // results[category.code] = {
name: category.name, // name: category.name,
results: [] // results: []
} // }
if (category.code === 'federation' && id) { // if (category.code === 'federation' && id) {
resultsEmpty = false // resultsEmpty = false
results[category.code]?.results.push({ // results[category.code]?.results.push({
title: t('components.audio.SearchBar.link.fediverse'), // title: t('components.audio.SearchBar.link.fediverse'),
routerUrl: { // routerUrl: {
name: 'search', // name: 'search',
query: { id } // query: { id }
} // }
}) // })
} // }
if (category.code === 'podcasts' && id) { // if (category.code === 'podcasts' && id) {
resultsEmpty = false // resultsEmpty = false
results[category.code]?.results.push({ // results[category.code]?.results.push({
title: t('components.audio.SearchBar.link.rss'), // title: t('components.audio.SearchBar.link.rss'),
routerUrl: { // routerUrl: {
name: 'search', // name: 'search',
query: { id, type: 'rss' } // query: { id, type: 'rss' }
} // }
}) // })
} // }
if (category.code === 'more') { // if (category.code === 'more') {
results[category.code]?.results.push({ // results[category.code]?.results.push({
title: t('components.audio.SearchBar.link.more'), // title: t('components.audio.SearchBar.link.more'),
routerUrl: { // routerUrl: {
name: 'search', // name: 'search',
query: { type: 'artists', q: query.value } // query: { type: 'artists', q: query.value }
} // }
}) // })
} // }
if (isCategoryGuard(category)) { // if (isCategoryGuard(category)) {
for (const result of initialResponse[category.code]) { // for (const result of initialResponse[category.code]) {
resultsEmpty = false // resultsEmpty = false
const id = category.getId(result) // const id = category.getId(result)
results[category.code]?.results.push({ // results[category.code]?.results.push({
title: category.getTitle(result), // title: category.getTitle(result),
id, // id,
routerUrl: { // routerUrl: {
name: category.route, // name: category.route,
params: { id } // params: { id }
}, // },
description: category.getDescription(result) // description: category.getDescription(result)
}) // })
} // }
} // }
} // }
return { // return {
results: resultsEmpty // results: resultsEmpty
? {} // ? {}
: results // : results
} // }
} // }
} // }
}) // })
}) })
</script> </script>

View File

@ -4,6 +4,8 @@ import { useTimeoutFn } from '@vueuse/core'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Button from '~/components/ui/Button.vue'
const { volume, mute } = usePlayer() const { volume, mute } = usePlayer()
const expanded = ref(false) const expanded = ref(false)
@ -32,8 +34,10 @@ const scroll = (event: WheelEvent) => {
</script> </script>
<template> <template>
<button <Button
class="circular control button" round
ghost
square
:class="['component-volume-control', {'expanded': expanded}]" :class="['component-volume-control', {'expanded': expanded}]"
@click.prevent.stop="" @click.prevent.stop=""
@mouseover="handleOver" @mouseover="handleOver"
@ -47,7 +51,7 @@ const scroll = (event: WheelEvent) => {
:aria-label="labels.unmute" :aria-label="labels.unmute"
@click.prevent.stop="mute" @click.prevent.stop="mute"
> >
<i class="volume off icon" /> <i class="bi bi-volume-mute-fill" />
</span> </span>
<span <span
v-else-if="volume < 0.5" v-else-if="volume < 0.5"
@ -56,7 +60,7 @@ const scroll = (event: WheelEvent) => {
:aria-label="labels.mute" :aria-label="labels.mute"
@click.prevent.stop="mute" @click.prevent.stop="mute"
> >
<i class="volume down icon" /> <i class="bi bi-volume-down-fill" />
</span> </span>
<span <span
v-else v-else
@ -65,7 +69,7 @@ const scroll = (event: WheelEvent) => {
:aria-label="labels.mute" :aria-label="labels.mute"
@click.prevent.stop="mute" @click.prevent.stop="mute"
> >
<i class="volume up icon" /> <i class="bi bi-volume-up-fill" />
</span> </span>
<div class="popup"> <div class="popup">
<label <label
@ -81,5 +85,5 @@ const scroll = (event: WheelEvent) => {
max="1" max="1"
> >
</div> </div>
</button> </Button>
</template> </template>

View File

@ -1,83 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Album } from '~/types' import type { Album } from '~/types'
import PlayButton from '~/components/audio/PlayButton.vue' import AlbumCard from '~/components/album/Card.vue'
import { momentFormat } from '~/utils/filters'
import { computed } from 'vue'
import { useStore } from '~/store'
interface Props { interface Props {
album: Album album: Album
} }
const props = defineProps<Props>() defineProps<Props>()
const store = useStore()
const imageUrl = computed(() => props.album.cover?.urls.original
? store.getters['instance/absoluteUrl'](props.album.cover.urls.medium_square_crop)
: null
)
</script> </script>
<template> <template>
<div class="card app-card component-album-card"> <AlbumCard :album="album" />
<router-link
class="discrete link"
:to="{name: 'library.albums.detail', params: {id: album.id}}"
>
<div
v-lazy:background-image="imageUrl"
:class="['ui', 'head-image', 'image', {'default-cover': !album.cover || !album.cover.urls.original}]"
>
<play-button
:icon-only="true"
:is-playable="album.is_playable"
:button-classes="['ui', 'circular', 'large', 'vibrant', 'icon', 'button']"
:album="album"
/>
</div>
</router-link>
<div class="content">
<strong>
<router-link
class="discrete link"
:to="{name: 'library.albums.detail', params: {id: album.id}}"
>
{{ album.title }}
</router-link>
</strong>
<div class="description">
<span>
<template
v-for="ac in album.artist_credit"
:key="ac.artist.id"
>
<router-link
class="discrete link"
:to="{ name: 'library.artists.detail', params: { id: ac.artist.id }}"
>
{{ ac.credit }}
</router-link>
<span>{{ ac.joinphrase }}</span>
</template>
</span>
</div>
</div>
<div class="extra content">
<span v-if="album.release_date">
{{ momentFormat(new Date(album.release_date), 'Y') }}
<span class="middle middledot symbol" />
</span>
<span>
{{ $t('components.audio.album.Card.meta.tracks', album.tracks_count) }}
</span>
<play-button
class="right floated basic icon"
:dropdown-only="true"
:is-playable="album.is_playable"
:dropdown-icon-classes="['ellipsis', 'horizontal', 'large really discrete']"
:album="album"
/>
</div>
</div>
</template> </template>

View File

@ -1,20 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Artist } from '~/types' import type { Artist } from '~/types'
import PlayButton from '~/components/audio/PlayButton.vue'
import TagsList from '~/components/tags/List.vue'
import { computed } from 'vue' import { computed } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import { truncate } from '~/utils/filters' import { truncate } from '~/utils/filters'
import { useI18n } from 'vue-i18n'
import PlayButton from '~/components/audio/PlayButton.vue'
import TagsList from '~/components/tags/List.vue'
interface Props { interface Props {
artist: Artist artist: Artist
} }
const { t } = useI18n()
const props = defineProps<Props>() const props = defineProps<Props>()
const cover = computed(() => !props.artist.cover?.urls.original const cover = computed(() => !props.artist.cover?.urls.original
? props.artist.albums.find(album => !!album.cover?.urls.original)?.cover ? undefined // TODO: Also check Albums. Like in props.artist.albums.find(album => !!album.cover?.urls.original)?.cover
: props.artist.cover : props.artist.cover
) )
@ -37,7 +41,7 @@ const imageUrl = computed(() => cover.value?.urls.original
> >
<play-button <play-button
:icon-only="true" :icon-only="true"
:is-playable="artist.is_playable" :is-playable="true /* TODO: check if artist.is_playable exists instead */"
:button-classes="['ui', 'circular', 'large', 'vibrant', 'icon', 'button']" :button-classes="['ui', 'circular', 'large', 'vibrant', 'icon', 'button']"
:artist="artist" :artist="artist"
/> />
@ -53,7 +57,7 @@ const imageUrl = computed(() => cover.value?.urls.original
</router-link> </router-link>
</strong> </strong>
<tags-list <TagsList
label-classes="tiny" label-classes="tiny"
:truncate-size="20" :truncate-size="20"
:limit="2" :limit="2"
@ -63,15 +67,15 @@ const imageUrl = computed(() => cover.value?.urls.original
</div> </div>
<div class="extra content"> <div class="extra content">
<span v-if="artist.content_category === 'music'"> <span v-if="artist.content_category === 'music'">
{{ $t('components.audio.artist.Card.meta.tracks', artist.tracks_count) }} {{ t('components.audio.artist.Card.meta.tracks', (0 /* TODO: check where artist.tracks_count exists */)) }}
</span> </span>
<span v-else> <span v-else>
{{ $t('components.audio.artist.Card.meta.episodes', artist.tracks_count) }} {{ t('components.audio.artist.Card.meta.episodes', (0 /* TODO: check where artist.tracks_count exists */)) }}
</span> </span>
<play-button <play-button
class="right floated basic icon" class="right floated basic icon"
:dropdown-only="true" :dropdown-only="true"
:is-playable="artist.is_playable" :is-playable="true /* TODO: check if is_playable can be derived from the data */"
:dropdown-icon-classes="['ellipsis', 'horizontal', 'large really discrete']" :dropdown-icon-classes="['ellipsis', 'horizontal', 'large really discrete']"
:artist="artist" :artist="artist"
/> />

View File

@ -7,6 +7,7 @@ import { useI18n } from 'vue-i18n'
import { usePlayer } from '~/composables/audio/player' import { usePlayer } from '~/composables/audio/player'
import { useQueue } from '~/composables/audio/queue' import { useQueue } from '~/composables/audio/queue'
import { useStore } from '~/store'
import usePlayOptions from '~/composables/audio/usePlayOptions' import usePlayOptions from '~/composables/audio/usePlayOptions'
@ -54,6 +55,8 @@ const { isPlaying } = usePlayer()
const { activateTrack } = usePlayOptions(props) const { activateTrack } = usePlayOptions(props)
const { t } = useI18n() const { t } = useI18n()
const store = useStore()
const actionsButtonLabel = computed(() => t('components.audio.podcast.MobileRow.button.actions')) const actionsButtonLabel = computed(() => t('components.audio.podcast.MobileRow.button.actions'))
</script> </script>
@ -71,13 +74,13 @@ const actionsButtonLabel = computed(() => t('components.audio.podcast.MobileRow.
> >
<img <img
v-if="track.album?.cover?.urls.original" v-if="track.album?.cover?.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.small_square_crop)"
alt="" alt=""
class="ui artist-track mini image" class="ui artist-track mini image"
> >
<img <img
v-else-if="track.cover" v-else-if="track.cover"
v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.small_square_crop)"
alt="" alt=""
class="ui artist-track mini image" class="ui artist-track mini image"
> >
@ -136,7 +139,7 @@ const actionsButtonLabel = computed(() => t('components.audio.podcast.MobileRow.
</p> </p>
</div> </div>
<div <div
v-if="$store.state.auth.authenticated && track.artist_credit?.[0].artist.content_category !== 'podcast'" v-if="store.state.auth.authenticated && track.artist_credit?.[0].artist.content_category !== 'podcast'"
:class="[ :class="[
'meta', 'meta',
'right', 'right',

View File

@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types' import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions' import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
// import type { Track } from '~/types'
import { useStore } from '~/store' import { useStore } from '~/store'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import SemanticModal from '~/components/semantic/Modal.vue' import Modal from '~/components/ui/Modal.vue'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import usePlayOptions from '~/composables/audio/usePlayOptions' import usePlayOptions from '~/composables/audio/usePlayOptions'
import useReport from '~/composables/moderation/useReport' import useReport from '~/composables/moderation/useReport'
@ -55,11 +55,13 @@ const show = useVModel(props, 'show', emit)
const { report, getReportableObjects } = useReport() const { report, getReportableObjects } = useReport()
const { enqueue, enqueueNext } = usePlayOptions(props) const { enqueue, enqueueNext } = usePlayOptions(props)
const store = useStore() const store = useStore()
const router = useRouter()
const { t } = useI18n()
const isFavorite = computed(() => store.getters['favorites/isFavorite'](props.track.id)) const isFavorite = computed(() => store.getters['favorites/isFavorite'](props.track.id))
const { t } = useI18n()
const favoriteButton = computed(() => isFavorite.value const favoriteButton = computed(() => isFavorite.value
? t('components.audio.podcast.Modal.button.removeFromFavorites') ? t('components.audio.podcast.Modal.button.removeFromFavorites')
: t('components.audio.podcast.Modal.button.addToFavorites') : t('components.audio.podcast.Modal.button.addToFavorites')
@ -90,18 +92,19 @@ const labels = computed(() => ({
</script> </script>
<template> <template>
<semantic-modal <Modal
ref="modal" ref="modal"
v-model:show="show" v-model="show"
:title="track.title"
:scrolling="true" :scrolling="true"
:additional-classes="['scrolling-track-options']" class="scrolling-track-options"
> >
<div class="header"> <template #topright>
<div class="ui large centered rounded image"> <div class="ui large centered rounded image">
<img <img
v-if="track.album && track.album.cover && track.album.cover.urls.original" v-if="track.album && track.album.cover && track.album.cover.urls.original"
v-lazy=" v-lazy="
$store.getters['instance/absoluteUrl']( store.getters['instance/absoluteUrl'](
track.album.cover.urls.medium_square_crop track.album.cover.urls.medium_square_crop
) )
" "
@ -111,7 +114,7 @@ const labels = computed(() => ({
<img <img
v-else-if="track.cover" v-else-if="track.cover"
v-lazy=" v-lazy="
$store.getters['instance/absoluteUrl']( store.getters['instance/absoluteUrl'](
track.cover.urls.medium_square_crop track.cover.urls.medium_square_crop
) )
" "
@ -133,18 +136,15 @@ const labels = computed(() => ({
src="../../../assets/audio/default-cover.png" src="../../../assets/audio/default-cover.png"
> >
</div> </div>
<h3 class="track-modal-title">
{{ track.title }}
</h3>
<h4 class="track-modal-subtitle"> <h4 class="track-modal-subtitle">
{{ generateTrackCreditString(track) }} {{ generateTrackCreditString(track) }}
</h4> </h4>
</div> </template>
<div class="ui hidden divider" /> <div class="ui hidden divider" />
<div class="content"> <div class="content">
<div class="ui one column unstackable grid"> <div class="ui one column unstackable grid">
<div <div
v-if="$store.state.auth.authenticated && track.artist_credit?.[0].artist?.content_category !== 'podcast'" v-if="store.state.auth.authenticated && track.artist_credit?.[0].artist?.content_category !== 'podcast'"
class="row" class="row"
> >
<div <div
@ -152,7 +152,7 @@ const labels = computed(() => ({
class="column" class="column"
role="button" role="button"
:aria-label="favoriteButton" :aria-label="favoriteButton"
@click.stop="$store.dispatch('favorites/toggle', track.id)" @click.stop="store.dispatch('favorites/toggle', track.id)"
> >
<i <i
:class="[ :class="[
@ -175,7 +175,7 @@ const labels = computed(() => ({
:aria-label="labels.addToQueue" :aria-label="labels.addToQueue"
@click.stop.prevent=" @click.stop.prevent="
enqueue(); enqueue();
modal.closeModal(); show=false
" "
> >
<i class="plus icon track-modal list-icon" /> <i class="plus icon track-modal list-icon" />
@ -189,7 +189,7 @@ const labels = computed(() => ({
:aria-label="labels.playNext" :aria-label="labels.playNext"
@click.stop.prevent=" @click.stop.prevent="
enqueueNext(true); enqueueNext(true);
modal.closeModal(); show=false
" "
> >
<i class="step forward icon track-modal list-icon" /> <i class="step forward icon track-modal list-icon" />
@ -202,11 +202,11 @@ const labels = computed(() => ({
role="button" role="button"
:aria-label="labels.startRadio" :aria-label="labels.startRadio"
@click.stop.prevent=" @click.stop.prevent="
$store.dispatch('radios/start', { store.dispatch('radios/start', {
type: 'similar', type: 'similar',
objectId: track.id, objectId: track.id,
}); });
modal.closeModal(); show=false
" "
> >
<i class="rss icon track-modal list-icon" /> <i class="rss icon track-modal list-icon" />
@ -218,7 +218,7 @@ const labels = computed(() => ({
class="column" class="column"
role="button" role="button"
:aria-label="labels.addToPlaylist" :aria-label="labels.addToPlaylist"
@click.stop="$store.commit('playlists/chooseTrack', track)" @click.stop="store.commit('playlists/chooseTrack', track)"
> >
<i class="list icon track-modal list-icon" /> <i class="list icon track-modal list-icon" />
<span class="track-modal list-item">{{ <span class="track-modal list-item">{{
@ -236,7 +236,7 @@ const labels = computed(() => ({
role="button" role="button"
:aria-label="albumDetailsButton" :aria-label="albumDetailsButton"
@click.prevent.exact=" @click.prevent.exact="
$router.push({ router.push({
name: 'library.albums.detail', name: 'library.albums.detail',
params: { id: track.album?.id }, params: { id: track.album?.id },
}) })
@ -258,7 +258,7 @@ const labels = computed(() => ({
class="column" class="column"
role="button" role="button"
:aria-label="artistDetailsButton" :aria-label="artistDetailsButton"
@click.prevent.exact="$router.push({ name: 'library.artists.detail', params: { id: ac.artist.id } })" @click.prevent.exact="router.push({ name: 'library.artists.detail', params: { id: ac.artist.id } })"
> >
<i class="user icon track-modal list-icon" /> <i class="user icon track-modal list-icon" />
<span class="track-modal list-item">{{ ac.artist.name }}</span> <span class="track-modal list-item">{{ ac.artist.name }}</span>
@ -271,7 +271,7 @@ const labels = computed(() => ({
role="button" role="button"
:aria-label="trackDetailsButton" :aria-label="trackDetailsButton"
@click.prevent.exact=" @click.prevent.exact="
$router.push({ router.push({
name: 'library.tracks.detail', name: 'library.tracks.detail',
params: { id: track.id }, params: { id: track.id },
}) })
@ -298,5 +298,5 @@ const labels = computed(() => ({
</div> </div>
</div> </div>
</div> </div>
</semantic-modal> </Modal>
</template> </template>

View File

@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Track, Album, Playlist, Library, Channel, Actor, Cover, ArtistCredit } from '~/types' import type { Track, Album, Playlist, Library, Channel, Actor, Cover, ArtistCredit } from '~/types'
import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions' import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
import { getArtistCoverUrl } from '~/utils/utils'
import { ref } from 'vue' import { ref } from 'vue'
import { useQueue } from '~/composables/audio/queue' import { useQueue } from '~/composables/audio/queue'
import { useStore } from '~/store'
import axios from 'axios' import axios from 'axios'
@ -48,6 +50,8 @@ const props = withDefaults(defineProps<Props>(), {
account: null account: null
}) })
const store = useStore()
const description = ref('') const description = ref('')
const renderedDescription = useMarkdown(description) const renderedDescription = useMarkdown(description)
@ -83,13 +87,25 @@ await fetchData()
> >
<img <img
v-if="track.cover?.urls.original" v-if="track.cover?.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.small_square_crop)"
alt=""
class="ui artist-track mini image"
>
<img
v-if="track.album?.cover?.urls.original"
v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.small_square_crop)"
alt=""
class="ui artist-track mini image"
>
<img
v-else-if="track.artist_credit.length && track.artist_credit[0].artist.cover"
v-lazy="getArtistCoverUrl(track.artist_credit)"
alt="" alt=""
class="ui artist-track mini image" class="ui artist-track mini image"
> >
<img <img
v-else-if="defaultCover" v-else-if="defaultCover"
v-lazy="$store.getters['instance/absoluteUrl'](defaultCover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](defaultCover.urls.small_square_crop)"
alt="" alt=""
class="ui artist-track mini image" class="ui artist-track mini image"
> >
@ -120,10 +136,10 @@ await fetchData()
class="meta right floated column" class="meta right floated column"
> >
<play-button <play-button
id="playmenu"
class="play-button basic icon" class="play-button basic icon"
:dropdown-only="true" :dropdown-only="true"
:is-playable="track.is_playable" :is-playable="track.is_playable"
discrete
:dropdown-icon-classes="[ :dropdown-icon-classes="[
'ellipsis', 'ellipsis',
'vertical', 'vertical',

View File

@ -3,7 +3,7 @@ import type { Track } from '~/types'
import PodcastRow from '~/components/audio/podcast/Row.vue' import PodcastRow from '~/components/audio/podcast/Row.vue'
import TrackMobileRow from '~/components/audio/track/MobileRow.vue' import TrackMobileRow from '~/components/audio/track/MobileRow.vue'
import Pagination from '~/components/vui/Pagination.vue' import Pagination from '~/components/ui/Pagination.vue'
interface Props { interface Props {
tracks: Track[] tracks: Track[]
@ -61,11 +61,9 @@ const { page } = defineModels<{ page: number, }>()
v-if="paginateResults" v-if="paginateResults"
class="ui center aligned basic segment desktop-and-up" class="ui center aligned basic segment desktop-and-up"
> >
<pagination <Pagination
v-bind="$attrs" v-model:page="page"
v-model:current="page" :pages="Math.ceil((total || 0)/paginateBy)"
:total="total"
:paginate-by="paginateBy"
/> />
</div> </div>
</div> </div>
@ -90,12 +88,10 @@ const { page } = defineModels<{ page: number, }>()
v-if="paginateResults" v-if="paginateResults"
class="ui center aligned basic segment tablet-and-below" class="ui center aligned basic segment tablet-and-below"
> >
<pagination <Pagination
v-if="paginateResults" v-if="paginateResults"
v-bind="$attrs" v-model:page="page"
v-model:current="page" :pages="Math.ceil((total || 0)/paginateBy)"
:total="total"
:compact="true"
/> />
</div> </div>
</div> </div>

View File

@ -7,12 +7,13 @@ import { useI18n } from 'vue-i18n'
import { usePlayer } from '~/composables/audio/player' import { usePlayer } from '~/composables/audio/player'
import { useQueue } from '~/composables/audio/queue' import { useQueue } from '~/composables/audio/queue'
import { useStore } from '~/store'
import usePlayOptions from '~/composables/audio/usePlayOptions' import usePlayOptions from '~/composables/audio/usePlayOptions'
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue' import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import TrackModal from '~/components/audio/track/Modal.vue' import TrackModal from '~/components/audio/track/Modal.vue'
import { generateTrackCreditString, getArtistCoverUrl } from '~/utils/utils' import { generateTrackCreditString } from '~/utils/utils'
interface Props extends PlayOptionsProps { interface Props extends PlayOptionsProps {
track: Track track: Track
@ -54,6 +55,8 @@ const { isPlaying } = usePlayer()
const { activateTrack } = usePlayOptions(props) const { activateTrack } = usePlayOptions(props)
const { t } = useI18n() const { t } = useI18n()
const store = useStore()
const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.button.actions')) const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.button.actions'))
</script> </script>
@ -70,20 +73,14 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
@click.prevent.exact="activateTrack(track, index)" @click.prevent.exact="activateTrack(track, index)"
> >
<img <img
v-if="track.album?.cover?.urls.original" v-if="track.cover"
v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.small_square_crop)"
alt="" alt=""
class="ui artist-track mini image" class="ui artist-track mini image"
> >
<img <img
v-else-if="track.cover" v-else-if="track.album?.cover?.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.small_square_crop)"
alt=""
class="ui artist-track mini image"
>
<img
v-else-if="track.artist_credit.length && track.artist_credit[0].artist.cover"
v-lazy="getArtistCoverUrl(track.artist_credit)"
alt="" alt=""
class="ui artist-track mini image" class="ui artist-track mini image"
> >
@ -113,13 +110,13 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
{{ generateTrackCreditString(track) }} {{ generateTrackCreditString(track) }}
<span class="middle middledot symbol" /> <span class="middle middledot symbol" />
<human-duration <human-duration
v-if="track.uploads[0] && track.uploads[0].duration" v-if="track.uploads?.[0]?.duration"
:duration="track.uploads[0].duration" :duration="track.uploads[0]?.duration"
/> />
</p> </p>
</div> </div>
<div <div
v-if="$store.state.auth.authenticated" v-if="store.state.auth.authenticated"
:class="[ :class="[
'meta', 'meta',
'right', 'right',

View File

@ -1,16 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types' import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions' import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
// import type { Track } from '~/types'
import { useStore } from '~/store'
import { useI18n } from 'vue-i18n'
import SemanticModal from '~/components/semantic/Modal.vue'
import { computed, ref } from 'vue'
import usePlayOptions from '~/composables/audio/usePlayOptions' import usePlayOptions from '~/composables/audio/usePlayOptions'
import useReport from '~/composables/moderation/useReport' import useReport from '~/composables/moderation/useReport'
import { useStore } from '~/store'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { computed, ref } from 'vue'
import { useVModel } from '@vueuse/core' import { useVModel } from '@vueuse/core'
import { generateTrackCreditString, getArtistCoverUrl } from '~/utils/utils' import { generateTrackCreditString, getArtistCoverUrl } from '~/utils/utils'
import Modal from '~/components/ui/Modal.vue'
interface Events { interface Events {
(e: 'update:show', value: boolean): void (e: 'update:show', value: boolean): void
} }
@ -59,6 +61,8 @@ const store = useStore()
const isFavorite = computed(() => store.getters['favorites/isFavorite'](props.track.id)) const isFavorite = computed(() => store.getters['favorites/isFavorite'](props.track.id))
const { t } = useI18n() const { t } = useI18n()
const router = useRouter()
const favoriteButton = computed(() => isFavorite.value const favoriteButton = computed(() => isFavorite.value
? t('components.audio.track.Modal.button.removeFromFavorites') ? t('components.audio.track.Modal.button.removeFromFavorites')
: t('components.audio.track.Modal.button.addToFavorites') : t('components.audio.track.Modal.button.addToFavorites')
@ -89,23 +93,24 @@ const labels = computed(() => ({
</script> </script>
<template> <template>
<semantic-modal <Modal
ref="modal" ref="modal"
v-model:show="show" v-model="show"
:title="track.title"
:scrolling="true" :scrolling="true"
:additional-classes="['scrolling-track-options']" class="scrolling-track-options"
> >
<div class="header"> <div class="header">
<div class="ui large centered rounded image"> <div class="ui large centered rounded image">
<img <img
v-if="track.album?.cover?.urls.original" v-if="track.album?.cover?.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)"
alt="" alt=""
class="ui centered image" class="ui centered image"
> >
<img <img
v-else-if="track.cover" v-else-if="track.cover"
v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)"
alt="" alt=""
class="ui centered image" class="ui centered image"
> >
@ -122,9 +127,6 @@ const labels = computed(() => ({
src="../../../assets/audio/default-cover.png" src="../../../assets/audio/default-cover.png"
> >
</div> </div>
<h3 class="track-modal-title">
{{ track.title }}
</h3>
<h4 class="track-modal-subtitle"> <h4 class="track-modal-subtitle">
{{ generateTrackCreditString(track) }} {{ generateTrackCreditString(track) }}
</h4> </h4>
@ -133,7 +135,7 @@ const labels = computed(() => ({
<div class="content"> <div class="content">
<div class="ui one column unstackable grid"> <div class="ui one column unstackable grid">
<div <div
v-if="$store.state.auth.authenticated && track.artist_credit?.[0].artist.content_category !== 'podcast'" v-if="store.state.auth.authenticated && track.artist_credit?.[0].artist.content_category !== 'podcast'"
class="row" class="row"
> >
<div <div
@ -141,7 +143,7 @@ const labels = computed(() => ({
class="column" class="column"
role="button" role="button"
:aria-label="favoriteButton" :aria-label="favoriteButton"
@click.stop="$store.dispatch('favorites/toggle', track.id)" @click.stop="store.dispatch('favorites/toggle', track.id)"
> >
<i :class="[ 'heart', 'favorite-icon', { favorited: isFavorite, pink: isFavorite }, 'icon', 'track-modal', 'list-icon' ]" /> <i :class="[ 'heart', 'favorite-icon', { favorited: isFavorite, pink: isFavorite }, 'icon', 'track-modal', 'list-icon' ]" />
<span class="track-modal list-item">{{ favoriteButton }}</span> <span class="track-modal list-item">{{ favoriteButton }}</span>
@ -152,7 +154,7 @@ const labels = computed(() => ({
class="column" class="column"
role="button" role="button"
:aria-label="labels.addToQueue" :aria-label="labels.addToQueue"
@click.stop.prevent="enqueue(); modal.closeModal()" @click.stop.prevent="enqueue(); show = false"
> >
<i class="plus icon track-modal list-icon" /> <i class="plus icon track-modal list-icon" />
<span class="track-modal list-item">{{ labels.addToQueue }}</span> <span class="track-modal list-item">{{ labels.addToQueue }}</span>
@ -163,7 +165,7 @@ const labels = computed(() => ({
class="column" class="column"
role="button" role="button"
:aria-label="labels.playNext" :aria-label="labels.playNext"
@click.stop.prevent="enqueueNext(true);modal.closeModal()" @click.stop.prevent="enqueueNext(true);show = false"
> >
<i class="step forward icon track-modal list-icon" /> <i class="step forward icon track-modal list-icon" />
<span class="track-modal list-item">{{ labels.playNext }}</span> <span class="track-modal list-item">{{ labels.playNext }}</span>
@ -174,7 +176,7 @@ const labels = computed(() => ({
class="column" class="column"
role="button" role="button"
:aria-label="labels.startRadio" :aria-label="labels.startRadio"
@click.stop.prevent="() => { $store.dispatch('radios/start', { type: 'similar', objectId: track.id }); modal.closeModal() }" @click.stop.prevent="() => { store.dispatch('radios/start', { type: 'similar', objectId: track.id }); show = false }"
> >
<i class="rss icon track-modal list-icon" /> <i class="rss icon track-modal list-icon" />
<span class="track-modal list-item">{{ labels.startRadio }}</span> <span class="track-modal list-item">{{ labels.startRadio }}</span>
@ -185,7 +187,7 @@ const labels = computed(() => ({
class="column" class="column"
role="button" role="button"
:aria-label="labels.addToPlaylist" :aria-label="labels.addToPlaylist"
@click.stop="$store.commit('playlists/chooseTrack', track)" @click.stop="store.commit('playlists/chooseTrack', track)"
> >
<i class="list icon track-modal list-icon" /> <i class="list icon track-modal list-icon" />
<span class="track-modal list-item"> <span class="track-modal list-item">
@ -202,7 +204,7 @@ const labels = computed(() => ({
class="column" class="column"
role="button" role="button"
:aria-label="albumDetailsButton" :aria-label="albumDetailsButton"
@click.prevent.exact="$router.push({ name: 'library.albums.detail', params: { id: track.album?.id } })" @click.prevent.exact="router.push({ name: 'library.albums.detail', params: { id: track.album?.id } })"
> >
<i class="compact disc icon track-modal list-icon" /> <i class="compact disc icon track-modal list-icon" />
<span class="track-modal list-item">{{ albumDetailsButton }}</span> <span class="track-modal list-item">{{ albumDetailsButton }}</span>
@ -218,7 +220,7 @@ const labels = computed(() => ({
class="column" class="column"
role="button" role="button"
:aria-label="artistDetailsButton" :aria-label="artistDetailsButton"
@click.prevent.exact="$router.push({ name: 'library.artists.detail', params: { id: ac.artist.id } })" @click.prevent.exact="router.push({ name: 'library.artists.detail', params: { id: ac.artist.id } })"
> >
<i class="user icon track-modal list-icon" /> <i class="user icon track-modal list-icon" />
<span class="track-modal list-item">{{ ac.credit }}</span> <span class="track-modal list-item">{{ ac.credit }}</span>
@ -230,7 +232,7 @@ const labels = computed(() => ({
class="column" class="column"
role="button" role="button"
:aria-label="trackDetailsButton" :aria-label="trackDetailsButton"
@click.prevent.exact="$router.push({ name: 'library.tracks.detail', params: { id: track.id } })" @click.prevent.exact="router.push({ name: 'library.tracks.detail', params: { id: track.id } })"
> >
<i class="info icon track-modal list-icon" /> <i class="info icon track-modal list-icon" />
<span class="track-modal list-item">{{ trackDetailsButton }}</span> <span class="track-modal list-item">{{ trackDetailsButton }}</span>
@ -250,5 +252,5 @@ const labels = computed(() => ({
</div> </div>
</div> </div>
</div> </div>
</semantic-modal> </Modal>
</template> </template>

View File

@ -4,5 +4,9 @@
<div class="audio-bar" /> <div class="audio-bar" />
<div class="audio-bar" /> <div class="audio-bar" />
<div class="audio-bar" /> <div class="audio-bar" />
<div class="audio-bar" />
<div class="audio-bar" />
<div class="audio-bar" />
<div class="audio-bar" />
</div> </div>
</template> </template>

View File

@ -6,11 +6,16 @@ import { computed, ref } from 'vue'
import usePlayOptions from '~/composables/audio/usePlayOptions' import usePlayOptions from '~/composables/audio/usePlayOptions'
import { usePlayer } from '~/composables/audio/player'
import { useQueue } from '~/composables/audio/queue'
import { useStore } from '~/store'
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue' import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import PlayIndicator from '~/components/audio/track/PlayIndicator.vue' import PlayIndicator from '~/components/audio/track/PlayIndicator.vue'
import PlayButton from '~/components/audio/PlayButton.vue' import PlayButton from '~/components/audio/PlayButton.vue'
import { usePlayer } from '~/composables/audio/player'
import { useQueue } from '~/composables/audio/queue' import Button from '~/components/ui/Button.vue'
const store = useStore()
interface Props extends PlayOptionsProps { interface Props extends PlayOptionsProps {
track: Track track: Track
@ -60,11 +65,14 @@ const hover = ref(false)
<template> <template>
<div <div
:class="[{ active }, 'track-row row']" :class="[{ active }, 'track-row row', $style.row]"
style="display: contents;"
@dblclick="activateTrack(track, index)" @dblclick="activateTrack(track, index)"
@mousemove="hover = true" @mousemove="hover = true"
@mouseout="hover = false" @mouseout="hover = false"
> >
<!-- 1. column: Play button or track position -->
<div <div
class="actions one wide left floated column" class="actions one wide left floated column"
role="button" role="button"
@ -78,32 +86,29 @@ const hover = ref(false)
!hover !hover
" "
/> />
<button <Button
v-else-if=" v-else-if="
!isPlaying && !isPlaying &&
active && active &&
!hover !hover
" "
class="ui really tiny basic icon button play-button paused" ghost
> icon="bi-play-fill"
<i class="play icon" /> />
</button> <Button
<button
v-else-if=" v-else-if="
isPlaying && isPlaying &&
active && active &&
hover hover
" "
class="ui really tiny basic icon button play-button" ghost
> icon="bi-pause-fill"
<i class="pause icon" /> />
</button> <Button
<button
v-else-if="hover" v-else-if="hover"
class="ui really tiny basic icon button play-button" ghost
> icon="bi-play-fill"
<i class="play icon" /> />
</button>
<span <span
v-else-if="showPosition" v-else-if="showPosition"
class="track-position" class="track-position"
@ -111,40 +116,35 @@ const hover = ref(false)
{{ `${track.position}`.padStart(2, '0') }} {{ `${track.position}`.padStart(2, '0') }}
</span> </span>
</div> </div>
<div <div
v-if="showArt"
class="image left floated column" class="image left floated column"
role="button" role="button"
@click.prevent.exact="activateTrack(track, index)" @click.prevent.exact="activateTrack(track, index)"
> >
<img <img
v-if="track.album?.cover?.urls.original" v-if="showArt && track.cover?.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.small_square_crop)"
alt="" :alt="track.title"
class="ui artist-track mini image" class="track_image"
> >
<img <img
v-else-if="track.cover?.urls.original" v-else-if="showArt && track.album?.cover?.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.small_square_crop)"
alt="" alt=""
class="ui artist-track mini image" class="track_image"
> >
<img <img
v-else-if="track.artist_credit?.length && track.artist_credit[0].artist.cover?.urls.original" v-else-if="showArt"
v-lazy="$store.getters['instance/absoluteUrl'](track.artist_credit[0].artist.cover.urls.medium_square_crop) "
alt="" alt=""
class="ui artist-track mini image" class="track_image"
>
<img
v-else
alt=""
class="ui artist-track mini image"
src="../../../assets/audio/default-cover.png" src="../../../assets/audio/default-cover.png"
> >
</div> </div>
<div <div
tabindex="0" tabindex="0"
class="content ellipsis left floated column" class="content ellipsis column left floated column"
> >
<a <a
@click="activateTrack(track, index)" @click="activateTrack(track, index)"
@ -152,22 +152,23 @@ const hover = ref(false)
{{ track.title }} {{ track.title }}
</a> </a>
</div> </div>
<div <div
v-if="showAlbum"
class="content ellipsis left floated column" class="content ellipsis left floated column"
> >
<router-link <router-link
v-if="showAlbum"
:to="{ name: 'library.albums.detail', params: { id: track.album?.id } }" :to="{ name: 'library.albums.detail', params: { id: track.album?.id } }"
> >
{{ track.album?.title }} {{ track.album?.title }}
</router-link> </router-link>
</div> </div>
<div <div
v-if="showArtist"
class="content ellipsis left floated column" class="content ellipsis left floated column"
> >
<template <template
v-for="ac in track.artist_credit" v-for="ac in (showArtist ? track.artist_credit : [])"
:key="ac.artist.id" :key="ac.artist.id"
> >
<router-link <router-link
@ -182,41 +183,49 @@ const hover = ref(false)
<span>{{ ac.joinphrase }}</span> <span>{{ ac.joinphrase }}</span>
</template> </template>
</div> </div>
<div <div
v-if="$store.state.auth.authenticated"
class="meta right floated column" class="meta right floated column"
> >
<track-favorite-icon <track-favorite-icon
class="tiny" v-if="store.state.auth.authenticated"
:border="false" ghost
:track="track" :track="track"
/> />
</div> </div>
<div <div
v-if="showDuration"
class="meta right floated column" class="meta right floated column"
> >
<human-duration <human-duration
v-if="track.uploads[0] && track.uploads[0].duration" v-if="showDuration && track.uploads && track.uploads.length > 0 && track.uploads[0].duration"
:duration="track.uploads[0].duration" :duration="track.uploads[0].duration"
/> />
</div> </div>
<div <div
v-if="displayActions" v-if="displayActions"
class="meta right floated column" class="meta right floated column"
> >
<play-button <PlayButton
id="playmenu"
class="play-button basic icon"
:dropdown-only="true" :dropdown-only="true"
:is-playable="track.is_playable" :is-playable="track.is_playable"
:dropdown-icon-classes="[
'ellipsis',
'vertical',
'large really discrete',
]"
:track="track" :track="track"
class="ui floating dropdown"
ghost
/> />
</div> </div>
</div> </div>
</template> </template>
<style module>
.row > :has(> :is(a, span)) {
line-height: 46px;
}
.row > div {
/* total height 64px, according to designs on penpot */
margin-bottom: 8px;
margin-right: 8px;
height: 48px;
}
</style>

View File

@ -8,8 +8,12 @@ import { ref, computed } from 'vue'
import axios from 'axios' import axios from 'axios'
import TrackMobileRow from '~/components/audio/track/MobileRow.vue' import TrackMobileRow from '~/components/audio/track/MobileRow.vue'
import Pagination from '~/components/vui/Pagination.vue' import Pagination from '~/components/ui/Pagination.vue'
import TrackRow from '~/components/audio/track/Row.vue' import TrackRow from '~/components/audio/track/Row.vue'
import Input from '~/components/ui/Input.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Loader from '~/components/ui/Loader.vue'
import Table from '~/components/ui/Table.vue'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
@ -62,7 +66,7 @@ const props = withDefaults(defineProps<Props>(), {
filters: () => ({}), filters: () => ({}),
nextUrl: null, nextUrl: null,
paginateResults: true, paginateResults: false,
total: 0, total: 0,
page: 1, page: 1,
paginateBy: 25, paginateBy: 25,
@ -83,11 +87,15 @@ const allTracks = computed(() => {
: tracks : tracks
}) })
const paginateResults = computed(() => props.paginateResults && allTracks.value.length < props.paginateBy)
const { t } = useI18n() const { t } = useI18n()
const labels = computed(() => ({ const labels = computed(() => ({
title: t('components.audio.track.Table.table.header.title'), title: t('components.audio.track.Table.table.header.title'),
album: t('components.audio.track.Table.table.header.album'), album: t('components.audio.track.Table.table.header.album'),
artist: t('components.audio.track.Table.table.header.artist') artist: t('components.audio.track.Table.table.header.artist'),
searchPlaceholder: t('views.Search.header.search')
})) }))
const isLoading = ref(false) const isLoading = ref(false)
@ -140,11 +148,16 @@ const updatePage = (page: number) => {
<template> <template>
<div> <div>
<!-- Show the search bar if search is true --> <!-- Show the search bar if search is true -->
<inline-search-bar
<Input
v-if="search" v-if="search"
v-model="query" v-model="query"
search
autofocus
:placeholder="labels.searchPlaceholder"
@search="performSearch" @search="performSearch"
/> />
<Spacer v-if="search" />
<!-- Add a header if needed --> <!-- Add a header if needed -->
@ -161,66 +174,41 @@ const updatePage = (page: number) => {
@refresh="fetchData()" @refresh="fetchData()"
/> />
</slot> </slot>
<div v-else>
<div <!-- Table on screens > 768px wide -->
:class="['track-table', 'ui', 'unstackable', 'grid', 'tablet-and-up']" <!-- TODO: Make responsive to parent container instead of screen -->
>
<div
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div>
<div class="track-table row">
<div
v-if="showPosition"
class="actions left floated column"
>
<i class="hashtag icon" />
</div>
<div <div
v-else v-else
class="actions left floated column" :class="['track-table', 'ui', 'unstackable', 'grid', 'tablet-and-up']"
/>
<div
v-if="showArt"
class="image left floated column"
/>
<div class="content ellipsis left floated column">
<b>{{ labels.title }}</b>
</div>
<div
v-if="showAlbum"
class="content ellipsisleft floated column"
> >
<b>{{ labels.album }}</b> <Loader v-if="isLoading" />
</div>
<div
v-if="showArtist"
class="content ellipsis left floated column"
>
<b>{{ labels.artist }}</b>
</div>
<div
v-if="$store.state.auth.authenticated"
class="meta right floated column"
/>
<div
v-if="showDuration"
class="meta right floated column"
>
<i
class="clock outline icon"
style="padding: 0.5rem"
/>
</div>
<div
v-if="displayActions"
class="meta right floated column"
/>
</div>
<!-- For each item, build a row --> <Table
:grid-template-columns="['48px', '56px', 'auto', 'auto', 'auto', '56px', '64px', '48px']"
:header-props="{ 'table-header': true }"
>
<template #header>
<label />
<label />
<label>
<span>{{ labels.title }}</span>
</label>
<label>
<span v-if="showAlbum">{{ labels.album }}</span>
</label>
<label>
<span v-if="showArtist">{{ labels.artist }}</span>
</label>
<label />
<label>
<i
v-if="showDuration"
class="bi bi-clock"
/>
</label>
<label />
</template>
<track-row <track-row
v-for="(track, index) in allTracks" v-for="(track, index) in allTracks"
@ -236,29 +224,25 @@ const updatePage = (page: number) => {
:show-duration="showDuration" :show-duration="showDuration"
:is-podcast="isPodcast" :is-podcast="isPodcast"
/> />
</div> </Table>
<div
v-if="tracks && paginateResults" <!-- Pagination -->
class="ui center aligned basic segment desktop-and-up"
> <Pagination
<pagination v-if="paginateResults"
:total="totalTracks" :pages="paginateBy"
:current="tracks !== undefined ? page : currentPage" :page="page"
:paginate-by="paginateBy"
@update:current="updatePage" @update:current="updatePage"
/> />
</div> </div>
</div>
<!-- Under 768px screen width -->
<!-- TODO: Make responsive to parent container instead of screen -->
<div <div
:class="['track-table', 'ui', 'unstackable', 'grid', 'tablet-and-below']" :class="['track-table', 'ui', 'unstackable', 'grid', 'tablet-and-below']"
> >
<div <Loader v-if="isLoading" />
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div>
<!-- For each item, build a row --> <!-- For each item, build a row -->
@ -275,19 +259,19 @@ const updatePage = (page: number) => {
:is-album="isAlbum" :is-album="isAlbum"
:is-podcast="isPodcast" :is-podcast="isPodcast"
/> />
<div <Pagination
v-if="tracks && paginateResults && totalTracks > paginateBy" v-if="paginateResults"
class="ui center aligned basic segment tablet-and-below" :pages="paginateBy"
> :page="page"
<pagination
v-if="paginateResults && totalTracks > paginateBy"
:paginate-by="paginateBy"
:total="totalTracks"
:current="tracks !== undefined ? page : currentPage"
:compact="true"
@update:current="updatePage" @update:current="updatePage"
/> />
</div> </div>
</div> </div>
</div>
</template> </template>
<style scoped>
[table-header] > label {
padding-left: 4px;
align-self: center !important;
}
</style>

View File

@ -1,20 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Track, Listening } from '~/types' import type { Track, Listening } from '~/types'
import { ref, reactive, watch } from 'vue' import { ref, reactive, watch, onMounted } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import { clone } from 'lodash-es' import { clone } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { getArtistCoverUrl } from '~/utils/utils'
import axios from 'axios' import axios from 'axios'
import usePage from '~/composables/navigation/usePage'
import useWebSocketHandler from '~/composables/useWebSocketHandler' import useWebSocketHandler from '~/composables/useWebSocketHandler'
import PlayButton from '~/components/audio/PlayButton.vue' import PlayButton from '~/components/audio/PlayButton.vue'
import TagsList from '~/components/tags/List.vue' import TagsList from '~/components/tags/List.vue'
import Section from '~/components/ui/Section.vue'
import Alert from '~/components/ui/Alert.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Loader from '~/components/ui/Loader.vue'
import Heading from '~/components/ui/Heading.vue'
import Pagination from '~/components/ui/Pagination.vue'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
import { getArtistCoverUrl } from '~/utils/utils'
interface Events { interface Events {
(e: 'count', count: number): void (e: 'count', count: number): void
} }
@ -23,55 +30,61 @@ interface Props {
filters: Record<string, string | boolean> filters: Record<string, string | boolean>
url: string url: string
isActivity?: boolean isActivity?: boolean
showCount?: boolean
limit?: number limit?: number
itemClasses?: string itemClasses?: string
websocketHandlers?: string[] websocketHandlers?: string[]
title?: string
} }
const emit = defineEmits<Events>() const emit = defineEmits<Events>()
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
isActivity: true, isActivity: true,
showCount: false, limit: 9,
limit: 5,
itemClasses: '', itemClasses: '',
websocketHandlers: () => [] websocketHandlers: () => [],
title: undefined
}) })
const store = useStore() const store = useStore()
const { t } = useI18n()
const objects = reactive([] as Listening[]) const objects = reactive([] as Listening[])
const count = ref(0) const count = ref(0)
const nextPage = ref<string | null>(null) const page = usePage()
const isLoading = ref(false) const isLoading = ref(false)
const fetchData = async (url = props.url) => { const fetchData = async (url = props.url) => {
isLoading.value = true isLoading.value = true
const params = { const params = {
...clone(props.filters), ...clone(props.filters),
page_size: props.limit page: page.value,
page_size: props.limit ?? 9
} }
try { try {
const response = await axios.get(url, { params }) const response = await axios.get(url, { params })
nextPage.value = response.data.next
count.value = response.data.count count.value = response.data.count
const newObjects = !props.isActivity const newObjects = !props.isActivity
? response.data.results.map((track: Track) => ({ track })) ? response.data.results.map((track: Track) => ({ track }))
: response.data.results : response.data.results
objects.push(...newObjects) objects.splice(0, objects.length, ...newObjects)
} catch (error) { } catch (error) {
useErrorHandler(error as Error) useErrorHandler(error as Error)
} } finally {
isLoading.value = false isLoading.value = false
} }
}
onMounted(() => {
setTimeout(fetchData, 1000)
})
watch( watch(
() => store.state.moderation.lastUpdate, [() => store.state.moderation.lastUpdate, page],
() => fetchData(), () => fetchData(),
{ immediate: true } { immediate: true }
) )
@ -79,73 +92,86 @@ watch(
watch(count, (to) => emit('count', to)) watch(count, (to) => emit('count', to))
watch(() => props.websocketHandlers.includes('Listen'), (to) => { watch(() => props.websocketHandlers.includes('Listen'), (to) => {
useWebSocketHandler('Listen', (event) => { if (to) {
// TODO (wvffle): Add reactivity to recently listened / favorited / added (#1316, #1534) useWebSocketHandler('Listen', (event: unknown) => {
// count.value += 1 // Handle WebSocket events for "Listen"
// objects.unshift(event as Listening) // Add the event to `objects` reactively
// objects.pop() objects.unshift(event as Listening)
})
// Keep the array size within limits (e.g., remove the last item if needed)
if (objects.length > props.limit) {
objects.pop()
}
}) })
}
}, { immediate: true })
</script> </script>
<template> <template>
<div class="component-track-widget"> <Section
<h3 v-if="!!$slots.title"> align-left
<slot name="title" /> :h2="title"
<span :columns-per-item="4"
v-if="showCount"
class="ui tiny circular label"
>{{ count }}</span>
</h3>
<div
v-if="count > 0"
class="ui divided unstackable items"
> >
<Loader
v-if="isLoading"
style="grid-column: 1 / -1;"
/>
<Alert
v-if="!isLoading && count === 0"
style="grid-column: 1 / -1;"
blue
align-items="center"
>
<h4>
<i class="bi bi-search" />
{{ t('components.audio.track.Widget.empty.noResults') }}
</h4>
</Alert>
<!-- TODO: Use activity.vue -->
<div <div
v-for="object in objects" v-for="object in (count > 0 ? objects : [])"
:key="object.id" :key="object.id"
class="funkwhale activity"
:class="['item', itemClasses]" :class="['item', itemClasses]"
> >
<div class="ui tiny image"> <div class="activity-image">
<img <img
v-if="object.track.album && object.track.album.cover" v-if="object.track.album && object.track.album.cover"
v-lazy="$store.getters['instance/absoluteUrl'](object.track.album.cover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](object.track.album.cover.urls.small_square_crop)"
alt="" alt=""
> >
<img <img
v-else-if="object.track.cover" v-else-if="object.track.cover"
v-lazy="$store.getters['instance/absoluteUrl'](object.track.cover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](object.track.cover.urls.small_square_crop)"
alt="" alt=""
> >
<img <img
v-else-if="object.track.artist_credit && object.track.artist_credit.length > 0" v-else-if="object.track.artist_credit && object.track.artist_credit.length > 1"
v-lazy="getArtistCoverUrl(object.track.artist_credit)" v-lazy="getArtistCoverUrl(object.track.artist_credit)"
alt="" alt=""
> >
<img <i
v-else v-else
alt="" class="bi bi-vinyl-fill"
src="../../../assets/audio/default-cover.png"
>
<play-button
class="play-overlay"
:icon-only="true"
:button-classes="['ui', 'circular', 'tiny', 'vibrant', 'icon', 'button']"
:track="object.track"
/> />
<!-- TODO: Add Playbutton overlay -->
</div> </div>
<div class="middle aligned content"> <div class="activity-content">
<div class="ui unstackable grid"> <router-link
<div class="thirteen wide stretched column"> class="funkwhale link artist"
<div class="ellipsis"> :to="{name: 'library.tracks.detail', params: {id: object.track.id}}"
<router-link :to="{name: 'library.tracks.detail', params: {id: object.track.id}}"> >
{{ object.track.title }} <Heading
:h3="object.track.title"
title
/>
</router-link> </router-link>
</div> <Spacer :size="2" />
<div <div
v-if="object.track.artist_credit" v-if="object.track.artist_credit"
class="meta ellipsis" class="funkwhale link artist"
> >
<span <span
v-for="ac in object.track.artist_credit" v-for="ac in object.track.artist_credit"
@ -160,20 +186,21 @@ watch(() => props.websocketHandlers.includes('Listen'), (to) => {
<span v-if="ac.joinphrase">{{ ac.joinphrase }}</span> <span v-if="ac.joinphrase">{{ ac.joinphrase }}</span>
</span> </span>
</div> </div>
<tags-list <Spacer :size="8" />
<TagsList
label-classes="tiny" label-classes="tiny"
:truncate-size="20" :truncate-size="20"
:limit="2" :limit="2"
:show-more="false" :show-more="false"
:tags="object.track.tags" :tags="object.track.tags"
/> />
<Spacer :size="4" />
<div <div
v-if="isActivity" v-if="isActivity"
class="extra" class="extra"
> >
<router-link <router-link
class="left floated" class="funkwhale link user"
:to="{name: 'profile.overview', params: {username: object.actor.name}}" :to="{name: 'profile.overview', params: {username: object.actor.name}}"
> >
<span class="at symbol" />{{ object.actor.name }} <span class="at symbol" />{{ object.actor.name }}
@ -181,48 +208,133 @@ watch(() => props.websocketHandlers.includes('Listen'), (to) => {
<span class="right floated"><human-date :date="object.creation_date" /></span> <span class="right floated"><human-date :date="object.creation_date" /></span>
</div> </div>
</div> </div>
<div class="one wide stretched column">
<play-button <play-button
class="basic icon"
:account="object.actor" :account="object.actor"
:dropdown-only="true" :dropdown-only="true"
:dropdown-icon-classes="['ellipsis', 'vertical', 'large really discrete']"
:track="object.track" :track="object.track"
square-small
/> />
</div> </div>
</div> <Pagination
</div> v-if="page && count > props.limit"
</div> v-model:page="page"
<div :pages="Math.ceil((count || 0) / props.limit)"
v-if="isLoading" style="grid-column: 1 / -1;"
class="ui inverted active dimmer" />
> </Section>
<div class="ui loader" />
</div>
</div>
<div
v-else
class="ui placeholder segment"
>
<div class="ui icon header">
<i class="music icon" />
{{ $t('components.audio.track.Widget.empty.noResults') }}
</div>
<div
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div>
</div>
<template v-if="nextPage">
<div class="ui hidden divider" />
<button
:class="['ui', 'basic', 'button']"
@click="fetchData(nextPage as string)"
>
{{ $t('components.audio.track.Widget.button.more') }}
</button>
</template>
</div>
</template> </template>
<style lang="scss" scoped>
.funkwhale {
&.activity {
padding-top: 14px;
border-top: 1px solid;
margin: -11px 0;
@include light-theme {
border-color: var(--fw-gray-300);
}
@include dark-theme {
border-color: var(--fw-gray-800);
}
display: grid;
grid-template-columns: auto 1fr auto;
gap: 12px;
grid-column: span 4;
&:last-child {
border-bottom: 1px solid;
}
> .activity-image {
width: 40px;
aspect-ratio: 1;
overflow: hidden;
border-radius: var(--fw-border-radius);
> img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
}
> i {
font-size: 40px;
line-height: 36px;
}
> .play-button {
position: absolute;
top: 0;
left: 0;
padding: 0 !important;
width: 100%;
aspect-ratio: 1;
margin: 0;
border: 0 !important;
opacity: 0;
}
}
&:hover {
.play-button {
opacity: 1;
}
}
> .activity-content {
a {
text-decoration: none;
color: var(--color);
&:hover {
text-decoration: underline;
}
}
> .track-title {
font-weight: 700;
line-height: 1.5em;
@include dark-theme {
color: var(--fw-gray-300);
}
}
.artist {
font-size: 15px;
}
.user, time {
line-height: 1.5em;
font-size: 0.8125rem;
color: var(--fw-gray-500);
}
}
}
}
@include light-theme {
.play-button {
background: rgba(255, 255, 255, .5);
&:hover {
--fw-text-color: var(--fw-gray-800) !important;
}
}
}
@include dark-theme {
.play-button {
background: rgba(0, 0, 0, .2);
&:hover {
background: rgba(0, 0, 0, .8);
--fw-text-color: var(--fw-gray-200) !important;
}
}
}
</style>

View File

@ -26,6 +26,19 @@ onScopeDispose(() => {
defineExpose({ defineExpose({
loadRandomPreset loadRandomPreset
}) })
let autoPresetInterval: ReturnType<typeof setInterval> | null = null
function toggleAutoPreset() {
if (autoPresetInterval) {
clearInterval(autoPresetInterval)
autoPresetInterval = null
} else {
autoPresetInterval = setInterval(() => {
loadRandomPreset()
}, 23000)
}
}
</script> </script>
<template> <template>
@ -36,6 +49,7 @@ defineExpose({
<canvas <canvas
ref="canvas" ref="canvas"
@click="loadRandomPreset()" @click="loadRandomPreset()"
@dblclick="toggleAutoPreset()"
/> />
</div> </div>
</template> </template>

View File

@ -60,7 +60,7 @@ store.state.auth.applicationSecret = undefined
<template> <template>
<main <main
v-title="labels.title" v-title="labels.title"
class="main pusher" class="main"
> >
<div class="ui vertical stripe segment"> <div class="ui vertical stripe segment">
<section class="ui text container"> <section class="ui text container">
@ -72,17 +72,17 @@ store.state.auth.applicationSecret = undefined
</div> </div>
<template v-else> <template v-else>
<router-link :to="{name: 'settings'}"> <router-link :to="{name: 'settings'}">
{{ $t('components.auth.ApplicationEdit.link.settings') }} {{ t('components.auth.ApplicationEdit.link.settings') }}
</router-link> </router-link>
<h2 class="ui header"> <h2 class="ui header">
{{ $t('components.auth.ApplicationEdit.header.appDetails') }} {{ t('components.auth.ApplicationEdit.header.appDetails') }}
</h2> </h2>
<div class="ui form"> <div class="ui form">
<p> <p>
{{ $t('components.auth.ApplicationEdit.help.appDetails') }} {{ t('components.auth.ApplicationEdit.help.appDetails') }}
</p> </p>
<div class="field"> <div class="field">
<label for="copy-id">{{ $t('components.auth.ApplicationEdit.label.appId') }}</label> <label for="copy-id">{{ t('components.auth.ApplicationEdit.label.appId') }}</label>
<copy-input <copy-input
id="copy-id" id="copy-id"
:value="application.client_id" :value="application.client_id"
@ -94,14 +94,14 @@ store.state.auth.applicationSecret = undefined
> >
<div class="ui small warning message"> <div class="ui small warning message">
<h3 class="header"> <h3 class="header">
{{ $t('components.auth.ApplicationEdit.header.appSecretWarning') }} {{ t('components.auth.ApplicationEdit.header.appSecretWarning') }}
</h3> </h3>
<p> <p>
{{ $t('components.auth.ApplicationEdit.message.appSecretWarning') }} {{ t('components.auth.ApplicationEdit.message.appSecretWarning') }}
</p> </p>
</div> </div>
<label for="copy-secret">{{ $t('components.auth.ApplicationEdit.label.appSecret') }}</label> <label for="copy-secret">{{ t('components.auth.ApplicationEdit.label.appSecret') }}</label>
<copy-input <copy-input
id="copy-secret" id="copy-secret"
:value="secret" :value="secret"
@ -111,7 +111,7 @@ store.state.auth.applicationSecret = undefined
v-if="application.token != undefined" v-if="application.token != undefined"
class="field" class="field"
> >
<label for="copy-secret">{{ $t('components.auth.ApplicationEdit.label.accessToken') }}</label> <label for="copy-secret">{{ t('components.auth.ApplicationEdit.label.accessToken') }}</label>
<copy-input <copy-input
id="copy-secret" id="copy-secret"
:value="application.token" :value="application.token"
@ -121,12 +121,12 @@ store.state.auth.applicationSecret = undefined
@click.prevent="refreshToken" @click.prevent="refreshToken"
> >
<i class="refresh icon" /> <i class="refresh icon" />
{{ $t('components.auth.ApplicationEdit.button.regenerateToken') }} {{ t('components.auth.ApplicationEdit.button.regenerateToken') }}
</a> </a>
</div> </div>
</div> </div>
<h2 class="ui header"> <h2 class="ui header">
{{ $t('components.auth.ApplicationEdit.header.editApp') }} {{ t('components.auth.ApplicationEdit.header.editApp') }}
</h2> </h2>
<application-form <application-form
:app="application" :app="application"

View File

@ -7,6 +7,8 @@ import { computedEager } from '@vueuse/core'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { uniq } from 'lodash-es' import { uniq } from 'lodash-es'
import Button from '~/components/ui/Button.vue'
import useScopes from '~/composables/auth/useScopes' import useScopes from '~/composables/auth/useScopes'
interface Events { interface Events {
@ -120,7 +122,7 @@ const allScopes = computed(() => {
class="ui negative message" class="ui negative message"
> >
<h4 class="header"> <h4 class="header">
{{ $t('components.auth.ApplicationForm.header.failure') }} {{ t('components.auth.ApplicationForm.header.failure') }}
</h4> </h4>
<ul class="list"> <ul class="list">
<li <li
@ -132,7 +134,7 @@ const allScopes = computed(() => {
</ul> </ul>
</div> </div>
<div class="ui field"> <div class="ui field">
<label for="application-name">{{ $t('components.auth.ApplicationForm.label.name') }}</label> <label for="application-name">{{ t('components.auth.ApplicationForm.label.name') }}</label>
<input <input
id="application-name" id="application-name"
v-model="fields.name" v-model="fields.name"
@ -142,7 +144,7 @@ const allScopes = computed(() => {
> >
</div> </div>
<div class="ui field"> <div class="ui field">
<label for="redirect-uris">{{ $t('components.auth.ApplicationForm.label.redirectUri') }}</label> <label for="redirect-uris">{{ t('components.auth.ApplicationForm.label.redirectUri') }}</label>
<input <input
id="redirect-uris" id="redirect-uris"
v-model="fields.redirect_uris" v-model="fields.redirect_uris"
@ -150,13 +152,13 @@ const allScopes = computed(() => {
type="text" type="text"
> >
<p class="help"> <p class="help">
{{ $t('components.auth.ApplicationForm.help.redirectUri') }} {{ t('components.auth.ApplicationForm.help.redirectUri') }}
</p> </p>
</div> </div>
<div class="ui field"> <div class="ui field">
<label>{{ $t('components.auth.ApplicationForm.label.scopes.label') }}</label> <label>{{ t('components.auth.ApplicationForm.label.scopes.label') }}</label>
<p> <p>
{{ $t('components.auth.ApplicationForm.label.scopes.description') }} {{ t('components.auth.ApplicationForm.label.scopes.description') }}
</p> </p>
<div class="ui stackable two column grid"> <div class="ui stackable two column grid">
<div <div
@ -198,16 +200,16 @@ const allScopes = computed(() => {
</div> </div>
</div> </div>
</div> </div>
<button <Button
:class="['ui', {'loading': isLoading}, 'success', 'button']" :is-loading="isLoading"
type="submit" type="submit"
> >
<span v-if="app !== null"> <span v-if="app !== null">
{{ $t('components.auth.ApplicationForm.button.update') }} {{ t('components.auth.ApplicationForm.button.update') }}
</span> </span>
<span v-else> <span v-else>
{{ $t('components.auth.ApplicationForm.button.create') }} {{ t('components.auth.ApplicationForm.button.create') }}
</span> </span>
</button> </Button>
</form> </form>
</template> </template>

View File

@ -53,12 +53,12 @@ const created = (application: Application) => {
<template> <template>
<main <main
v-title="labels.title" v-title="labels.title"
class="main pusher" class="main"
> >
<div class="ui vertical stripe segment"> <div class="ui vertical stripe segment">
<section class="ui text container"> <section class="ui text container">
<router-link :to="{name: 'settings'}"> <router-link :to="{name: 'settings'}">
{{ $t('components.auth.ApplicationNew.link.settings') }} {{ t('components.auth.ApplicationNew.link.settings') }}
</router-link> </router-link>
<h2 class="ui header"> <h2 class="ui header">
{{ labels.title }} {{ labels.title }}

View File

@ -9,6 +9,8 @@ import { ref, computed } from 'vue'
import useSharedLabels from '~/composables/locale/useSharedLabels' import useSharedLabels from '~/composables/locale/useSharedLabels'
import useScopes from '~/composables/auth/useScopes' import useScopes from '~/composables/auth/useScopes'
import useFormData from '~/composables/useFormData' import useFormData from '~/composables/useFormData'
import Button from '~/components/ui/Button.vue'
import Layout from '~/components/ui/Layout.vue'
interface Props { interface Props {
clientId: string clientId: string
@ -112,31 +114,30 @@ whenever(() => props.clientId, fetchApplication, { immediate: true })
</script> </script>
<template> <template>
<main <layout
v-title="labels.title" v-title="labels.title"
class="main pusher" main
class="main"
> >
<section class="ui vertical stripe segment"> <section class="ui vertical stripe segment">
<div class="ui small text container"> <div class="ui small text container">
<h2> <h2>
<i class="lock open icon" />{{ $t('components.auth.Authorize.header.authorize') }} <i class="bi bi-unlock-fill" />{{ t('components.auth.Authorize.header.authorize') }}
</h2> </h2>
<div <Alert
v-if="errors.length > 0" v-if="errors.length > 0"
red
role="alert" role="alert"
class="ui negative message"
> >
<h4 <h4
v-if="application" v-if="application"
class="header"
> >
{{ $t('components.auth.Authorize.header.authorizeFailure') }} {{ t('components.auth.Authorize.header.authorizeFailure') }}
</h4> </h4>
<h4 <h4
v-else v-else
class="header"
> >
{{ $t('components.auth.Authorize.header.fetchFailure') }} {{ t('components.auth.Authorize.header.fetchFailure') }}
</h4> </h4>
<ul class="list"> <ul class="list">
<li <li
@ -146,20 +147,18 @@ whenever(() => props.clientId, fetchApplication, { immediate: true })
{{ error }} {{ error }}
</li> </li>
</ul> </ul>
</div> </Alert>
<div <Loader
v-if="isLoading" v-if="isLoading"
class="ui inverted active dimmer" class="ui inverted active dimmer"
> />
<div class="ui loader" />
</div>
<form <form
v-else-if="application && !code" v-else-if="application && !code"
:class="['ui', {loading: isLoading}, 'form']" :class="['ui', {loading: isLoading}, 'form']"
@submit.prevent="submit" @submit.prevent="submit"
> >
<h3> <h3>
{{ $t('components.auth.Authorize.header.access', {app_name: application.name}) }} {{ t('components.auth.Authorize.header.access', {app_name: application.name}) }}
</h3> </h3>
<h4 <h4
@ -171,23 +170,23 @@ whenever(() => props.clientId, fetchApplication, { immediate: true })
v-if="topic.write && !topic.read" v-if="topic.write && !topic.read"
:class="['ui', 'basic', 'right floated', 'tiny', 'vertically-spaced component-label label']" :class="['ui', 'basic', 'right floated', 'tiny', 'vertically-spaced component-label label']"
> >
<i class="pencil icon" /> <i class="bi bi-pencil" />
{{ $t('components.auth.Authorize.header.writeOnly') }} {{ t('components.auth.Authorize.header.writeOnly') }}
</span> </span>
<span <span
v-else-if="!topic.write && topic.read" v-else-if="!topic.write && topic.read"
:class="['ui', 'basic', 'right floated', 'tiny', 'vertically-spaced component-label label']" :class="['ui', 'basic', 'right floated', 'tiny', 'vertically-spaced component-label label']"
> >
{{ $t('components.auth.Authorize.header.readOnly') }} {{ t('components.auth.Authorize.header.readOnly') }}
</span> </span>
<span <span
v-else-if="topic.write && topic.read" v-else-if="topic.write && topic.read"
:class="['ui', 'basic', 'right floated', 'tiny', 'vertically-spaced component-label label']" :class="['ui', 'basic', 'right floated', 'tiny', 'vertically-spaced component-label label']"
> >
<i class="pencil icon" /> <i class="bi bi-pencil" />
{{ $t('components.auth.Authorize.header.allScopes') }} {{ t('components.auth.Authorize.header.allScopes') }}
</span> </span>
<i :class="[topic.icon, 'icon']" /> <i :class="[topic.icon, 'bi']" />
<div class="content"> <div class="content">
{{ topic.label }} {{ topic.label }}
<div class="sub header"> <div class="sub header">
@ -196,7 +195,7 @@ whenever(() => props.clientId, fetchApplication, { immediate: true })
</div> </div>
</h4> </h4>
<div v-if="unknownRequestedScopes.length > 0"> <div v-if="unknownRequestedScopes.length > 0">
<p><strong>{{ $t('components.auth.Authorize.message.unknownPermissions') }}</strong></p> <p><strong>{{ t('components.auth.Authorize.message.unknownPermissions') }}</strong></p>
<ul <ul
v-for="(unknownscope, key) in unknownRequestedScopes" v-for="(unknownscope, key) in unknownRequestedScopes"
:key="key" :key="key"
@ -204,17 +203,16 @@ whenever(() => props.clientId, fetchApplication, { immediate: true })
<li>{{ unknownscope }}</li> <li>{{ unknownscope }}</li>
</ul> </ul>
</div> </div>
<button <Button
class="ui success labeled icon button" icon="bi-unlock"
type="submit" type="submit"
> >
<i class="lock open icon" /> {{ t('components.auth.Authorize.button.authorize', { app: application.name }) }}
{{ $t('components.auth.Authorize.button.authorize', { app: application.name }) }} </Button>
</button>
<p <p
v-if="redirectUri === 'urn:ietf:wg:oauth:2.0:oob'" v-if="redirectUri === 'urn:ietf:wg:oauth:2.0:oob'"
> >
{{ $t('components.auth.Authorize.help.copyCode') }} {{ t('components.auth.Authorize.help.copyCode') }}
</p> </p>
<p <p
v-else v-else
@ -225,10 +223,10 @@ whenever(() => props.clientId, fetchApplication, { immediate: true })
</p> </p>
</form> </form>
<div v-else-if="code"> <div v-else-if="code">
<p><strong>{{ $t('components.auth.Authorize.help.pasteCode') }}</strong></p> <p><strong>{{ t('components.auth.Authorize.help.pasteCode') }}</strong></p>
<copy-input :value="code" /> <copy-input :value="code" />
</div> </div>
</div> </div>
</section> </section>
</main> </layout>
</template> </template>

View File

@ -2,12 +2,16 @@
import type { BackendError } from '~/types' import type { BackendError } from '~/types'
import { onBeforeRouteLeave, type RouteLocationRaw, useRouter } from 'vue-router' import { onBeforeRouteLeave, type RouteLocationRaw, useRouter } from 'vue-router'
import { ref, reactive, computed, onMounted, nextTick } from 'vue' import { ref, reactive, computed } from 'vue'
import { useEventListener } from '@vueuse/core' import { useEventListener } from '@vueuse/core'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useStore } from '~/store' import { useStore } from '~/store'
import PasswordInput from '~/components/forms/PasswordInput.vue' import Alert from '~/components/ui/Alert.vue'
import Input from '~/components/ui/Input.vue'
import Button from '~/components/ui/Button.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Layout from '~/components/ui/Layout.vue'
interface Props { interface Props {
next?: RouteLocationRaw next?: RouteLocationRaw
@ -44,12 +48,6 @@ const labels = computed(() => ({
usernamePlaceholder: t('components.auth.LoginForm.placeholder.username') usernamePlaceholder: t('components.auth.LoginForm.placeholder.username')
})) }))
const username = ref()
onMounted(async () => {
await nextTick()
username.value?.focus()
})
const isLoading = ref(false) const isLoading = ref(false)
const errors = ref([] as string[]) const errors = ref([] as string[])
const submit = async () => { const submit = async () => {
@ -77,82 +75,97 @@ const submit = async () => {
</script> </script>
<template> <template>
<form <Layout
class="ui form" form
stack
style="max-width: 600px"
@submit.prevent="submit()" @submit.prevent="submit()"
> >
<div <Alert
v-if="errors.length > 0" v-if="errors.length > 0"
role="alert" red
class="ui negative message"
> >
<h4 class="header"> <h4 class="header">
{{ $t('components.auth.LoginForm.header.loginFailure') }} {{ t('components.auth.LoginForm.header.loginFailure') }}
</h4> </h4>
<ul class="list"> <component
<li :is="errors.length>1 ? 'ul' : 'div'"
v-if="errors[0] == 'invalid_credentials' && $store.state.instance.settings.moderation.signup_approval_enabled.value" class="list"
>
<component
:is="errors.length>1 ? 'li' : 'div'"
v-if="errors[0] == 'invalid_credentials' && store.state.instance.settings.moderation.signup_approval_enabled.value"
>
{{ t('components.auth.LoginForm.help.approvalRequired') }}
</component>
<component
:is="errors.length>1 ? 'li' : 'div'"
v-else-if="errors[0] == 'invalid_credentials'"
>
{{ t('components.auth.LoginForm.help.invalidCredentials') }}
</component>
<component
:is="errors.length>1 ? 'li' : 'div'"
v-else
> >
{{ $t('components.auth.LoginForm.help.approvalRequired') }}
</li>
<li v-else-if="errors[0] == 'invalid_credentials'">
{{ $t('components.auth.LoginForm.help.invalidCredentials') }}
</li>
<li v-else>
{{ errors[0] }} {{ errors[0] }}
</li> </component>
</ul> </component>
</div> </Alert>
<template v-if="domain === $store.getters['instance/domain']"> <Spacer />
<div class="field"> <template v-if="domain === store.getters['instance/domain']">
<label for="username-field"> <Input
{{ $t('components.auth.LoginForm.label.username') }}
<template v-if="showSignup">
<span class="middle pipe symbol" />
<router-link :to="{ path: '/signup' }">
{{ $t('components.auth.LoginForm.link.createAccount') }}
</router-link>
</template>
</label>
<input
id="username-field" id="username-field"
ref="username" ref="username"
v-model="credentials.username" v-model="credentials.username"
autocomplete="username"
required required
name="username" name="username"
type="text" type="text"
autofocus autofocus
:placeholder="labels.usernamePlaceholder" :placeholder="labels.usernamePlaceholder"
> >
</div> <template #label>
<div class="field"> {{ t('components.auth.LoginForm.label.username') }}
<label for="password-field"> <template v-if="showSignup">
{{ $t('components.auth.LoginForm.label.password') }} <span class="middle pipe symbol" />
<router-link :to="{ path: '/signup' }">
{{ t('components.auth.LoginForm.link.createAccount') }}
</router-link>
</template>
</template>
</Input>
<Input
v-model="credentials.password"
password
name="password-field"
autocomplete="current-password"
field-id="password-field"
required
>
<template #label>
{{ t('components.auth.LoginForm.label.password') }}
<span class="middle pipe symbol" /> <span class="middle pipe symbol" />
<router-link <router-link
tabindex="1" tabindex="1"
:to="{ name: 'auth.password-reset', query: { email: credentials.username } }" :to="{ name: 'auth.password-reset', query: { email: credentials.username } }"
> >
{{ $t('components.auth.LoginForm.link.resetPassword') }} {{ t('components.auth.LoginForm.link.resetPassword') }}
</router-link> </router-link>
</label> </template>
<password-input </Input>
v-model="credentials.password"
field-id="password-field"
required
/>
</div>
</template> </template>
<template v-else> <template v-else>
<p> <p>
{{ $t('components.auth.LoginForm.message.redirect', { domain: $store.getters['instance/domain'] }) }} {{ t('components.auth.LoginForm.message.redirect', { domain: store.getters['instance/domain'] }) }}
</p> </p>
</template> </template>
<button <Button
:class="['ui', { 'loading': isLoading }, 'right', 'floated', buttonClasses, 'button']" solid
primary
type="submit" type="submit"
> >
{{ $t('components.auth.LoginForm.button.login') }} {{ t('components.auth.LoginForm.button.login') }}
</button> </Button>
</form> </Layout>
</template> </template>

View File

@ -1,8 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useStore } from '~/store'
import Button from '~/components/ui/Button.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Alert from '~/components/ui/Alert.vue'
import Link from '~/components/ui/Link.vue'
const store = useStore()
const { t } = useI18n() const { t } = useI18n()
const labels = computed(() => ({ const labels = computed(() => ({
title: t('components.auth.Logout.title') title: t('components.auth.Logout.title')
})) }))
@ -11,40 +19,43 @@ const labels = computed(() => ({
<template> <template>
<main <main
v-title="labels.title" v-title="labels.title"
class="main pusher" class="main"
> >
<section class="ui vertical stripe segment">
<div <div
v-if="$store.state.auth.authenticated" v-if="store.state.auth.authenticated"
class="ui small text container" class="ui small text container"
> >
<h2> <h2>
{{ $t('components.auth.Logout.header.confirm') }} {{ t('components.auth.Logout.header.confirm') }}
</h2> </h2>
<p> <p>
{{ $t('components.auth.Logout.message.loggedIn', { username: $store.state.auth.username }) }} {{ t('components.auth.Logout.message.loggedIn', { username: store.state.auth.username }) }}
</p> </p>
<button <Spacer />
class="ui button" <Button
@click="$store.dispatch('auth/logout')" solid
primary
@click="store.dispatch('auth/logout')"
> >
{{ $t('components.auth.Logout.button.logout') }} {{ t('components.auth.Logout.button.logout') }}
</button> </Button>
</div> </div>
<div <Alert
v-else v-else
class="ui small text container" yellow
> >
<h2> <h2>
{{ $t('components.auth.Logout.header.unauthenticated') }} {{ t('components.auth.Logout.header.unauthenticated') }}
</h2> </h2>
<router-link <template #actions>
<Link
solid
primary
to="/login" to="/login"
class="ui button"
> >
{{ $t('components.auth.Logout.link.login') }} {{ t('components.auth.Logout.link.login') }}
</router-link> </Link>
</div> </template>
</section> </Alert>
</main> </main>
</template> </template>

View File

@ -5,12 +5,21 @@ import axios from 'axios'
import { clone } from 'lodash-es' import { clone } from 'lodash-es'
import useMarkdown, { useMarkdownRaw } from '~/composables/useMarkdown' import useMarkdown, { useMarkdownRaw } from '~/composables/useMarkdown'
import { ref } from 'vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Layout from '~/components/ui/Layout.vue'
import Input from '~/components/ui/Input.vue'
import Toggle from '~/components/ui/Toggle.vue'
import Alert from '~/components/ui/Alert.vue'
import Button from '~/components/ui/Button.vue'
interface Props { interface Props {
plugin: Plugin plugin: Plugin
libraries: Library[] libraries: Library[]
} }
const { t } = useI18n()
const props = defineProps<Props>() const props = defineProps<Props>()
const description = useMarkdown(() => props.plugin.description ?? '') const description = useMarkdown(() => props.plugin.description ?? '')
@ -53,8 +62,9 @@ const submitAndScan = async () => {
</script> </script>
<template> <template>
<form <Layout
:class="['ui segment form', {loading: isLoading}]" form
:class="['ui form', {loading: isLoading}]"
@submit.prevent="submit" @submit.prevent="submit"
> >
<h3>{{ plugin.label }}</h3> <h3>{{ plugin.label }}</h3>
@ -63,23 +73,20 @@ const submitAndScan = async () => {
:html="description" :html="description"
/> />
<template v-if="plugin.homepage"> <template v-if="plugin.homepage">
<div class="ui small hidden divider" />
<a <a
:href="plugin.homepage" :href="plugin.homepage"
target="_blank" target="_blank"
> >
<i class="external icon" /> <i class="external icon" />
{{ $t('components.auth.Plugin.link.documentation') }} {{ t('components.auth.Plugin.link.documentation') }}
</a> </a>
</template> </template>
<div class="ui clearing hidden divider" /> <Alert
<div
v-if="errors.length > 0" v-if="errors.length > 0"
role="alert" red
class="ui negative message"
> >
<h4 class="header"> <h4 class="header">
{{ $t('components.auth.Plugin.header.failure') }} {{ t('components.auth.Plugin.header.failure') }}
</h4> </h4>
<ul class="list"> <ul class="list">
<li <li
@ -89,23 +96,19 @@ const submitAndScan = async () => {
{{ error }} {{ error }}
</li> </li>
</ul> </ul>
</div> </Alert>
<div class="field"> <div class="field">
<div class="ui toggle checkbox"> <Toggle
<input
:id="`${plugin.name}-enabled`"
v-model="enabled" v-model="enabled"
type="checkbox" big
> :label="t('components.auth.Plugin.label.pluginEnabled')"
<label :for="`${plugin.name}-enabled`">{{ $t('components.auth.Plugin.label.pluginEnabled') }}</label> />
</div> </div>
</div>
<div class="ui clearing hidden divider" />
<div <div
v-if="plugin.source" v-if="plugin.source"
class="field" class="field"
> >
<label for="plugin-library">{{ $t('components.auth.Plugin.label.library') }}</label> <label for="plugin-library">{{ t('components.auth.Plugin.label.library') }}</label>
<select <select
id="plugin-library" id="plugin-library"
v-model="values['library']" v-model="values['library']"
@ -119,7 +122,7 @@ const submitAndScan = async () => {
</option> </option>
</select> </select>
<div> <div>
{{ $t('components.auth.Plugin.description.library') }} {{ t('components.auth.Plugin.description.library') }}
</div> </div>
</div> </div>
<template v-if="(plugin.conf?.length ?? 0) > 0"> <template v-if="(plugin.conf?.length ?? 0) > 0">
@ -131,12 +134,12 @@ const submitAndScan = async () => {
v-if="field.type === 'text'" v-if="field.type === 'text'"
class="field" class="field"
> >
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label> <Input
<input
:id="`plugin-${field.name}`" :id="`plugin-${field.name}`"
v-model="values[field.name]" v-model="values[field.name]"
:label="field.label || field.name"
type="text" type="text"
> />
<sanitized-html <sanitized-html
v-if="field.help" v-if="field.help"
:html="useMarkdownRaw(field.help)" :html="useMarkdownRaw(field.help)"
@ -146,10 +149,10 @@ const submitAndScan = async () => {
v-if="field.type === 'long_text'" v-if="field.type === 'long_text'"
class="field" class="field"
> >
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
<textarea <textarea
:id="`plugin-${field.name}`" :id="`plugin-${field.name}`"
v-model="values[field.name]" v-model="values[field.name]"
:label="field.label || field.name"
type="text" type="text"
rows="5" rows="5"
/> />
@ -162,12 +165,12 @@ const submitAndScan = async () => {
v-if="field.type === 'url'" v-if="field.type === 'url'"
class="field" class="field"
> >
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label> <Input
<input
:id="`plugin-${field.name}`" :id="`plugin-${field.name}`"
v-model="values[field.name]" v-model="values[field.name]"
:label="field.label || field.name"
type="url" type="url"
> />
<sanitized-html <sanitized-html
v-if="field.help" v-if="field.help"
:html="useMarkdownRaw(field.help)" :html="useMarkdownRaw(field.help)"
@ -177,12 +180,12 @@ const submitAndScan = async () => {
v-if="field.type === 'password'" v-if="field.type === 'password'"
class="field" class="field"
> >
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label> <Input
<input
:id="`plugin-${field.name}`" :id="`plugin-${field.name}`"
v-model="values[field.name]" v-model="values[field.name]"
type="password" :label="field.label || field.name"
> password
/>
<sanitized-html <sanitized-html
v-if="field.help" v-if="field.help"
:html="useMarkdownRaw(field.help)" :html="useMarkdownRaw(field.help)"
@ -192,30 +195,27 @@ const submitAndScan = async () => {
v-if="field.type === 'boolean'" v-if="field.type === 'boolean'"
class="field" class="field"
> >
<div class="ui toggle checkbox"> <Toggle
<input
:id="`plugin-${field.name}`"
v-model="values[field.name]" v-model="values[field.name]"
type="checkbox" :label="field.label || field.name"
> />
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
</div>
</div> </div>
</template> </template>
</template> </template>
<button <Button
type="submit" type="submit"
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']" primary
:class="[{'loading': isLoading}]"
> >
{{ $t('components.auth.Plugin.button.save') }} {{ t('components.auth.Plugin.button.save') }}
</button> </Button>
<button <Button
v-if="plugin.source" v-if="plugin.source"
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']" primary
:class="[{'loading': isLoading}]"
@click.prevent="submitAndScan" @click.prevent="submitAndScan"
> >
{{ $t('components.auth.Plugin.button.scan') }} {{ t('components.auth.Plugin.button.scan') }}
</button> </Button>
<div class="ui clearing hidden divider" /> </Layout>
</form>
</template> </template>

View File

@ -3,9 +3,8 @@ import type { BackendError, Application, PrivacyLevel } from '~/types'
import type { $ElementType } from 'utility-types' import type { $ElementType } from 'utility-types'
import axios from 'axios' import axios from 'axios'
import $ from 'jquery'
import { computed, reactive, ref, onMounted } from 'vue' import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useStore } from '~/store' import { useStore } from '~/store'
@ -13,10 +12,20 @@ import { useStore } from '~/store'
import useSharedLabels from '~/composables/locale/useSharedLabels' import useSharedLabels from '~/composables/locale/useSharedLabels'
import useLogger from '~/composables/useLogger' import useLogger from '~/composables/useLogger'
import DangerousButton from '~/components/common/DangerousButton.vue'
import SubsonicTokenForm from '~/components/auth/SubsonicTokenForm.vue' import SubsonicTokenForm from '~/components/auth/SubsonicTokenForm.vue'
import AttachmentInput from '~/components/common/AttachmentInput.vue' import AttachmentInput from '~/components/common/AttachmentInput.vue'
import PasswordInput from '~/components/forms/PasswordInput.vue' import PasswordInput from '~/components/forms/PasswordInput.vue'
import Input from '~/components/ui/Input.vue'
import Layout from '~/components/ui/Layout.vue'
import Slider from '~/components/ui/Slider.vue'
import Alert from '~/components/ui/Alert.vue'
import Heading from '~/components/ui/Heading.vue'
import Button from '~/components/ui/Button.vue'
import Link from '~/components/ui/Link.vue'
import Textarea from '~/components/ui/Textarea.vue'
const SETTINGS_ORDER: FieldId[] = ['summary', 'privacy_level'] const SETTINGS_ORDER: FieldId[] = ['summary', 'privacy_level']
type Field = { id: 'summary', type: 'content', value: { text: string, content_type: 'text/markdown' } } type Field = { id: 'summary', type: 'content', value: { text: string, content_type: 'text/markdown' } }
@ -260,43 +269,39 @@ const changeEmail = async () => {
isChangingEmail.value = false isChangingEmail.value = false
} }
onMounted(() => {
$('select.dropdown').dropdown()
})
fetchApps() fetchApps()
fetchOwnedApps() fetchOwnedApps()
</script> </script>
<template> <template>
<main <Layout
v-title="labels.title" v-title="labels.title"
class="main pusher" main
stack
> >
<div class="ui vertical stripe segment"> <Heading
<section class="ui text container"> :h1="t('components.auth.Settings.header.accountSettings')"
<h2 class="ui header"> page-heading
{{ $t('components.auth.Settings.header.accountSettings') }} />
</h2> <Layout
<form form
class="ui form"
@submit.prevent="submitSettings()" @submit.prevent="submitSettings()"
> >
<div <Alert
v-if="settings.success" v-if="settings.success"
class="ui positive message" green
> >
<h4 class="header"> <h4 class="header">
{{ $t('components.auth.Settings.header.settingsUpdated') }} {{ t('components.auth.Settings.header.settingsUpdated') }}
</h4> </h4>
</div> </Alert>
<div <Alert
v-if="settings.errors.length > 0" v-if="settings.errors.length > 0"
red
role="alert" role="alert"
class="ui negative message"
> >
<h4 class="header"> <h4 class="header">
{{ $t('components.auth.Settings.header.updateFailure') }} {{ t('components.auth.Settings.header.updateFailure') }}
</h4> </h4>
<ul class="list"> <ul class="list">
<li <li
@ -306,57 +311,50 @@ fetchOwnedApps()
{{ error }} {{ error }}
</li> </li>
</ul> </ul>
</div> </Alert>
<div <div
v-for="f in orderedSettingsFields" v-for="f in orderedSettingsFields"
:key="f.id" :key="f.id + sharedLabels.fields[f.id].help"
class="field" class="field"
> >
<Textarea
v-if="f.type === 'content'"
v-model="f.value.text"
:label="sharedLabels.fields[f.id].label"
:placeholder="sharedLabels.fields[f.id].help"
/>
<template v-else>
<label :for="f.id">{{ sharedLabels.fields[f.id].label }}</label> <label :for="f.id">{{ sharedLabels.fields[f.id].label }}</label>
<p v-if="sharedLabels.fields[f.id].help"> <p v-if="sharedLabels.fields[f.id].help">
{{ sharedLabels.fields[f.id].help }} {{ sharedLabels.fields[f.id].help }}
</p> </p>
<select <Slider
v-if="f.type === 'dropdown'" v-if="f.type === 'dropdown'"
:id="f.id"
v-model="f.value" v-model="f.value"
class="ui dropdown" :options="Object.fromEntries(f.choices.map(c => [c, sharedLabels.fields[f.id].choices?.[c] || c]))"
>
<option
v-for="(c, key) in f.choices"
:key="key"
:value="c"
>
{{ sharedLabels.fields[f.id].choices?.[c] }}
</option>
</select>
<content-form
v-if="f.type === 'content'"
v-model="f.value.text"
:field-id="f.id"
/> />
</template>
</div> </div>
<button <Button
:class="['ui', { loading: isLoading }, 'button']" primary
:is-loading="isLoading"
type="submit" type="submit"
> >
{{ $t('components.auth.Settings.button.updateSettings') }} {{ t('components.auth.Settings.button.updateSettings') }}
</button> </Button>
</form> </Layout>
</section>
<section class="ui text container"> <section class="ui text container">
<div class="ui hidden divider" />
<h2 class="ui header"> <h2 class="ui header">
{{ $t('components.auth.Settings.header.avatar') }} {{ t('components.auth.Settings.header.avatar') }}
</h2> </h2>
<div class="ui form"> <Layout form>
<div <Alert
v-if="avatarErrors.length > 0" v-if="avatarErrors.length > 0"
red
role="alert" role="alert"
class="ui negative message"
> >
<h4 class="header"> <h4 class="header">
{{ $t('components.auth.Settings.header.avatarFailure') }} {{ t('components.auth.Settings.header.avatarFailure') }}
</h4> </h4>
<ul class="list"> <ul class="list">
<li <li
@ -366,46 +364,44 @@ fetchOwnedApps()
{{ error }} {{ error }}
</li> </li>
</ul> </ul>
</div> </Alert>
<attachment-input <attachment-input
v-model="avatar.uuid" v-model="avatar.uuid"
:initial-value="initialAvatar" :initial-value="initialAvatar"
@update:model-value="submitAvatar($event)" @update:model-value="submitAvatar($event)"
@delete="avatar = {uuid: null}" @delete="avatar = {uuid: null}"
> >
{{ $t('components.auth.Settings.label.avatar') }} {{ t('components.auth.Settings.label.avatar') }}
</attachment-input> </attachment-input>
</div> </Layout>
</section> </section>
<section class="ui text container"> <section class="ui text container">
<div class="ui hidden divider" />
<h2 class="ui header"> <h2 class="ui header">
{{ $t('components.auth.Settings.header.changePassword') }} {{ t('components.auth.Settings.header.changePassword') }}
</h2> </h2>
<div class="ui message"> <div class="ui message">
{{ $t('components.auth.Settings.description.changePassword.paragraph1') }}&nbsp;{{ $t('components.auth.Settings.description.changePassword.paragraph2') }} {{ t('components.auth.Settings.description.changePassword.paragraph1') }}&nbsp;{{ t('components.auth.Settings.description.changePassword.paragraph2') }}
</div> </div>
<form <Layout
class="ui form" form
@submit.prevent="submitPassword()" @submit.prevent="submitPassword()"
> >
<div <Alert
v-if="passwordError" v-if="passwordError"
role="alert" role="alert"
class="ui negative message"
> >
<h4 class="header"> <h4 class="header">
{{ $t('components.auth.Settings.header.passwordFailure') }} {{ t('components.auth.Settings.header.passwordFailure') }}
</h4> </h4>
<ul class="list"> <ul class="list">
<li v-if="passwordError == 'invalid_credentials'"> <li v-if="passwordError == 'invalid_credentials'">
{{ $t('components.auth.Settings.help.changePassword') }} {{ t('components.auth.Settings.help.changePassword') }}
</li> </li>
</ul> </ul>
</div> </Alert>
<div class="field"> <div class="field">
<label for="old-password-field">{{ $t('components.auth.Settings.label.currentPassword') }}</label> <label for="old-password-field">{{ t('components.auth.Settings.label.currentPassword') }}</label>
<password-input <password-input
v-model="credentials.oldPassword" v-model="credentials.oldPassword"
field-id="old-password-field" field-id="old-password-field"
@ -413,7 +409,7 @@ fetchOwnedApps()
/> />
</div> </div>
<div class="field"> <div class="field">
<label for="new-password-field">{{ $t('components.auth.Settings.label.newPassword') }}</label> <label for="new-password-field">{{ t('components.auth.Settings.label.newPassword') }}</label>
<password-input <password-input
v-model="credentials.newPassword" v-model="credentials.newPassword"
field-id="new-password-field" field-id="new-password-field"
@ -423,35 +419,31 @@ fetchOwnedApps()
<dangerous-button <dangerous-button
:class="['ui', {'loading': isLoadingPassword}, {disabled: !credentials.newPassword || !credentials.oldPassword}, 'warning', 'button']" :class="['ui', {'loading': isLoadingPassword}, {disabled: !credentials.newPassword || !credentials.oldPassword}, 'warning', 'button']"
:action="submitPassword" :action="submitPassword"
:title="t('components.auth.Settings.modal.changePassword.header')"
> >
{{ $t('components.auth.Settings.button.password') }} {{ t('components.auth.Settings.button.password') }}
<template #modal-header>
<p>
{{ $t('components.auth.Settings.modal.changePassword.header') }}
</p>
</template>
<template #modal-content> <template #modal-content>
<div> <div>
<p> <p>
{{ $t('components.auth.Settings.modal.changePassword.content.warning') }} {{ t('components.auth.Settings.modal.changePassword.content.warning') }}
</p> </p>
<ul> <ul>
<li> <li>
{{ $t('components.auth.Settings.modal.changePassword.content.logout') }} {{ t('components.auth.Settings.modal.changePassword.content.logout') }}
</li> </li>
<li> <li>
{{ $t('components.auth.Settings.modal.changePassword.content.subsonic') }} {{ t('components.auth.Settings.modal.changePassword.content.subsonic') }}
</li> </li>
</ul> </ul>
</div> </div>
</template> </template>
<template #modal-confirm> <template #modal-confirm>
<div> <div>
{{ $t('components.auth.Settings.button.disableSubsonic') }} {{ t('components.auth.Settings.button.disableSubsonic') }}
</div> </div>
</template> </template>
</dangerous-button> </dangerous-button>
</form> </Layout>
<div class="ui hidden divider" /> <div class="ui hidden divider" />
<subsonic-token-form /> <subsonic-token-form />
</section> </section>
@ -460,42 +452,41 @@ fetchOwnedApps()
id="content-filters" id="content-filters"
class="ui text container" class="ui text container"
> >
<div class="ui hidden divider" />
<h2 class="ui header"> <h2 class="ui header">
<i class="eye slash outline icon" /> <i class="bi bi-eye-slash" />
<div class="content"> <div class="content">
{{ $t('components.auth.Settings.header.contentFilters') }} {{ t('components.auth.Settings.header.contentFilters') }}
</div> </div>
</h2> </h2>
<p> <p>
{{ $t('components.auth.Settings.description.contentFilters') }} {{ t('components.auth.Settings.description.contentFilters') }}
</p> </p>
<button <Button
class="ui icon button" primary
@click="$store.dispatch('moderation/fetchContentFilters')" icon="bi-arrow-clockwise"
@click="store.dispatch('moderation/fetchContentFilters')"
> >
<i class="refresh icon" />&nbsp; {{ t('components.auth.Settings.button.refresh') }}
{{ $t('components.auth.Settings.button.refresh') }} </Button>
</button>
<h3 class="ui header"> <h3 class="ui header">
{{ $t('components.auth.Settings.header.hiddenArtists') }} {{ t('components.auth.Settings.header.hiddenArtists') }}
</h3> </h3>
<table class="ui compact very basic unstackable table"> <table class="ui compact very basic unstackable table">
<thead> <thead>
<tr> <tr>
<th> <th>
{{ $t('components.auth.Settings.table.artists.header.name') }} {{ t('components.auth.Settings.table.artists.header.name') }}
</th> </th>
<th> <th>
{{ $t('components.auth.Settings.table.artists.header.creationDate') }} {{ t('components.auth.Settings.table.artists.header.creationDate') }}
</th> </th>
<th /> <th />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr
v-for="filter in $store.getters['moderation/artistFilters']()" v-for="filter in store.getters['moderation/artistFilters']()"
:key="filter.uuid" :key="filter.uuid"
> >
<td> <td>
@ -507,12 +498,12 @@ fetchOwnedApps()
<human-date :date="filter.creation_date" /> <human-date :date="filter.creation_date" />
</td> </td>
<td> <td>
<button <Button
class="ui basic tiny button" secondary
@click="$store.dispatch('moderation/deleteContentFilter', filter.uuid)" @click="store.dispatch('moderation/deleteContentFilter', filter.uuid)"
> >
{{ $t('components.auth.Settings.button.delete') }} {{ t('components.auth.Settings.button.delete') }}
</button> </Button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -524,21 +515,22 @@ fetchOwnedApps()
> >
<div class="ui hidden divider" /> <div class="ui hidden divider" />
<h2 class="ui header"> <h2 class="ui header">
<i class="open lock icon" /> <i class="bi bi-unlock-fill" />
<div class="content"> <div class="content">
{{ $t('components.auth.Settings.header.authorizedApps') }} {{ t('components.auth.Settings.header.authorizedApps') }}
</div> </div>
</h2> </h2>
<p> <p>
{{ $t('components.auth.Settings.description.authorizedApps') }} {{ t('components.auth.Settings.description.authorizedApps') }}
</p> </p>
<button <Button
:class="['ui', 'icon', { loading: isLoadingApps }, 'button']" primary
icon="bi-arrow-clockwise"
:is-loading="isLoadingApps"
@click="fetchApps()" @click="fetchApps()"
> >
<i class="refresh icon" />&nbsp; {{ t('components.auth.Settings.button.refresh') }}
{{ $t('components.auth.Settings.button.refresh') }} </Button>
</button>
<table <table
v-if="apps.length > 0" v-if="apps.length > 0"
class="ui compact very basic unstackable table" class="ui compact very basic unstackable table"
@ -546,10 +538,10 @@ fetchOwnedApps()
<thead> <thead>
<tr> <tr>
<th> <th>
{{ $t('components.auth.Settings.table.authorizedApps.header.application') }} {{ t('components.auth.Settings.table.authorizedApps.header.application') }}
</th> </th>
<th> <th>
{{ $t('components.auth.Settings.table.authorizedApps.header.permissions') }} {{ t('components.auth.Settings.table.authorizedApps.header.permissions') }}
</th> </th>
<th /> <th />
</tr> </tr>
@ -568,23 +560,15 @@ fetchOwnedApps()
<td> <td>
<dangerous-button <dangerous-button
:class="['ui', 'tiny', 'danger', { loading: isRevoking.has(app.client_id) }, 'button']" :class="['ui', 'tiny', 'danger', { loading: isRevoking.has(app.client_id) }, 'button']"
:title="t('components.auth.Settings.modal.revokeApp.header', {app: app.name})"
@confirm="revokeApp(app.client_id)" @confirm="revokeApp(app.client_id)"
> >
{{ $t('components.auth.Settings.button.revoke') }} {{ t('components.auth.Settings.button.revoke') }}
<template #modal-header>
<p>
{{ $t('components.auth.Settings.modal.revokeApp.header', {app: app.name}) }}
</p>
</template>
<template #modal-content> <template #modal-content>
<p> {{ t('components.auth.Settings.modal.revokeApp.content.warning') }}
{{ $t('components.auth.Settings.modal.revokeApp.content.warning') }}
</p>
</template> </template>
<template #modal-confirm> <template #modal-confirm>
<div> {{ t('components.auth.Settings.button.revokeAccess') }}
{{ $t('components.auth.Settings.button.revokeAccess') }}
</div>
</template> </template>
</dangerous-button> </dangerous-button>
</td> </td>
@ -593,9 +577,9 @@ fetchOwnedApps()
</table> </table>
<empty-state v-else> <empty-state v-else>
<template #title> <template #title>
{{ $t('components.auth.Settings.header.noApps') }} {{ t('components.auth.Settings.header.noApps') }}
</template> </template>
{{ $t('components.auth.Settings.help.noApps') }} {{ t('components.auth.Settings.help.noApps') }}
</empty-state> </empty-state>
</section> </section>
<section <section
@ -604,20 +588,20 @@ fetchOwnedApps()
> >
<div class="ui hidden divider" /> <div class="ui hidden divider" />
<h2 class="ui header"> <h2 class="ui header">
<i class="code icon" /> <i class="bi bi-code-slash" />
<div class="content"> <div class="content">
{{ $t('components.auth.Settings.header.yourApps') }} {{ t('components.auth.Settings.header.yourApps') }}
</div> </div>
</h2> </h2>
<p> <p>
{{ $t('components.auth.Settings.description.yourApps') }} {{ t('components.auth.Settings.description.yourApps') }}
</p> </p>
<router-link <Link
class="ui success button" class="ui success button"
:to="{name: 'settings.applications.new'}" :to="{name: 'settings.applications.new'}"
> >
{{ $t('components.auth.Settings.link.newApp') }} {{ t('components.auth.Settings.link.newApp') }}
</router-link> </Link>
<table <table
v-if="ownedApps.length > 0" v-if="ownedApps.length > 0"
class="ui compact very basic unstackable table" class="ui compact very basic unstackable table"
@ -625,13 +609,13 @@ fetchOwnedApps()
<thead> <thead>
<tr> <tr>
<th> <th>
{{ $t('components.auth.Settings.table.yourApps.header.application') }} {{ t('components.auth.Settings.table.yourApps.header.application') }}
</th> </th>
<th> <th>
{{ $t('components.auth.Settings.table.yourApps.header.scopes') }} {{ t('components.auth.Settings.table.yourApps.header.scopes') }}
</th> </th>
<th> <th>
{{ $t('components.auth.Settings.table.yourApps.header.creationDate') }} {{ t('components.auth.Settings.table.yourApps.header.creationDate') }}
</th> </th>
<th /> <th />
</tr> </tr>
@ -653,42 +637,35 @@ fetchOwnedApps()
<human-date :date="app.created" /> <human-date :date="app.created" />
</td> </td>
<td> <td>
<router-link <Link
class="ui tiny success button" class="ui tiny success button"
:to="{name: 'settings.applications.edit', params: {id: app.client_id}}" :to="{name: 'settings.applications.edit', params: {id: app.client_id}}"
> >
{{ $t('components.auth.Settings.button.edit') }} {{ t('components.auth.Settings.button.edit') }}
</router-link> </Link>
<dangerous-button <DangerousButton
:class="['ui', 'tiny', 'danger', { loading: isDeleting.has(app.client_id) }, 'button']" :is-loading="isDeleting.has(app.client_id)"
class="tiny"
:title="t('components.auth.Settings.modal.deleteApp.header', {app: app.name})"
@confirm="deleteApp(app.client_id)" @confirm="deleteApp(app.client_id)"
> >
{{ $t('components.auth.Settings.button.remove') }} {{ t('components.auth.Settings.button.remove') }}
<template #modal-header>
<p>
{{ $t('components.auth.Settings.modal.deleteApp.header', {app: app.name}) }}
</p>
</template>
<template #modal-content> <template #modal-content>
<p> {{ t('components.auth.Settings.modal.deleteApp.content.warning') }}
{{ $t('components.auth.Settings.modal.deleteApp.content.warning') }}
</p>
</template> </template>
<template #modal-confirm> <template #modal-confirm>
<div> {{ t('components.auth.Settings.button.removeApp') }}
{{ $t('components.auth.Settings.button.removeApp') }}
</div>
</template> </template>
</dangerous-button> </DangerousButton>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<empty-state v-else> <empty-state v-else>
<template #title> <template #title>
{{ $t('components.auth.Settings.header.noPersonalApps') }} {{ t('components.auth.Settings.header.noPersonalApps') }}
</template> </template>
{{ $t('components.auth.Settings.help.noPersonalApps') }} {{ t('components.auth.Settings.help.noPersonalApps') }}
</empty-state> </empty-state>
</section> </section>
@ -698,46 +675,48 @@ fetchOwnedApps()
> >
<div class="ui hidden divider" /> <div class="ui hidden divider" />
<h2 class="ui header"> <h2 class="ui header">
<i class="code icon" /> <i class="bi bi-code" />
<div class="content"> <div class="content">
{{ $t('components.auth.Settings.header.plugins') }} {{ t('components.auth.Settings.header.plugins') }}
</div> </div>
</h2> </h2>
<p> <p>
{{ $t('components.auth.Settings.description.plugins') }} {{ t('components.auth.Settings.description.plugins') }}
</p> </p>
<router-link <Link
class="ui success button" primary
solid
:to="{name: 'settings.plugins'}" :to="{name: 'settings.plugins'}"
icon="bi-puzzle-fill"
> >
{{ $t('components.auth.Settings.link.managePlugins') }} {{ t('components.auth.Settings.link.managePlugins') }}
</router-link> </Link>
</section> </section>
<section class="ui text container"> <section class="ui text container">
<div class="ui hidden divider" /> <div class="ui hidden divider" />
<h2 class="ui header"> <h2 class="ui header">
<i class="comment icon" /> <i class="bi bi-envelope-at" />
<div class="content"> <div class="content">
{{ $t('components.auth.Settings.header.changeEmail') }} {{ t('components.auth.Settings.header.changeEmail') }}
</div> </div>
</h2> </h2>
<p> <p>
{{ $t('components.auth.Settings.description.changeEmail') }} {{ t('components.auth.Settings.description.changeEmail') }}
</p> </p>
<p> <p>
{{ $t('components.auth.Settings.message.currentEmail', { email: $store.state.auth.profile?.email }) }} {{ t('components.auth.Settings.message.currentEmail', { email: store.state.auth.profile?.email }) }}
</p> </p>
<form <Layout
class="ui form" form
@submit.prevent="changeEmail" @submit.prevent="changeEmail"
> >
<div <Alert
v-if="changeEmailErrors.length > 0" v-if="changeEmailErrors.length > 0"
red
role="alert" role="alert"
class="ui negative message"
> >
<h4 class="header"> <h4 class="header">
{{ $t('components.auth.Settings.header.emailFailure') }} {{ t('components.auth.Settings.header.emailFailure') }}
</h4> </h4>
<ul class="list"> <ul class="list">
<li <li
@ -747,57 +726,57 @@ fetchOwnedApps()
{{ error }} {{ error }}
</li> </li>
</ul> </ul>
</div> </Alert>
<div class="field"> <div class="field">
<label for="new-email">{{ $t('components.auth.Settings.label.newEmail') }}</label> <label for="new-email">{{ t('components.auth.Settings.label.newEmail') }}</label>
<input <Input
id="new-email" id="new-email"
v-model="newEmail" v-model="newEmail"
required required
type="email" type="email"
> />
</div> </div>
<div class="field"> <div class="field">
<label for="current-password-field-email">{{ $t('components.auth.Settings.label.password') }}</label> <label for="current-password-field-email">{{ t('components.auth.Settings.label.password') }}</label>
<password-input <password-input
v-model="emailPassword" v-model="emailPassword"
field-id="current-password-field-email" field-id="current-password-field-email"
required required
/> />
</div> </div>
<button <Button
primary
type="submit" type="submit"
class="ui button"
> >
{{ $t('components.auth.Settings.button.update') }} {{ t('components.auth.Settings.button.update') }}
</button> </Button>
</form> </Layout>
</section> </section>
<section class="ui text container"> <section class="ui text container">
<div class="ui hidden divider" /> <div class="ui hidden divider" />
<h2 class="ui header"> <h2 class="ui header">
<i class="trash icon" /> <i class="bi bi-trash" />
<div class="content"> <div class="content">
{{ $t('components.auth.Settings.header.deleteAccount') }} {{ t('components.auth.Settings.header.deleteAccount') }}
</div> </div>
</h2> </h2>
<p> <p>
{{ $t('components.auth.Settings.description.deleteAccount') }} {{ t('components.auth.Settings.description.deleteAccount') }}
</p> </p>
<div <Alert
yellow
role="alert" role="alert"
class="ui warning message"
> >
{{ $t('components.auth.Settings.warning.deleteAccount') }} {{ t('components.auth.Settings.warning.deleteAccount') }}
</div> </Alert>
<div class="ui form"> <Layout form>
<div <Alert
v-if="accountDeleteErrors.length > 0" v-if="accountDeleteErrors.length > 0"
red
role="alert" role="alert"
class="ui negative message"
> >
<h4 class="header"> <h4 class="header">
{{ $t('components.auth.Settings.header.accountFailure') }} {{ t('components.auth.Settings.header.accountFailure') }}
</h4> </h4>
<ul class="list"> <ul class="list">
<li <li
@ -807,9 +786,9 @@ fetchOwnedApps()
{{ error }} {{ error }}
</li> </li>
</ul> </ul>
</div> </Alert>
<div class="field"> <div class="field">
<label for="current-password-field">{{ $t('components.auth.Settings.label.currentPassword') }}</label> <label for="current-password-field">{{ t('components.auth.Settings.label.currentPassword') }}</label>
<password-input <password-input
v-model="deleteAccountPassword" v-model="deleteAccountPassword"
field-id="current-password-field" field-id="current-password-field"
@ -817,30 +796,21 @@ fetchOwnedApps()
/> />
</div> </div>
<dangerous-button <dangerous-button
:class="['ui', {'loading': isDeletingAccount}, {disabled: !deleteAccountPassword}, {danger: deleteAccountPassword}, 'button']" :is-loading="isDeletingAccount"
:disabled="!deleteAccountPassword || undefined"
:class="{danger: deleteAccountPassword}"
:action="deleteAccount" :action="deleteAccount"
:title="t('components.auth.Settings.modal.deleteAccount.header')"
> >
{{ $t('components.auth.Settings.button.deleteAccount') }} {{ t('components.auth.Settings.button.deleteAccount') }}
<template #modal-header>
<p>
{{ $t('components.auth.Settings.modal.deleteAccount.header') }}
</p>
</template>
<template #modal-content> <template #modal-content>
<div> {{ t('components.auth.Settings.modal.deleteAccount.content.warning') }}
<p>
{{ $t('components.auth.Settings.modal.deleteAccount.content.warning') }}
</p>
</div>
</template> </template>
<template #modal-confirm> <template #modal-confirm>
<div> {{ t('components.auth.Settings.button.deleteAccountConfirm') }}
{{ $t('components.auth.Settings.button.deleteAccountConfirm') }}
</div>
</template> </template>
</dangerous-button> </dangerous-button>
</div> </Layout>
</section> </section>
</div> </Layout>
</main>
</template> </template>

View File

@ -9,7 +9,12 @@ import { useStore } from '~/store'
import axios from 'axios' import axios from 'axios'
import LoginForm from '~/components/auth/LoginForm.vue' import LoginForm from '~/components/auth/LoginForm.vue'
import PasswordInput from '~/components/forms/PasswordInput.vue' import Alert from '~/components/ui/Alert.vue'
import Input from '~/components/ui/Input.vue'
import Textarea from '~/components/ui/Textarea.vue'
import Button from '~/components/ui/Button.vue'
import Layout from '~/components/ui/Layout.vue'
import useLogger from '~/composables/useLogger' import useLogger from '~/composables/useLogger'
interface Props { interface Props {
@ -86,54 +91,59 @@ fetchInstanceSettings()
<template> <template>
<div v-if="submitted"> <div v-if="submitted">
<div class="ui success message"> <Alert
<p v-if="signupRequiresApproval"> v-if="signupRequiresApproval"
{{ $t('components.auth.SignupForm.message.awaitingReview') }} yellow
</p> >
<p v-else> {{ t('components.auth.SignupForm.message.awaitingReview') }}
{{ $t('components.auth.SignupForm.message.accountCreated') }} </Alert>
</p> <Alert
</div> v-else
green
>
{{ t('components.auth.SignupForm.message.accountCreated') }}
</Alert>
<h2> <h2>
{{ $t('components.auth.SignupForm.header.login') }} {{ t('components.auth.SignupForm.header.login') }}
</h2> </h2>
<login-form <login-form
style="max-width: 600px"
button-classes="basic success" button-classes="basic success"
:show-signup="false" :show-signup="false"
/> />
</div> </div>
<form <Layout
v-else v-else
:class="['ui', {'loading': isLoadingInstanceSetting}, 'form']" form
stack
style="max-width: 600px"
@submit.prevent="submit()" @submit.prevent="submit()"
> >
<p <Alert
v-if="!$store.state.instance.settings.users.registration_enabled.value" v-if="!store.state.instance.settings.users.registration_enabled.value"
class="ui message" red
> >
{{ $t('components.auth.SignupForm.message.registrationClosed') }} {{ t('components.auth.SignupForm.message.registrationClosed') }}
</p> </Alert>
<p <Alert
v-else-if="signupRequiresApproval" v-else-if="signupRequiresApproval"
class="ui message" yellow
> >
{{ $t('components.auth.SignupForm.message.requiresReview') }} {{ t('components.auth.SignupForm.message.requiresReview') }}
</p> </Alert>
<template v-if="formCustomization?.help_text"> <template v-if="formCustomization?.help_text">
<rendered-description <rendered-description
:content="formCustomization.help_text" :content="formCustomization.help_text"
:fetch-html="fetchDescriptionHtml" :fetch-html="fetchDescriptionHtml"
:permissive="true" :permissive="true"
/> />
<div class="ui hidden divider" />
</template> </template>
<div <Alert
v-if="errors.length > 0" v-if="errors.length > 0"
role="alert" red
class="ui negative message"
> >
<h4 class="header"> <h4 class="header">
{{ $t('components.auth.SignupForm.header.signupFailure') }} {{ t('components.auth.SignupForm.header.signupFailure') }}
</h4> </h4>
<ul class="list"> <ul class="list">
<li <li
@ -143,81 +153,79 @@ fetchInstanceSettings()
{{ error }} {{ error }}
</li> </li>
</ul> </ul>
</div> </Alert>
<div class="required field"> <Input
<label for="username-field">{{ $t('components.auth.SignupForm.label.username') }}</label>
<input
id="username-field" id="username-field"
ref="username" ref="username"
v-model="payload.username" v-model="payload.username"
:label="t('components.auth.SignupForm.label.username')"
name="username" name="username"
required required
type="text" type="text"
autofocus autofocus
:placeholder="labels.usernamePlaceholder" :placeholder="labels.usernamePlaceholder"
> />
</div> <Input
<div class="required field">
<label for="email-field">{{ $t('components.auth.SignupForm.label.email') }}</label>
<input
id="email-field" id="email-field"
ref="email" ref="email"
v-model="payload.email" v-model="payload.email"
:label="t('components.auth.SignupForm.label.email')"
autocomplete="email"
name="email" name="email"
required required
type="email" type="email"
:placeholder="labels.emailPlaceholder" :placeholder="labels.emailPlaceholder"
> />
</div> <Input
<div class="required field">
<label for="password-field">{{ $t('components.auth.SignupForm.label.password') }}</label>
<password-input
v-model="payload.password1" v-model="payload.password1"
password
autocomplete="new-password"
:label="t('components.auth.SignupForm.label.password')"
field-id="password-field" field-id="password-field"
/> />
</div> <Input
<div v-if="!store.state.instance.settings.users.registration_enabled.value && payload.invitation"
v-if="!$store.state.instance.settings.users.registration_enabled.value"
class="required field"
>
<label for="invitation-code">{{ $t('components.auth.SignupForm.label.invitation') }}</label>
<input
id="invitation-code" id="invitation-code"
v-model="payload.invitation" v-model="payload.invitation"
:label="t('components.auth.SignupForm.label.invitation')"
required required
type="text" type="text"
name="invitation" name="invitation"
:placeholder="labels.placeholder" :placeholder="labels.placeholder"
> />
</div>
<template v-if="signupRequiresApproval && (formCustomization?.fields.length ?? 0) > 0">
<div <div
v-for="(field, idx) in formCustomization?.fields" v-for="(field, idx) in
( signupRequiresApproval && formCustomization && (formCustomization.fields.length ?? 0) > 0
? formCustomization.fields
: []
)"
:key="idx" :key="idx"
:class="[{required: field.required}, 'field']" :class="[{required: field.required}, 'field']"
> >
<label :for="`custom-field-${idx}`">{{ field.label }}</label> <!-- TODO: as string is probably leading to issues with editform. -->
<textarea <Textarea
v-if="field.input_type === 'long_text'" v-if="field.input_type === 'long_text'"
:id="`custom-field-${idx}`" :id="`custom-field-${idx}`"
v-model="payload.request_fields[field.label]" v-model="payload.request_fields[field.label] as string"
:required="field.required" :label="field.label"
:required="field.required || undefined"
rows="5" rows="5"
/> />
<input <Input
v-else v-else
:id="`custom-field-${idx}`" :id="`custom-field-${idx}`"
v-model="payload.request_fields[field.label]" v-model="payload.request_fields[field.label] as string"
:label="field.label"
type="text" type="text"
:required="field.required" :required="field.required"
> />
</div> </div>
</template> <Button
<button primary
:class="['ui', buttonClasses, {'loading': isLoading}, ' right floated button']" auto
type="submit" type="submit"
> >
{{ $t('components.auth.SignupForm.button.create') }} {{ t('components.auth.SignupForm.button.create') }}
</button> </Button>
</form> </Layout>
</template> </template>

View File

@ -6,7 +6,11 @@ import { computed, ref } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import axios from 'axios' import axios from 'axios'
import DangerousButton from '~/components/common/DangerousButton.vue'
import PasswordInput from '~/components/forms/PasswordInput.vue' import PasswordInput from '~/components/forms/PasswordInput.vue'
import Alert from '~/components/ui/Alert.vue'
import Button from '~/components/ui/Button.vue'
const { t } = useI18n() const { t } = useI18n()
const store = useStore() const store = useStore()
@ -82,43 +86,43 @@ fetchToken()
@submit.prevent="requestNewToken()" @submit.prevent="requestNewToken()"
> >
<h2> <h2>
{{ $t('components.auth.SubsonicTokenForm.header.subsonic') }} {{ t('components.auth.SubsonicTokenForm.header.subsonic') }}
</h2> </h2>
<p <p
v-if="!subsonicEnabled" v-if="!subsonicEnabled"
class="ui message" class="ui message"
> >
{{ $t('components.auth.SubsonicTokenForm.message.unavailable') }} {{ t('components.auth.SubsonicTokenForm.message.unavailable') }}
</p> </p>
<p> <p>
{{ $t('components.auth.SubsonicTokenForm.description.subsonic.paragraph1') }}&nbsp;{{ $t('components.auth.SubsonicTokenForm.description.subsonic.paragraph2') }} {{ t('components.auth.SubsonicTokenForm.description.subsonic.paragraph1') }}&nbsp;{{ t('components.auth.SubsonicTokenForm.description.subsonic.paragraph2') }}
</p> </p>
<p> <p>
{{ $t('components.auth.SubsonicTokenForm.description.subsonic.paragraph3') }} {{ t('components.auth.SubsonicTokenForm.description.subsonic.paragraph3') }}
</p> </p>
<p> <p>
<a <a
href="https://docs.funkwhale.audio/users/apps.html#subsonic-compatible-clients" href="https://docs.funkwhale.audio/users/apps.html#subsonic-compatible-clients"
target="_blank" target="_blank"
> >
{{ $t('components.auth.SubsonicTokenForm.link.apps') }} {{ t('components.auth.SubsonicTokenForm.link.apps') }}
</a> </a>
</p> </p>
<div <Alert
v-if="success" v-if="success"
class="ui positive message" green
> >
<h4 class="header"> <h4 class="header">
{{ successMessage }} {{ successMessage }}
</h4> </h4>
</div> </Alert>
<div <Alert
v-if="subsonicEnabled && errors.length > 0" v-if="subsonicEnabled && errors.length > 0"
red
role="alert" role="alert"
class="ui negative message"
> >
<h4 class="header"> <h4 class="header">
{{ $t('components.auth.SubsonicTokenForm.header.error') }} {{ t('components.auth.SubsonicTokenForm.header.error') }}
</h4> </h4>
<ul class="list"> <ul class="list">
<li <li
@ -128,7 +132,7 @@ fetchToken()
{{ error }} {{ error }}
</li> </li>
</ul> </ul>
</div> </Alert>
<template v-if="subsonicEnabled"> <template v-if="subsonicEnabled">
<div <div
v-if="token" v-if="token"
@ -147,58 +151,42 @@ fetchToken()
:default-show="showToken" :default-show="showToken"
/> />
</div> </div>
<dangerous-button <DangerousButton
v-if="token" v-if="token"
:class="['ui', {'loading': isLoading}, 'button']" :is-loading="isLoading"
:action="requestNewToken" :action="requestNewToken"
:title="t('components.auth.SubsonicTokenForm.modal.newPassword.header')"
> >
{{ $t('components.auth.SubsonicTokenForm.button.newPassword') }} {{ t('components.auth.SubsonicTokenForm.button.newPassword') }}
<template #modal-header>
<p>
{{ $t('components.auth.SubsonicTokenForm.modal.newPassword.header') }}
</p>
</template>
<template #modal-content> <template #modal-content>
<p> {{ t('components.auth.SubsonicTokenForm.modal.newPassword.content.warning') }}
{{ $t('components.auth.SubsonicTokenForm.modal.newPassword.content.warning') }}
</p>
</template> </template>
<template #modal-confirm> <template #modal-confirm>
<div> {{ t('components.auth.SubsonicTokenForm.button.confirmNewPassword') }}
{{ $t('components.auth.SubsonicTokenForm.button.confirmNewPassword') }}
</div>
</template> </template>
</dangerous-button> </DangerousButton>
<button <Button
v-else v-else
color="" primary
:class="['ui', {'loading': isLoading}, 'button']" :is-loading="isLoading"
@click="requestNewToken" @click="requestNewToken"
> >
{{ $t('components.auth.SubsonicTokenForm.button.confirmNewPassword') }} {{ t('components.auth.SubsonicTokenForm.button.confirmNewPassword') }}
</button> </Button>
<dangerous-button <DangerousButton
v-if="token" v-if="token"
:class="['ui', {'loading': isLoading}, 'warning', 'button']" :is-loading="isLoading"
:action="disable" :action="disable"
:title="t('components.auth.SubsonicTokenForm.modal.disableSubsonic.header')"
> >
{{ $t('components.auth.SubsonicTokenForm.button.disable') }} {{ t('components.auth.SubsonicTokenForm.button.disable') }}
<template #modal-header>
<p>
{{ $t('components.auth.SubsonicTokenForm.modal.disableSubsonic.header') }}
</p>
</template>
<template #modal-content> <template #modal-content>
<p> {{ t('components.auth.SubsonicTokenForm.modal.disableSubsonic.content.warning') }}
{{ $t('components.auth.SubsonicTokenForm.modal.disableSubsonic.content.warning') }}
</p>
</template> </template>
<template #modal-confirm> <template #modal-confirm>
<div> {{ t('components.auth.SubsonicTokenForm.button.confirmDisable') }}
{{ $t('components.auth.SubsonicTokenForm.button.confirmDisable') }}
</div>
</template> </template>
</dangerous-button> </DangerousButton>
</template> </template>
</form> </form>
</template> </template>

View File

@ -2,20 +2,24 @@
import type { BackendError, Channel } from '~/types' import type { BackendError, Channel } from '~/types'
import { computed, watch, ref } from 'vue' import { computed, watch, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import axios from 'axios' import axios from 'axios'
import Layout from '~/components/ui/Layout.vue'
import Alert from '~/components/ui/Alert.vue'
import Input from '~/components/ui/Input.vue'
interface Events { interface Events {
(e: 'submittable', value: boolean): void (e: 'submittable', value: boolean): void
(e: 'loading', value: boolean): void (e: 'loading', value: boolean): void
(e: 'created'): void (e: 'created'): void
} }
interface Props { const channel = defineModel<Channel>({ required: true })
channel: Channel
} const { t } = useI18n()
const emit = defineEmits<Events>() const emit = defineEmits<Events>()
const props = defineProps<Props>()
const title = ref('') const title = ref('')
@ -28,7 +32,7 @@ const submit = async () => {
try { try {
await axios.post('albums/', { await axios.post('albums/', {
title: title.value, title: title.value,
artist: props.channel.artist?.id artist: channel.value.artist?.id
}) })
emit('created') emit('created')
@ -49,17 +53,17 @@ defineExpose({
</script> </script>
<template> <template>
<form <Layout
form
:class="['ui', {loading: isLoading}, 'form']" :class="['ui', {loading: isLoading}, 'form']"
@submit.stop.prevent @submit.stop.prevent
> >
<div <Alert
v-if="errors.length > 0" v-if="errors.length > 0"
role="alert" red
class="ui negative message"
> >
<h4 class="header"> <h4 class="header">
{{ $t('components.channels.AlbumForm.header.error') }} {{ t('components.channels.AlbumForm.header.error') }}
</h4> </h4>
<ul class="list"> <ul class="list">
<li <li
@ -69,15 +73,13 @@ defineExpose({
{{ error }} {{ error }}
</li> </li>
</ul> </ul>
</div> </Alert>
<div class="ui required field"> <div class="ui required field">
<label for="album-title"> <Input
{{ $t('components.channels.AlbumForm.label.albumTitle') }}
</label>
<input
v-model="title" v-model="title"
type="text" type="text"
> :label="t('components.channels.AlbumForm.label.albumTitle')"
/>
</div> </div>
</form> </Layout>
</template> </template>

View File

@ -1,68 +1,105 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Channel } from '~/types' import type { Channel, BackendError } from '~/types'
import SemanticModal from '~/components/semantic/Modal.vue'
import ChannelAlbumForm from '~/components/channels/AlbumForm.vue'
import { watch, ref } from 'vue'
interface Events { import axios from 'axios'
(e: 'created'): void
}
interface Props { import { watch, computed, ref } from 'vue'
channel: Channel import { useI18n } from 'vue-i18n'
} import { useModal } from '~/ui/composables/useModal.ts'
const emit = defineEmits<Events>() import Layout from '~/components/ui/Layout.vue'
defineProps<Props>() import Modal from '~/components/ui/Modal.vue'
import Button from '~/components/ui/Button.vue'
import Input from '~/components/ui/Input.vue'
import Alert from '~/components/ui/Alert.vue'
const { t } = useI18n()
const channel = defineModel<Channel>({ required: true })
const emit = defineEmits(['created'])
const newAlbumTitle = ref<string>('')
const isLoading = ref(false) const isLoading = ref(false)
const submittable = ref(false) const submittable = computed(() => newAlbumTitle.value.length > 0)
const show = ref(false) const errors = ref<string[]>([])
watch(show, () => { const isOpen = useModal('album').isOpen
watch(isOpen, () => {
isLoading.value = false isLoading.value = false
submittable.value = false newAlbumTitle.value = '' // Reset the title to ensure submittable becomes false
}) })
const albumForm = ref() const submit = async () => {
isLoading.value = true
errors.value = []
try {
await axios.post('albums/', {
title: newAlbumTitle.value,
artist: channel.value.artist?.id
})
} catch (error) {
errors.value = (error as BackendError).backendErrors
} finally {
isLoading.value = false
emit('created')
}
}
defineExpose({ defineExpose({
show submit
}) })
</script> </script>
<template> <template>
<semantic-modal <Modal
v-model:show="show" v-model="isOpen"
:title="channel?.artist?.content_category === 'podcast' ? t('components.channels.AlbumModal.header.newSeries') : t('components.channels.AlbumModal.header.newAlbum')"
class="small" class="small"
:cancel="t('components.channels.AlbumModal.button.cancel')"
>
<template #alert>
<Alert
v-if="errors?.length > 0"
red
> >
<h4 class="header"> <h4 class="header">
<span v-if="channel.content_category === 'podcast'"> {{ t('components.channels.AlbumForm.header.error') }}
{{ $t('components.channels.AlbumModal.header.newSeries') }}
</span>
<span v-else>
{{ $t('components.channels.AlbumModal.header.newAlbum') }}
</span>
</h4> </h4>
<div class="scrolling content"> <ul class="list">
<channel-album-form <li
ref="albumForm" v-for="(error, key) in errors"
:channel="channel" :key="key"
@loading="isLoading = $event"
@submittable="submittable = $event"
@created="emit('created')"
/>
</div>
<div class="actions">
<button class="ui basic cancel button">
{{ $t('components.channels.AlbumModal.button.cancel') }}
</button>
<button
:class="['ui', 'primary', {loading: isLoading}, 'button']"
:disabled="!submittable"
@click.stop.prevent="albumForm.submit()"
> >
{{ $t('components.channels.AlbumModal.button.create') }} {{ error }}
</button> </li>
</div> </ul>
</semantic-modal> </Alert>
</template>
<Layout
form
:class="['ui', {loading: isLoading}, 'form']"
@submit.stop.prevent
>
<Input
v-model="newAlbumTitle"
required
type="text"
:label="t('components.channels.AlbumForm.label.albumTitle')"
/>
</Layout>
<template #actions>
<Button
:is-loading="isLoading"
:disabled="!submittable"
primary
@click.stop.prevent="submit()"
>
{{ t('components.channels.AlbumModal.button.create') }}
</Button>
</template>
</Modal>
</template> </template>

View File

@ -3,7 +3,16 @@ import type { Album, Channel } from '~/types'
import axios from 'axios' import axios from 'axios'
import { useVModel } from '@vueuse/core' import { useVModel } from '@vueuse/core'
import { reactive, ref, watch } from 'vue' import { ref, watch, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { useModal } from '~/ui/composables/useModal.ts'
import AlbumModal from '~/components/channels/AlbumModal.vue'
import Link from '~/components/ui/Link.vue'
import Spacer from '~/components/ui/Spacer.vue'
const { t } = useI18n()
interface Events { interface Events {
(e: 'update:modelValue', value: string): void (e: 'update:modelValue', value: string): void
@ -21,6 +30,11 @@ const props = withDefaults(defineProps<Props>(), {
}) })
const value = useVModel(props, 'modelValue', emit) const value = useVModel(props, 'modelValue', emit)
const localChannel = ref(props.channel ?? { artist: {} } as Channel)
watch(() => props.channel, (newChannel) => {
localChannel.value = newChannel ?? { artist: {} } as Channel
})
const albums = reactive<Album[]>([]) const albums = reactive<Album[]>([])
@ -45,13 +59,12 @@ watch(() => props.channel, fetchData, { immediate: true })
</script> </script>
<template> <template>
<div>
<label for="album-dropdown"> <label for="album-dropdown">
<span v-if="channel && channel.artist && channel.artist.content_category === 'podcast'"> <span v-if="channel && channel.artist && channel.artist.content_category === 'podcast'">
{{ $t('components.channels.AlbumSelect.label.series') }} {{ t('components.channels.AlbumSelect.label.series') }}
</span> </span>
<span v-else> <span v-else>
{{ $t('components.channels.AlbumSelect.label.album') }} {{ t('components.channels.AlbumSelect.label.album') }}
</span> </span>
</label> </label>
<select <select
@ -60,7 +73,7 @@ watch(() => props.channel, fetchData, { immediate: true })
class="ui search normal dropdown" class="ui search normal dropdown"
> >
<option value=""> <option value="">
{{ $t('components.channels.AlbumSelect.option.none') }} {{ t('components.channels.AlbumSelect.option.none') }}
</option> </option>
<option <option
v-for="album in albums" v-for="album in albums"
@ -68,10 +81,21 @@ watch(() => props.channel, fetchData, { immediate: true })
:value="album.id" :value="album.id"
> >
{{ album.title }} {{ album.title }}
<span> {{ t('components.channels.AlbumSelect.meta.tracks', album.tracks_count) }}
{{ $t('components.channels.AlbumSelect.meta.tracks', album.tracks_count) }}
</span>
</option> </option>
</select> </select>
</div> <Spacer :size="4" />
<Link
solid
primary
icon="bi-plus"
:to="useModal('album').to"
>
{{ t('components.channels.AlbumSelect.add') }}
<AlbumModal
v-if="channel"
v-model="localChannel"
@created="fetchData"
/>
</Link>
</template> </template>

View File

@ -4,6 +4,7 @@ import type { License } from '~/types'
import { computed, reactive, ref } from 'vue' import { computed, reactive, ref } from 'vue'
import axios from 'axios' import axios from 'axios'
import { useVModel } from '@vueuse/core' import { useVModel } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
interface Events { interface Events {
(e: 'update:modelValue', value: string): void (e: 'update:modelValue', value: string): void
@ -13,6 +14,8 @@ interface Props {
modelValue: string | null modelValue: string | null
} }
const { t } = useI18n()
const emit = defineEmits<Events>() const emit = defineEmits<Events>()
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
modelValue: null modelValue: null
@ -55,7 +58,7 @@ fetchLicenses()
<template> <template>
<div> <div>
<label for="license-dropdown"> <label for="license-dropdown">
{{ $t('components.channels.LicenseSelect.label.license') }} {{ t('components.channels.LicenseSelect.label.license') }}
</label> </label>
<select <select
id="license-dropdown" id="license-dropdown"
@ -63,7 +66,7 @@ fetchLicenses()
class="ui search normal dropdown" class="ui search normal dropdown"
> >
<option value=""> <option value="">
{{ $t('components.channels.LicenseSelect.option.none') }} {{ t('components.channels.LicenseSelect.option.none') }}
</option> </option>
<option <option
v-for="l in featuredLicenses" v-for="l in featuredLicenses"
@ -84,7 +87,7 @@ fetchLicenses()
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
> >
{{ $t('components.channels.LicenseSelect.link.license') }} {{ t('components.channels.LicenseSelect.link.license') }}
</a> </a>
</p> </p>
</div> </div>

View File

@ -4,8 +4,12 @@ import type { Channel } from '~/types'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import { useRoute } from 'vue-router'
import LoginModal from '~/components/common/LoginModal.vue' import LoginModal from '~/components/common/LoginModal.vue'
import Button from '~/components/ui/Button.vue'
const route = useRoute()
interface Events { interface Events {
(e: 'unsubscribed'): void (e: 'unsubscribed'): void
@ -43,28 +47,29 @@ const loginModal = ref()
</script> </script>
<template> <template>
<button <Button
v-if="$store.state.auth.authenticated" v-if="store.state.auth.authenticated"
:class="['ui', 'pink', {'inverted': isSubscribed}, {'favorited': isSubscribed}, 'icon', 'labeled', 'button']" :class="['pink', {'favorited': isSubscribed}]"
outline
:aria-pressed="isSubscribed || undefined"
@click.stop="toggle" @click.stop="toggle"
> >
<i class="heart icon" />
{{ title }} {{ title }}
</button> </Button>
<button <Button
v-else v-else
:class="['ui', 'pink', 'icon', 'labeled', 'button']" outline
icon="bi-heart"
@click="loginModal.show = true" @click="loginModal.show = true"
> >
<i class="heart icon" />
{{ title }} {{ title }}
<login-modal <login-modal
ref="loginModal" ref="loginModal"
class="small" class="small"
:next-route="$route.fullPath" :next-route="route.fullPath"
:message="message.authMessage" :message="message.authMessage"
:cover="channel.artist?.cover!" :cover="channel.artist?.cover!"
@created="loginModal.show = false" @created="loginModal.show = false"
/> />
</button> </Button>
</template> </template>

View File

@ -3,13 +3,13 @@ import type { BackendError, Channel, Upload, Track } from '~/types'
import type { VueUploadItem } from 'vue-upload-component' import type { VueUploadItem } from 'vue-upload-component'
import { computed, ref, reactive, watchEffect, watch } from 'vue' import { computed, ref, reactive, watchEffect, watch } from 'vue'
import { whenever, useCurrentElement } from '@vueuse/core' import { whenever } from '@vueuse/core'
import { humanSize } from '~/utils/filters' import { humanSize } from '~/utils/filters'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useStore } from '~/store' import { useStore } from '~/store'
import axios from 'axios' import axios from 'axios'
import $ from 'jquery' import { type paths, type operations, type components } from '~/generated/types.ts'
import UploadMetadataForm from '~/components/channels/UploadMetadataForm.vue' import UploadMetadataForm from '~/components/channels/UploadMetadataForm.vue'
import FileUploadWidget from '~/components/library/FileUploadWidget.vue' import FileUploadWidget from '~/components/library/FileUploadWidget.vue'
@ -18,13 +18,19 @@ import AlbumSelect from '~/components/channels/AlbumSelect.vue'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
import Layout from '~/components/ui/Layout.vue'
import Alert from '~/components/ui/Alert.vue'
import Button from '~/components/ui/Button.vue'
import Loader from '~/components/ui/Loader.vue'
import Spacer from '~/components/ui/Spacer.vue'
interface Events { interface Events {
(e: 'status', status: UploadStatus): void (e: 'status', status: UploadStatus): void
(e: 'step', step: 1 | 2 | 3): void
} }
interface Props { interface Props {
channel?: Channel | null channel: Channel | null,
filter: 'podcast' | 'music' | undefined,
} }
interface QuotaStatus { interface QuotaStatus {
@ -57,8 +63,12 @@ const store = useStore()
const errors = ref([] as string[]) const errors = ref([] as string[])
const values = reactive({ const values = reactive<{
channel: props.channel?.uuid ?? null, channelUuid: string | null; // Channel UUID
license: string | null;
album: string | null;
}>({
channelUuid: props.channel?.uuid ?? null,
license: null, license: null,
album: null album: null
}) })
@ -68,29 +78,91 @@ const files = ref([] as VueUploadItem[])
// //
// Channels // Channels
// //
const availableChannels = reactive({ const availableChannels = ref<Channel[] | null>(null)
channels: [] as Channel[],
count: 0, /*
loading: false availableChannels>1? :=1 :=0
}) | | |
v v v
props select a channel | create empty channel
| | null
v v |
channelDropdownId v
|
v
selectedChannel
|
v
as a model to Album
|
v
albums
*/
// In the channel dropdown, we can select a value
//
const channelDropdownId = ref<Channel['artist']['id'] | null>(null)
const isLoading = ref(false)
const selectedChannel = computed(() =>
// Deeplink / Preset channel
props.channel
? props.channel
// Not yet loaded the available channels
: availableChannels.value === null
? null
// No channels available
: availableChannels.value.length === 0
? (createEmptyChannel(), null)
// Exactly one available channel
: availableChannels.value.length === 1
? availableChannels.value[0]
// Multiple available channels
: availableChannels.value.find(({ artist }) => artist.id === channelDropdownId.value) || null
)
const emptyChannelCreateRequest:components['schemas']['ChannelCreateRequest'] = {
name: store.state.auth.fullUsername,
username: store.state.auth.username,
description: null,
tags: [],
content_category: 'music'
}
const createEmptyChannel = async () => {
try {
await axios.post(
'channels/',
(emptyChannelCreateRequest satisfies operations['create_channel_2']['requestBody']['content']['application/json'])
)
} catch (error) {
errors.value = (error as BackendError).backendErrors
}
}
const fetchChannels = async () => { const fetchChannels = async () => {
availableChannels.loading = true isLoading.value = true
try { try {
const response = await axios.get('channels/', { params: { scope: 'me' } }) const response = await axios.get<paths['/api/v2/channels/']['get']['responses']['200']['content']['application/json']>(
availableChannels.channels = response.data.results 'channels/',
availableChannels.count = response.data.count { params: { scope: 'me' } }
)
availableChannels.value = response.data.results.filter(channel =>
props.filter === undefined
? true
: channel.artist?.content_category === props.filter
)
} catch (error) { } catch (error) {
errors.value = (error as BackendError).backendErrors errors.value = (error as BackendError).backendErrors
} }
availableChannels.loading = false isLoading.value = false
} }
const selectedChannel = computed(() => availableChannels.channels.find((channel) => channel.uuid === values.channel) ?? null)
//
// Quota and space // Quota and space
// //
const quotaStatus = ref() const quotaStatus = ref()
@ -125,13 +197,13 @@ const remainingSpace = computed(() => Math.max(
// //
const includeDraftUploads = ref() const includeDraftUploads = ref()
const draftUploads = ref([] as Upload[]) const draftUploads = ref([] as Upload[])
whenever(() => values.channel !== null, async () => { whenever(() => values.channelUuid !== null, async () => {
files.value = [] files.value = []
draftUploads.value = [] draftUploads.value = []
try { try {
const response = await axios.get('uploads', { const response = await axios.get('uploads', {
params: { import_status: 'draft', channel: values.channel } params: { import_status: 'draft', channel: values.channelUuid }
}) })
draftUploads.value = response.data.results as Upload[] draftUploads.value = response.data.results as Upload[]
@ -157,12 +229,6 @@ const beforeFileUpload = (newFile: VueUploadItem) => {
} }
} }
const baseImportMetadata = computed(() => ({
channel: values.channel,
import_status: 'draft',
import_metadata: { license: values.license, album: values.album }
}))
// //
// Uploaded files // Uploaded files
// //
@ -213,6 +279,13 @@ const uploadedFilesById = computed(() => uploadedFiles.value.reduce((acc: Record
// //
// Metadata // Metadata
// //
const baseImportMetadata = computed(() => ({
channel: selectedChannel,
import_status: 'draft',
import_metadata: { license: values.license, album: values.album }
}))
type Metadata = Pick<Track, 'title' | 'position' | 'tags'> & { cover: string | null, description: string } type Metadata = Pick<Track, 'title' | 'position' | 'tags'> & { cover: string | null, description: string }
const uploadImportData = reactive({} as Record<string, Metadata>) const uploadImportData = reactive({} as Record<string, Metadata>)
const audioMetadata = reactive({} as Record<string, Record<string, string>>) const audioMetadata = reactive({} as Record<string, Record<string, string>>)
@ -237,7 +310,7 @@ const fetchAudioMetadata = async (uuid: string) => {
for (const key of ['title', 'position', 'tags'] as const) { for (const key of ['title', 'position', 'tags'] as const) {
if (uploadImportData[uuid][key] === undefined) { if (uploadImportData[uuid][key] === undefined) {
uploadImportData[uuid][key] = response.data[key] as never // uploadImportData[uuid][key] = response.data[key] as never
} }
} }
@ -301,70 +374,7 @@ const retry = async (file: VueUploadItem) => {
fetchChannels() fetchChannels()
fetchQuota() fetchQuota()
// watch(selectedUploadId, async (_, from) => {
// Dropdown
//
const el = useCurrentElement()
watch(() => availableChannels.channels, () => {
$(el.value).find('#channel-dropdown').dropdown({
onChange (value) {
values.channel = value
},
values: availableChannels.channels.map((channel) => {
const value = {
name: channel.artist?.name ?? '',
value: channel.uuid,
selected: props.channel?.uuid === channel.uuid
} as {
name: string
value: string
selected: boolean
image?: string
imageClass?: string
icon?: string
iconClass?: string
}
if (channel.artist?.cover?.urls.medium_square_crop) {
value.image = store.getters['instance/absoluteUrl'](channel.artist.cover.urls.medium_square_crop)
value.imageClass = channel.artist.content_category !== 'podcast'
? 'ui image avatar'
: 'ui image'
} else {
value.icon = 'user'
value.iconClass = channel.artist?.content_category !== 'podcast'
? 'circular icon'
: 'bordered icon'
}
return value
})
})
$(el.value).find('#channel-dropdown').dropdown('hide')
})
//
// Step
//
const step = ref<1 | 2 | 3>(1)
watchEffect(() => {
emit('step', step.value)
if (step.value === 2) {
selectedUploadId.value = null
}
})
watch(selectedUploadId, async (to, from) => {
if (to) {
step.value = 3
}
if (!to && step.value !== 2) {
step.value = 2
}
if (from) { if (from) {
await patchUpload(from, { import_metadata: uploadImportData[from] }) await patchUpload(from, { import_metadata: uploadImportData[from] })
} }
@ -400,23 +410,28 @@ const labels = computed(() => ({
editTitle: t('components.channels.UploadForm.button.edit') editTitle: t('components.channels.UploadForm.button.edit')
})) }))
const isLoading = ref(false)
const publish = async () => { const publish = async () => {
isLoading.value = true isLoading.value = true
errors.value = [] errors.value = []
try { try {
await axios.post('uploads/action/', { // Post list of uuids of uploadedFiles to axios action:publish
/* { import_status: components["schemas"]["ImportStatusEnum"];
audio_file: string;} */
await axios.post<paths['/api/v2/uploads/action/']['post']['responses']['200']['content']['application/json']>('uploads/action/', {
action: 'publish', action: 'publish',
objects: uploadedFiles.value.map((file) => file.response?.uuid) objects: uploadedFiles.value.map((file) => file.response?.uuid)
}) })
// Tell the store that the uploaded files are pending import
store.commit('channels/publish', { store.commit('channels/publish', {
uploads: uploadedFiles.value.map((file) => ({ ...file.response, import_status: 'pending' })), uploads: uploadedFiles.value.map((file) => ({ ...file.response, import_status: 'pending' })),
channel: selectedChannel.value channel: selectedChannel.value
}) })
} catch (error) { } catch (error) {
// TODO: Use inferred error type instead of typecasting
errors.value = (error as BackendError).backendErrors errors.value = (error as BackendError).backendErrors
} }
@ -424,23 +439,24 @@ const publish = async () => {
} }
defineExpose({ defineExpose({
step,
publish publish
}) })
</script> </script>
<template> <template>
<form <Layout
:class="['ui', { loading: availableChannels.loading }, 'form component-file-upload']" form
gap-8
:class="['ui', { loading: isLoading }, 'form component-file-upload']"
@submit.stop.prevent @submit.stop.prevent
> >
<div <!-- Error message -->
<Alert
v-if="errors.length > 0" v-if="errors.length > 0"
role="alert" red
class="ui negative message"
> >
<h4 class="header"> <h4 class="header">
{{ $t('components.channels.UploadForm.header.error') }} {{ t('components.channels.UploadForm.header.error') }}
</h4> </h4>
<ul class="list"> <ul class="list">
<li <li
@ -450,74 +466,88 @@ defineExpose({
{{ error }} {{ error }}
</li> </li>
</ul> </ul>
</div> </Alert>
<div :class="['ui', 'required', {hidden: step > 1}, 'field']">
<label for="channel-dropdown"> <!-- Select Album and License -->
{{ $t('components.channels.UploadForm.label.channel') }}
</label> <div :class="['ui', 'required', 'field']">
<div <label
id="channel-dropdown" v-if="availableChannels !== null && availableChannels.length === 1"
class="ui search normal selection dropdown"
> >
<div class="text" /> {{ `${t('components.channels.UploadForm.label.channel')}: ${selectedChannel?.artist.name}` }}
<i class="dropdown icon" /> </label>
</div> <label
v-else
for="channel-dropdown"
>
{{ t('components.channels.UploadForm.label.channel') }}
</label>
<select
v-if="availableChannels !== null && availableChannels.length > 1"
id="channel-dropdown"
v-model="channelDropdownId"
class="dropdown"
>
<option
v-for="availableChannel in availableChannels"
:key="availableChannel.artist.id"
:value="availableChannel.artist.id"
>
{{ availableChannel.artist.name }}
</option>
</select>
</div> </div>
<album-select <album-select
v-if="selectedChannel !== null"
v-model.number="values.album" v-model.number="values.album"
:channel="selectedChannel" :channel="selectedChannel"
:class="['ui', {hidden: step > 1}, 'field']"
/> />
<div>
<license-select <license-select
v-if="values.license !== null"
v-model="values.license" v-model="values.license"
:class="['ui', {hidden: step > 1}, 'field']" :class="['ui', 'field']"
/> />
<div :class="['ui', {hidden: step > 1}, 'message']">
<div class="content"> <div class="content">
<p> <p>
<i class="copyright icon" /> <i class="copyright icon" />
{{ $t('components.channels.UploadForm.help.license') }} {{ t('components.channels.UploadForm.help.license') }}
</p> </p>
</div> </div>
</div> </div>
<template v-if="step === 2 || step === 3">
<div <!-- Files to upload -->
v-if="remainingSpace === 0" <template v-if="remainingSpace === 0">
role="alert" <Alert
class="ui warning message" red
> >
<div class="content"> <i class="bi bi-exclamation-triangle" />
<p> {{ t('components.channels.UploadForm.warning.quota') }}
<i class="warning icon" /> </Alert>
{{ $t('components.channels.UploadForm.warning.quota') }} </template>
</p>
</div>
</div>
<template v-else> <template v-else>
<div <Alert
v-if="step === 2 && draftUploads?.length > 0 && includeDraftUploads === undefined" v-if="draftUploads?.length > 0 && includeDraftUploads === undefined"
class="ui visible info message" blue
> >
<p> <p>
<i class="redo icon" /> <i class="bi bi-circle-clockwise" />
{{ $t('components.channels.UploadForm.message.pending') }} {{ t('components.channels.UploadForm.message.pending') }}
</p> </p>
<button <Button
class="ui basic button"
@click.stop.prevent="includeDraftUploads = false" @click.stop.prevent="includeDraftUploads = false"
> >
{{ $t('components.channels.UploadForm.button.ignore') }} {{ t('components.channels.UploadForm.button.ignore') }}
</button> </Button>
<button <Button
class="ui basic button"
@click.stop.prevent="includeDraftUploads = true" @click.stop.prevent="includeDraftUploads = true"
> >
{{ $t('components.channels.UploadForm.button.resume') }} {{ t('components.channels.UploadForm.button.resume') }}
</button> </Button>
</div> </Alert>
<div <Alert
v-if="uploadedFiles.length > 0" v-if="uploadedFiles.length > 0"
:class="[{hidden: step === 3}]" v-bind="{[ uploadedFiles.some(file=>file.error) ? 'red' : 'green' ]:true}"
> >
<div <div
v-for="file in uploadedFiles" v-for="file in uploadedFiles"
@ -525,27 +555,22 @@ defineExpose({
class="channel-file" class="channel-file"
> >
<div class="content"> <div class="content">
<div <Button
v-if="file.response?.uuid" v-if="file.response?.uuid"
role="button" icon="bi-pencil-fill"
class="ui basic icon button" class="ui basic icon button"
:title="labels.editTitle" :title="labels.editTitle"
@click.stop.prevent="selectedUploadId = file.response?.uuid" @click.stop.prevent="selectedUploadId = file.response?.uuid"
> />
<i class="pencil icon" />
</div>
<div <div
v-if="file.error" v-if="file.error"
class="ui basic danger icon label" class="ui basic danger icon label"
:title="file.error.toString()" :title="file.error.toString()"
@click.stop.prevent="selectedUploadId = file.response?.uuid" @click.stop.prevent="selectedUploadId = file.response?.uuid"
> >
<i class="warning sign icon" /> <i class="bi bi-exclamation-triangle-fill" />
</div> </div>
<div <Loader v-else-if="file.active && !file.response" />
v-else-if="file.active && !file.response"
class="ui active slow inline loader"
/>
</div> </div>
<h4 class="ui header"> <h4 class="ui header">
<template v-if="file.metadata.title"> <template v-if="file.metadata.title">
@ -564,13 +589,13 @@ defineExpose({
</template> </template>
<template v-else> <template v-else>
<span v-if="file.active"> <span v-if="file.active">
{{ $t('components.channels.UploadForm.status.uploading') }} {{ t('components.channels.UploadForm.status.uploading') }}
</span> </span>
<span v-else-if="file.error"> <span v-else-if="file.error">
{{ $t('components.channels.UploadForm.status.errored') }} {{ t('components.channels.UploadForm.status.errored') }}
</span> </span>
<span v-else> <span v-else>
{{ $t('components.channels.UploadForm.status.pending') }} {{ t('components.channels.UploadForm.status.pending') }}
</span> </span>
<span class="middle middledot symbol" /> <span class="middle middledot symbol" />
{{ humanSize(file.size ?? 0) }} {{ humanSize(file.size ?? 0) }}
@ -580,52 +605,60 @@ defineExpose({
</template> </template>
<span class="middle middledot symbol" /> <span class="middle middledot symbol" />
<a @click.stop.prevent="remove(file)"> <a @click.stop.prevent="remove(file)">
{{ $t('components.channels.UploadForm.button.remove') }} {{ t('components.channels.UploadForm.button.remove') }}
</a> </a>
<template v-if="file.error"> <template v-if="file.error">
<span class="middle middledot symbol" /> <span class="middle middledot symbol" />
<a @click.stop.prevent="retry(file)"> <a @click.stop.prevent="retry(file)">
{{ $t('components.channels.UploadForm.button.retry') }} {{ t('components.channels.UploadForm.button.retry') }}
</a> </a>
</template> </template>
</div> </div>
</h4> </h4>
</div> </div>
</div> </Alert>
</template>
<upload-metadata-form <upload-metadata-form
v-if="selectedUpload" v-if="selectedUpload"
v-model:values="uploadImportData[selectedUploadId]" v-model:values="uploadImportData[selectedUploadId]"
:upload="selectedUpload" :upload="selectedUpload"
/> />
<div <Alert
v-if="step === 2" blue
class="ui message" class="ui message"
> >
<div class="content"> <Layout
<p> flex
<i class="info icon" /> gap-8
{{ $t('components.channels.UploadForm.description.extensions', {extensions: $store.state.ui.supportedExtensions.join(', ')}) }} >
</p> <i class="bi bi-info-circle-fill" />
</div> {{ t('components.channels.UploadForm.description.extensions', {extensions: store.state.ui.supportedExtensions.join(', ')}) }}
</div> </Layout>
<file-upload-widget </Alert>
<FileUploadWidget
v-if="selectedChannel && selectedChannel.uuid"
ref="upload" ref="upload"
v-model="files" v-model="files"
:class="['ui', 'icon', 'basic', 'button', 'channels', {hidden: step === 3}]" :class="['ui', 'button', 'channels']"
:channel="selectedChannel.uuid"
:data="baseImportMetadata" :data="baseImportMetadata"
@input-file="beforeFileUpload" @input-file="beforeFileUpload"
> >
<div> <div>
<i class="upload icon" />&nbsp; <i class="bi bi-upload" />&nbsp;
{{ $t('components.channels.UploadForm.message.dragAndDrop') }} {{ t('components.channels.UploadForm.message.dragAndDrop') }}
</div> </div>
<div class="ui very small divider" /> <div class="ui very small divider" />
<div> <Button
{{ $t('components.channels.UploadForm.label.openBrowser') }} primary
</div> icon="bi-folder2-open"
</file-upload-widget> >
<div class="ui hidden divider" /> {{ t('components.channels.UploadForm.label.openBrowser') }}
</template> </Button>
</template> <Spacer
</form> class="divider"
:size="32"
/>
</FileUploadWidget>
</Layout>
</template> </template>

View File

@ -1,11 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Upload, Track } from '~/types' import type { Upload, Track } from '~/types'
import { reactive, computed, watch } from 'vue' import { reactive, computed, watch, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import TagsSelector from '~/components/library/TagsSelector.vue'
import AttachmentInput from '~/components/common/AttachmentInput.vue' import AttachmentInput from '~/components/common/AttachmentInput.vue'
import Input from '~/components/ui/Input.vue'
import Pills from '~/components/ui/Pills.vue'
type Values = Pick<Track, 'title' | 'position' | 'tags'> & { cover: string | null, description: string } type Values = Pick<Track, 'title' | 'position' | 'tags'> & { cover: string | null, description: string }
interface Events { interface Events {
(e: 'update:values', values: Values): void (e: 'update:values', values: Values): void
@ -16,15 +19,31 @@ interface Props {
values: Partial<Values> | null values: Partial<Values> | null
} }
const { t } = useI18n()
const emit = defineEmits<Events>() const emit = defineEmits<Events>()
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
values: null values: null
}) })
// TODO: Make Tags work
type Item = { type: 'custom' | 'preset', label: string }
type tagsModel = {
currents: Item[],
others?: Item[],
}
const tags = ref<tagsModel>({
currents: [],
others: []
})
// TODO: check if `position: 0` is a good default
const newValues = reactive<Values>({ const newValues = reactive<Values>({
position: 0,
description: '', description: '',
title: '', title: '',
tags: [], tags: tags.value.currents.map(tag => tag.label),
cover: null, cover: null,
...(props.values ?? props.upload.import_metadata ?? {}) ...(props.values ?? props.upload.import_metadata ?? {})
}) })
@ -34,54 +53,45 @@ watch(newValues, (values) => emit('update:values', values), { immediate: true })
</script> </script>
<template> <template>
<div :class="['ui', {loading: isLoading}, 'form']"> <Layout
<div class="ui required field"> form
<label for="upload-title"> :class="[{loading: isLoading}]"
{{ $t('components.channels.UploadMetadataForm.label.title') }}
</label>
<input
v-model="newValues.title"
type="text"
> >
</div> <Input
v-model="newValues.title"
:label="t('components.channels.UploadMetadataForm.label.title')"
required
/>
<attachment-input <attachment-input
v-model="newValues.cover" v-model="newValues.cover"
@delete="newValues.cover = ''" @delete="newValues.cover = ''"
> >
{{ $t('components.channels.UploadMetadataForm.label.image') }} {{ t('components.channels.UploadMetadataForm.label.image') }}
</attachment-input> </attachment-input>
<div class="ui small hidden divider" />
<div class="ui two fields">
<div class="ui field">
<label for="upload-tags"> <label for="upload-tags">
{{ $t('components.channels.UploadMetadataForm.label.tags') }} {{ t('components.channels.UploadMetadataForm.label.tags') }}
</label> </label>
<tags-selector <!-- TODO: Make Tags work -->
id="upload-tags" <Pills
v-model="newValues.tags" :get="(v) => { tags = v }"
:required="false" :set="() => tags"
label="Custom Tags"
cancel="Cancel"
/> />
</div> <Spacer />
<div class="ui field"> <Input
<label for="upload-position">
{{ $t('components.channels.UploadMetadataForm.label.position') }}
</label>
<input
v-model="newValues.position" v-model="newValues.position"
type="number" type="number"
min="1" min="1"
step="1" step="1"
> :label="t('components.channels.UploadMetadataForm.label.position')"
</div> />
</div>
<div class="ui field">
<label for="upload-description"> <label for="upload-description">
{{ $t('components.channels.UploadMetadataForm.label.description') }} {{ t('components.channels.UploadMetadataForm.label.description') }}
</label> </label>
<content-form <content-form
v-model="newValues.description" v-model="newValues.description"
field-id="upload-description" field-id="upload-description"
/> />
</div> </Layout>
</div>
</template> </template>

View File

@ -1,6 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import SemanticModal from '~/components/semantic/Modal.vue' import Modal from '~/components/ui/Modal.vue'
import ChannelUploadForm from '~/components/channels/UploadForm.vue' import ChannelUploadForm from '~/components/channels/UploadForm.vue'
import Popover from '~/components/ui/Popover.vue'
import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
import Button from '~/components/ui/Button.vue'
import { humanSize } from '~/utils/filters' import { humanSize } from '~/utils/filters'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useStore } from '~/store' import { useStore } from '~/store'
@ -15,6 +18,8 @@ const update = (value: boolean) => store.commit('channels/showUploadModal', { sh
const { t } = useI18n() const { t } = useI18n()
const { filter } = defineProps<{ filter: 'podcast' | 'music' }>()
const uploadForm = ref() const uploadForm = ref()
const statusData = ref() const statusData = ref()
@ -45,31 +50,30 @@ const statusInfo = computed(() => {
const step = ref(1) const step = ref(1)
const isLoading = ref(false) const isLoading = ref(false)
const open = ref(false)
const title = computed(() =>
[t('components.channels.UploadModal.header'),
t('components.channels.UploadModal.header.publish'),
t('components.channels.UploadModal.header.uploadFiles'),
t('components.channels.UploadModal.header.uploadDetails'),
t('components.channels.UploadModal.header.processing')
][step.value]
)
</script> </script>
<template> <template>
<semantic-modal <Modal
v-model:show="$store.state.channels.showUploadModal" v-model="store.state.channels.showUploadModal"
:title="title"
class="small" class="small"
> >
<h4 class="header">
<span v-if="step === 1">
{{ $t('components.channels.UploadModal.header.publish') }}
</span>
<span v-else-if="step === 2">
{{ $t('components.channels.UploadModal.header.uploadFiles') }}
</span>
<span v-else-if="step === 3">
{{ $t('components.channels.UploadModal.header.uploadDetails') }}
</span>
<span v-else-if="step === 4">
{{ $t('components.channels.UploadModal.header.processing') }}
</span>
</h4>
<div class="scrolling content"> <div class="scrolling content">
<channel-upload-form <channel-upload-form
ref="uploadForm" ref="uploadForm"
:channel="$store.state.channels.uploadModalConfig.channel ?? null" :filter="filter"
:channel="store.state.channels.uploadModalConfig.channel ?? null"
@step="step = $event" @step="step = $event"
@loading="isLoading = $event" @loading="isLoading = $event"
@status="statusData = $event" @status="statusData = $event"
@ -82,74 +86,74 @@ const isLoading = ref(false)
</template> </template>
<div class="ui very small hidden divider" /> <div class="ui very small hidden divider" />
<template v-if="statusData && statusData.quotaStatus"> <template v-if="statusData && statusData.quotaStatus">
{{ $t('components.channels.UploadModal.meta.quota', humanSize((statusData.quotaStatus.remaining - statusData.uploadedSize) * 1000 * 1000)) }} {{ t('components.channels.UploadModal.meta.quota', humanSize((statusData.quotaStatus.remaining - statusData.uploadedSize) * 1000 * 1000)) }}
</template> </template>
</div> </div>
<div class="ui hidden clearing divider mobile-only" /> <div class="ui hidden clearing divider mobile-only" />
<button <Button
v-if="step === 1" v-if="step === 1"
class="ui basic cancel button" color="secondary"
variant="outline"
@click="update(false)"
> >
{{ $t('components.channels.UploadModal.button.cancel') }} {{ t('components.channels.UploadModal.button.cancel') }}
</button> </Button>
<button <Button
v-else-if="step < 3" v-else-if="step < 3"
class="ui basic button" color="secondary"
variant="outline"
@click.stop.prevent="uploadForm.step -= 1" @click.stop.prevent="uploadForm.step -= 1"
> >
{{ $t('components.channels.UploadModal.button.previous') }} {{ t('components.channels.UploadModal.button.previous') }}
</button> </Button>
<button <Button
v-else-if="step === 3" v-else-if="step === 3"
class="ui basic button" color="secondary"
@click.stop.prevent="uploadForm.step -= 1" @click.stop.prevent="uploadForm.step -= 1"
> >
{{ $t('components.channels.UploadModal.button.update') }} {{ t('components.channels.UploadModal.button.update') }}
</button> </Button>
<button <Button
v-if="step === 1" v-if="step === 1"
class="ui primary button" color="secondary"
@click.stop.prevent="uploadForm.step += 1" @click.stop.prevent="uploadForm.step += 1"
> >
{{ $t('components.channels.UploadModal.button.next') }} {{ t('components.channels.UploadModal.button.next') }}
</button> </Button>
<div <div class="ui primary buttons">
v-if="step === 2" <Button
class="ui primary buttons" :is-loading="isLoading"
>
<button
:class="['ui', 'primary button', {loading: isLoading}]"
type="submit" type="submit"
:disabled="!statusData?.canSubmit || undefined" :disabled="!statusData?.canSubmit"
@click.prevent.stop="uploadForm.publish" @click.prevent.stop="uploadForm.publish"
> >
{{ $t('components.channels.UploadModal.button.publish') }} {{ t('components.channels.UploadModal.button.publish') }}
</button> </Button>
<button
ref="dropdown" <Popover v-model="open">
v-dropdown <template #default="{ toggleOpen }">
class="ui floating dropdown icon button" <Button
:disabled="!statusData?.canSubmit || undefined" color="primary"
> icon="bi-chevron-down"
<i class="dropdown icon" /> :disabled="!statusData?.canSubmit"
<div class="menu"> @click.stop="toggleOpen()"
<div />
role="button" </template>
class="basic item" <template #items>
@click="update(false)" <PopoverItem @click="update(false)">
> {{ t('components.channels.UploadModal.button.finishLater') }}
{{ $t('components.channels.UploadModal.button.finishLater') }} </PopoverItem>
</div> </template>
</div> </Popover>
</button> </div>
</div>
<button <Button
v-if="step === 4" v-if="step === 4"
class="ui basic cancel button" color="secondary"
@click="update(false)" @click="update(false)"
> >
{{ $t('components.channels.UploadModal.button.close') }} {{ t('components.channels.UploadModal.button.close') }}
</button> </Button>
</div> </div>
</semantic-modal> </Modal>
</template> </template>

View File

@ -1,9 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import type { BackendError } from '~/types' import type { BackendError } from '~/types'
import { ref, computed, reactive, watch } from 'vue' import { ref, computed, reactive, watch, useSlots } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import DangerousButton from '~/components/common/DangerousButton.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Button from '~/components/ui/Button.vue'
import Table from '~/components/ui/Table.vue'
import Alert from '~/components/ui/Alert.vue'
import axios from 'axios' import axios from 'axios'
interface Action { interface Action {
@ -11,7 +18,7 @@ interface Action {
label: string label: string
isDangerous?: boolean isDangerous?: boolean
allowAll?: boolean allowAll?: boolean
confirmColor?: string confirmColor?: 'success' | 'danger'
confirmationMessage?: string confirmationMessage?: string
filterChackable?: (item: any) => boolean filterChackable?: (item: any) => boolean
} }
@ -54,6 +61,13 @@ const checkable = computed(() => {
.map(item => item[props.idField] as string) .map(item => item[props.idField] as string)
}) })
// objects is `any`.
// Can we narrow down this type?
// TODO: Search `action-table` globally and narrow all
// `objectsData` props
// Type Custom = A | B | C
const objects = computed(() => props.objectsData.results.map(object => { const objects = computed(() => props.objectsData.results.map(object => {
return props.customObjects.find(custom => custom[props.idField] === object[props.idField]) return props.customObjects.find(custom => custom[props.idField] === object[props.idField])
?? object ?? object
@ -154,160 +168,36 @@ const launchAction = async () => {
isLoading.value = false isLoading.value = false
} }
const slots = useSlots()
// Count the number of elements inside the "header-cells" slot
const columnCount = computed(() => {
return slots['header-cells'] ? slots['header-cells']({}).length : 0
})
// Compute the correct number of columns
const gridColumns = computed(() => {
let columns = columnCount.value
// Add 1 column if for the checkbox if there are more than one action
if (props.actions.length > 0) {
columns += 1
}
return Array.from({ length: columns }, () => 'auto' as const)
})
</script> </script>
<template> <template>
<div class="table-wrapper component-action-table"> <div class="table-wrapper component-action-table">
<table class="ui compact very basic unstackable table"> <Table
<thead> v-if="objectsData.count > 0"
<tr> :grid-template-columns="gridColumns"
<th colspan="1000"> class="ui compact very basic unstackable table"
<div
v-if="refreshable"
class="right floated"
> >
<span v-if="needsRefresh"> <template #header>
{{ $t('components.common.ActionTable.message.needsRefresh') }} <label v-if="actions.length > 0">
</span>
<button
class="ui basic icon button"
:title="labels.refresh"
:aria-label="labels.refresh"
@click="$emit('refresh')"
>
<i class="refresh icon" />
</button>
</div>
<div
v-if="actionUrl && actions.length > 0"
class="ui small left floated form"
>
<div class="ui inline fields">
<div class="field">
<label for="actions-select">{{ $t('components.common.ActionTable.label.actions') }}</label>
<select
id="actions-select"
v-model="currentActionName"
class="ui dropdown"
>
<option
v-for="action in actions"
:key="action.name"
:value="action.name"
>
{{ action.label }}
</option>
</select>
</div>
<div class="field">
<dangerous-button
v-if="selectAll || currentAction?.isDangerous"
:class="['ui', {disabled: checked.length === 0}, {'loading': isLoading}, 'button']"
:confirm-color="currentAction?.confirmColor ?? 'success'"
:aria-label="labels.performAction"
@confirm="launchAction"
>
{{ $t('components.common.ActionTable.button.go') }}
<template #modal-header>
<p>
<span key="1">
{{ $t('components.common.ActionTable.modal.performAction.header', { action: currentActionName }, affectedObjectsCount) }}
</span>
</p>
</template>
<template #modal-content>
<p>
<template v-if="currentAction?.confirmationMessage">
{{ currentAction?.confirmationMessage }}
</template>
<span v-else>
{{ $t('components.common.ActionTable.modal.performAction.content.warning') }}
</span>
</p>
</template>
<template #modal-confirm>
<div :aria-label="labels.performAction">
{{ $t('components.common.ActionTable.button.launch') }}
</div>
</template>
</dangerous-button>
<button
v-else
:disabled="checked.length === 0"
:aria-label="labels.performAction"
:class="['ui', {disabled: checked.length === 0}, {'loading': isLoading}, 'button']"
@click="launchAction"
>
{{ $t('components.common.ActionTable.button.go') }}
</button>
</div>
<div class="count field">
<span v-if="selectAll">
{{ $t('components.common.ActionTable.button.allSelected', objectsData.count) }}
</span>
<span v-else>
{{ $t('components.common.ActionTable.button.selected', { total: objectsData.count }, checked.length) }}
</span>
<template v-if="currentAction?.allowAll && checkable.length > 0 && checkable.length === checked.length">
<a
v-if="!selectAll"
href=""
@click.prevent="selectAll = true"
>
<span key="3">
{{ $t('components.common.ActionTable.button.selectElement', objectsData.count) }}
</span>
</a>
<a
v-else
href=""
@click.prevent="selectAll = false"
>
<span key="4">
{{ $t('components.common.ActionTable.button.selectCurrentPage') }}
</span>
</a>
</template>
</div>
</div>
<div
v-if="errors.length > 0"
role="alert"
class="ui negative message"
>
<h4 class="header">
{{ $t('components.common.ActionTable.header.error') }}
</h4>
<ul class="list">
<li
v-for="(error, key) in errors"
:key="key"
>
{{ error }}
</li>
</ul>
</div>
<div
v-if="result"
class="ui positive message"
>
<p>
<span>
{{ $t('components.common.ActionTable.message.success', { action: result.action }, result.updated) }}
</span>
</p>
<slot
name="action-success-footer"
:result="result"
/>
</div>
</div>
</th>
</tr>
<tr>
<th v-if="actions.length > 0">
<div class="ui checkbox"> <div class="ui checkbox">
<!-- TODO (wvffle): Check if we don't have to migrate to v-model --> <!-- TODO (wvffle): Check if we don't have to migrate to v-model -->
<input <input
@ -318,16 +208,163 @@ const launchAction = async () => {
@change="toggleCheckAll" @change="toggleCheckAll"
> >
</div> </div>
</th> </label>
<slot name="header-cells" /> <slot name="header-cells" />
</tr> </template>
</thead>
<tbody v-if="objectsData.count > 0"> <div
<tr v-if="actionUrl && actions.length > 0 || refreshable"
:style="{ gridColumn: `span ${gridColumns.length}`, height: '128px' }"
>
<Layout
v-if="actionUrl && actions.length > 0"
stack
no-gap
>
<label for="actions-select">{{ t('components.common.ActionTable.label.actions') }}</label>
<Layout
flex
class="ui form"
>
<select
id="actions-select"
v-model="currentActionName"
class="dropdown"
>
<option
v-for="action in actions"
:key="action.name"
:value="action.name"
>
{{ action.label }}
</option>
</select>
<dangerous-button
v-if="selectAll || currentAction?.isDangerous"
:disabled="checked.length === 0 || undefined"
:is-loading="isLoading"
:confirm-color="currentAction?.confirmColor ?? 'success'"
style="margin-top: 7px;"
:aria-label="labels.performAction"
:title="t('components.common.ActionTable.modal.performAction.header', { action: currentActionName }, affectedObjectsCount)"
@confirm="launchAction"
>
{{ t('components.common.ActionTable.button.go') }}
<template #modal-content>
<template v-if="currentAction?.confirmationMessage">
{{ currentAction?.confirmationMessage }}
</template>
<span v-else>
{{ t('components.common.ActionTable.modal.performAction.content.warning') }}
</span>
</template>
<template #modal-confirm>
<span :aria-label="labels.performAction">
{{ t('components.common.ActionTable.button.launch') }}
</span>
</template>
</dangerous-button>
<Button
v-else
primary
:disabled="checked.length === 0"
:aria-label="labels.performAction"
:class="[{disabled: checked.length === 0}, {'loading': isLoading}]"
style="margin-top: 7px;"
@click="launchAction"
>
{{ t('components.common.ActionTable.button.go') }}
</Button>
<div class="count field">
<span v-if="selectAll">
{{ t('components.common.ActionTable.button.allSelected', objectsData.count) }}
</span>
<span v-else>
{{ t('components.common.ActionTable.button.selected', { total: objectsData.count }, checked.length) }}
</span>
<template v-if="currentAction?.allowAll && checkable.length > 0 && checkable.length === checked.length">
<a
v-if="!selectAll"
href=""
@click.prevent="selectAll = true"
>
<span key="3">
{{ t('components.common.ActionTable.button.selectElement', objectsData.count) }}
</span>
</a>
<a
v-else
href=""
@click.prevent="selectAll = false"
>
<span key="4">
{{ t('components.common.ActionTable.button.selectCurrentPage') }}
</span>
</a>
</template>
</div>
<Alert
v-if="errors.length > 0"
red
>
<h4 class="header">
{{ t('components.common.ActionTable.header.error') }}
</h4>
<ul class="list">
<li
v-for="(error, key) in errors"
:key="key"
>
{{ error }}
</li>
</ul>
</Alert>
<Alert
v-if="result"
green
>
<p>
<span>
{{ t('components.common.ActionTable.message.success', { action: result.action }, result.updated) }}
</span>
</p>
<slot
name="action-success-footer"
:result="result"
/>
</Alert>
</Layout>
</Layout>
<Spacer grow />
<Layout
v-if="refreshable"
label
class="right floated"
>
<span v-if="needsRefresh">
{{ t('components.common.ActionTable.message.needsRefresh') }}
</span>
<Button
primary
icon="bi-arrow-clockwise"
:title="labels.refresh"
:aria-label="labels.refresh"
style="align-self: end;"
@click="$emit('refresh')"
>
{{ labels.refresh }}
</Button>
</Layout>
</div>
<template
v-for="(obj, index) in objects" v-for="(obj, index) in objects"
:key="index" :key="index"
> >
<td <label
v-if="actions.length > 0" v-if="actions.length > 0"
class="collapsing" class="collapsing"
> >
@ -339,13 +376,12 @@ const launchAction = async () => {
:checked="checked.indexOf(obj[idField]) > -1" :checked="checked.indexOf(obj[idField]) > -1"
@click="toggleCheck($event, obj[idField], index)" @click="toggleCheck($event, obj[idField], index)"
> >
</td> </label>
<slot <slot
name="row-cells" name="row-cells"
:obj="obj" :obj="obj"
/> />
</tr> </template>
</tbody> </Table>
</table>
</div> </div>
</template> </template>

View File

@ -5,7 +5,7 @@ import { hashCode, intToRGB } from '~/utils/color'
import { computed } from 'vue' import { computed } from 'vue'
interface Props { interface Props {
actor: Actor actor: { full_username : string; preferred_username?:string; icon?:Actor['icon'] }
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@ -18,12 +18,29 @@ const defaultAvatarStyle = computed(() => ({ backgroundColor: `#${actorColor.val
<img <img
v-if="actor.icon && actor.icon.urls.original" v-if="actor.icon && actor.icon.urls.original"
alt="" alt=""
:src="actor.icon.urls.medium_square_crop" :src="actor.icon.urls.small_square_crop"
class="ui avatar circular image" class="ui tiny avatar circular image"
> >
<span <span
v-else v-else
:style="defaultAvatarStyle" :style="defaultAvatarStyle"
class="ui avatar circular label" class="ui tiny avatar circular label"
>{{ actor.preferred_username?.[0] || "" }}</span> >
{{ actor.preferred_username?.[0] || "" }}
</span>
</template> </template>
<style lang="scss" scoped>
.ui.circular.avatar {
float: left;
text-align: center;
border-radius: 50%;
&.label {
align-content: center;
padding: 4px 8px;
margin-right: 8px;
}
}
</style>

View File

@ -1,23 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Actor } from '~/types' import type { Actor } from '~/types'
import type { components } from '~/generated/types'
import { toRefs } from '@vueuse/core' import { toRefs } from '@vueuse/core'
import { computed } from 'vue' import { computed } from 'vue'
import { truncate } from '~/utils/filters' import { truncate } from '~/utils/filters'
import Pill from '~/components/ui/Pill.vue'
interface Props { interface Props {
actor: Actor actor: Actor | components['schemas']['APIActor']
avatar?: boolean avatar?: boolean
admin?: boolean admin?: boolean
displayName?: boolean displayName?: boolean
truncateLength?: number truncateLength?: number
discrete?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
avatar: true, avatar: true,
admin: false, admin: false,
displayName: false, displayName: false,
truncateLength: 30 truncateLength: 30,
discrete: false
}) })
const { displayName, actor, truncateLength, admin, avatar } = toRefs(props) const { displayName, actor, truncateLength, admin, avatar } = toRefs(props)
@ -27,7 +32,7 @@ const repr = computed(() => {
? actor.value.preferred_username ? actor.value.preferred_username
: actor.value.full_username : actor.value.full_username
return truncate(name, truncateLength.value) return truncate(name || '', truncateLength.value)
}) })
const url = computed(() => { const url = computed(() => {
@ -35,15 +40,15 @@ const url = computed(() => {
return { name: 'manage.moderation.accounts.detail', params: { id: actor.value.full_username } } return { name: 'manage.moderation.accounts.detail', params: { id: actor.value.full_username } }
} }
if (actor.value.is_local) { if (actor.value?.is_local) {
return { name: 'profile.overview', params: { username: actor.value.preferred_username } } return { name: 'profile.overview', params: { username: actor.value?.preferred_username } }
} }
return { return {
name: 'profile.full.overview', name: 'profile.full.overview',
params: { params: {
username: actor.value.preferred_username, username: actor.value?.preferred_username,
domain: actor.value.domain domain: actor.value?.domain
} }
} }
}) })
@ -52,13 +57,28 @@ const url = computed(() => {
<template> <template>
<router-link <router-link
:to="url" :to="url"
:title="actor.full_username" class="username"
@click.stop.prevent=""
> >
<Pill>
<template #image>
<actor-avatar <actor-avatar
v-if="avatar" v-if="avatar"
:actor="actor" :actor="actor"
/> />
<span>&nbsp;</span> <i
<slot>{{ repr }}</slot> v-else
class="bi bi-person-circle"
style="font-size: 24px;"
/>
</template>
{{ repr }}
</Pill>
</router-link> </router-link>
</template> </template>
<style lang="scss" scoped>
a.username {
text-decoration: none;
}
</style>

View File

@ -5,8 +5,12 @@ import axios from 'axios'
import { useVModel } from '@vueuse/core' import { useVModel } from '@vueuse/core'
import { reactive, ref, watch } from 'vue' import { reactive, ref, watch } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import { useI18n } from 'vue-i18n'
import useFormData from '~/composables/useFormData' import useFormData from '~/composables/useFormData'
import Button from '~/components/ui/Button.vue'
import Alert from '~/components/ui/Alert.vue'
interface Events { interface Events {
(e: 'update:modelValue', value: string | null): void (e: 'update:modelValue', value: string | null): void
(e: 'delete'): void (e: 'delete'): void
@ -20,6 +24,8 @@ interface Props {
initialValue?: string | undefined initialValue?: string | undefined
} }
const { t } = useI18n()
const emit = defineEmits<Events>() const emit = defineEmits<Events>()
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
imageClass: '', imageClass: '',
@ -101,13 +107,13 @@ const getAttachmentUrl = (uuid: string) => {
<template> <template>
<div class="ui form"> <div class="ui form">
<div <Alert
v-if="errors.length > 0" v-if="errors.length > 0"
red
role="alert" role="alert"
class="ui negative message"
> >
<h4 class="header"> <h4 class="header">
{{ $t('components.common.AttachmentInput.header.failure') }} {{ t('components.common.AttachmentInput.header.failure') }}
</h4> </h4>
<ul class="list"> <ul class="list">
<li <li
@ -117,7 +123,7 @@ const getAttachmentUrl = (uuid: string) => {
{{ error }} {{ error }}
</li> </li>
</ul> </ul>
</div> </Alert>
<div class="ui field"> <div class="ui field">
<span id="avatarLabel"> <span id="avatarLabel">
<slot /> <slot />
@ -144,7 +150,7 @@ const getAttachmentUrl = (uuid: string) => {
<div class="eleven wide column"> <div class="eleven wide column">
<div class="file-input"> <div class="file-input">
<label :for="attachmentId"> <label :for="attachmentId">
{{ $t('components.common.AttachmentInput.label.upload') }} {{ t('components.common.AttachmentInput.label.upload') }}
</label> </label>
<input <input
:id="attachmentId" :id="attachmentId"
@ -159,21 +165,22 @@ const getAttachmentUrl = (uuid: string) => {
</div> </div>
<div class="ui very small hidden divider" /> <div class="ui very small hidden divider" />
<p> <p>
{{ $t('components.common.AttachmentInput.help.upload') }} {{ t('components.common.AttachmentInput.help.upload') }}
</p> </p>
<button <Button
v-if="value" v-if="value"
class="ui basic tiny button" destructive
icon="bi-trash"
@click.stop.prevent="remove(value as string)" @click.stop.prevent="remove(value as string)"
> >
{{ $t('components.common.AttachmentInput.button.remove') }} {{ t('components.common.AttachmentInput.button.remove') }}
</button> </Button>
<div <div
v-if="isLoading" v-if="isLoading"
class="ui active inverted dimmer" class="ui active inverted dimmer"
> >
<div class="ui indeterminate text loader"> <div class="ui indeterminate text loader">
{{ $t('components.common.AttachmentInput.loader.uploading') }} {{ t('components.common.AttachmentInput.loader.uploading') }}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useVModel } from '@vueuse/core' import { useVModel } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
interface Events { interface Events {
(e: 'update:modelValue', value: boolean): void (e: 'update:modelValue', value: boolean): void
@ -9,6 +10,8 @@ interface Props {
modelValue: boolean modelValue: boolean
} }
const { t } = useI18n()
const emit = defineEmits<Events>() const emit = defineEmits<Events>()
const props = defineProps<Props>() const props = defineProps<Props>()
const value = useVModel(props, 'modelValue', emit) const value = useVModel(props, 'modelValue', emit)
@ -21,10 +24,10 @@ const value = useVModel(props, 'modelValue', emit)
@click.prevent="value = !value" @click.prevent="value = !value"
> >
<span v-if="value"> <span v-if="value">
{{ $t('components.common.CollapseLink.button.expand') }} {{ t('components.common.CollapseLink.button.expand') }}
</span> </span>
<span v-else> <span v-else>
{{ $t('components.common.CollapseLink.button.collapse') }} {{ t('components.common.CollapseLink.button.collapse') }}
</span> </span>
<i :class="[{ down: !value, right: value }, 'angle', 'icon']" /> <i :class="[{ down: !value, right: value }, 'angle', 'icon']" />
</a> </a>

View File

@ -1,10 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import axios from 'axios' import { useVModel, useTextareaAutosize, syncRef } from '@vueuse/core'
import { useVModel, watchDebounced, useTextareaAutosize, syncRef } from '@vueuse/core' import { computed } from 'vue'
import { ref, computed, watchEffect, onMounted, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import useLogger from '~/composables/useLogger' import Textarea from '~/components/ui/Textarea.vue'
interface Events { interface Events {
(e: 'update:modelValue', value: string): void (e: 'update:modelValue', value: string): void
@ -28,110 +27,27 @@ const props = withDefaults(defineProps<Props>(), {
required: false required: false
}) })
const logger = useLogger()
const { t } = useI18n() const { t } = useI18n()
const { textarea, input } = useTextareaAutosize() const { textarea, input } = useTextareaAutosize()
const value = useVModel(props, 'modelValue', emit) const value = useVModel(props, 'modelValue', emit)
syncRef(value, input) syncRef(value, input)
const isPreviewing = ref(false)
const preview = ref()
const isLoadingPreview = ref(false)
const labels = computed(() => ({ const labels = computed(() => ({
placeholder: props.placeholder ?? t('components.common.ContentForm.placeholder.input') placeholder: props.placeholder ?? t('components.common.ContentForm.placeholder.input')
})) }))
const remainingChars = computed(() => props.charLimit - props.modelValue.length) const remainingChars = computed(() => props.charLimit - props.modelValue.length)
const loadPreview = async () => {
isLoadingPreview.value = true
try {
const response = await axios.post('text-preview/', { text: value.value, permissive: props.permissive })
preview.value = response.data.rendered
} catch (error) {
logger.error(error)
}
isLoadingPreview.value = false
}
watchDebounced(value, async () => {
await loadPreview()
}, { immediate: true, debounce: 500 })
watchEffect(async () => {
if (isPreviewing.value) {
if (value.value && !preview.value && !isLoadingPreview.value) {
await loadPreview()
}
}
})
watch(isPreviewing, (to, from) => {
if (from === true) {
textarea.value.focus()
}
}, { flush: 'post' })
onMounted(async () => {
if (props.autofocus) {
await nextTick()
textarea.value.focus()
}
})
</script> </script>
<template> <template>
<div class="content-form ui segments"> <Textarea
<div class="ui segment">
<div class="ui tiny secondary pointing menu">
<button
:class="[{active: !isPreviewing}, 'item']"
@click.prevent="isPreviewing = false"
>
{{ $t('components.common.ContentForm.button.write') }}
</button>
<button
:class="[{active: isPreviewing}, 'item']"
@click.prevent="isPreviewing = true"
>
{{ $t('components.common.ContentForm.button.preview') }}
</button>
</div>
<template v-if="isPreviewing">
<div
v-if="isLoadingPreview"
class="ui placeholder"
>
<div class="paragraph">
<div class="line" />
<div class="line" />
<div class="line" />
<div class="line" />
</div>
</div>
<p v-else-if="!preview">
{{ $t('components.common.ContentForm.empty.noContent') }}
</p>
<sanitized-html
v-else
:html="preview"
/>
</template>
<template v-else>
<div class="ui transparent input">
<textarea
ref="textarea" ref="textarea"
v-model="value" v-model="value"
:required="required" :required="required || undefined"
:placeholder="labels.placeholder" :placeholder="labels.placeholder"
:autofocus="autofocus || undefined"
/> />
</div>
<div class="ui very small hidden divider" />
</template>
</div>
<div class="ui bottom attached segment">
<span <span
v-if="charLimit" v-if="charLimit"
:class="['right', 'floated', {'ui danger text': remainingChars < 0}]" :class="['right', 'floated', {'ui danger text': remainingChars < 0}]"
@ -139,8 +55,6 @@ onMounted(async () => {
{{ remainingChars }} {{ remainingChars }}
</span> </span>
<p> <p>
{{ $t('components.common.ContentForm.help.markdown') }} {{ t('components.common.ContentForm.help.markdown') }}
</p> </p>
</div>
</div>
</template> </template>

View File

@ -1,43 +1,79 @@
<script setup lang="ts"> <script setup lang="ts">
import { toRefs, useClipboard } from '@vueuse/core' import { toRefs, useClipboard } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import Button from '~/components/ui/Button.vue'
import Input from '~/components/ui/Input.vue'
interface Props { interface Props {
value: string value: string
buttonClasses?: string buttonClasses?: string
id?: string id?: string
label: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
buttonClasses: 'accent', buttonClasses: 'accent',
id: 'copy-input' id: 'copy-input',
label: 'label'
}) })
const { t } = useI18n()
const { value } = toRefs(props) const { value } = toRefs(props)
const { copy, isSupported: canCopy, copied } = useClipboard({ source: value, copiedDuring: 5000 }) const { copy, isSupported: canCopy, copied } = useClipboard({ source: value, copiedDuring: 5000 })
</script> </script>
<template> <template>
<div class="ui fluid action input component-copy-input">
<p <p
v-if="copied" v-if="copied"
class="message" class="message"
> >
{{ $t('components.common.CopyInput.message.success') }} {{ t('components.common.CopyInput.message.success') }}
</p> </p>
<input <Input
:id="id" :id="id"
:value="value" v-model="value"
readonly
:name="id" :name="id"
type="text" type="text"
readonly :label="label"
> >
<button <template #input-right>
:class="['ui', buttonClasses, 'right', 'labeled', 'icon', 'button']" <Button
:class="['ui', buttonClasses, 'input-right']"
min-content
secondary
:disabled="!canCopy || undefined" :disabled="!canCopy || undefined"
@click="copy()" @click="copy()"
> >
<i class="copy icon" /> <i class="bi bi-copy" />
{{ $t('components.common.CopyInput.button.copy') }} {{ t('components.common.CopyInput.button.copy') }}
</button> </Button>
</div>
</template> </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>

View File

@ -1,23 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import SemanticModal from '~/components/semantic/Modal.vue'
import { ref } from 'vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '~/components/ui/Button.vue'
import Modal from '~/components/ui/Modal.vue'
import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
interface Events { interface Events {
(e: 'confirm'): void (e: 'confirm'): void
} }
interface Props { // Note that properties such as [disabled] and 'destructive' | 'primary' are inherited.
action?: () => void const props = defineProps<{
disabled?: boolean title?: string
confirmColor?: 'danger' | 'success' action?:() => void,
} confirmColor?:'success' | 'danger',
popoverItem?: boolean
}>()
const { t } = useI18n()
const emit = defineEmits<Events>() const emit = defineEmits<Events>()
const props = withDefaults(defineProps<Props>(), {
action: () => undefined,
disabled: false,
confirmColor: 'danger'
})
const showModal = ref(false) const showModal = ref(false)
@ -29,40 +32,35 @@ const confirm = () => {
</script> </script>
<template> <template>
<button <component
:class="[{disabled: disabled}]" :is="props.popoverItem ? PopoverItem : Button"
:disabled="disabled" destructive
v-bind="$attrs"
@click.prevent.stop="showModal = true" @click.prevent.stop="showModal = true"
> >
<slot /> <slot />
<semantic-modal <Modal
v-model:show="showModal" v-model="showModal"
class="small" destructive
:title="title || t('components.common.DangerousButton.header.confirm')"
:cancel="t('components.common.DangerousButton.button.cancel')"
> >
<h4 class="header">
<slot name="modal-header">
{{ $t('components.common.DangerousButton.header.confirm') }}
</slot>
</h4>
<div class="scrolling content"> <div class="scrolling content">
<div class="description"> <div class="description">
<slot name="modal-content" /> <slot name="modal-content" />
</div> </div>
</div> </div>
<div class="actions"> <template #actions>
<button class="ui basic cancel button"> <Button
{{ $t('components.common.DangerousButton.button.cancel') }} v-bind="{[{success: 'primary', danger: 'destructive'}[confirmColor || 'danger']]: true}"
</button>
<button
:class="['ui', 'confirm', confirmColor, 'button']"
@click="confirm" @click="confirm"
> >
<slot name="modal-confirm"> <slot name="modal-confirm">
{{ $t('components.common.DangerousButton.button.confirm') }} {{ t('components.common.DangerousButton.button.confirm') }}
</slot> </slot>
</button> </Button>
</div> </template>
</semantic-modal> </Modal>
</button> </component>
</template> </template>

View File

@ -1,11 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import moment from 'moment' import moment from 'moment'
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
interface Props { interface Props {
seconds?: number seconds?: number
} }
const { t } = useI18n()
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
seconds: 0 seconds: 0
}) })
@ -19,10 +22,10 @@ const duration = computed(() => {
<template> <template>
<span> <span>
<span v-if="duration.hours > 0"> <span v-if="duration.hours > 0">
{{ $t('components.common.Duration.meta.hours', duration) }} {{ t('components.common.Duration.meta.hours', duration) }}
</span> </span>
<span v-else> <span v-else>
{{ $t('components.common.Duration.meta.minutes', duration) }} {{ t('components.common.Duration.meta.minutes', duration) }}
</span> </span>
</span> </span>
</template> </template>

View File

@ -1,4 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '~/components/ui/Button.vue'
import Alert from '~/components/ui/Alert.vue'
import Spacer from '~/components/ui/Spacer.vue'
interface Events { interface Events {
(e: 'refresh'): void (e: 'refresh'): void
} }
@ -7,6 +13,8 @@ interface Props {
refresh?: boolean refresh?: boolean
} }
const { t } = useI18n()
const emit = defineEmits<Events>() const emit = defineEmits<Events>()
withDefaults(defineProps<Props>(), { withDefaults(defineProps<Props>(), {
refresh: false refresh: false
@ -14,24 +22,28 @@ withDefaults(defineProps<Props>(), {
</script> </script>
<template> <template>
<div class="ui small placeholder segment component-placeholder component-empty-state"> <Alert
blue
align-items="center"
>
<h4 class="ui header"> <h4 class="ui header">
<div class="content"> <div class="content">
<slot name="title"> <slot name="title">
<i class="search icon" /> <i class="bi bi-search" />
{{ $t('components.common.EmptyState.header.noResults') }} {{ t('components.common.EmptyState.header.noResults') }}
</slot> </slot>
</div> </div>
</h4> </h4>
<div class="inline center aligned text"> <div class="inline center aligned text">
<slot /> <slot />
<button <Spacer :size="16" />
<Button
v-if="refresh" v-if="refresh"
class="ui button" primary
@click="emit('refresh')" @click="emit('refresh')"
> >
{{ $t('components.common.EmptyState.button.refresh') }} {{ t('components.common.EmptyState.button.refresh') }}
</button> </Button>
</div>
</div> </div>
</Alert>
</template> </template>

View File

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useToggle } from '@vueuse/core' import { useToggle } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
interface Props { interface Props {
content: string content: string
@ -11,6 +12,8 @@ const props = withDefaults(defineProps<Props>(), {
length: 150 length: 150
}) })
const { t } = useI18n()
const [expanded, toggleExpanded] = useToggle(false) const [expanded, toggleExpanded] = useToggle(false)
const truncated = computed(() => props.content.slice(0, props.length)) const truncated = computed(() => props.content.slice(0, props.length))
</script> </script>
@ -27,10 +30,10 @@ const truncated = computed(() => props.content.slice(0, props.length))
> >
<br> <br>
<span v-if="expanded"> <span v-if="expanded">
{{ $t('components.common.ExpandableDiv.button.less') }} {{ t('components.common.ExpandableDiv.button.less') }}
</span> </span>
<span v-else> <span v-else>
{{ $t('components.common.ExpandableDiv.button.more') }} {{ t('components.common.ExpandableDiv.button.more') }}
</span> </span>
</a> </a>
</div> </div>

View File

@ -25,8 +25,15 @@ const realDate = useTimeAgo(date)
> >
<i <i
v-if="props.icon" v-if="props.icon"
class="outline clock icon" class="bi bi-clock"
/> />
{{ realDate }} {{ realDate }}
</time> </time>
</template> </template>
<style scoped>
i {
margin-right: 8px;
font-size: 14px;
}
</style>

View File

@ -3,6 +3,9 @@ import { useVModel } from '@vueuse/core'
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Input from '~/components/ui/Input.vue'
import Layout from '~/components/ui/Layout.vue'
interface Events { interface Events {
(e: 'update:modelValue', value: string): void (e: 'update:modelValue', value: string): void
(e: 'search', query: string): void (e: 'search', query: string): void
@ -25,16 +28,11 @@ const labels = computed(() => ({
searchPlaceholder: t('components.common.InlineSearchBar.placeholder.search'), searchPlaceholder: t('components.common.InlineSearchBar.placeholder.search'),
clear: t('components.common.InlineSearchBar.button.clear') clear: t('components.common.InlineSearchBar.button.clear')
})) }))
const search = () => {
value.value = ''
emit('search', value.value)
}
</script> </script>
<template> <template>
<form <Layout
class="ui inline form" form
@submit.stop.prevent="emit('search', value)" @submit.stop.prevent="emit('search', value)"
> >
<div :class="['ui', 'action', {icon: value}, 'input']"> <div :class="['ui', 'action', {icon: value}, 'input']">
@ -42,27 +40,16 @@ const search = () => {
for="search-query" for="search-query"
class="hidden" class="hidden"
> >
{{ $t('components.common.InlineSearchBar.label.search') }} {{ t('components.common.InlineSearchBar.label.search') }}
</label> </label>
<input <Input
id="search-query" id="search-query"
v-model="value" v-model="value"
search
name="search-query" name="search-query"
type="text" type="text"
:placeholder="placeholder || labels.searchPlaceholder" :placeholder="placeholder || labels.searchPlaceholder"
>
<i
v-if="value"
class="x link icon"
:title="labels.clear"
@click.stop.prevent="search"
/> />
<button
type="submit"
class="ui icon basic button"
>
<i class="search icon" />
</button>
</div> </div>
</form> </Layout>
</template> </template>

View File

@ -2,9 +2,13 @@
import type { RouteLocationRaw } from 'vue-router' import type { RouteLocationRaw } from 'vue-router'
import type { Cover } from '~/types' import type { Cover } from '~/types'
import SemanticModal from '~/components/semantic/Modal.vue'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useStore } from '~/store'
import Modal from '~/components/ui/Modal.vue'
import Link from '~/components/ui/Link.vue'
import Spacer from '~/components/ui/Spacer.vue'
interface Props { interface Props {
nextRoute: RouteLocationRaw nextRoute: RouteLocationRaw
@ -14,6 +18,8 @@ interface Props {
defineProps<Props>() defineProps<Props>()
const store = useStore()
const show = ref(false) const show = ref(false)
const { t } = useI18n() const { t } = useI18n()
@ -26,10 +32,10 @@ const labels = computed(() => ({
</script> </script>
<template> <template>
<semantic-modal v-model:show="show"> <Modal
<h4 class="header"> v-model="show"
{{ labels.header }} :title="labels.header"
</h4> >
<div <div
v-if="cover" v-if="cover"
class="image content" class="image content"
@ -57,22 +63,21 @@ const labels = computed(() => ({
{{ message }} {{ message }}
</p> </p>
</div> </div>
<div class="actions"> <template #actions>
<router-link <Spacer grow />
<Link
:to="{path: '/login', query: { next: nextRoute as string }}" :to="{path: '/login', query: { next: nextRoute as string }}"
class="ui labeled icon button" icon="bi-key-fill"
> >
<i class="key icon" />
{{ labels.login }} {{ labels.login }}
</router-link> </Link>
<router-link <Link
v-if="$store.state.instance.settings.users.registration_enabled.value" v-if="store.state.instance.settings.users.registration_enabled.value"
:to="{path: '/signup'}" :to="{path: '/signup'}"
class="ui labeled icon button" icon="bi-person-fill"
> >
<i class="user icon" />
{{ labels.signup }} {{ labels.signup }}
</router-link> </Link>
</div> </template>
</semantic-modal> </Modal>
</template> </template>

View File

@ -1,35 +1,49 @@
<script setup lang="ts"> <script setup lang="ts">
import $ from 'jquery' import { computed, onMounted, ref } from 'vue'
import { onMounted } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import Alert from '~/components/ui/Alert.vue'
interface Message { interface Message {
content: string content: string
key: string key: string
color?: 'blue' | 'red' | 'purple' | 'green' | 'yellow'
error?: boolean | string
date?: Date
} }
const props = defineProps<{ message: Message }>() const props = defineProps<{ message: Message }>()
const isVisible = ref(true)
const store = useStore() const store = useStore()
onMounted(() => {
const params = { const messageColor = computed(() => {
context: '#app', if (props.message.color) {
message: props.message.content, return props.message.color
showProgress: 'top',
position: 'bottom right',
progressUp: true,
onRemove () {
store.commit('ui/removeMessage', props.message.key)
},
...props.message
} }
// @ts-expect-error fomantic ui if (props.message.error || props.message.content?.toLowerCase().includes('error')) {
$('body').toast(params) return 'red'
$('.ui.toast.visible').last().attr('role', 'alert') }
return 'blue'
})
onMounted(() => {
setTimeout(() => {
isVisible.value = false
store.commit('ui/removeMessage', props.message.key)
}, 5000)
}) })
</script> </script>
<template> <template>
<div /> <Transition name="fade">
<Alert
v-if="isVisible"
role="alert"
:[messageColor]="true"
class="is-notification"
>
{{ message.content }}
</Alert>
</Transition>
</template> </template>

View File

@ -3,10 +3,15 @@ import type { BackendError } from '~/types'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { whenever } from '@vueuse/core' import { whenever } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import axios from 'axios' import axios from 'axios'
import clip from 'text-clipper' import clip from 'text-clipper'
import Layout from '~/components/ui/Layout.vue'
import Button from '~/components/ui/Button.vue'
import Alert from '~/components/ui/Alert.vue'
interface Events { interface Events {
(e: 'updated', data: unknown): void (e: 'updated', data: unknown): void
} }
@ -19,8 +24,11 @@ interface Props {
fetchHtml?: boolean fetchHtml?: boolean
permissive?: boolean permissive?: boolean
truncateLength?: number truncateLength?: number
moreLink?: boolean
} }
const { t } = useI18n()
const emit = defineEmits<Events>() const emit = defineEmits<Events>()
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
content: null, content: null,
@ -29,7 +37,8 @@ const props = withDefaults(defineProps<Props>(), {
canUpdate: true, canUpdate: true,
fetchHtml: false, fetchHtml: false,
permissive: false, permissive: false,
truncateLength: 500 truncateLength: 200,
moreLink: true
}) })
const preview = ref('') const preview = ref('')
@ -46,6 +55,8 @@ const truncatedHtml = computed(() => clip(props.content?.html ?? '', props.trunc
})) }))
const showMore = ref(false) const showMore = ref(false)
// Truncated or full Html or an empty string
const html = computed(() => props.fetchHtml const html = computed(() => props.fetchHtml
? preview.value ? preview.value
: props.truncateLength > 0 && !showMore.value : props.truncateLength > 0 && !showMore.value
@ -81,53 +92,57 @@ const submit = async () => {
</script> </script>
<template> <template>
<div> <Layout
<template v-if="content && !isUpdating"> v-if="content && !isUpdating"
<sanitized-html :html="html" /> flex
gap-4
>
<!-- Render the truncated or full description -->
<sanitized-html
:html="html"
:class="['description', isTruncated ? 'truncated' : '']"
/>
<!-- Display the `show more` / `show less` button -->
<template v-if="isTruncated"> <template v-if="isTruncated">
<div class="ui small hidden divider" />
<a <a
v-if="showMore === false" v-if="showMore === false && props.moreLink !== false"
class="more"
style="align-self: flex-end; color: var(--fw-primary);"
href="" href=""
@click.stop.prevent="showMore = true" @click.stop.prevent="showMore = true"
> >
{{ $t('components.common.RenderedDescription.button.more') }} {{ t('components.common.RenderedDescription.button.more') }}
</a> </a>
<a <a
v-else v-else-if="props.moreLink !== false"
class="more"
style="align-self: center; color: var(--fw-primary);"
href="" href=""
@click.stop.prevent="showMore = false" @click.stop.prevent="showMore = false"
> >
{{ $t('components.common.RenderedDescription.button.less') }} {{ t('components.common.RenderedDescription.button.less') }}
</a> </a>
</template> </template>
</template> </Layout>
<p v-else-if="!isUpdating"> <span v-else-if="!isUpdating">
{{ $t('components.common.RenderedDescription.empty.noDescription') }} {{ t('components.common.RenderedDescription.empty.noDescription') }}
</p>
<template v-if="!isUpdating && canUpdate && updateUrl">
<div class="ui hidden divider" />
<span
role="button"
@click="isUpdating = true"
>
<i class="pencil icon" />
{{ $t('components.common.RenderedDescription.button.edit') }}
</span> </span>
</template>
<!-- [DISABLED] Display an edit form -->
<!-- TODO: Check if we want to revive in-situ editing here -->
<form <form
v-if="isUpdating" v-if="isUpdating"
class="ui form"
@submit.prevent="submit()" @submit.prevent="submit()"
> >
<div <Alert
v-if="errors.length > 0" v-if="errors.length > 0"
red
title="{{ t('components.common.RenderedDescription.header.failure') }}"
role="alert" role="alert"
class="ui negative message"
> >
<h4 class="header">
{{ $t('components.common.RenderedDescription.header.failure') }}
</h4>
<ul class="list"> <ul class="list">
<li <li
v-for="(error, key) in errors" v-for="(error, key) in errors"
@ -136,25 +151,43 @@ const submit = async () => {
{{ error }} {{ error }}
</li> </li>
</ul> </ul>
</div> </Alert>
<content-form <content-form
v-model="text" v-model="text"
:autofocus="true" :autofocus="true"
/> />
<a <Button
class="left floated" class="left floated"
solid
secondary
@click.prevent="isUpdating = false" @click.prevent="isUpdating = false"
> >
{{ $t('components.common.RenderedDescription.button.cancel') }} {{ t('components.common.RenderedDescription.button.cancel') }}
</a> </Button>
<button <Button
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']" :class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']"
type="submit" type="submit"
:disabled="isLoading" :disabled="isLoading"
solid
primary
> >
{{ $t('components.common.RenderedDescription.button.update') }} {{ t('components.common.RenderedDescription.button.update') }}
</button> </Button>
<div class="ui clearing hidden divider" />
</form> </form>
</div>
</template> </template>
<style lang="scss" scoped>
.description {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
white-space: normal;
&.truncated {
-webkit-line-clamp: 1; /* Number of lines to show */
line-clamp: 1;
max-height: 72px;
flex-shrink: 1;
}
}
</style>

View File

@ -18,7 +18,7 @@ withDefaults(defineProps<Props>(), {
class="tooltip" class="tooltip"
> >
<slot> <slot>
<i class="question circle icon" /> <i class="bi bi-question-circle" />
</slot> </slot>
</component> </component>
<slot v-else /> <slot v-else />

View File

@ -3,14 +3,23 @@ import type { User } from '~/types'
import { hashCode, intToRGB } from '~/utils/color' import { hashCode, intToRGB } from '~/utils/color'
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useStore } from '~/store'
import Link from '~/components/ui/Link.vue'
interface Props { interface Props {
user: User user: User
avatar?: boolean avatar?: boolean
discrete?: boolean
} }
const store = useStore()
const { t } = useI18n()
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
avatar: true avatar: true,
discrete: false
}) })
const userColor = computed(() => intToRGB(hashCode(props.user.username + props.user.id))) const userColor = computed(() => intToRGB(hashCode(props.user.username + props.user.id)))
@ -18,21 +27,29 @@ const defaultAvatarStyle = computed(() => ({ backgroundColor: `#${userColor.valu
</script> </script>
<template> <template>
<span class="component-user-link"> <Link
:to="user"
:title="user.full_username"
class="username"
:solid="!discrete"
:round="!discrete"
>
<template v-if="avatar"> <template v-if="avatar">
<img <img
v-if="user.avatar && user.avatar.urls.medium_square_crop" v-if="user.avatar && user.avatar.urls.small_square_crop"
v-lazy="$store.getters['instance/absoluteUrl'](user.avatar.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](user.avatar.urls.small_square_crop)"
class="ui tiny circular avatar" class="ui avatar tiny circular image"
alt="" alt=""
> >
<span <span
v-else v-else
:style="defaultAvatarStyle" :style="defaultAvatarStyle"
class="ui circular label" class="ui tiny avatar circular label"
>{{ user.username[0] }}</span> >
{{ user.username[0] }}
</span>
&nbsp; &nbsp;
</template> </template>
{{ $t('components.common.UserLink.link.username', {username: user.username}) }} {{ t('components.common.UserLink.link.username', {username: user.username}) }}
</span> </Link>
</template> </template>

View File

@ -2,6 +2,8 @@
import { SUPPORTED_LOCALES, setI18nLanguage } from '~/init/locale' import { SUPPORTED_LOCALES, setI18nLanguage } from '~/init/locale'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { computed } from 'vue' import { computed } from 'vue'
import { useStore } from '~/store'
import { useRoute } from 'vue-router'
import useThemeList from '~/composables/useThemeList' import useThemeList from '~/composables/useThemeList'
import useTheme from '~/composables/useTheme' import useTheme from '~/composables/useTheme'
@ -10,9 +12,13 @@ interface Events {
(e: 'show:shortcuts-modal'): void (e: 'show:shortcuts-modal'): void
} }
const route = useRoute()
const store = useStore()
const emit = defineEmits<Events>() const emit = defineEmits<Events>()
const { t } = useI18n() const { t, locale } = useI18n()
const themes = useThemeList() const themes = useThemeList()
const { theme } = useTheme() const { theme } = useTheme()
@ -48,7 +54,7 @@ const labels = computed(() => ({
<a <a
v-for="(language, key) in SUPPORTED_LOCALES" v-for="(language, key) in SUPPORTED_LOCALES"
:key="key" :key="key"
:class="[{'active': $i18n.locale === key},'item']" :class="[{'active': locale === key},'item']"
:value="key" :value="key"
@click="setI18nLanguage(key)" @click="setI18nLanguage(key)"
>{{ language }}</a> >{{ language }}</a>
@ -74,27 +80,27 @@ const labels = computed(() => ({
</a> </a>
</div> </div>
</div> </div>
<template v-if="$store.state.auth.authenticated"> <template v-if="store.state.auth.authenticated">
<div class="divider" /> <div class="divider" />
<router-link <router-link
class="item" class="item"
:to="{name: 'profile.overview', params: { username: $store.state.auth.username },}" :to="{name: 'profile.overview', params: { username: store.state.auth.username },}"
> >
<i class="user icon" /> <i class="user icon" />
{{ labels.profile }} {{ labels.profile }}
</router-link> </router-link>
<router-link <router-link
v-if="$store.state.auth.authenticated" v-if="store.state.auth.authenticated"
class="item" class="item"
:to="{name: 'notifications'}" :to="{name: 'notifications'}"
> >
<i class="bell icon" /> <i class="bell icon" />
<div <div
v-if="$store.state.ui.notifications.inbox > 0" v-if="store.state.ui.notifications.inbox > 0"
:title="labels.notifications" :title="labels.notifications"
:class="['ui', 'circular', 'mini', 'right floated', 'accent', 'label']" :class="['ui', 'circular', 'mini', 'right floated', 'accent', 'label']"
> >
{{ $store.state.ui.notifications.inbox }} {{ store.state.ui.notifications.inbox }}
</div> </div>
{{ labels.notifications }} {{ labels.notifications }}
</router-link> </router-link>
@ -155,14 +161,14 @@ const labels = computed(() => ({
{{ labels.shortcuts }} {{ labels.shortcuts }}
</a> </a>
<router-link <router-link
v-if="$route.path != '/about'" v-if="route.path != '/about'"
class="item" class="item"
:to="{ name: 'about' }" :to="{ name: 'about' }"
> >
<i class="question circle outline icon" /> <i class="question circle outline icon" />
{{ labels.about }} {{ labels.about }}
</router-link> </router-link>
<template v-if="$store.state.auth.authenticated && $route.path != '/logout'"> <template v-if="store.state.auth.authenticated && route.path != '/logout'">
<div class="divider" /> <div class="divider" />
<router-link <router-link
class="item" class="item"
@ -173,7 +179,7 @@ const labels = computed(() => ({
{{ labels.logout }} {{ labels.logout }}
</router-link> </router-link>
</template> </template>
<template v-if="!$store.state.auth.authenticated"> <template v-if="!store.state.auth.authenticated">
<div class="divider" /> <div class="divider" />
<router-link <router-link
class="item" class="item"
@ -183,7 +189,7 @@ const labels = computed(() => ({
{{ labels.login }} {{ labels.login }}
</router-link> </router-link>
</template> </template>
<template v-if="!$store.state.auth.authenticated && $store.state.instance.settings.users.registration_enabled.value"> <template v-if="!store.state.auth.authenticated && store.state.instance.settings.users.registration_enabled.value">
<router-link <router-link
class="item" class="item"
:to="{ name: 'signup' }" :to="{ name: 'signup' }"

View File

@ -8,6 +8,8 @@ import useTheme from '~/composables/useTheme'
import { useVModel } from '@vueuse/core' import { useVModel } from '@vueuse/core'
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useStore } from '~/store'
import { useRouter } from 'vue-router'
import { SUPPORTED_LOCALES } from '~/init/locale' import { SUPPORTED_LOCALES } from '~/init/locale'
@ -21,6 +23,9 @@ interface Props {
show: boolean show: boolean
} }
const router = useRouter()
const store = useStore()
const emit = defineEmits<Events>() const emit = defineEmits<Events>()
const props = defineProps<Props>() const props = defineProps<Props>()
@ -62,18 +67,18 @@ const locale = computed(() => SUPPORTED_LOCALES[i18nLocale.value as SupportedLan
:fullscreen="false" :fullscreen="false"
> >
<div <div
v-if="$store.state.auth.authenticated" v-if="store.state.auth.authenticated"
class="header" class="header"
> >
<img <img
v-if="$store.state.auth.profile?.avatar && $store.state.auth.profile?.avatar.urls.medium_square_crop" v-if="store.state.auth.profile?.avatar && store.state.auth.profile?.avatar.urls.medium_square_crop"
v-lazy="$store.getters['instance/absoluteUrl']($store.state.auth.profile?.avatar.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](store.state.auth.profile?.avatar.urls.medium_square_crop)"
alt="" alt=""
class="ui centered small circular image" class="ui centered small circular image"
> >
<actor-avatar <actor-avatar
v-else v-else
:actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username,}" :actor="{preferred_username: store.state.auth.username, full_username: store.state.auth.username,}"
/> />
<h3 class="user-modal title"> <h3 class="user-modal title">
{{ labels.header }} {{ labels.header }}
@ -124,12 +129,12 @@ const locale = computed(() => SUPPORTED_LOCALES[i18nLocale.value as SupportedLan
</div> </div>
</div> </div>
<div class="ui divider" /> <div class="ui divider" />
<template v-if="$store.state.auth.authenticated"> <template v-if="store.state.auth.authenticated">
<div class="row"> <div class="row">
<div <div
class="column" class="column"
role="button" role="button"
@click.prevent.exact="$router.push({name: 'profile.overview', params: { username: $store.state.auth.username }})" @click.prevent.exact="router.push({name: 'profile.overview', params: { username: store.state.auth.username }})"
> >
<i class="user icon user-modal list-icon" /> <i class="user icon user-modal list-icon" />
<span class="user-modal list-item">{{ labels.profile }}</span> <span class="user-modal list-item">{{ labels.profile }}</span>
@ -137,7 +142,7 @@ const locale = computed(() => SUPPORTED_LOCALES[i18nLocale.value as SupportedLan
</div> </div>
<div class="row"> <div class="row">
<router-link <router-link
v-if="$store.state.auth.authenticated" v-if="store.state.auth.authenticated"
v-slot="{ navigate }" v-slot="{ navigate }"
custom custom
:to="{ name: 'notifications' }" :to="{ name: 'notifications' }"
@ -212,7 +217,7 @@ const locale = computed(() => SUPPORTED_LOCALES[i18nLocale.value as SupportedLan
<div class="ui divider" /> <div class="ui divider" />
<router-link <router-link
v-if="$store.state.auth.authenticated" v-if="store.state.auth.authenticated"
v-slot="{ navigate }" v-slot="{ navigate }"
custom custom
:to="{ name: 'logout' }" :to="{ name: 'logout' }"
@ -244,7 +249,7 @@ const locale = computed(() => SUPPORTED_LOCALES[i18nLocale.value as SupportedLan
</div> </div>
</router-link> </router-link>
<router-link <router-link
v-if="!$store.state.auth.authenticated && $store.state.instance.settings.users.registration_enabled.value" v-if="!store.state.auth.authenticated && store.state.instance.settings.users.registration_enabled.value"
v-slot="{ navigate }" v-slot="{ navigate }"
custom custom
:to="{ name: 'signup' }" :to="{ name: 'signup' }"

View File

@ -10,11 +10,16 @@ import { sortedUniq } from 'lodash-es'
import { useStore } from '~/store' import { useStore } from '~/store'
import axios from 'axios' import axios from 'axios'
import $ from 'jquery'
import TrackTable from '~/components/audio/track/Table.vue' import TrackTable from '~/components/audio/track/Table.vue'
import RadioButton from '~/components/radios/Button.vue' import RadioButton from '~/components/radios/Button.vue'
import Pagination from '~/components/vui/Pagination.vue' import Layout from '~/components/ui/Layout.vue'
import Header from '~/components/ui/Header.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Link from '~/components/ui/Link.vue'
import Alert from '~/components/ui/Alert.vue'
import Pagination from '~/components/ui/Pagination.vue'
import Loader from '~/components/ui/Loader.vue'
import useSharedLabels from '~/composables/locale/useSharedLabels' import useSharedLabels from '~/composables/locale/useSharedLabels'
import useOrdering from '~/composables/navigation/useOrdering' import useOrdering from '~/composables/navigation/useOrdering'
@ -58,15 +63,15 @@ const fetchFavorites = async () => {
isLoading.value = true isLoading.value = true
const params = { const params = {
favorites: 'true',
page: page.value, page: page.value,
page_size: paginateBy.value, page_size: paginateBy.value,
ordering: orderingString.value ordering: orderingString.value,
scope: store.state.auth.fullUsername
} }
const measureLoading = logger.time('Loading user favorites') const measureLoading = logger.time('Loading user favorites')
try { try {
const response = await axios.get('tracks/', { params }) const response = await axios.get('favorites/tracks/', { params })
results.length = 0 results.length = 0
results.push(...response.data.results) results.push(...response.data.results)
@ -86,16 +91,20 @@ const fetchFavorites = async () => {
} }
} }
watch(page, fetchFavorites) onMounted(() => {
fetchFavorites() fetchFavorites()
})
watch([() => paginateBy, page],
() => fetchFavorites(),
{ deep: true }
)
onOrderingUpdate(() => { onOrderingUpdate(() => {
page.value = 1 page.value = 1
fetchFavorites() fetchFavorites()
}) })
onMounted(() => $('.ui.dropdown').dropdown())
const { t } = useI18n() const { t } = useI18n()
const labels = computed(() => ({ const labels = computed(() => ({
title: t('components.favorites.List.title') title: t('components.favorites.List.title')
@ -105,42 +114,50 @@ const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value]
</script> </script>
<template> <template>
<main <Layout
v-title="labels.title" v-title="labels.title"
class="main pusher" main
stack
no-gap
align-left
> >
<section class="ui vertical center aligned stripe segment"> <Header
<div :class="['ui', { 'active': isLoading }, 'inverted', 'dimmer']"> page-heading
<div class="ui text loader"> :h1="labels.title"
{{ $t('components.favorites.List.loader.loading') }}
</div>
</div>
<h2
v-if="results"
class="ui center aligned icon header"
> >
<i class="circular inverted heart pink icon" /> <template #action>
{{ $t('components.favorites.List.header.favorites', $store.state.favorites.count) }} <RadioButton
</h2> v-if="store.state.favorites.count > 0"
<radio-button
v-if="$store.state.favorites.count > 0"
type="favorites" type="favorites"
/> />
</section> </template>
<section </Header>
v-if="$store.state.favorites.count > 0"
class="ui vertical stripe segment" <Loader v-if="isLoading" />
<Layout
v-if="store.state.favorites.count > 0"
form
stack
:class="['ui', { 'loading': isLoading }, 'form']"
> >
<div :class="['ui', { 'loading': isLoading }, 'form']"> <Spacer :size="16" />
<div class="fields"> <Layout
<div class="field"> flex
<label for="favorites-ordering"> style="justify-content: flex-end;"
{{ $t('components.favorites.List.ordering.label') }} >
</label> <Layout
stack
no-gap
label
for="favorites-ordering"
>
<span class="label">
{{ t('components.favorites.List.ordering.label') }}
</span>
<select <select
id="favorites-ordering" id="favorites-ordering"
v-model="ordering" v-model="ordering"
class="ui dropdown" class="dropdown"
> >
<option <option
v-for="option in orderingOptions" v-for="option in orderingOptions"
@ -150,32 +167,42 @@ const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value]
{{ sharedLabels.filters[option[1]] }} {{ sharedLabels.filters[option[1]] }}
</option> </option>
</select> </select>
</div> </Layout>
<div class="field"> <Layout
<label for="favorites-ordering-direction"> stack
{{ $t('components.favorites.List.ordering.direction.label') }} no-gap
</label> label
for="favorites-ordering-direction"
>
<span class="label">
{{ t('components.favorites.List.ordering.direction.label') }}
</span>
<select <select
id="favorites-ordering-direction" id="favorites-ordering-direction"
v-model="orderingDirection" v-model="orderingDirection"
class="ui dropdown" class="dropdown"
> >
<option value="+"> <option value="+">
{{ $t('components.favorites.List.ordering.direction.ascending') }} {{ t('components.favorites.List.ordering.direction.ascending') }}
</option> </option>
<option value="-"> <option value="-">
{{ $t('components.favorites.List.ordering.direction.descending') }} {{ t('components.favorites.List.ordering.direction.descending') }}
</option> </option>
</select> </select>
</div> </Layout>
<div class="field"> <Layout
<label for="favorites-results"> stack
{{ $t('components.favorites.List.pagination.results') }} no-gap
</label> label
for="favorites-results"
>
<span class="label">
{{ t('components.favorites.List.pagination.results') }}
</span>
<select <select
id="favorites-results" id="favorites-results"
v-model="paginateBy" v-model="paginateBy"
class="ui dropdown" class="dropdown"
> >
<option <option
v-for="opt in paginateOptions" v-for="opt in paginateOptions"
@ -185,39 +212,42 @@ const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value]
{{ opt }} {{ opt }}
</option> </option>
</select> </select>
</div> </Layout>
</div> </Layout>
</div> <Pagination
<track-table v-if="page && results && count > paginateBy"
v-model:page="page"
:pages="Math.ceil((count || 0) / paginateBy)"
style="grid-column: 1 / -1;"
/>
<TrackTable
v-if="results" v-if="results"
:search="true"
:show-artist="true" :show-artist="true"
:show-album="true" :show-album="true"
:tracks="results" :tracks="results"
/> />
<div class="ui center aligned basic segment"> </Layout>
<pagination <Alert
v-if="results && count > paginateBy" v-else-if="!isLoading"
v-model:current="page" blue
:paginate-by="paginateBy" align-items="center"
:total="count" >
<i
class="bi bi-heartbreak-fill"
style="font-size: 100px;"
/> />
</div> <Spacer />
</section> {{ t('components.favorites.List.empty.noFavorites') }}
<div <Spacer :size="32" />
v-else <Link
class="ui placeholder segment" to="/library"
solid
primary
icon="bi-headphones"
> >
<div class="ui icon header"> {{ t('components.favorites.List.link.library') }}
<i class="broken heart icon" /> </Link>
{{ $t('components.favorites.List.empty.noFavorites') }} </Alert>
</div> </Layout>
<router-link
:to="'/library'"
class="ui success labeled icon button"
>
<i class="headphones icon" />
{{ $t('components.favorites.List.link.library') }}
</router-link>
</div>
</main>
</template> </template>

View File

@ -6,16 +6,20 @@ import { useI18n } from 'vue-i18n'
import { useStore } from '~/store' import { useStore } from '~/store'
import { computed } from 'vue' import { computed } from 'vue'
import Button from '~/components/ui/Button.vue'
interface Props { interface Props {
track?: QueueTrack | Track track?: QueueTrack | Track
button?: boolean button?: boolean
border?: boolean border?: boolean
ghost?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
track: () => ({} as Track), track: () => ({} as Track),
button: false, button: false,
border: false border: false,
ghost: false
}) })
const { t } = useI18n() const { t } = useI18n()
@ -29,26 +33,27 @@ const title = computed(() => isFavorite.value
</script> </script>
<template> <template>
<button <Button
v-if="button" v-if="button"
:icon="isFavorite ? 'bi-heart-fill' : 'bi-heart'"
:class="['ui', 'pink', {'inverted': isFavorite}, {'favorited': isFavorite}, 'icon', 'labeled', 'button']" :class="['ui', 'pink', {'inverted': isFavorite}, {'favorited': isFavorite}, 'icon', 'labeled', 'button']"
@click.stop="$store.dispatch('favorites/toggle', track.id)" @click.stop="store.dispatch('favorites/toggle', track.id)"
> >
<i class="heart icon" />
<span v-if="isFavorite"> <span v-if="isFavorite">
{{ $t('components.favorites.TrackFavoriteIcon.label.inFavorites') }} {{ t('components.favorites.TrackFavoriteIcon.label.inFavorites') }}
</span> </span>
<span v-else> <span v-else>
{{ $t('components.favorites.TrackFavoriteIcon.button.add') }} {{ t('components.favorites.TrackFavoriteIcon.button.add') }}
</span> </span>
</button> </Button>
<button <Button
v-else v-else
:ghost="ghost"
:secondary="!ghost"
:icon="isFavorite ? 'bi-heart-fill' : 'bi-heart'"
:class="['ui', 'favorite-icon', {'pink': isFavorite}, {'favorited': isFavorite}, 'basic', 'circular', 'icon', {'really': !border}, 'button']" :class="['ui', 'favorite-icon', {'pink': isFavorite}, {'favorited': isFavorite}, 'basic', 'circular', 'icon', {'really': !border}, 'button']"
:aria-label="title" :aria-label="title"
:title="title" :title="title"
@click.stop="$store.dispatch('favorites/toggle', track.id)" @click.stop="store.dispatch('favorites/toggle', track.id)"
> />
<i :class="['heart', {'pink': isFavorite}, 'basic', 'icon']" />
</button>
</template> </template>

View File

@ -2,10 +2,17 @@
import type { BackendError } from '~/types' import type { BackendError } from '~/types'
import axios from 'axios' import axios from 'axios'
import SemanticModal from '~/components/semantic/Modal.vue'
import { useTimeoutFn } from '@vueuse/core' import { useTimeoutFn } from '@vueuse/core'
import { ref } from 'vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Modal from '~/components/ui/Modal.vue'
import Button from '~/components/ui/Button.vue'
const { t } = useI18n()
interface Events { interface Events {
(e: 'refresh'): void (e: 'refresh'): void
} }
@ -68,20 +75,22 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
</script> </script>
<template> <template>
<div <Button
role="button" secondary
icon="bi-arrow-clockwise"
:loading="isLoading"
low-height
@click="fetch" @click="fetch"
> >
<div> <div>
<slot /> <slot />
</div> </div>
<semantic-modal <Modal
v-model:show="showModal" v-model="showModal"
:title="t('components.federation.FetchButton.header.refresh')"
class="small" class="small"
:cancel="t('components.federation.FetchButton.button.close')"
> >
<h3 class="header">
{{ $t('components.federation.FetchButton.header.refresh') }}
</h3>
<div class="scrolling content"> <div class="scrolling content">
<template v-if="data && data.status != 'pending'"> <template v-if="data && data.status != 'pending'">
<div <div
@ -89,10 +98,10 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
class="ui message" class="ui message"
> >
<h4 class="header"> <h4 class="header">
{{ $t('components.federation.FetchButton.header.skipped') }} {{ t('components.federation.FetchButton.header.skipped') }}
</h4> </h4>
<p> <p>
{{ $t('components.federation.FetchButton.description.skipped') }} {{ t('components.federation.FetchButton.description.skipped') }}
</p> </p>
</div> </div>
<div <div
@ -100,10 +109,10 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
class="ui success message" class="ui success message"
> >
<h4 class="header"> <h4 class="header">
{{ $t('components.federation.FetchButton.header.success') }} {{ t('components.federation.FetchButton.header.success') }}
</h4> </h4>
<p> <p>
{{ $t('components.federation.FetchButton.description.success') }} {{ t('components.federation.FetchButton.description.success') }}
</p> </p>
</div> </div>
<div <div
@ -111,16 +120,16 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
class="ui error message" class="ui error message"
> >
<h4 class="header"> <h4 class="header">
{{ $t('components.federation.FetchButton.header.failure') }} {{ t('components.federation.FetchButton.header.failure') }}
</h4> </h4>
<p> <p>
{{ $t('components.federation.FetchButton.description.failure') }} {{ t('components.federation.FetchButton.description.failure') }}
</p> </p>
<table class="ui very basic collapsing celled table"> <table class="ui very basic collapsing celled table">
<tbody> <tbody>
<tr> <tr>
<td> <td>
{{ $t('components.federation.FetchButton.table.error.label.type') }} {{ t('components.federation.FetchButton.table.error.label.type') }}
</td> </td>
<td> <td>
{{ data.detail.error_code }} {{ data.detail.error_code }}
@ -128,32 +137,32 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
</tr> </tr>
<tr> <tr>
<td> <td>
{{ $t('components.federation.FetchButton.table.error.label.detail') }} {{ t('components.federation.FetchButton.table.error.label.detail') }}
</td> </td>
<td> <td>
<span v-if="data.detail.error_code === 'http' && data.detail.status_code"> <span v-if="data.detail.error_code === 'http' && data.detail.status_code">
{{ $t('components.federation.FetchButton.table.error.value.httpStatus', {status: data.detail.status_code}) }} {{ t('components.federation.FetchButton.table.error.value.httpStatus', {status: data.detail.status_code}) }}
</span> </span>
<span v-else-if="['http', 'request'].includes(data.detail.error_code)"> <span v-else-if="['http', 'request'].includes(data.detail.error_code)">
{{ $t('components.federation.FetchButton.table.error.value.httpError') }} {{ t('components.federation.FetchButton.table.error.value.httpError') }}
</span> </span>
<span v-else-if="data.detail.error_code === 'timeout'"> <span v-else-if="data.detail.error_code === 'timeout'">
{{ $t('components.federation.FetchButton.table.error.value.timeoutError') }} {{ t('components.federation.FetchButton.table.error.value.timeoutError') }}
</span> </span>
<span v-else-if="data.detail.error_code === 'connection'"> <span v-else-if="data.detail.error_code === 'connection'">
{{ $t('components.federation.FetchButton.table.error.value.connectionError') }} {{ t('components.federation.FetchButton.table.error.value.connectionError') }}
</span> </span>
<span v-else-if="['invalid_json', 'invalid_jsonld', 'missing_jsonld_type'].includes(data.detail.error_code)"> <span v-else-if="['invalid_json', 'invalid_jsonld', 'missing_jsonld_type'].includes(data.detail.error_code)">
{{ $t('components.federation.FetchButton.table.error.value.invalidJsonError') }} {{ t('components.federation.FetchButton.table.error.value.invalidJsonError') }}
</span> </span>
<span v-else-if="data.detail.error_code === 'validation'"> <span v-else-if="data.detail.error_code === 'validation'">
{{ $t('components.federation.FetchButton.table.error.value.invalidAttributesError') }} {{ t('components.federation.FetchButton.table.error.value.invalidAttributesError') }}
</span> </span>
<span v-else-if="data.detail.error_code === 'unhandled'"> <span v-else-if="data.detail.error_code === 'unhandled'">
{{ $t('components.federation.FetchButton.table.error.value.unknownError') }} {{ t('components.federation.FetchButton.table.error.value.unknownError') }}
</span> </span>
<span v-else> <span v-else>
{{ $t('components.federation.FetchButton.table.error.value.unknownError') }} {{ t('components.federation.FetchButton.table.error.value.unknownError') }}
</span> </span>
</td> </td>
</tr> </tr>
@ -166,7 +175,7 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
class="ui active inverted dimmer" class="ui active inverted dimmer"
> >
<div class="ui text loader"> <div class="ui text loader">
{{ $t('components.federation.FetchButton.loader.fetchRequest') }} {{ t('components.federation.FetchButton.loader.fetchRequest') }}
</div> </div>
</div> </div>
<div <div
@ -174,7 +183,7 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
class="ui active inverted dimmer" class="ui active inverted dimmer"
> >
<div class="ui text loader"> <div class="ui text loader">
{{ $t('components.federation.FetchButton.loader.awaitingResult') }} {{ t('components.federation.FetchButton.loader.awaitingResult') }}
</div> </div>
</div> </div>
<div <div
@ -183,7 +192,7 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
class="ui negative message" class="ui negative message"
> >
<h4 class="header"> <h4 class="header">
{{ $t('components.federation.FetchButton.header.saveFailure') }} {{ t('components.federation.FetchButton.header.saveFailure') }}
</h4> </h4>
<ul class="list"> <ul class="list">
<li <li
@ -200,25 +209,21 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
class="ui warning message" class="ui warning message"
> >
<h4 class="header"> <h4 class="header">
{{ $t('components.federation.FetchButton.header.pending') }} {{ t('components.federation.FetchButton.header.pending') }}
</h4> </h4>
<p> <p>
{{ $t('components.federation.FetchButton.description.pending') }} {{ t('components.federation.FetchButton.description.pending') }}
</p> </p>
</div> </div>
</div> </div>
<div class="actions"> <div class="actions">
<button class="ui basic cancel button"> <Button
{{ $t('components.federation.FetchButton.button.close') }}
</button>
<button
v-if="data && data.status === 'finished'" v-if="data && data.status === 'finished'"
class="ui confirm success button"
@click.prevent="showModal = false; emit('refresh')" @click.prevent="showModal = false; emit('refresh')"
> >
{{ $t('components.federation.FetchButton.button.reload') }} {{ t('components.federation.FetchButton.button.reload') }}
</button> </Button>
</div>
</semantic-modal>
</div> </div>
</Modal>
</Button>
</template> </template>

View File

@ -1,11 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Library } from '~/types' import type { Library } from '~/types'
import { ref, reactive } from 'vue' import { ref, reactive, onMounted, watch } from 'vue'
import { useStore } from '~/store'
import { useI18n } from 'vue-i18n'
import axios from 'axios' import axios from 'axios'
import LibraryCard from '~/views/content/remote/Card.vue' import LibraryCard from '~/views/content/remote/Card.vue'
import Button from '~/components/ui/Button.vue'
import Section from '~/components/ui/Section.vue'
import Loader from '~/components/ui/Loader.vue'
import Alert from '~/components/ui/Alert.vue'
import Spacer from '~/components/ui/Spacer.vue'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
@ -15,8 +22,12 @@ interface Events {
interface Props { interface Props {
url: string url: string
title?: string
} }
const { t } = useI18n()
const store = useStore()
const emit = defineEmits<Events>() const emit = defineEmits<Events>()
const props = defineProps<Props>() const props = defineProps<Props>()
@ -29,12 +40,12 @@ const fetchData = async (url = props.url) => {
try { try {
const response = await axios.get(url, { const response = await axios.get(url, {
params: { params: {
page_size: 6 page_size: 3
} }
}) })
nextPage.value = response.data.next nextPage.value = response.data.next
libraries.push(...response.data.results) libraries.splice(0, libraries.length, ...response.data.results)
emit('loaded', libraries) emit('loaded', libraries)
} catch (error) { } catch (error) {
useErrorHandler(error as Error) useErrorHandler(error as Error)
@ -43,55 +54,49 @@ const fetchData = async (url = props.url) => {
isLoading.value = false isLoading.value = false
} }
onMounted(() => {
setTimeout(fetchData, 1000)
})
watch(() => props.url, () => {
fetchData() fetchData()
})
</script> </script>
<template> <template>
<div class="wrapper"> <Section
<h3 align-left
v-if="!!$slots.title" :h2="title"
class="ui header" :columns-per-item="3"
> >
<slot name="title" /> <Loader
</h3>
<p
v-if="!isLoading && libraries.length > 0"
class="ui subtitle"
>
<slot />
</p>
<p
v-if="!isLoading && libraries.length === 0"
class="ui subtitle"
>
{{ $t('components.federation.LibraryWidget.empty.noMatch') }}
</p>
<div class="ui hidden divider" />
<div class="ui cards">
<div
v-if="isLoading" v-if="isLoading"
class="ui inverted active dimmer" style="grid-column: 1 / -1;"
/>
<Alert
v-if="!isLoading && libraries.length === 0"
blue
style="grid-column: 1 / -1;"
> >
<div class="ui loader" /> {{ t('components.federation.LibraryWidget.empty.noMatch') }}
</div> </Alert>
<library-card <library-card
v-for="library in libraries" v-for="library in libraries"
:key="library.uuid" :key="library.uuid"
:display-scan="false" :display-scan="false"
:display-follow="$store.state.auth.authenticated && library.actor.full_username != $store.state.auth.fullUsername" :display-follow="store.state.auth.authenticated && library.actor.full_username != store.state.auth.fullUsername"
:initial-library="library" :initial-library="library"
:display-copy-fid="true" :display-copy-fid="true"
/> />
</div>
<template v-if="nextPage"> <template v-if="nextPage">
<div class="ui hidden divider" /> <Spacer />
<button <Button
v-if="nextPage" v-if="nextPage"
:class="['ui', 'basic', 'button']" primary
@click="fetchData(nextPage)" @click="fetchData(nextPage)"
> >
{{ $t('components.federation.LibraryWidget.button.showMore') }} {{ t('components.federation.LibraryWidget.button.showMore') }}
</button> </Button>
</template> </template>
</div> </Section>
</template> </template>

View File

@ -1,16 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Actor } from '~/types' import type { components } from '~/generated/types'
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useStore } from '~/store' import { useStore } from '~/store'
import Button from '~/components/ui/Button.vue'
const { t } = useI18n()
interface Events { interface Events {
(e: 'unfollowed'): void (e: 'unfollowed'): void
(e: 'followed'): void (e: 'followed'): void
} }
interface Props { interface Props {
actor: Actor actor: components['schemas']['FullActor']
} }
const emit = defineEmits<Events>() const emit = defineEmits<Events>()
@ -33,19 +38,20 @@ const toggle = () => {
</script> </script>
<template> <template>
<button <Button
secondary
:class="['ui', 'pink', {'inverted': isApproved || isPending}, {'favorited': isApproved}, 'icon', 'labeled', 'button']" :class="['ui', 'pink', {'inverted': isApproved || isPending}, {'favorited': isApproved}, 'icon', 'labeled', 'button']"
:icon="isPending ? 'bi-heart' : 'bi-heart-fill'"
@click.stop="toggle" @click.stop="toggle"
> >
<i class="heart icon" />
<span v-if="isApproved"> <span v-if="isApproved">
{{ $t('components.audio.LibraryFollowButton.button.unfollow') }} {{ t('components.audio.LibraryFollowButton.button.unfollow') }}
</span> </span>
<span v-else-if="isPending"> <span v-else-if="isPending">
{{ $t('components.audio.LibraryFollowButton.button.cancel') }} {{ t('components.audio.LibraryFollowButton.button.cancel') }}
</span> </span>
<span v-else> <span v-else>
{{ $t('components.audio.LibraryFollowButton.button.follow') }} {{ t('components.audio.LibraryFollowButton.button.follow') }}
</span> </span>
</button> </button>
</template> </template>

View File

@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useClipboard, useVModel } from '@vueuse/core' import { useClipboard, useVModel } from '@vueuse/core'
import { useStore } from '~/store' import { useStore } from '~/store'
import Input from '~/components/ui/Input.vue'
interface Events { interface Events {
(e: 'update:modelValue', value: string): void (e: 'update:modelValue', value: string): void
} }
@ -23,16 +25,12 @@ const props = withDefaults(defineProps<Props>(), {
const value = useVModel(props, 'modelValue', emit) const value = useVModel(props, 'modelValue', emit)
const showPassword = ref(props.defaultShow)
const { t } = useI18n() const { t } = useI18n()
const labels = computed(() => ({ const labels = computed(() => ({
title: t('components.forms.PasswordInput.title'), title: t('components.forms.PasswordInput.title'),
copy: t('components.forms.PasswordInput.button.copy') copy: t('components.forms.PasswordInput.button.copy')
})) }))
const passwordInputType = computed(() => showPassword.value ? 'text' : 'password')
const store = useStore() const store = useStore()
const { isSupported: canCopy, copy } = useClipboard({ source: value }) const { isSupported: canCopy, copy } = useClipboard({ source: value })
const copyPassword = () => { const copyPassword = () => {
@ -45,30 +43,38 @@ const copyPassword = () => {
</script> </script>
<template> <template>
<div class="ui fluid action input"> <div>
<input <Input
:id="fieldId" :id="fieldId"
v-model="value" v-model="value"
password
required required
name="password"
:type="passwordInputType"
> >
<button <template #input-right>
type="button"
:title="labels.title"
class="ui icon button"
@click.prevent="showPassword = !showPassword"
>
<i class="eye icon" />
</button>
<button <button
v-if="copyButton && canCopy" v-if="copyButton && canCopy"
role="switch"
type="button" type="button"
class="ui icon button" class="input-right copy"
:title="labels.copy" :title="labels.copy"
@click.prevent="copyPassword" @click.prevent="copyPassword"
> >
<i class="copy icon" /> <i class="bi bi-copy" />
</button> </button>
</template>
</Input>
</div> </div>
</template> </template>
<style lang="scss" scoped>
.funkwhale.input .input-right.copy {
position: absolute;
background:transparent;
border:none;
appearance:none;
right: 40px;
bottom: 12px;
font-size: 18px;
color: var(--fw-placeholder-color);
}
</style>

View File

@ -4,15 +4,24 @@ import type { Track, Album, Artist, Library, ArtistCredit } from '~/types'
import { momentFormat } from '~/utils/filters' import { momentFormat } from '~/utils/filters'
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { sum } from 'lodash-es' import { sum } from 'lodash-es'
import { useStore } from '~/store'
import { useQueue } from '~/composables/audio/queue'
import axios from 'axios' import axios from 'axios'
import ArtistCreditLabel from '~/components/audio/ArtistCreditLabel.vue' import ArtistCreditLabel from '~/components/audio/ArtistCreditLabel.vue'
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
import PlayButton from '~/components/audio/PlayButton.vue' import PlayButton from '~/components/audio/PlayButton.vue'
import TagsList from '~/components/tags/List.vue'
import AlbumDropdown from './AlbumDropdown.vue' import AlbumDropdown from './AlbumDropdown.vue'
import Layout from '~/components/ui/Layout.vue'
import Header from '~/components/ui/Header.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Loader from '~/components/ui/Loader.vue'
import Button from '~/components/ui/Button.vue'
import HumanDuration from '~/components/common/HumanDuration.vue'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
import useLogger from '~/composables/useLogger' import useLogger from '~/composables/useLogger'
@ -22,9 +31,11 @@ interface Events {
} }
interface Props { interface Props {
id: number id: number | string
} }
const store = useStore()
const emit = defineEmits<Events>() const emit = defineEmits<Events>()
const props = defineProps<Props>() const props = defineProps<Props>()
@ -51,10 +62,16 @@ const publicLibraries = computed(() => libraries.value?.filter(library => librar
const logger = useLogger() const logger = useLogger()
const { t } = useI18n() const { t } = useI18n()
const labels = computed(() => ({ const labels = computed(() => ({
title: t('components.library.AlbumBase.title') title: t('components.library.AlbumBase.title'),
shuffle: t('components.audio.Player.label.shuffleQueue')
})) }))
const {
shuffle
} = useQueue()
const isLoading = ref(false) const isLoading = ref(false)
const fetchData = async () => { const fetchData = async () => {
isLoading.value = true isLoading.value = true
@ -63,12 +80,10 @@ const fetchData = async () => {
artistCredit.value = albumResponse.data.artist_credit artistCredit.value = albumResponse.data.artist_credit
// fetch the first artist of the album
const artistResponse = await axios.get(`artists/${albumResponse.data.artist_credit[0].artist.id}/`) const artistResponse = await axios.get(`artists/${albumResponse.data.artist_credit[0].artist.id}/`)
artist.value = artistResponse.data artist.value = artistResponse.data
if (artist.value?.channel) {
artist.value.channel.artist = artist.value
}
object.value = albumResponse.data object.value = albumResponse.data
if (object.value) { if (object.value) {
@ -116,6 +131,8 @@ const fetchTracks = async () => {
watch(() => props.id, fetchData, { immediate: true }) watch(() => props.id, fetchData, { immediate: true })
const router = useRouter() const router = useRouter()
const route = useRoute()
const remove = async () => { const remove = async () => {
isLoading.value = true isLoading.value = true
try { try {
@ -131,32 +148,20 @@ const remove = async () => {
</script> </script>
<template> <template>
<main> <Loader
<div
v-if="isLoading" v-if="isLoading"
v-title="labels.title" v-title="labels.title"
class="ui vertical segment" />
<Header
v-if="object"
:h1="object.title"
page-heading
> >
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" /> <template #image>
</div>
<template v-if="object">
<section class="ui vertical stripe segment channel-serie">
<div class="ui stackable grid container">
<div class="ui seven wide column">
<div
v-if="isSerie"
class="padded basic segment"
>
<div
v-if="isSerie"
class="ui two column grid"
>
<div class="column">
<div class="large two-images">
<img <img
v-if="object.cover && object.cover.urls.original" v-if="object.cover && object.cover.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](object.cover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](object.cover.urls.large_square_crop)"
alt="" :alt="object.title"
class="channel-image" class="channel-image"
> >
<img <img
@ -165,129 +170,95 @@ const remove = async () => {
class="channel-image" class="channel-image"
src="../../assets/audio/default-cover.png" src="../../assets/audio/default-cover.png"
> >
<img
v-if="object.cover && object.cover.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](object.cover.urls.medium_square_crop)"
alt=""
class="channel-image"
>
<img
v-else
alt=""
class="channel-image"
src="../../assets/audio/default-cover.png"
>
</div>
</div>
<div class="ui column right aligned">
<tags-list
v-if="object.tags && object.tags.length > 0"
:tags="object.tags"
/>
<div class="ui small hidden divider" />
<human-duration
v-if="totalDuration > 0"
:duration="totalDuration"
/>
<template v-if="totalTracks > 0">
<div class="ui hidden very small divider" />
<span v-if="isSerie">
{{ $t('components.library.AlbumBase.meta.episodes', totalTracks) }}
</span>
<span v-else>
{{ $t('components.library.AlbumBase.meta.tracks', totalTracks) }}
</span>
</template> </template>
<div class="ui small hidden divider" />
<play-button
class="vibrant"
:tracks="object.tracks"
:is-playable="object.is_playable"
/>
<div class="ui hidden horizontal divider" />
<album-dropdown
:object="object"
:public-libraries="publicLibraries"
:is-loading="isLoading"
:is-album="isAlbum"
:is-serie="isSerie"
:is-channel="isChannel"
:artist-credit="artistCredit"
@remove="remove"
/>
</div>
</div>
<div class="ui small hidden divider" />
<header>
<h2
class="ui header"
:title="object.title"
>
{{ object.title }}
</h2>
<artist-credit-label <artist-credit-label
v-if="artistCredit" v-if="artistCredit"
:artist-credit="artistCredit" :artist-credit="artistCredit"
/> />
</header> <!-- Metadata: -->
</div> <Layout
<div gap-4
v-else class="meta"
class="ui center aligned text padded basic segment"
> >
<img <Layout
v-if="object.cover && object.cover.urls.original" flex
v-lazy="$store.getters['instance/absoluteUrl'](object.cover.urls.medium_square_crop)" gap-4
alt=""
class="channel-image"
> >
<img
v-else
alt=""
class="channel-image"
src="../../assets/audio/default-cover.png"
>
<div class="ui hidden divider" />
<header>
<h2
class="ui header"
:title="object.title"
>
{{ object.title }}
</h2>
<artist-credit-label
v-if="artistCredit"
:artist-credit="artistCredit"
/>
</header>
<div
v-if="object.release_date || (totalTracks > 0)"
class="ui small hidden divider"
/>
<template v-if="object.release_date"> <template v-if="object.release_date">
{{ momentFormat(new Date(object.release_date ?? '1970-01-01'), 'Y') }} {{ momentFormat(new Date(object.release_date ?? '1970-01-01'), 'Y') }}
<span class="middle middledot symbol" /> <i class="bi bi-dot" />
</template> </template>
<template v-if="totalTracks > 0"> <template v-if="totalTracks > 0">
<span v-if="isSerie"> <span v-if="isSerie">
{{ $t('components.library.AlbumBase.meta.episodes', totalTracks) }} {{ t('components.library.AlbumBase.meta.episodes', totalTracks) }}
</span> </span>
<span v-else> <span v-else>
{{ $t('components.library.AlbumBase.meta.tracks', totalTracks) }} {{ t('components.library.AlbumBase.meta.tracks', totalTracks) }}
</span> </span>
<span class="middle middledot symbol" />
</template> </template>
<i
v-if="totalDuration > 0"
class="bi bi-dot"
/>
<human-duration <human-duration
v-if="totalDuration > 0" v-if="totalDuration > 0"
:duration="totalDuration" :duration="totalDuration"
/> />
<div class="ui small hidden divider" /> <!--TODO: License -->
<play-button </Layout>
class="vibrant" </Layout>
:album="object" <RenderedDescription
v-if="object.description"
:content="{ html: object.description.html }"
:truncate-length="50"
/>
<Layout flex>
<PlayButton
v-if="object.tracks"
split
:tracks="object.tracks"
low-height
:is-playable="object.is_playable" :is-playable="object.is_playable"
/> />
<div class="ui horizontal hidden divider" /> <Button
v-if="object?.tracks?.length && object?.tracks?.length > 2"
primary
icon="bi-shuffle"
low-height
:aria-label="labels.shuffle"
@click.prevent.stop="shuffle()"
>
{{ labels.shuffle }}
</Button>
<DangerousButton
v-if="artistCredit[0] &&
store.state.auth.authenticated &&
artistCredit[0].artist.channel
/* TODO: Re-implement once attributed_to is not only a number
&& artistCredit[0].artist.attributed_to?.full_username === store.state.auth.fullUsername
*/"
:is-loading="isLoading"
low-height
icon="bi-trash"
@confirm="remove()"
>
{{ t('components.library.AlbumDropdown.button.delete') }}
</DangerousButton>
<Spacer
h
grow
/>
<TrackFavoriteIcon
v-if="store.state.auth.authenticated"
square-small
:album="object"
/>
<TrackPlaylistIcon
v-if="store.state.auth.authenticated"
square-small
:album="object"
/>
<!-- TODO: Share Button -->
<album-dropdown <album-dropdown
:object="object" :object="object"
:public-libraries="publicLibraries" :public-libraries="publicLibraries"
@ -298,48 +269,13 @@ const remove = async () => {
:artist-credit="artistCredit" :artist-credit="artistCredit"
@remove="remove" @remove="remove"
/> />
<div v-if="(object.tags && object.tags.length > 0) || object.description || $store.state.auth.authenticated && object.is_local"> </Layout>
<div class="ui small hidden divider" /> </Header>
<div class="ui divider" />
<div class="ui small hidden divider" /> <div style="flex 1;">
<template v-if="object.tags && object.tags.length > 0">
<tags-list :tags="object.tags" />
<div class="ui small hidden divider" />
</template>
<rendered-description
v-if="object.description"
:content="object.description"
:can-update="false"
/>
<router-link
v-else-if="$store.state.auth.authenticated && object.is_local"
:to="{name: 'library.albums.edit', params: {id: object.id }}"
>
<i class="pencil icon" />
{{ $t('components.library.AlbumBase.link.addDescription') }}
</router-link>
</div>
</div>
<template v-if="isSerie">
<div class="ui hidden divider" />
<rendered-description
v-if="object.description"
:content="object.description"
:can-update="false"
/>
<router-link
v-else-if="$store.state.auth.authenticated && object.is_local"
:to="{name: 'library.albums.edit', params: {id: object.id }}"
>
<i class="pencil icon" />
{{ $t('components.library.AlbumBase.link.addDescription') }}
</router-link>
</template>
</div>
<div class="nine wide column">
<router-view <router-view
v-if="object" v-if="object"
:key="$route.fullPath" :key="route.fullPath"
:paginate-by="paginateBy" :paginate-by="paginateBy"
:total-tracks="totalTracks" :total-tracks="totalTracks"
:is-serie="isSerie" :is-serie="isSerie"
@ -350,8 +286,16 @@ const remove = async () => {
@libraries-loaded="libraries = $event" @libraries-loaded="libraries = $event"
/> />
</div> </div>
</div>
</section>
</template>
</main>
</template> </template>
<style scoped lang="scss">
.meta {
font-size: 15px;
@include light-theme {
color: var(--fw-gray-700);
}
@include dark-theme {
color: var(--fw-gray-500);
}
}
</style>

View File

@ -5,8 +5,13 @@ import LibraryWidget from '~/components/federation/LibraryWidget.vue'
import ChannelEntries from '~/components/audio/ChannelEntries.vue' import ChannelEntries from '~/components/audio/ChannelEntries.vue'
import TrackTable from '~/components/audio/track/Table.vue' import TrackTable from '~/components/audio/track/Table.vue'
import PlayButton from '~/components/audio/PlayButton.vue' import PlayButton from '~/components/audio/PlayButton.vue'
import TagsList from '~/components/tags/List.vue'
import Pagination from '~/components/vui/Pagination.vue' import Pagination from '~/components/vui/Pagination.vue'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Loader from '~/components/ui/Loader.vue'
import Spacer from '~/components/ui/Spacer.vue'
interface Events { interface Events {
(e: 'libraries-loaded', libraries: Library[]): void (e: 'libraries-loaded', libraries: Library[]): void
@ -24,26 +29,25 @@ interface Props {
const emit = defineEmits<Events>() const emit = defineEmits<Events>()
const props = defineProps<Props>() const props = defineProps<Props>()
const { t } = useI18n()
const getDiscKey = (disc: Track[]) => disc?.map(track => track.id).join('|') ?? '' const getDiscKey = (disc: Track[]) => disc?.map(track => track.id).join('|') ?? ''
const page = ref(1) const page = ref(1)
const discCount = computed(() => props.object.tracks.reduce((acc, track) => { const discCount = computed(() => props.object?.tracks?.reduce((acc, track) => {
acc.add(track.disc_number) acc.add(track.disc_number)
return acc return acc
}, new Set()).size) }, new Set()).size)
const discs = computed(() => props.object.tracks const discs = computed(() => props.object?.tracks?.reduce((acc: Track[][], track: Track) => {
.reduce((acc: Track[][], track: Track) => { const discNumber = track.disc_number - (props.object?.tracks?.[0]?.disc_number ?? 1)
const discNumber = track.disc_number - (props.object.tracks[0]?.disc_number ?? 1)
acc[discNumber].push(track) acc[discNumber].push(track)
return acc return acc
}, Array(discCount.value).fill(undefined).map(() => [])) }, Array(discCount.value).fill(undefined).map(() => [])))
)
const paginatedDiscs = computed(() => props.object.tracks.slice(props.paginateBy * (page.value - 1), props.paginateBy * page.value) const paginatedDiscs = computed(() => props.object?.tracks?.slice(props.paginateBy * (page.value - 1), props.paginateBy * page.value)
.reduce((acc: Track[][], track: Track) => { .reduce((acc: Track[][], track: Track) => {
const discNumber = track.disc_number - (props.object.tracks[0]?.disc_number ?? 1) const discNumber = track.disc_number - (props.object?.tracks?.[0]?.disc_number ?? 1)
acc[discNumber].push(track) acc[discNumber].push(track)
return acc return acc
}, Array(discCount.value).fill(undefined).map(() => [])) }, Array(discCount.value).fill(undefined).map(() => []))
@ -54,38 +58,45 @@ const paginatedDiscs = computed(() => props.object.tracks.slice(props.paginateBy
<template> <template>
<div <div
v-if="!isLoadingTracks" v-if="!isLoadingTracks"
class="ui vertical segment"
> >
<h2 class="ui header"> <h2 class="ui header">
<span v-if="isSerie"> <span v-if="isSerie">
{{ $t('components.library.AlbumDetail.header.episodes') }} {{ t('components.library.AlbumDetail.header.episodes') }}
</span> </span>
<span v-else> <span v-else>
{{ $t('components.library.AlbumDetail.header.tracks') }} {{ t('components.library.AlbumDetail.header.tracks') }}
</span> </span>
</h2> </h2>
<channel-entries <channel-entries
v-if="artistCredit && artistCredit[0].artist.channel && isSerie" v-if="artistCredit && artistCredit[0].artist.channel && isSerie"
:default-cover="null"
:is-podcast="isSerie" :is-podcast="isSerie"
:limit="50" :limit="50"
:filters="{channel: artistCredit[0].artist.channel.uuid, album: object.id, ordering: '-creation_date'}" :filters="{channel: artistCredit[0].artist.channel, album: object.id, ordering: '-creation_date'}"
/> />
<Loader v-if="isLoadingTracks" />
<template v-else> <template v-else>
<template v-if="discCount > 1"> <TagsList
v-if="object.tags && object.tags.length > 0"
style="margin-top: -16px;"
:tags="object.tags"
/>
<template v-if="(discCount || 0) > 1">
<div <div
v-for="tracks, index in paginatedDiscs" v-for="tracks, index in paginatedDiscs"
:key="index + getDiscKey(tracks)" :key="index + getDiscKey(tracks)"
> >
<template v-if="tracks.length > 0"> <template v-if="tracks.length > 0">
<div class="ui hidden divider" /> <div class="ui hidden divider" />
<play-button <PlayButton
class="right floated mini inverted vibrant" class="right floated mini inverted vibrant"
:tracks="discs[index]" :tracks="discs ? discs[index] : []"
/> />
<h3> <h3>
{{ $t('components.library.AlbumDetail.meta.volume', {number: tracks[0].disc_number}) }} {{ t('components.library.AlbumDetail.meta.volume', { number: tracks[0].disc_number }) }}
</h3> </h3>
<track-table <track-table
:is-album="true" :is-album="true"
@ -99,7 +110,7 @@ const paginatedDiscs = computed(() => props.object.tracks.slice(props.paginateBy
</template> </template>
</div> </div>
</template> </template>
<template v-else> <template v-else-if="object.tracks">
<track-table <track-table
:is-album="true" :is-album="true"
:tracks="object.tracks" :tracks="object.tracks"
@ -124,14 +135,13 @@ const paginatedDiscs = computed(() => props.object.tracks.slice(props.paginateBy
</template> </template>
<template v-if="artistCredit && !artistCredit[0]?.artist.channel && !isSerie"> <template v-if="artistCredit && !artistCredit[0]?.artist.channel && !isSerie">
<h2> <Spacer />
{{ $t('components.library.AlbumDetail.header.libraries') }}
</h2>
<library-widget <library-widget
:url="'albums/' + object.id + '/libraries/'" :url="'albums/' + object.id + '/libraries/'"
:title="t('components.library.AlbumDetail.header.libraries')"
@loaded="emit('libraries-loaded', $event)" @loaded="emit('libraries-loaded', $event)"
> >
{{ $t('components.library.AlbumDetail.description.libraries') }} {{ t('components.library.AlbumDetail.description.libraries') }}
</library-widget> </library-widget>
</template> </template>
</div> </div>

View File

@ -3,17 +3,18 @@ import type { Album, ArtistCredit, Library } from '~/types'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useStore } from '~/store'
import { getDomain } from '~/utils' import { getDomain } from '~/utils'
import useReport from '~/composables/moderation/useReport' import useReport from '~/composables/moderation/useReport'
import EmbedWizard from '~/components/audio/EmbedWizard.vue' import EmbedWizard from '~/components/audio/EmbedWizard.vue'
import SemanticModal from '~/components/semantic/Modal.vue'
interface Events { import Modal from '~/components/ui/Modal.vue'
(e: 'remove'): void import Popover from '~/components/ui/Popover.vue'
} import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
import OptionsButton from '~/components/ui/button/Options.vue'
interface Props { interface Props {
isLoading: boolean isLoading: boolean
@ -23,9 +24,10 @@ interface Props {
isAlbum: boolean isAlbum: boolean
isChannel: boolean isChannel: boolean
isSerie: boolean isSerie: boolean
} }
const emit = defineEmits<Events>() const store = useStore()
const props = defineProps<Props>() const props = defineProps<Props>()
const { report, getReportableObjects } = useReport() const { report, getReportableObjects } = useReport()
@ -38,147 +40,131 @@ const labels = computed(() => ({
more: t('components.library.AlbumDropdown.button.more') more: t('components.library.AlbumDropdown.button.more')
})) }))
const isEmbedable = computed(() => (props.isChannel && props.artistCredit[0].artist?.channel?.actor) || props.publicLibraries.length) // TODO: What is the condition for an album to be embeddable?
// (a) props.publicLibraries.length
// (b) I am the channel's artist: props.isChannel && props.artistCredit[0].artist?.channel?.actor)
const isEmbedable = computed(() => (props.publicLibraries.length))
const musicbrainzUrl = computed(() => props.object?.mbid ? `https://musicbrainz.org/release/${props.object.mbid}` : null) const musicbrainzUrl = computed(() => props.object?.mbid ? `https://musicbrainz.org/release/${props.object.mbid}` : null)
const discogsUrl = computed(() => `https://discogs.com/search/?type=release&title=${encodeURI(props.object?.title)}&artist=${encodeURI(props.object?.artist_credit[0].artist.name)}`) const discogsUrl = computed(() => `https://discogs.com/search/?type=release&title=${encodeURI(props.object?.title)}&artist=${encodeURI(props.object?.artist_credit[0].artist.name)}`)
const remove = () => emit('remove') const open = ref(false)
</script> </script>
<template> <template>
<span> <span>
<Modal
<semantic-modal
v-if="isEmbedable" v-if="isEmbedable"
v-model:show="showEmbedModal" v-model="showEmbedModal"
:title="t('components.library.AlbumDropdown.modal.embed.header')"
:cancel="t('components.library.AlbumDropdown.button.cancel')"
> >
<h4 class="header">
{{ $t('components.library.AlbumDropdown.modal.embed.header') }}
</h4>
<div class="scrolling content"> <div class="scrolling content">
<div class="description"> <div class="description">
<embed-wizard <embed-wizard
:id="object.id" :id="object.id"
type="album" type="album"
/> />
</div> </div>
</div> </div>
<div class="actions"> </Modal>
<button class="ui basic deny button"> <Popover v-model="open">
{{ $t('components.library.AlbumDropdown.button.cancel') }} <template #default="{ toggleOpen }">
</button> <OptionsButton
</div>
</semantic-modal>
<button
v-dropdown="{direction: 'downward'}"
class="ui floating dropdown circular icon basic button"
:title="labels.more" :title="labels.more"
> is-square-small
<i class="ellipsis vertical icon" /> @click="toggleOpen()"
<div class="menu"> />
<a </template>
v-if="domain != $store.getters['instance/domain']"
:href="object.fid"
target="_blank"
class="basic item"
>
<i class="external icon" />
{{ $t('components.library.AlbumDropdown.link.domain') }}
</a>
<div <template #items>
<PopoverItem
v-if="domain != store.getters['instance/domain']"
:to="object.fid"
icon="bi-box-arrow-up-right"
target="_blank"
>
{{ t('components.library.AlbumDropdown.link.domain') }}
</PopoverItem>
<PopoverItem
v-if="isEmbedable" v-if="isEmbedable"
role="button" icon="bi-code"
class="basic item"
@click="showEmbedModal = !showEmbedModal" @click="showEmbedModal = !showEmbedModal"
> >
<i class="code icon" /> {{ t('components.library.AlbumDropdown.button.embed') }}
{{ $t('components.library.AlbumDropdown.button.embed') }} </PopoverItem>
</div>
<a <PopoverItem
v-if="isAlbum && musicbrainzUrl" v-if="isAlbum && musicbrainzUrl"
:href="musicbrainzUrl" :to="musicbrainzUrl"
icon="bi-box-arrow-up-right"
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
class="basic item"
> >
<i class="external icon" /> {{ t('components.library.AlbumDropdown.link.musicbrainz') }}
{{ $t('components.library.AlbumDropdown.link.musicbrainz') }} </PopoverItem>
</a>
<a <PopoverItem
v-if="!isChannel && isAlbum" v-if="!isChannel && isAlbum"
:href="discogsUrl" :to="discogsUrl"
icon="bi-box-arrow-up-right"
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
class="basic item"
> >
<i class="external icon" /> {{ t('components.library.AlbumDropdown.link.discogs') }}
{{ $t('components.library.AlbumDropdown.link.discogs') }} </PopoverItem>
</a>
<router-link <PopoverItem
v-if="object.is_local" v-if="object.is_local"
:to="{ name: 'library.albums.edit', params: { id: object.id } }" :to="{ name: 'library.albums.edit', params: { id: object.id } }"
class="basic item" icon="bi-pencil"
> >
<i class="edit icon" /> {{ t('components.library.AlbumDropdown.button.edit') }}
{{ $t('components.library.AlbumDropdown.button.edit') }} </PopoverItem>
</router-link>
<dangerous-button <hr>
v-if="artistCredit[0] && $store.state.auth.authenticated && artistCredit[0].artist.channel && artistCredit[0].artist.attributed_to?.full_username === $store.state.auth.fullUsername"
:class="['ui', {loading: isLoading}, 'item']" <PopoverItem
@confirm="remove()" v-for="obj in getReportableObjects({
> album: object
<i class="ui trash icon" /> /*
{{ $t('components.library.AlbumDropdown.button.delete') }}
<template #modal-header> TODO: The type of the following field has changed to number.
<p> Find out if we want to load the corresponding channel instead.
{{ $t('components.library.AlbumDropdown.modal.delete.header') }}
</p> , channel: artistCredit[0]?.artist.channel
</template> */
<template #modal-content> })"
<div>
<p>
{{ $t('components.library.AlbumDropdown.modal.delete.content.warning') }}
</p>
</div>
</template>
<template #modal-confirm>
<p>
{{ $t('components.library.AlbumDropdown.button.delete') }}
</p>
</template>
</dangerous-button>
<div class="divider" />
<div
v-for="obj in getReportableObjects({album: object, channel: artistCredit[0]?.artist.channel})"
:key="obj.target.type + obj.target.id" :key="obj.target.type + obj.target.id"
role="button" icon="bi-flag"
class="basic item" @click="report(obj)"
@click.stop.prevent="report(obj)"
> >
<i class="share icon" /> {{ obj.label }} {{ obj.label }}
</div> </PopoverItem>
<div class="divider" />
<router-link <hr>
v-if="$store.state.auth.availablePermissions['library']"
class="basic item" <PopoverItem
:to="{name: 'manage.library.albums.detail', params: {id: object.id}}" v-if="store.state.auth.availablePermissions['library']"
:to="{
name: 'manage.library.albums.detail',
params: { id: object.id }
}"
icon="bi-wrench"
> >
<i class="wrench icon" /> {{ t('components.library.AlbumDropdown.link.moderation') }}
{{ $t('components.library.AlbumDropdown.link.moderation') }} </PopoverItem>
</router-link>
<a <PopoverItem
v-if="$store.state.auth.profile && $store.state.auth.profile?.is_superuser" v-if="store.state.auth.profile?.is_superuser"
class="basic item" :to="store.getters['instance/absoluteUrl'](`/api/admin/music/album/${object.id}`)"
:href="$store.getters['instance/absoluteUrl'](`/api/admin/music/album/${object.id}`)" icon="bi-wrench"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<i class="wrench icon" /> {{ t('components.library.AlbumDropdown.link.django') }}
{{ $t('components.library.AlbumDropdown.link.django') }} </PopoverItem>
</a> </template>
</div> </Popover>
</button>
</span> </span>
</template> </template>

View File

@ -3,6 +3,7 @@ import type { EditObjectType } from '~/composables/moderation/useEditConfigs'
import type { Album, Library, Actor } from '~/types' import type { Album, Library, Actor } from '~/types'
import { useStore } from '~/store' import { useStore } from '~/store'
import { useI18n } from 'vue-i18n'
import EditForm from '~/components/library/EditForm.vue' import EditForm from '~/components/library/EditForm.vue'
@ -14,32 +15,29 @@ interface Props {
defineProps<Props>() defineProps<Props>()
const { t } = useI18n()
const store = useStore() const store = useStore()
const canEdit = store.state.auth.availablePermissions.library const canEdit = store.state.auth.availablePermissions.library
</script> </script>
<template> <template>
<section class="ui vertical stripe segment">
<div class="ui text container">
<h2> <h2>
<span v-if="canEdit"> <span v-if="canEdit">
{{ $t('components.library.AlbumEdit.header.edit') }} {{ t('components.library.AlbumEdit.header.edit') }}
</span> </span>
<span v-else> <span v-else>
{{ $t('components.library.AlbumEdit.header.suggest') }} {{ t('components.library.AlbumEdit.header.suggest') }}
</span> </span>
</h2> </h2>
<div <div
v-if="!object.is_local" v-if="!object.is_local"
class="ui message" class="ui message"
> >
{{ $t('components.library.AlbumEdit.message.remote') }} {{ t('components.library.AlbumEdit.message.remote') }}
</div> </div>
<edit-form <edit-form
v-else v-else
:object-type="objectType" :object-type="objectType"
:object="object" :object="object"
/> />
</div>
</section>
</template> </template>

View File

@ -1,22 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
import type { OrderingProps } from '~/composables/navigation/useOrdering' import type { OrderingProps } from '~/composables/navigation/useOrdering'
import type { Album, BackendResponse } from '~/types' import type { PaginatedAlbumList } from '~/types'
import type { operations } from '~/generated/types.ts'
import type { RouteRecordName } from 'vue-router' import type { RouteRecordName } from 'vue-router'
import type { OrderingField } from '~/store/ui' import type { OrderingField } from '~/store/ui'
import { computed, onMounted, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useRouteQuery } from '@vueuse/router' import { useRouteQuery } from '@vueuse/router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { syncRef } from '@vueuse/core' import { syncRef } from '@vueuse/core'
import { sortedUniq } from 'lodash-es' import { sortedUniq } from 'lodash-es'
import { useStore } from '~/store' import { useStore } from '~/store'
import { useModal } from '~/ui/composables/useModal.ts'
import axios from 'axios' import axios from 'axios'
import $ from 'jquery'
import TagsSelector from '~/components/library/TagsSelector.vue' import Pagination from '~/components/ui/Pagination.vue'
import AlbumCard from '~/components/audio/album/Card.vue' import Card from '~/components/ui/Card.vue'
import Pagination from '~/components/vui/Pagination.vue' import AlbumCard from '~/components/album/Card.vue'
import Layout from '~/components/ui/Layout.vue'
import Header from '~/components/ui/Header.vue'
import Input from '~/components/ui/Input.vue'
import Alert from '~/components/ui/Alert.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Pills from '~/components/ui/Pills.vue'
import Loader from '~/components/ui/Loader.vue'
import useSharedLabels from '~/composables/locale/useSharedLabels' import useSharedLabels from '~/composables/locale/useSharedLabels'
import useOrdering from '~/composables/navigation/useOrdering' import useOrdering from '~/composables/navigation/useOrdering'
@ -38,13 +46,13 @@ const props = withDefaults(defineProps<Props>(), {
const page = usePage() const page = usePage()
const tags = useRouteQuery<string[]>('tag', []) const tags = useRouteQuery<string[]>('tag', [], { transform: (param: string | string[] | null) => (param === null ? [] : Array.isArray(param) ? param : [param]).filter(p => p.trim() !== '') })
const q = useRouteQuery('query', '') const q = useRouteQuery('query', '')
const query = ref(q.value) const query = ref(q.value ?? '')
syncRef(q, query, { direction: 'ltr' }) syncRef(q, query, { direction: 'ltr' })
const result = ref<BackendResponse<Album>>() const result = ref<PaginatedAlbumList>()
const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [ const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [
['creation_date', 'creation_date'], ['creation_date', 'creation_date'],
@ -60,21 +68,22 @@ const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirectio
const isLoading = ref(false) const isLoading = ref(false)
const fetchData = async () => { const fetchData = async () => {
isLoading.value = true isLoading.value = true
const params = { const params : operations['get_album_fetches_2']['parameters']['query'] = {
scope: props.scope, scope: props.scope,
page: page.value, page: page.value,
page_size: paginateBy.value, page_size: paginateBy.value,
q: query.value, q: query.value,
// @ts-expect-error TODO: add strict types to useOrdering
ordering: orderingString.value, ordering: orderingString.value,
playable: 'true', playable: true,
tag: tags.value, tag: tags.value,
include_channels: 'true', include_channels: true,
content_category: 'music' content_category: 'music'
} }
const measureLoading = logger.time('Fetching albums') const measureLoading = logger.time('Fetching albums')
try { try {
const response = await axios.get('albums/', { const response = await axios.get<PaginatedAlbumList>('albums/', {
params, params,
paramsSerializer: { paramsSerializer: {
indexes: null indexes: null
@ -93,7 +102,7 @@ const fetchData = async () => {
const store = useStore() const store = useStore()
watch(() => store.state.moderation.lastUpdate, fetchData) watch(() => store.state.moderation.lastUpdate, fetchData)
watch([page, tags, q, () => props.scope], fetchData) watch([page, tags, q, ordering, orderingDirection, () => props.scope], fetchData)
fetchData() fetchData()
const search = () => { const search = () => {
@ -106,59 +115,62 @@ onOrderingUpdate(() => {
fetchData() fetchData()
}) })
onMounted(() => $('.ui.dropdown').dropdown())
const { t } = useI18n() const { t } = useI18n()
const labels = computed(() => ({ const labels = computed(() => ({
searchPlaceholder: t('components.library.Albums.placeholder.search'), searchPlaceholder: t('components.library.Albums.placeholder.search'),
title: t('components.library.Albums.title') title: t('components.library.Albums.title')
})) }))
const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value].sort((a, b) => a - b))) const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value].sort((a, b) => a - b)))
</script> </script>
<template> <template>
<main v-title="labels.title"> <Layout
<section class="ui vertical stripe segment"> v-title="labels.title"
<h2 class="ui header"> stack
{{ $t('components.library.Albums.header.browse') }} main
</h2> >
<form <Header
page-heading
:h1="t('components.library.Albums.header.browse')"
/>
<Layout
form
flex
:class="['ui', {'loading': isLoading}, 'form']" :class="['ui', {'loading': isLoading}, 'form']"
@submit.prevent="search" @submit.prevent="search"
> >
<div class="fields"> <Input
<div class="field"> id="album-search"
<label for="albums-search">
{{ $t('components.library.Albums.label.search') }}
</label>
<div class="ui action input">
<input
id="albums-search"
v-model="query" v-model="query"
type="text" search
name="search" name="search"
:label="t('components.library.Albums.label.search')"
autofocus
:placeholder="labels.searchPlaceholder" :placeholder="labels.searchPlaceholder"
/>
<Pills
:get="model => { tags = model.currents.map(({ label }) => label) }"
:set="model => ({
...model,
currents: tags.map(tag => ({ type: 'custom' as const, label: tag })),
})"
:label="t('components.library.Albums.label.tags')"
style="max-width: 150px;"
/>
<Layout
stack
no-gap
label
for="album-ordering"
> >
<button <span class="label">
class="ui icon button" {{ t('components.library.Albums.ordering.label') }}
type="submit" </span>
:aria-label="t('components.library.Albums.button.search')"
>
<i class="search icon" />
</button>
</div>
</div>
<div class="field">
<label for="tags-search">{{ $t('components.library.Albums.label.tags') }}</label>
<tags-selector v-model="tags" />
</div>
<div class="field">
<label for="album-ordering">{{ $t('components.library.Albums.ordering.label') }}</label>
<select <select
id="album-ordering" id="album-ordering"
v-model="ordering" v-model="ordering"
class="ui dropdown" class="dropdown"
> >
<option <option
v-for="(option, key) in orderingOptions" v-for="(option, key) in orderingOptions"
@ -168,28 +180,42 @@ const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value]
{{ sharedLabels.filters[option[1]] }} {{ sharedLabels.filters[option[1]] }}
</option> </option>
</select> </select>
</div> </Layout>
<div class="field"> <Layout
<label for="album-ordering-direction">{{ $t('components.library.Albums.ordering.direction.label') }}</label> stack
no-gap
label
for="album-ordering-direction"
>
<span class="label">
{{ t('components.library.Albums.ordering.direction.label') }}
</span>
<select <select
id="album-ordering-direction" id="album-ordering-direction"
v-model="orderingDirection" v-model="orderingDirection"
class="ui dropdown" class="dropdown"
> >
<option value="+"> <option value="+">
{{ $t('components.library.Albums.ordering.direction.ascending') }} {{ t('components.library.Albums.ordering.direction.ascending') }}
</option> </option>
<option value="-"> <option value="-">
{{ $t('components.library.Albums.ordering.direction.descending') }} {{ t('components.library.Albums.ordering.direction.descending') }}
</option> </option>
</select> </select>
</div> </Layout>
<div class="field"> <Layout
<label for="album-results">{{ $t('components.library.Albums.pagination.results') }}</label> stack
no-gap
label
for="album-results"
>
<span class="label">
{{ t('components.library.Albums.pagination.results') }}
</span>
<select <select
id="album-results" id="album-results"
v-model="paginateBy" v-model="paginateBy"
class="ui dropdown" class="dropdown"
> >
<option <option
v-for="opt in paginateOptions" v-for="opt in paginateOptions"
@ -199,55 +225,55 @@ const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value]
{{ opt }} {{ opt }}
</option> </option>
</select> </select>
</div> </Layout>
</div> </Layout>
</form> <Loader v-if="isLoading" />
<div class="ui hidden divider" /> <Pagination
<div v-if="page && result && result.count > paginateBy"
v-if="result" v-model:page="page"
transition-duration="0" :pages="Math.ceil((result.count || 0)/paginateBy)"
item-selector=".column" />
percent-position="true" <Layout
stagger="0" v-if="result && result.results.length > 0"
class="" grid
style="display:flex; flex-wrap:wrap; gap: 32px; margin-top:32px;"
> >
<div <AlbumCard
v-if="result.results.length > 0"
class="ui app-cards cards"
>
<album-card
v-for="album in result.results" v-for="album in result.results"
:key="album.id" :key="album.id"
:album="album" :album="album"
/> />
</div> </Layout>
<div <Layout
v-else v-else-if="result && result.results.length === 0"
class="ui placeholder segment sixteen wide column" stack
style="text-align: center; display: flex; align-items: center"
> >
<div class="ui icon header"> <Alert blue>
<i class="compact disc icon" /> <i class="bi bi-disc" />
{{ $t('components.library.Albums.empty.noResults') }} {{ t('components.library.Albums.empty.noResults') }}
</div> </Alert>
<router-link <Card
v-if="$store.state.auth.authenticated" v-if="store.state.auth.authenticated"
:to="{name: 'content.index'}" :title="t('components.library.Albums.link.addMusic')"
class="ui success button labeled icon" solid
small
primary
style="text-align: center;"
:to="useModal('upload').to"
> >
<i class="upload icon" /> <template #image>
{{ $t('components.library.Albums.link.addMusic') }} <i
</router-link> class="bi bi-upload"
</div> style="font-size: 100px; position: relative; top: 50px;"
</div>
<div class="ui center aligned basic segment">
<pagination
v-if="result && result.count > paginateBy"
v-model:current="page"
:paginate-by="paginateBy"
:total="result.count"
/> />
</div> </template>
</section> </Card>
</main> </Layout>
<Spacer grow />
<Pagination
v-if="page && result && result.count > paginateBy"
v-model:page="page"
:pages="Math.ceil((result.count || 0)/paginateBy)"
/>
</Layout>
</template> </template>

View File

@ -1,25 +1,36 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Track, Album, Artist, Library } from '~/types' import type { Track, Album, Artist, Library, Cover } from '~/types'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { sum } from 'lodash-es'
import { getDomain } from '~/utils' import { getDomain } from '~/utils'
import { useStore } from '~/store' import { useStore } from '~/store'
import axios from 'axios' import axios from 'axios'
import EmbedWizard from '~/components/audio/EmbedWizard.vue'
import SemanticModal from '~/components/semantic/Modal.vue'
import PlayButton from '~/components/audio/PlayButton.vue'
import RadioButton from '~/components/radios/Button.vue'
import TagsList from '~/components/tags/List.vue'
import useReport from '~/composables/moderation/useReport' import useReport from '~/composables/moderation/useReport'
import useLogger from '~/composables/useLogger' import useLogger from '~/composables/useLogger'
import { useModal } from '~/ui/composables/useModal.ts'
import HumanDuration from '~/components/common/HumanDuration.vue'
import EmbedWizard from '~/components/audio/EmbedWizard.vue'
import Loader from '~/components/ui/Loader.vue'
import Header from '~/components/ui/Header.vue'
import Button from '~/components/ui/Button.vue'
import Link from '~/components/ui/Link.vue'
import OptionsButton from '~/components/ui/button/Options.vue'
import PlayButton from '~/components/audio/PlayButton.vue'
import RadioButton from '~/components/radios/Button.vue'
import Popover from '~/components/ui/Popover.vue'
import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
import Layout from '~/components/ui/Layout.vue'
import Modal from '~/components/ui/Modal.vue'
import Spacer from '~/components/ui/Spacer.vue'
import RenderedDescription from '../common/RenderedDescription.vue'
interface Props { interface Props {
id: number id: number | string
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@ -36,26 +47,50 @@ const nextTracksUrl = ref(null)
const totalAlbums = ref(0) const totalAlbums = ref(0)
const totalTracks = ref(0) const totalTracks = ref(0)
const dropdown = ref()
const logger = useLogger() const logger = useLogger()
const store = useStore() const store = useStore()
const router = useRouter() const router = useRouter()
const route = useRoute()
const domain = computed(() => getDomain(object.value?.fid ?? '')) const domain = computed(() => getDomain(object.value?.fid ?? ''))
const isPlayable = computed(() => !!object.value?.albums.some(album => album.is_playable))
// TODO: Re-implement `!!object.value?.albums.some(album => album.is_playable)` instead of `true`
const isPlayable = computed(() => true)
const wikipediaUrl = computed(() => `https://en.wikipedia.org/w/index.php?search=${encodeURI(object.value?.name ?? '')}`) const wikipediaUrl = computed(() => `https://en.wikipedia.org/w/index.php?search=${encodeURI(object.value?.name ?? '')}`)
const musicbrainzUrl = computed(() => object.value?.mbid ? `https://musicbrainz.org/artist/${object.value.mbid}` : null) const musicbrainzUrl = computed(() => object.value?.mbid ? `https://musicbrainz.org/artist/${object.value.mbid}` : null)
const discogsUrl = computed(() => `https://discogs.com/search/?type=artist&title=${encodeURI(object.value?.name ?? '')}`) const discogsUrl = computed(() => `https://discogs.com/search/?type=artist&title=${encodeURI(object.value?.name ?? '')}`)
const publicLibraries = computed(() => libraries.value?.filter(library => library.privacy_level === 'everyone') ?? []) const publicLibraries = computed(() => libraries.value?.filter(library => library.privacy_level === 'everyone') ?? [])
const cover = computed(() => object.value?.cover?.urls.original
? object.value.cover // TODO: This is cover logic. We use it a lot. Should all go into a single, smart, parametrised function.
: object.value?.albums.find(album => album.cover?.urls.original)?.cover // Something like `useCover.ts`!
) const cover = computed(() => {
const headerStyle = computed(() => cover.value?.urls.original const artistCover = object.value?.cover
? { backgroundImage: `url(${store.getters['instance/absoluteUrl'](cover.value.urls.original)})` }
: '' // const albumCover: Cover | null = object.value?.albums
) // .find(album => album.cover?.urls.large_square_crop)?.cover
const trackCover = tracks.value?.find(
(track: Track) => track.cover
)?.cover
const fallback : Cover = {
uuid: '',
mimetype: 'jpeg',
creation_date: '',
size: 0,
urls: {
original: `${import.meta.env.BASE_URL}embed-default-cover.jpeg`,
small_square_crop: `${import.meta.env.BASE_URL}embed-default-cover.jpeg`,
medium_square_crop: `${import.meta.env.BASE_URL}embed-default-cover.jpeg`,
large_square_crop: `${import.meta.env.BASE_URL}embed-default-cover.jpeg`
}
}
return artistCover
// || albumCover
|| trackCover
|| fallback
})
const { t } = useI18n() const { t } = useI18n()
const labels = computed(() => ({ const labels = computed(() => ({
@ -91,187 +126,216 @@ const fetchData = async () => {
isLoading.value = false isLoading.value = false
} }
const totalDuration = computed(() => sum((tracks.value ?? []).map(track => track.uploads[0]?.duration ?? 0)))
watch(() => props.id, fetchData, { immediate: true }) watch(() => props.id, fetchData, { immediate: true })
const isOpen = useModal('artist-description').isOpen
</script> </script>
<template> <template>
<main v-title="labels.title"> <Loader v-if="isLoading" />
<div <Header
v-if="isLoading" v-if="object && !isLoading"
class="ui vertical segment" v-title="labels.title"
:h1="object.name"
page-heading
> >
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" /> <template #image>
</div> <img
<template v-if="object && !isLoading"> v-lazy="cover.urls.large_square_crop"
<section :alt="object.name"
v-title="object.name" class="channel-image"
:class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" >
:style="headerStyle" </template>
<Layout
flex
class="meta"
no-gap
> >
<div class="segment-content">
<h2 class="ui center aligned icon header">
<i class="circular inverted users violet icon" />
<div class="content">
{{ object.name }}
<div <div
v-if="albums" v-if="albums"
class="sub header"
> >
{{ $t('components.library.ArtistBase.meta.tracks', totalTracks) }} {{ t('components.library.ArtistBase.meta.tracks', totalTracks) }}
{{ $t('components.library.ArtistBase.meta.albums', totalAlbums) }} {{ t('components.library.ArtistBase.meta.albums', totalAlbums) }}
</div> </div>
</div> <div v-if="totalDuration > 0">
</h2> <i class="bi bi-dot" />
<tags-list <human-duration
v-if="object.tags && object.tags.length > 0" v-if="totalDuration > 0"
:tags="object.tags" :duration="totalDuration"
/> />
<div class="ui hidden divider" /> </div>
<div class="header-buttons"> </Layout>
<div class="ui buttons"> <Layout
flex
gap-4
>
<RenderedDescription
v-if="object.description"
class="description"
:content="{ ...object.description, text: object.description.text ?? undefined }"
:truncate-length="100"
:more-link="false"
/>
<Spacer grow />
<Link
v-if="object.description"
:to="useModal('artist-description').to"
style="color: var(--fw-primary); text-decoration: underline;"
thin-font
force-underline
>
{{ t('components.common.RenderedDescription.button.more') }}
</Link>
</Layout>
<Modal
v-if="object.description"
v-model="isOpen"
:title="object.name"
>
<img
v-if="object.cover"
v-lazy="object.cover.urls.original"
:alt="object.name"
style="object-fit: cover; width: 100%; height: 100%;"
>
<sanitized-html
v-if="object.description"
:html="object.description.html"
/>
</Modal>
<Layout flex>
<PlayButton
:is-playable="isPlayable"
split
:artist="object"
low-height
>
{{ t('components.library.ArtistBase.button.play') }}
</PlayButton>
<radio-button <radio-button
type="artist" type="artist"
:object-id="object.id" :object-id="object.id"
low-height
/> />
</div> <Spacer grow />
<div class="ui buttons"> <Popover>
<play-button <template #default="{ toggleOpen }">
:is-playable="isPlayable" <OptionsButton
class="vibrant" is-square-small
:artist="object" @click="toggleOpen"
> />
{{ $t('components.library.ArtistBase.button.play') }} </template>
</play-button>
</div>
<semantic-modal <template #items>
v-if="publicLibraries.length > 0" <PopoverItem
v-model:show="showEmbedModal" v-if="object.fid && domain != store.getters['instance/domain']"
:to="object.fid"
target="_blank"
icon="bi-box-arrow-up-right"
>
{{ t('components.library.ArtistBase.link.domain', {domain: domain}) }}
</PopoverItem>
<PopoverItem
v-if="publicLibraries.length > 0"
icon="bi-code-square"
@click="showEmbedModal = true"
>
{{ t('components.library.ArtistBase.button.embed') }}
</PopoverItem>
<PopoverItem
:to="wikipediaUrl"
target="_blank"
rel="noreferrer noopener"
icon="bi-wikipedia"
>
{{ t('components.library.ArtistBase.link.wikipedia') }}
</PopoverItem>
<PopoverItem
v-if="musicbrainzUrl"
:to="musicbrainzUrl"
target="_blank"
rel="noreferrer noopener"
icon="bi-box-arrow-up-right"
>
{{ t('components.library.ArtistBase.link.musicbrainz') }}
</PopoverItem>
<PopoverItem
:to="discogsUrl"
target="_blank"
rel="noreferrer noopener"
icon="bi-box-arrow-up-right"
>
{{ t('components.library.ArtistBase.link.discogs') }}
</PopoverItem>
<PopoverItem
v-if="object.is_local"
:to="{name: 'library.artists.edit', params: {id: object.id }}"
icon="bi-pencil-fill"
>
{{ t('components.library.ArtistBase.button.edit') }}
</PopoverItem>
<hr v-if="getReportableObjects({artist: object}).length>0">
<PopoverItem
v-for="obj in getReportableObjects({artist: object})"
:key="obj.target.type + obj.target.id"
icon="bi-share-fill"
@click="report(obj)"
>
{{ obj.label }}
</PopoverItem>
<hr v-if="getReportableObjects({artist: object}).length>0">
<PopoverItem
v-if="store.state.auth.availablePermissions['library']"
:to="{name: 'manage.library.artists.detail', params: {id: object.id}}"
icon="bi-wrench"
>
{{ t('components.library.ArtistBase.link.moderation') }}
</PopoverItem>
<PopoverItem
v-if="store.state.auth.profile && store.state.auth.profile.is_superuser"
:to="store.getters['instance/absoluteUrl'](`/api/admin/music/artist/${object.id}`)"
target="_blank"
rel="noopener noreferrer"
icon="bi-wrench"
>
{{ t('components.library.ArtistBase.link.django') }}
</PopoverItem>
</template>
</Popover>
</Layout>
<Modal
v-if="publicLibraries.length > 0"
v-model="showEmbedModal"
:title="t('components.library.ArtistBase.modal.embed.header')"
> >
<h4 class="header">
{{ $t('components.library.ArtistBase.modal.embed.header') }}
</h4>
<div class="scrolling content">
<div class="description">
<embed-wizard <embed-wizard
:id="object.id" :id="object.id"
type="artist" type="artist"
/> />
</div> <template #actions>
</div> <Button secondary>
<div class="actions"> {{ t('components.library.ArtistBase.button.cancel') }}
<button class="ui deny button"> </Button>
{{ $t('components.library.ArtistBase.button.cancel') }} </template>
</button> </Modal>
</div> </Header>
</semantic-modal> <hr>
<div class="ui buttons">
<button
class="ui button"
@click="dropdown.click()"
>
{{ $t('components.library.ArtistBase.button.more') }}
</button>
<button
ref="dropdown"
v-dropdown
class="ui floating dropdown icon button"
>
<i class="dropdown icon" />
<div class="menu">
<a
v-if="domain != $store.getters['instance/domain']"
:href="object.fid"
target="_blank"
class="basic item"
>
<i class="external icon" />
{{ $t('components.library.ArtistBase.link.domain', {domain: domain}) }}
</a>
<button
v-if="publicLibraries.length > 0"
role="button"
class="basic item"
@click.prevent="showEmbedModal = !showEmbedModal"
>
<i class="code icon" />
{{ $t('components.library.ArtistBase.button.embed') }}
</button>
<a
:href="wikipediaUrl"
target="_blank"
rel="noreferrer noopener"
class="basic item"
>
<i class="wikipedia w icon" />
{{ $t('components.library.ArtistBase.link.wikipedia') }}
</a>
<a
v-if="musicbrainzUrl"
:href="musicbrainzUrl"
target="_blank"
rel="noreferrer noopener"
class="basic item"
>
<i class="external icon" />
{{ $t('components.library.ArtistBase.link.musicbrainz') }}
</a>
<a
:href="discogsUrl"
target="_blank"
rel="noreferrer noopener"
class="basic item"
>
<i class="external icon" />
{{ $t('components.library.ArtistBase.link.discogs') }}
</a>
<router-link
v-if="object.is_local"
:to="{name: 'library.artists.edit', params: {id: object.id }}"
class="basic item"
>
<i class="edit icon" />
{{ $t('components.library.ArtistBase.button.edit') }}
</router-link>
<div class="divider" />
<div
v-for="obj in getReportableObjects({artist: object})"
:key="obj.target.type + obj.target.id"
role="button"
class="basic item"
@click.stop.prevent="report(obj)"
>
<i class="share icon" /> {{ obj.label }}
</div>
<div class="divider" />
<router-link
v-if="$store.state.auth.availablePermissions['library']"
class="basic item"
:to="{name: 'manage.library.artists.detail', params: {id: object.id}}"
>
<i class="wrench icon" />
{{ $t('components.library.ArtistBase.link.moderation') }}
</router-link>
<a
v-if="$store.state.auth.profile && $store.state.auth.profile.is_superuser"
class="basic item"
:href="$store.getters['instance/absoluteUrl'](`/api/admin/music/artist/${object.id}`)"
target="_blank"
rel="noopener noreferrer"
>
<i class="wrench icon" />
{{ $t('components.library.ArtistBase.link.django') }}
</a>
</div>
</button>
</div>
</div>
</div>
</section>
<router-view <router-view
:key="$route.fullPath" :key="route.fullPath"
:tracks="tracks" :tracks="tracks"
:next-tracks-url="nextTracksUrl" :next-tracks-url="nextTracksUrl"
:next-albums-url="nextAlbumsUrl" :next-albums-url="nextAlbumsUrl"
@ -282,5 +346,29 @@ watch(() => props.id, fetchData, { immediate: true })
@libraries-loaded="libraries = $event" @libraries-loaded="libraries = $event"
/> />
</template> </template>
</main>
</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>

View File

@ -4,12 +4,21 @@ import type { ContentFilter } from '~/store/moderation'
import { ref, computed, reactive } from 'vue' import { ref, computed, reactive } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import { useI18n } from 'vue-i18n'
import axios from 'axios' import axios from 'axios'
import LibraryWidget from '~/components/federation/LibraryWidget.vue' import LibraryWidget from '~/components/federation/LibraryWidget.vue'
import TrackTable from '~/components/audio/track/Table.vue' import TrackTable from '~/components/audio/track/Table.vue'
import AlbumCard from '~/components/audio/album/Card.vue' import TagsList from '~/components/tags/List.vue'
import AlbumCard from '~/components/album/Card.vue'
import Layout from '~/components/ui/Layout.vue'
import Heading from '~/components/ui/Heading.vue'
import Loader from '~/components/ui/Loader.vue'
import Link from '~/components/ui/Link.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Button from '~/components/ui/Button.vue'
import Alert from '~/components/ui/Alert.vue'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
@ -26,6 +35,8 @@ interface Props {
nextAlbumsUrl?: string | null nextAlbumsUrl?: string | null
} }
const { t } = useI18n()
const emit = defineEmits<Events>() const emit = defineEmits<Events>()
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
nextTracksUrl: null, nextTracksUrl: null,
@ -57,87 +68,82 @@ const loadMoreAlbums = async () => {
</script> </script>
<template> <template>
<div v-if="object"> <Layout
<div v-if="object"
v-if="contentFilter" stack
class="ui small text container" >
<TagsList
v-if="object.tags && object.tags.length > 0"
style="margin-top: -16px;"
:tags="object.tags"
/>
<Alert
v-if="contentFilter"
blue
> >
<div class="ui hidden divider" />
<div class="ui message">
<p> <p>
{{ $t('components.library.ArtistDetail.message.filter') }} {{ t('components.library.ArtistDetail.message.filter') }}
</p> </p>
<router-link <Link
class="right floated" class="right floated"
:to="{name: 'settings'}" :to="{name: 'settings'}"
> >
{{ $t('components.library.ArtistDetail.link.filter') }} {{ t('components.library.ArtistDetail.link.filter') }}
</router-link> </Link>
<button <Button
class="ui basic tiny button" class="tiny"
@click="$store.dispatch('moderation/deleteContentFilter', contentFilter.uuid)" @click="store.dispatch('moderation/deleteContentFilter', contentFilter.uuid)"
> >
{{ $t('components.library.ArtistDetail.button.filter') }} {{ t('components.library.ArtistDetail.button.filter') }}
</button> </Button>
</div> </Alert>
</div> <Loader v-if="isLoadingAlbums" />
<section <template v-else-if="albums && albums.length > 0">
v-if="isLoadingAlbums" <Heading
class="ui vertical stripe segment" :h2="t('components.library.ArtistDetail.header.album')"
> section-heading
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" /> />
</section> <Layout flex>
<section
v-else-if="albums && albums.length > 0"
class="ui vertical stripe segment"
>
<h2>
{{ $t('components.library.ArtistDetail.header.album') }}
</h2>
<div class="ui cards app-cards">
<album-card <album-card
v-for="album in allAlbums" v-for="album in allAlbums"
:key="album.id" :key="album.id"
:album="album" :album="album"
/> />
</div> <Spacer
<div class="ui hidden divider" /> h
<button grow
/>
<Button
v-if="loadMoreAlbumsUrl !== null" v-if="loadMoreAlbumsUrl !== null"
:class="['ui', {loading: isLoadingMoreAlbums}, 'button']" primary
:is-loading="isLoadingMoreAlbums"
@click="loadMoreAlbums()" @click="loadMoreAlbums()"
> >
{{ $t('components.library.ArtistDetail.button.more') }} {{ t('components.library.ArtistDetail.button.more') }}
</button> </Button>
</section> </Layout>
<section </template>
v-if="tracks.length > 0" <template v-if="tracks.length > 0">
class="ui vertical stripe segment" <Heading
> :h2="t('components.library.ArtistDetail.header.track')"
<track-table section-heading
/>
<TrackTable
:is-artist="true" :is-artist="true"
:show-position="false" :show-position="false"
:track-only="true" :track-only="true"
:tracks="tracks.slice(0,5)" :tracks="tracks.slice(0,5)"
> />
<template #header>
<h2>
{{ $t('components.library.ArtistDetail.header.track') }}
</h2>
<div class="ui hidden divider" />
</template> </template>
</track-table> <Heading
</section> :h2="t('components.library.ArtistDetail.header.library')"
<section class="ui vertical stripe segment"> section-heading
<h2> />
{{ $t('components.library.ArtistDetail.header.library') }} <LibraryWidget
</h2>
<library-widget
:url="'artists/' + object.id + '/libraries/'" :url="'artists/' + object.id + '/libraries/'"
@loaded="emit('libraries-loaded', $event)" @loaded="emit('libraries-loaded', $event)"
> >
{{ $t('components.library.ArtistDetail.description.library') }} {{ t('components.library.ArtistDetail.description.library') }}
</library-widget> </LibraryWidget>
</section> </Layout>
</div>
</template> </template>

Some files were not shown because too many files have changed in this diff Show More