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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@ -3,11 +3,14 @@ import type { Cover, Track, BackendResponse, BackendError } from '~/types'
import { clone } from 'lodash-es'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import axios from 'axios'
import PodcastTable from '~/components/audio/podcast/Table.vue'
import TrackTable from '~/components/audio/track/Table.vue'
import Loader from '~/components/ui/Loader.vue'
interface Events {
(e: 'fetched', data: BackendResponse<Track[]>): void
}
@ -18,6 +21,7 @@ interface Props {
defaultCover: Cover | null
isPodcast: boolean
}
const { t } = useI18n()
const emit = defineEmits<Events>()
const props = withDefaults(defineProps<Props>(), {
@ -61,51 +65,45 @@ watch(page, fetchData, { immediate: true })
<template>
<div>
<slot />
<div class="ui hidden divider" />
<div
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div>
<podcast-table
v-if="isPodcast"
v-model:page="page"
:paginate-by="limit"
:default-cover="defaultCover"
:is-podcast="isPodcast"
:show-art="true"
:show-position="false"
:tracks="channels"
:show-artist="false"
:show-album="false"
:paginate-results="true"
:total="count"
/>
<track-table
v-else
v-model:page="page"
:default-cover="defaultCover"
:is-podcast="isPodcast"
:show-art="true"
:show-position="false"
:tracks="channels"
:show-artist="false"
:show-album="false"
:paginate-results="true"
:total="count"
:paginate-by="limit"
:filters="filters"
/>
<template v-if="!isLoading && channels.length === 0">
<empty-state
:refresh="true"
@refresh="fetchData()"
>
<p>
{{ $t('components.audio.ChannelEntries.help.subscribe') }}
</p>
</empty-state>
</template>
<Loader v-if="isLoading" />
</div>
<podcast-table
v-if="isPodcast"
v-model:page="page"
:paginate-by="limit"
:default-cover="defaultCover"
:is-podcast="isPodcast"
:show-art="true"
:show-position="false"
:tracks="channels"
:show-artist="false"
:show-album="false"
:paginate-results="true"
:total="count"
/>
<track-table
v-else
v-model:page="page"
:default-cover="defaultCover"
:is-podcast="isPodcast"
:show-art="true"
:show-position="false"
:tracks="channels"
:show-artist="false"
:show-album="false"
:paginate-results="true"
:total="count"
:paginate-by="limit"
:filters="filters"
/>
<template v-if="!isLoading && channels.length === 0">
<empty-state
:refresh="true"
@refresh="fetchData()"
>
<p>
{{ t('components.audio.ChannelEntries.help.subscribe') }}
</p>
</empty-state>
</template>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,43 +1,79 @@
<script setup lang="ts">
import { toRefs, useClipboard } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import Button from '~/components/ui/Button.vue'
import Input from '~/components/ui/Input.vue'
interface Props {
value: string
buttonClasses?: string
id?: string
label: string
}
const props = withDefaults(defineProps<Props>(), {
buttonClasses: 'accent',
id: 'copy-input'
id: 'copy-input',
label: 'label'
})
const { t } = useI18n()
const { value } = toRefs(props)
const { copy, isSupported: canCopy, copied } = useClipboard({ source: value, copiedDuring: 5000 })
</script>
<template>
<div class="ui fluid action input component-copy-input">
<p
v-if="copied"
class="message"
>
{{ $t('components.common.CopyInput.message.success') }}
</p>
<input
:id="id"
:value="value"
:name="id"
type="text"
readonly
>
<button
:class="['ui', buttonClasses, 'right', 'labeled', 'icon', 'button']"
:disabled="!canCopy || undefined"
@click="copy()"
>
<i class="copy icon" />
{{ $t('components.common.CopyInput.button.copy') }}
</button>
</div>
<p
v-if="copied"
class="message"
>
{{ t('components.common.CopyInput.message.success') }}
</p>
<Input
:id="id"
v-model="value"
readonly
:name="id"
type="text"
:label="label"
>
<template #input-right>
<Button
:class="['ui', buttonClasses, 'input-right']"
min-content
secondary
:disabled="!canCopy || undefined"
@click="copy()"
>
<i class="bi bi-copy" />
{{ t('components.common.CopyInput.button.copy') }}
</Button>
</template>
</Input>
</template>
<style scoped>
.input-right {
position: absolute;
right: 0px;
bottom: 0px;
height: 48px;
min-width: 48px;
display: flex;
.button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
margin-right: 0px !important;
}
}
p.message {
background-color: var(--hover-background-color);
padding: 8px;
position: absolute;
bottom: -32px;
right: 0px;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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