chore(eslint): apply automatic fixes to format front/src

This commit is contained in:
jon r 2025-02-22 22:00:32 +01:00
parent d333b79ee1
commit 8b2a5e54ce
181 changed files with 6692 additions and 5513 deletions

View File

@ -4,8 +4,8 @@ import { watchEffect, computed, onMounted, nextTick } from 'vue'
import { type QueueTrack, useQueue } from '~/composables/audio/queue' import { type QueueTrack, useQueue } from '~/composables/audio/queue'
import { useStore } from '~/store' import { useStore } from '~/store'
import useLogger from '~/composables/useLogger' import useLogger from '~/composables/useLogger'
import { useStyleTag, useIntervalFn} from '@vueuse/core' import { useStyleTag, useIntervalFn } from '@vueuse/core'
import { color } from '~/composables/color'; import { color } from '~/composables/color'
import { generateTrackCreditStringFromQueue } from '~/utils/utils' import { generateTrackCreditStringFromQueue } from '~/utils/utils'
@ -80,8 +80,15 @@ store.dispatch('auth/fetchUser')
<template> <template>
<div class="funkwhale responsive"> <div class="funkwhale responsive">
<Sidebar /> <Sidebar />
<RouterView v-bind="color({}, ['default', 'solid'])()" v-slot="{ Component }"> <RouterView
<Transition v-if="Component" name="main" mode="out-in"> v-slot="{ Component }"
v-bind="color({}, ['default', 'solid'])()"
>
<Transition
v-if="Component"
name="main"
mode="out-in"
>
<KeepAlive :max="10"> <KeepAlive :max="10">
<Suspense> <Suspense>
<component :is="Component" /> <component :is="Component" />

View File

@ -6,7 +6,6 @@ import { useQueue } from '~/composables/audio/queue'
import { useStore } from '~/store' import { useStore } from '~/store'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import onKeyboardShortcut from '~/composables/onKeyboardShortcut' import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
const { t } = useI18n() const { t } = useI18n()
@ -91,9 +90,9 @@ const { width } = useWindowSize()
</keep-alive> </keep-alive>
</template> </template>
<template v-else> <template v-else>
<!-- Display a proper 404 page or error message --> <!-- Display a proper 404 page or error message -->
<h1>404 - Page Not Found</h1> <h1>404 - Page Not Found</h1>
</template> </template>
</router-view> </router-view>
<audio-player /> <audio-player />

View File

@ -24,7 +24,7 @@ const labels = computed(() => ({
title: t('components.About.title') title: t('components.About.title')
})) }))
const podName = computed(() => (n => n === "" ? "No name" : n ?? 'Funkwhale')(get(nodeinfo.value, 'metadata.nodeName'))) const podName = computed(() => (n => n === '' ? 'No name' : n ?? 'Funkwhale')(get(nodeinfo.value, 'metadata.nodeName')))
const banner = computed(() => get(nodeinfo.value, 'metadata.banner')) const banner = computed(() => get(nodeinfo.value, 'metadata.banner'))
const shortDescription = computed(() => get(nodeinfo.value, 'metadata.shortDescription')) const shortDescription = computed(() => get(nodeinfo.value, 'metadata.shortDescription'))
@ -84,14 +84,21 @@ const federationEnabled = computed(() => {
</script> </script>
<template> <template>
<Layout stack main <Layout
v-title="labels.title" v-title="labels.title"
stack
main
style="align-items: center;" style="align-items: center;"
> >
<!-- About funkwhale --> <!-- About funkwhale -->
<Link to="/" width="full" alignText="stretch" style="width:min(480px, 100%)"> <Link
<logo-text/> to="/"
width="full"
align-text="stretch"
style="width:min(480px, 100%)"
>
<logo-text />
</Link> </Link>
<h2 class="header"> <h2 class="header">
@ -102,13 +109,15 @@ const federationEnabled = computed(() => {
{{ t('components.About.description.funkwhale') }} {{ t('components.About.description.funkwhale') }}
</p> </p>
<Layout flex style="justify-content: center;"> <Layout
flex
<Card :title="t('components.About.header.signup')" style="justify-content: center;"
>
<Card
v-if="!store.state.auth.authenticated" v-if="!store.state.auth.authenticated"
:title="t('components.About.header.signup')"
width="256px" width="256px"
> >
<template v-if="openRegistrations"> <template v-if="openRegistrations">
<p> <p>
{{ t('components.About.description.signup') }} {{ t('components.About.description.signup') }}
@ -155,23 +164,27 @@ const federationEnabled = computed(() => {
</div> </div>
</Card> </Card>
<Card :title="t('components.About.message.greeting', {username: store.state.auth.username})" <Card
v-else v-else
width= "256px" :title="t('components.About.message.greeting', {username: store.state.auth.username})"
width="256px"
> >
<p v-if="defaultUploadQuota"> <p v-if="defaultUploadQuota">
{{ t('components.About.description.quota', {quota: defaultUploadQuota}) }} {{ t('components.About.description.quota', {quota: defaultUploadQuota}) }}
</p> </p>
<template #action> <template #action>
<Button full disabled> <Button
full
disabled
>
{{ t('components.About.message.loggedIn') }} {{ t('components.About.message.loggedIn') }}
</Button> </Button>
</template> </template>
</Card> </Card>
<Card :title="podName" <Card
:title="podName"
width="256px" width="256px"
> >
<section <section
@ -222,20 +235,22 @@ const federationEnabled = computed(() => {
</div> </div>
<template #action> <template #action>
<Link alignText="center" <Link
align-text="center"
to="/about/pod" to="/about/pod"
> >
{{ t('components.About.link.learnMore') }} {{ t('components.About.link.learnMore') }}
</Link> </Link>
</template> </template>
</Card> </Card>
</Layout> </Layout>
<Layout
<Layout flex style="justify-content: center;"> flex
style="justify-content: center;"
<Card width="256px" >
<Card
width="256px"
to="/" to="/"
:title="t('components.About.header.publicContent')" :title="t('components.About.header.publicContent')"
icon="bi-box-arrow-up-right" icon="bi-box-arrow-up-right"
@ -244,26 +259,25 @@ const federationEnabled = computed(() => {
{{ t('components.About.description.publicContent') }} {{ t('components.About.description.publicContent') }}
</Card> </Card>
<Card width="256px" <Card
width="256px"
:title="t('components.About.link.findOtherPod')" :title="t('components.About.link.findOtherPod')"
to="https://funkwhale.audio/#get-started" to="https://funkwhale.audio/#get-started"
icon="bi-box-arrow-up-right" icon="bi-box-arrow-up-right"
> >
{{ t('components.About.description.publicContent') }} {{ t('components.About.description.publicContent') }}
</Card> </Card>
<Card width="256px" <Card
width="256px"
:title="t('components.About.header.findApp')" :title="t('components.About.header.findApp')"
to="https://funkwhale.audio/apps" to="https://funkwhale.audio/apps"
icon="bi-box-arrow-up-right" icon="bi-box-arrow-up-right"
> >
{{ t('components.About.description.findApp') }} {{ t('components.About.description.findApp') }}
</Card> </Card>
</Layout> </Layout>
<section <section
:class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
:style="headerStyle" :style="headerStyle"
@ -274,7 +288,6 @@ const federationEnabled = computed(() => {
</h1> </h1>
</section> </section>
<!-- About Pod --> <!-- About Pod -->
<div class="about-pod-info-container"> <div class="about-pod-info-container">
<div class="about-pod-info-toc"> <div class="about-pod-info-toc">

View File

@ -246,8 +246,8 @@ if (!isWebGLSupported) {
@click="enter" @click="enter"
/> />
<Button <Button
secondary
v-else v-else
secondary
:aria-label="labels.exitFullscreen" :aria-label="labels.exitFullscreen"
:title="labels.exitFullscreen" :title="labels.exitFullscreen"
icon="bi-fullscreen-exit" icon="bi-fullscreen-exit"
@ -305,7 +305,10 @@ if (!isWebGLSupported) {
@click.stop.prevent="" @click.stop.prevent=""
> >
<Pill> <Pill>
<template #image v-if="ac.artist.cover"> <template
v-if="ac.artist.cover"
#image
>
<img v-lazy="ac.artist.cover?.urls.medium_square_crop"> <img v-lazy="ac.artist.cover?.urls.medium_square_crop">
</template> </template>
{{ ac.credit ?? t('components.Queue.meta.unknownArtist') }} {{ ac.credit ?? t('components.Queue.meta.unknownArtist') }}
@ -341,8 +344,14 @@ if (!isWebGLSupported) {
<i class="loading spinner icon" /> <i class="loading spinner icon" />
</p> </p>
</div> </div>
<Spacer :size="16" class="desktop-and-below" /> <Spacer
<Layout flex class="additional-controls desktop-and-below"> :size="16"
class="desktop-and-below"
/>
<Layout
flex
class="additional-controls desktop-and-below"
>
<track-favorite-icon <track-favorite-icon
v-if="store.state.auth.authenticated" v-if="store.state.auth.authenticated"
:track="currentTrack" :track="currentTrack"
@ -428,7 +437,10 @@ if (!isWebGLSupported) {
</template> </template>
</i18n-t> </i18n-t>
<span class="middle pipe symbol" /> <span class="middle pipe symbol" />
<span style="margin-right: 8px;" v-t="'components.Queue.meta.end'" /> <span
v-t="'components.Queue.meta.end'"
style="margin-right: 8px;"
/>
<span :title="labels.duration"> <span :title="labels.duration">
{{ endsIn }} {{ endsIn }}
</span> </span>

View File

@ -170,8 +170,9 @@ watch(() => props.initialId, () => {
</script> </script>
<template> <template>
<Layout stack <Layout
v-if="type === 'both'" v-if="type === 'both'"
stack
> >
<Button <Button
secondary secondary
@ -188,18 +189,23 @@ watch(() => props.initialId, () => {
{{ t('components.RemoteSearchForm.button.rss') }} {{ t('components.RemoteSearchForm.button.rss') }}
</Button> </Button>
</Layout> </Layout>
<Layout form <Layout
v-else v-else
id="remote-search" id="remote-search"
form
:class="['ui', {loading: isLoading}, 'form']" :class="['ui', {loading: isLoading}, 'form']"
@submit.stop.prevent="submit" @submit.stop.prevent="submit"
> >
<Alert red <Alert
v-if="errors.length > 0" v-if="errors.length > 0"
red
role="alert" role="alert"
title="t('components.RemoteSearchForm.header.fetchFailed')" title="t('components.RemoteSearchForm.header.fetchFailed')"
> >
<ul class="list" v-if="errors.length > 1"> <ul
v-if="errors.length > 1"
class="list"
>
<li <li
v-for="(error, key) in errors" v-for="(error, key) in errors"
:key="key" :key="key"
@ -207,7 +213,9 @@ watch(() => props.initialId, () => {
{{ error }} {{ error }}
</li> </li>
</ul> </ul>
<p v-else>{{ errors[0] }}</p> <p v-else>
{{ errors[0] }}
</p>
</Alert> </Alert>
<p v-if="type === 'rss'"> <p v-if="type === 'rss'">
{{ t('components.RemoteSearchForm.description.rss') }} {{ t('components.RemoteSearchForm.description.rss') }}
@ -216,7 +224,6 @@ watch(() => props.initialId, () => {
{{ t('components.RemoteSearchForm.description.fediverse') }} {{ t('components.RemoteSearchForm.description.fediverse') }}
</p> </p>
<Input <Input
id="object-id" id="object-id"
v-model="id" v-model="id"
@ -237,8 +244,9 @@ watch(() => props.initialId, () => {
{{ t('components.RemoteSearchForm.button.search') }} {{ t('components.RemoteSearchForm.button.search') }}
</Button> </Button>
</Layout> </Layout>
<Alert red <Alert
v-if="!isLoading && obj?.status === 'finished' && !redirectRoute" v-if="!isLoading && obj?.status === 'finished' && !redirectRoute"
red
> >
{{ t('components.RemoteSearchForm.warning.unsupported') }} {{ t('components.RemoteSearchForm.warning.unsupported') }}
</Alert> </Alert>

View File

@ -65,8 +65,9 @@ const checkAndSwitch = async (url: string) => {
</script> </script>
<template> <template>
<Modal :title="t('views.ChooseInstance.header.chooseInstance')" <Modal
v-model="show" v-model="show"
:title="t('views.ChooseInstance.header.chooseInstance')"
@update="isError = false" @update="isError = false"
> >
<h3 class="header"> <h3 class="header">

View File

@ -273,9 +273,9 @@ onMounted(() => {
@show-language-modal-event="isLanguageModalOpen=true" @show-language-modal-event="isLanguageModalOpen=true"
/> />
<Modal <Modal
:title="labels.language"
ref="languageModal" ref="languageModal"
v-model="isLanguageModalOpen" v-model="isLanguageModalOpen"
:title="labels.language"
:fullscreen="false" :fullscreen="false"
> >
<!-- TODO: Is this actually a popover menu, not a modal? --> <!-- TODO: Is this actually a popover menu, not a modal? -->
@ -306,9 +306,9 @@ onMounted(() => {
</div> </div>
</Modal> </Modal>
<Modal <Modal
:title="labels.theme"
ref="themeModal" ref="themeModal"
v-model:show="isThemeModalOpen" v-model:show="isThemeModalOpen"
:title="labels.theme"
:fullscreen="false" :fullscreen="false"
> >
<i <i

View File

@ -192,8 +192,8 @@ const move = (idx: number, increment: number) => {
</table> </table>
<div class="ui hidden divider" /> <div class="ui hidden divider" />
<Button <Button
color = "primary"
v-if="value.fields?.length < maxFields" v-if="value.fields?.length < maxFields"
color="primary"
@click.stop.prevent="addField" @click.stop.prevent="addField"
> >
{{ t('components.admin.SignupFormBuilder.button.add') }} {{ t('components.admin.SignupFormBuilder.button.add') }}

View File

@ -51,8 +51,9 @@ const imageUrl = computed(() => props.album.cover?.urls.original
/> />
</template> </template>
<template #default <template
v-for="ac in album.artist_credit" v-for="ac in album.artist_credit"
#default
:key="ac.artist.id" :key="ac.artist.id"
> >
<Link <Link
@ -68,11 +69,14 @@ const imageUrl = computed(() => props.album.cover?.urls.original
<span v-if="album.release_date"> <span v-if="album.release_date">
{{ momentFormat(new Date(album.release_date), 'Y') }} {{ momentFormat(new Date(album.release_date), 'Y') }}
</span> </span>
<i class="bi bi-dot"/> <i class="bi bi-dot" />
<span> <span>
{{ t('components.audio.album.Card.meta.tracks', album.tracks_count) }} {{ t('components.audio.album.Card.meta.tracks', album.tracks_count) }}
</span> </span>
<Spacer h grow /> <Spacer
h
grow
/>
<PlayButton <PlayButton
:dropdown-only="true" :dropdown-only="true"
discrete discrete

View File

@ -14,7 +14,6 @@ import Section from '~/components/ui/Section.vue'
import Loader from '~/components/ui/Loader.vue' import Loader from '~/components/ui/Loader.vue'
import Pagination from '~/components/ui/Pagination.vue' import Pagination from '~/components/ui/Pagination.vue'
interface Props { interface Props {
filters: Record<string, string | boolean> filters: Record<string, string | boolean>
showCount?: boolean showCount?: boolean
@ -82,17 +81,17 @@ watch(
> >
<inline-search-bar <inline-search-bar
v-if="search" v-if="search"
style="grid-column: 1 / -1;"
v-model="query" v-model="query"
style="grid-column: 1 / -1;"
@search="performSearch" @search="performSearch"
/> />
<Loader v-if="isLoading" /> <Loader v-if="isLoading" />
<template v-if="!isLoading && albums.length > 0"> <template v-if="!isLoading && albums.length > 0">
<album-card <album-card
v-for="album in albums" v-for="album in albums"
:key="album.id" :key="album.id"
:album="album" :album="album"
/> />
</template> </template>
<slot <slot
v-if="!isLoading && albums.length === 0" v-if="!isLoading && albums.length === 0"
@ -100,8 +99,8 @@ watch(
> >
<empty-state <empty-state
:refresh="true" :refresh="true"
@refresh="fetchData"
style="grid-column: 1 / -1;" style="grid-column: 1 / -1;"
@refresh="fetchData"
/> />
</slot> </slot>
<Pagination <Pagination

View File

@ -67,7 +67,7 @@ const cover = computed(() => {
v-lazy="cover.urls.medium_square_crop" v-lazy="cover.urls.medium_square_crop"
:alt="artist.name" :alt="artist.name"
class="channel-image" class="channel-image"
/> >
</template> </template>
<template #footer> <template #footer>
@ -85,7 +85,6 @@ const cover = computed(() => {
discrete discrete
/> />
</template> </template>
</Card> </Card>
</template> </template>

View File

@ -4,7 +4,6 @@ import type { Artist } from '~/types'
import { reactive, ref, watch, onMounted } from 'vue' import { reactive, ref, watch, onMounted } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import axios from 'axios' import axios from 'axios'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
@ -14,7 +13,6 @@ import ArtistCard from '~/components/artist/Card.vue'
import Section from '~/components/ui/Section.vue' import Section from '~/components/ui/Section.vue'
import Pagination from '~/components/ui/Pagination.vue' import Pagination from '~/components/ui/Pagination.vue'
interface Props { interface Props {
filters: Record<string, string | boolean> filters: Record<string, string | boolean>
search?: boolean search?: boolean
@ -97,14 +95,14 @@ watch(
<inline-search-bar <inline-search-bar
v-if="!isLoading && search" v-if="!isLoading && search"
v-model="query" v-model="query"
@search="performSearch"
style="grid-column: 1 / -1;" style="grid-column: 1 / -1;"
@search="performSearch"
/>
<artist-card
v-for="artist in artists"
:key="artist.id"
:artist="artist"
/> />
<artist-card
v-for="artist in artists"
:key="artist.id"
:artist="artist"
/>
<Pagination <Pagination
v-if="artists && count > limit" v-if="artists && count > limit"
v-model:page="page" v-model:page="page"

View File

@ -28,7 +28,11 @@ const getRoute = (ac: ArtistCredit) => {
v-for="ac in props.artistCredit" v-for="ac in props.artistCredit"
:key="ac.artist.id" :key="ac.artist.id"
> >
<Link solid secondary round min-content <Link
solid
secondary
round
min-content
:to="getRoute(ac)" :to="getRoute(ac)"
> >
<img <img

View File

@ -66,11 +66,11 @@ const updatedAgo = computed(() => moment(props.object.artist?.modification_date)
v-lazy="imageUrl" v-lazy="imageUrl"
:alt="object.artist?.name" :alt="object.artist?.name"
class="channel-image" class="channel-image"
/> >
</template> </template>
<template #default> <template #default>
<Spacer :size="8"/> <Spacer :size="8" />
</template> </template>
<template #footer> <template #footer>
@ -89,7 +89,10 @@ const updatedAgo = computed(() => moment(props.object.artist?.modification_date)
<span v-else> <span v-else>
{{ t('components.audio.ChannelCard.meta.tracks', object.artist?.tracks_count ?? 0) }} {{ t('components.audio.ChannelCard.meta.tracks', object.artist?.tracks_count ?? 0) }}
</span> </span>
<Spacer h grow /> <Spacer
h
grow
/>
<PlayButton <PlayButton
:dropdown-only="true" :dropdown-only="true"
:is-playable="true" :is-playable="true"

View File

@ -9,7 +9,7 @@ import axios from 'axios'
import PodcastTable from '~/components/audio/podcast/Table.vue' import PodcastTable from '~/components/audio/podcast/Table.vue'
import TrackTable from '~/components/audio/track/Table.vue' import TrackTable from '~/components/audio/track/Table.vue'
import Loader from'~/components/ui/Loader.vue' import Loader from '~/components/ui/Loader.vue'
interface Events { interface Events {
(e: 'fetched', data: BackendResponse<Track[]>): void (e: 'fetched', data: BackendResponse<Track[]>): void
@ -66,44 +66,44 @@ watch(page, fetchData, { immediate: true })
<div> <div>
<slot /> <slot />
<Loader v-if="isLoading" /> <Loader v-if="isLoading" />
</div> </div>
<podcast-table <podcast-table
v-if="isPodcast" v-if="isPodcast"
v-model:page="page" v-model:page="page"
:paginate-by="limit" :paginate-by="limit"
:default-cover="defaultCover" :default-cover="defaultCover"
:is-podcast="isPodcast" :is-podcast="isPodcast"
:show-art="true" :show-art="true"
:show-position="false" :show-position="false"
:tracks="channels" :tracks="channels"
:show-artist="false" :show-artist="false"
:show-album="false" :show-album="false"
:paginate-results="true" :paginate-results="true"
:total="count" :total="count"
/> />
<track-table <track-table
v-else v-else
v-model:page="page" v-model:page="page"
:default-cover="defaultCover" :default-cover="defaultCover"
:is-podcast="isPodcast" :is-podcast="isPodcast"
:show-art="true" :show-art="true"
:show-position="false" :show-position="false"
:tracks="channels" :tracks="channels"
:show-artist="false" :show-artist="false"
:show-album="false" :show-album="false"
:paginate-results="true" :paginate-results="true"
:total="count" :total="count"
:paginate-by="limit" :paginate-by="limit"
:filters="filters" :filters="filters"
/> />
<template v-if="!isLoading && channels.length === 0"> <template v-if="!isLoading && channels.length === 0">
<empty-state <empty-state
:refresh="true" :refresh="true"
@refresh="fetchData()" @refresh="fetchData()"
> >
<p> <p>
{{ t('components.audio.ChannelEntries.help.subscribe') }} {{ t('components.audio.ChannelEntries.help.subscribe') }}
</p> </p>
</empty-state> </empty-state>
</template> </template>
</template> </template>

View File

@ -8,7 +8,6 @@ import { useQueue } from '~/composables/audio/queue'
import { useStore } from '~/store' import { useStore } from '~/store'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue' import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import PlayButton from '~/components/audio/PlayButton.vue' import PlayButton from '~/components/audio/PlayButton.vue'

View File

@ -164,7 +164,8 @@ defineExpose({
</script> </script>
<template> <template>
<Layout form <Layout
form
class="ui form" class="ui form"
@submit.prevent.stop="submit" @submit.prevent.stop="submit"
> >

View File

@ -76,22 +76,22 @@ fetchData()
</template> </template>
<template v-if="nextPage"> <template v-if="nextPage">
<Button <Button
secondary
v-if="nextPage" v-if="nextPage"
secondary
@click="fetchData(nextPage)" @click="fetchData(nextPage)"
> >
{{ t('components.audio.ChannelSeries.button.showMore') }} {{ t('components.audio.ChannelSeries.button.showMore') }}
</Button> </Button>
</template> </template>
</Layout> </Layout>
<template v-if="!isLoading && albums.length === 0"> <template v-if="!isLoading && albums.length === 0">
<empty-state <empty-state
:refresh="true" :refresh="true"
@refresh="fetchData()" @refresh="fetchData()"
> >
<p> <p>
{{ t('components.audio.ChannelSeries.help.subscribe') }} {{ t('components.audio.ChannelSeries.help.subscribe') }}
</p> </p>
</empty-state> </empty-state>
</template> </template>
</template> </template>

View File

@ -26,7 +26,7 @@ interface Props {
const emit = defineEmits<Events>() const emit = defineEmits<Events>()
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
limit: 5, limit: 5
}) })
const result = ref<PaginatedChannelList>() const result = ref<PaginatedChannelList>()
@ -43,7 +43,7 @@ const fetchData = async (url = 'channels/') => {
const params: operations['get_channels_2']['parameters']['query'] = { const params: operations['get_channels_2']['parameters']['query'] = {
...clone(props.filters), ...clone(props.filters),
page: page.value, page: page.value,
page_size: props.limit, page_size: props.limit
} }
try { try {
@ -75,14 +75,17 @@ watch([() => props.filters, page],
small-items small-items
:h2="title || undefined" :h2="title || undefined"
> >
<Loader v-if="isLoading" style="grid-column: 1 / -1;" /> <Loader
v-if="isLoading"
style="grid-column: 1 / -1;"
/>
<template <template
v-if="!isLoading && result?.count === 0" v-if="!isLoading && result?.count === 0"
> >
<empty-state <empty-state
:refresh="true" :refresh="true"
@refresh="fetchData('channels/')"
style="grid-column: 1 / -1;" style="grid-column: 1 / -1;"
@refresh="fetchData('channels/')"
/> />
</template> </template>
<Pagination <Pagination

View File

@ -14,7 +14,6 @@ import OptionsButton from '~/components/ui/button/Options.vue'
import Popover from '~/components/ui/Popover.vue' import Popover from '~/components/ui/Popover.vue'
import PopoverItem from '~/components/ui/popover/PopoverItem.vue' import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
interface Props extends PlayOptionsProps { interface Props extends PlayOptionsProps {
split?: boolean split?: boolean
dropdownIconClasses?: string[] dropdownIconClasses?: string[]
@ -110,8 +109,8 @@ const isOpen = ref(false)
<OptionsButton <OptionsButton
v-if="dropdownOnly" v-if="dropdownOnly"
v-bind="$attrs" v-bind="$attrs"
:is-ghost="discrete"
@click="isOpen = !isOpen" @click="isOpen = !isOpen"
:isGhost="discrete"
/> />
<Button <Button
v-else v-else
@ -124,7 +123,7 @@ const isOpen = ref(false)
:aria-label="labels.replacePlay" :aria-label="labels.replacePlay"
:class="[...buttonClasses, 'play-button']" :class="[...buttonClasses, 'play-button']"
:isloading="isLoading" :isloading="isLoading"
:dropdownOnly="dropdownOnly" :dropdown-only="dropdownOnly"
@click.stop.prevent="replacePlay()" @click.stop.prevent="replacePlay()"
@split-click="isOpen = !isOpen" @split-click="isOpen = !isOpen"
> >
@ -137,7 +136,9 @@ const isOpen = ref(false)
v-else v-else
:class="['bi', playIconClass]" :class="['bi', playIconClass]"
/> />
<template v-if="!discrete && !iconOnly">&nbsp;<slot>{{ t('components.audio.PlayButton.button.discretePlay') }}</slot></template> <template v-if="!discrete && !iconOnly">
&nbsp;<slot>{{ t('components.audio.PlayButton.button.discretePlay') }}</slot>
</template>
</template> </template>
</Button> </Button>
@ -200,7 +201,7 @@ const isOpen = ref(false)
</span> </span>
</PopoverItem> </PopoverItem>
<hr v-if="filterableArtists || Object.keys(getReportableObjects({track, album, artist, playlist, account, channel})).length > 0" /> <hr v-if="filterableArtists || Object.keys(getReportableObjects({track, album, artist, playlist, account, channel})).length > 0">
<PopoverItem <PopoverItem
v-if="filterableArtists" v-if="filterableArtists"

View File

@ -51,9 +51,11 @@ const { t } = useI18n()
/** Toggle between null and player */ /** Toggle between null and player */
const togglePlayer = () => { const togglePlayer = () => {
store.commit('ui/queueFocused', store.commit('ui/queueFocused',
store.state.ui.queueFocused === 'queue' ? null store.state.ui.queueFocused === 'queue'
: store.state.ui.queueFocused === 'player' ? null ? null
: 'player' : store.state.ui.queueFocused === 'player'
? null
: 'player'
) )
} }
@ -166,11 +168,11 @@ const hideArtist = () => {
class="ui tiny image" class="ui tiny image"
@click.stop.prevent="router.push({name: 'library.tracks.detail', params: {id: currentTrack.id }})" @click.stop.prevent="router.push({name: 'library.tracks.detail', params: {id: currentTrack.id }})"
> >
<!-- TODO: Use smaller covers --> <!-- TODO: Use smaller covers -->
<img <img
ref="cover" ref="cover"
alt=""
v-lazy="store.getters['instance/absoluteUrl'](currentTrack.coverUrl)" v-lazy="store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
alt=""
> >
</div> </div>
<div <div
@ -217,11 +219,11 @@ const hideArtist = () => {
</div> </div>
<div class="controls track-controls queue-not-focused desktop-and-below"> <div class="controls track-controls queue-not-focused desktop-and-below">
<div class="ui tiny image"> <div class="ui tiny image">
<!-- TODO: Use smaller covers --> <!-- TODO: Use smaller covers -->
<img <img
ref="cover" ref="cover"
alt=""
v-lazy="store.getters['instance/absoluteUrl'](currentTrack.coverUrl)" v-lazy="store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
alt=""
> >
</div> </div>
<div class="middle aligned content ellipsis"> <div class="middle aligned content ellipsis">
@ -358,8 +360,7 @@ const hideArtist = () => {
class="close-control desktop-and-below" class="close-control desktop-and-below"
icon="bi-x" icon="bi-x"
@click.stop="store.commit('ui/queueFocused', null)" @click.stop="store.commit('ui/queueFocused', null)"
> />
</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -72,7 +72,7 @@ const labels = computed(() => ({
<template> <template>
<div> <div>
/front/src/components/audio/Search.vue /front/src/components/audio/Search.vue
<h2> <h2>
{{ t('components.audio.Search.header.search') }} {{ t('components.audio.Search.header.search') }}
</h2> </h2>

View File

@ -9,7 +9,6 @@ import { usePlayer } from '~/composables/audio/player'
import { useQueue } from '~/composables/audio/queue' import { useQueue } from '~/composables/audio/queue'
import { useStore } from '~/store' import { useStore } from '~/store'
import usePlayOptions from '~/composables/audio/usePlayOptions' import usePlayOptions from '~/composables/audio/usePlayOptions'
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue' import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'

View File

@ -93,9 +93,9 @@ const labels = computed(() => ({
<template> <template>
<Modal <Modal
:title="track.title"
ref="modal" ref="modal"
v-model="show" v-model="show"
:title="track.title"
:scrolling="true" :scrolling="true"
class="scrolling-track-options" class="scrolling-track-options"
> >

View File

@ -9,7 +9,6 @@ import { usePlayer } from '~/composables/audio/player'
import { useQueue } from '~/composables/audio/queue' import { useQueue } from '~/composables/audio/queue'
import { useStore } from '~/store' import { useStore } from '~/store'
import usePlayOptions from '~/composables/audio/usePlayOptions' import usePlayOptions from '~/composables/audio/usePlayOptions'
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue' import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'

View File

@ -93,9 +93,10 @@ const labels = computed(() => ({
</script> </script>
<template> <template>
<Modal :title="track.title" <Modal
ref="modal" ref="modal"
v-model="show" v-model="show"
:title="track.title"
:scrolling="true" :scrolling="true"
class="scrolling-track-options" class="scrolling-track-options"
> >

View File

@ -13,7 +13,6 @@ import { usePlayer } from '~/composables/audio/player'
import { useQueue } from '~/composables/audio/queue' import { useQueue } from '~/composables/audio/queue'
import { useStore } from '~/store' import { useStore } from '~/store'
const store = useStore() const store = useStore()
interface Props extends PlayOptionsProps { interface Props extends PlayOptionsProps {
@ -65,10 +64,10 @@ const hover = ref(false)
<template> <template>
<div <div
:class="[{ active }, 'track-row row', $style.row]" :class="[{ active }, 'track-row row', $style.row]"
style="display: contents;"
@dblclick="activateTrack(track, index)" @dblclick="activateTrack(track, index)"
@mousemove="hover = true" @mousemove="hover = true"
@mouseout="hover = false" @mouseout="hover = false"
style="display: contents;"
> >
<!-- 1. column: Play button or track position --> <!-- 1. column: Play button or track position -->
@ -153,63 +152,62 @@ const hover = ref(false)
</div> </div>
<!-- third column: title ! --> <!-- third column: title ! -->
<div <div
tabindex="0" tabindex="0"
class="content ellipsis column" class="content ellipsis column"
>
<a
@click="activateTrack(track, index)"
> >
<a {{ track.title }}
@click="activateTrack(track, index)" </a>
> </div>
{{ track.title }}
</a>
</div>
<!-- 4. column: album link --> <!-- 4. column: album link -->
<div <div
class="content ellipsis column" class="content ellipsis column"
>
<router-link
v-if="showAlbum"
:to="{ name: 'library.albums.detail', params: { id: track.album?.id } }"
>
{{ track.album?.title }}
</router-link>
</div>
<!-- 5. column: artist link -->
<div
class="content ellipsis column"
>
<template
v-for="ac in track.artist_credit"
v-if="showArtist"
:key="ac.artist.id"
> >
<router-link <router-link
v-if="showAlbum" class="artist link"
:to="{ name: 'library.albums.detail', params: { id: track.album?.id } }" :to="{
name: 'library.artists.detail',
params: { id: ac.artist?.id },
}"
> >
{{ track.album?.title }} {{ ac.credit }}
</router-link> </router-link>
</div> <span>{{ ac.joinphrase }}</span>
</template>
</div>
<!-- 5. column: artist link --> <!-- 6. column: favorite icon -->
<div <div
class="content ellipsis column" class="meta column"
> >
<template <track-favorite-icon
v-if="showArtist" v-if="store.state.auth.authenticated"
v-for="ac in track.artist_credit" ghost
:key="ac.artist.id" :track="track"
> />
<router-link </div>
class="artist link"
:to="{
name: 'library.artists.detail',
params: { id: ac.artist?.id },
}"
>
{{ ac.credit }}
</router-link>
<span>{{ ac.joinphrase }}</span>
</template>
</div>
<!-- 6. column: favorite icon -->
<div
class="meta column"
>
<track-favorite-icon
v-if="store.state.auth.authenticated"
ghost
:track="track"
/>
</div>
<!-- 7. column: duration --> <!-- 7. column: duration -->
<div <div

View File

@ -2,11 +2,9 @@
import type { Track } from '~/types' import type { Track } from '~/types'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { clone, uniqBy } from 'lodash-es' import { clone, uniqBy, sortedUniq } from 'lodash-es'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import { sortedUniq } from 'lodash-es'
import axios from 'axios' import axios from 'axios'
@ -151,15 +149,15 @@ const updatePage = (page: number) => {
<template> <template>
<div> <div>
<!-- Show the search bar if search is true --> <!-- Show the search bar if search is true -->
<Input search <Input
v-if="search" v-if="search"
v-model="query" v-model="query"
@search="performSearch" search
autofocus autofocus
:placeholder="labels.searchPlaceholder" :placeholder="labels.searchPlaceholder"
@search="performSearch"
/> />
<Spacer v-if="search" /> <Spacer v-if="search" />
@ -206,7 +204,10 @@ const updatePage = (page: number) => {
</label> </label>
<label /> <label />
<label> <label>
<i v-if="showDuration" class="bi bi-clock" /> <i
v-if="showDuration"
class="bi bi-clock"
/>
</label> </label>
<label /> <label />
</template> </template>
@ -225,7 +226,6 @@ const updatePage = (page: number) => {
:show-duration="showDuration" :show-duration="showDuration"
:is-podcast="isPodcast" :is-podcast="isPodcast"
/> />
</Table> </Table>
<!-- Pagination --> <!-- Pagination -->

View File

@ -22,7 +22,6 @@ import Pagination from '~/components/ui/Pagination.vue'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
interface Events { interface Events {
(e: 'count', count: number): void (e: 'count', count: number): void
} }
@ -97,19 +96,19 @@ watch(() => props.websocketHandlers.includes('Listen'), (to) => {
// Handle WebSocket events for "Listen" // Handle WebSocket events for "Listen"
// Add the event to `objects` reactively // Add the event to `objects` reactively
objects.unshift(event as Listening); objects.unshift(event as Listening)
// Keep the array size within limits (e.g., remove the last item if needed) // Keep the array size within limits (e.g., remove the last item if needed)
if (objects.length > props.limit) { if (objects.length > props.limit) {
objects.pop(); objects.pop()
} }
// Recompute coverUrl for the updated `objects` // Recompute coverUrl for the updated `objects`
console.log('WebSocket event received:', event); console.log('WebSocket event received:', event)
console.log('Updated cover URL:', coverUrl.value); console.log('Updated cover URL:', coverUrl.value)
}); })
} }
}, { immediate: true }); }, { immediate: true })
</script> </script>
<template> <template>
@ -118,7 +117,8 @@ watch(() => props.websocketHandlers.includes('Listen'), (to) => {
medium-items medium-items
align-left align-left
> >
<Loader v-if="isLoading" <Loader
v-if="isLoading"
style="grid-column: 1 / -1;" style="grid-column: 1 / -1;"
/> />
<Alert <Alert
@ -133,11 +133,13 @@ watch(() => props.websocketHandlers.includes('Listen'), (to) => {
</h4> </h4>
</Alert> </Alert>
<!-- TODO: Use activity.vue --> <!-- TODO: Use activity.vue -->
<div class="funkwhale activity" <div
v-if="count > 0"
v-for="object in objects" v-for="object in objects"
v-if="count > 0"
:key="object.id" :key="object.id"
:class="['item', itemClasses]"> class="funkwhale activity"
:class="['item', itemClasses]"
>
<div class="activity-image"> <div class="activity-image">
<img <img
v-if="object.track.album && object.track.album.cover" v-if="object.track.album && object.track.album.cover"
@ -161,13 +163,16 @@ watch(() => props.websocketHandlers.includes('Listen'), (to) => {
<!-- TODO: Add Playbutton overlay --> <!-- TODO: Add Playbutton overlay -->
</div> </div>
<div class="activity-content"> <div class="activity-content">
<router-link <router-link
class="funkwhale link artist" class="funkwhale link artist"
:to="{name: 'library.tracks.detail', params: {id: object.track.id}}" :to="{name: 'library.tracks.detail', params: {id: object.track.id}}"
> >
<Heading :h3="object.track.title" title /> <Heading
</router-link> :h3="object.track.title"
<Spacer :size="2"/> title
/>
</router-link>
<Spacer :size="2" />
<div <div
v-if="object.track.artist_credit" v-if="object.track.artist_credit"
class="funkwhale link artist" class="funkwhale link artist"
@ -192,7 +197,7 @@ watch(() => props.websocketHandlers.includes('Listen'), (to) => {
:show-more="false" :show-more="false"
:tags="object.track.tags" :tags="object.track.tags"
/> />
<Spacer :size="4"/> <Spacer :size="4" />
<div <div
v-if="isActivity" v-if="isActivity"
class="extra" class="extra"
@ -222,7 +227,6 @@ watch(() => props.websocketHandlers.includes('Listen'), (to) => {
</Section> </Section>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.funkwhale { .funkwhale {
&.activity { &.activity {

View File

@ -114,8 +114,9 @@ whenever(() => props.clientId, fetchApplication, { immediate: true })
</script> </script>
<template> <template>
<layout main <layout
v-title="labels.title" v-title="labels.title"
main
class="main" class="main"
> >
<section class="ui vertical stripe segment"> <section class="ui vertical stripe segment">
@ -123,8 +124,9 @@ whenever(() => props.clientId, fetchApplication, { immediate: true })
<h2> <h2>
<i class="bi bi-unlock-fill" />{{ t('components.auth.Authorize.header.authorize') }} <i class="bi bi-unlock-fill" />{{ t('components.auth.Authorize.header.authorize') }}
</h2> </h2>
<Alert red <Alert
v-if="errors.length > 0" v-if="errors.length > 0"
red
role="alert" role="alert"
> >
<h4 <h4

View File

@ -75,72 +75,85 @@ const submit = async () => {
</script> </script>
<template> <template>
<Layout form stack <Layout
form
stack
style="max-width: 600px" style="max-width: 600px"
@submit.prevent="submit()" @submit.prevent="submit()"
> >
<Alert red <Alert
v-if="errors.length > 0" v-if="errors.length > 0"
red
> >
<h4 class="header"> <h4 class="header">
{{ t('components.auth.LoginForm.header.loginFailure') }} {{ t('components.auth.LoginForm.header.loginFailure') }}
</h4> </h4>
<component :is="errors.length>1 ? 'ul' : 'div'" class="list"> <component
<component :is="errors.length>1 ? 'li' : 'div'" :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" v-if="errors[0] == 'invalid_credentials' && store.state.instance.settings.moderation.signup_approval_enabled.value"
> >
{{ t('components.auth.LoginForm.help.approvalRequired') }} {{ t('components.auth.LoginForm.help.approvalRequired') }}
</component> </component>
<component :is="errors.length>1 ? 'li' : 'div'" <component
v-else-if="errors[0] == 'invalid_credentials'"> :is="errors.length>1 ? 'li' : 'div'"
v-else-if="errors[0] == 'invalid_credentials'"
>
{{ t('components.auth.LoginForm.help.invalidCredentials') }} {{ t('components.auth.LoginForm.help.invalidCredentials') }}
</component> </component>
<component :is="errors.length>1 ? 'li' : 'div'" v-else> <component
:is="errors.length>1 ? 'li' : 'div'"
v-else
>
{{ errors[0] }} {{ errors[0] }}
</component> </component>
</component> </component>
</Alert> </Alert>
<Spacer /> <Spacer />
<template v-if="domain === store.getters['instance/domain']"> <template v-if="domain === store.getters['instance/domain']">
<Input <Input
id="username-field" id="username-field"
ref="username" ref="username"
autocomplete="username" v-model="credentials.username"
v-model="credentials.username" autocomplete="username"
required required
name="username" name="username"
type="text" type="text"
autofocus autofocus
:placeholder="labels.usernamePlaceholder" :placeholder="labels.usernamePlaceholder"
> >
<template #label> <template #label>
{{ t('components.auth.LoginForm.label.username') }} {{ t('components.auth.LoginForm.label.username') }}
<template v-if="showSignup"> <template v-if="showSignup">
<span class="middle pipe symbol" />
<router-link :to="{ path: '/signup' }">
{{ t('components.auth.LoginForm.link.createAccount') }}
</router-link>
</template>
</template>
</Input>
<Input password
name="password-field"
autocomplete="current-password"
v-model="credentials.password"
field-id="password-field"
required
>
<template #label>
{{ t('components.auth.LoginForm.label.password') }}
<span class="middle pipe symbol" /> <span class="middle pipe symbol" />
<router-link <router-link :to="{ path: '/signup' }">
tabindex="1" {{ t('components.auth.LoginForm.link.createAccount') }}
:to="{ name: 'auth.password-reset', query: { email: credentials.username } }"
>
{{ t('components.auth.LoginForm.link.resetPassword') }}
</router-link> </router-link>
</template> </template>
</Input> </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') }}
</router-link>
</template>
</Input>
</template> </template>
<template v-else> <template v-else>
<p> <p>

View File

@ -40,8 +40,9 @@ const labels = computed(() => ({
{{ t('components.auth.Logout.button.logout') }} {{ t('components.auth.Logout.button.logout') }}
</Button> </Button>
</div> </div>
<Alert yellow <Alert
v-else v-else
yellow
> >
<h2> <h2>
{{ t('components.auth.Logout.header.unauthenticated') }} {{ t('components.auth.Logout.header.unauthenticated') }}
@ -49,7 +50,7 @@ const labels = computed(() => ({
<Link <Link
solid solid
secondary secondary
buttonWidth button-width
to="/login" to="/login"
> >
{{ t('components.auth.Logout.link.login') }} {{ t('components.auth.Logout.link.login') }}

View File

@ -272,23 +272,27 @@ fetchOwnedApps()
</script> </script>
<template> <template>
<Layout main stack <Layout
v-title="labels.title" v-title="labels.title"
main
stack
> >
<Header :h1="t('components.auth.Settings.header.accountSettings')"> <Header :h1="t('components.auth.Settings.header.accountSettings')" />
</Header> <Layout
<Layout form form
@submit.prevent="submitSettings()" @submit.prevent="submitSettings()"
> >
<Alert green <Alert
v-if="settings.success" v-if="settings.success"
green
> >
<h4 class="header"> <h4 class="header">
{{ t('components.auth.Settings.header.settingsUpdated') }} {{ t('components.auth.Settings.header.settingsUpdated') }}
</h4> </h4>
</Alert> </Alert>
<Alert red <Alert
v-if="settings.errors.length > 0" v-if="settings.errors.length > 0"
red
role="alert" role="alert"
> >
<h4 class="header"> <h4 class="header">
@ -340,466 +344,472 @@ fetchOwnedApps()
{{ t('components.auth.Settings.button.updateSettings') }} {{ t('components.auth.Settings.button.updateSettings') }}
</Button> </Button>
</Layout> </Layout>
<section class="ui text container"> <section class="ui text container">
<h2 class="ui header"> <h2 class="ui header">
{{ t('components.auth.Settings.header.avatar') }} {{ t('components.auth.Settings.header.avatar') }}
</h2> </h2>
<Layout form> <Layout form>
<Alert red <Alert
v-if="avatarErrors.length > 0" v-if="avatarErrors.length > 0"
role="alert" red
>
<h4 class="header">
{{ t('components.auth.Settings.header.avatarFailure') }}
</h4>
<ul class="list">
<li
v-for="(error, key) in avatarErrors"
:key="key"
>
{{ error }}
</li>
</ul>
</Alert>
<attachment-input
v-model="avatar.uuid"
:initial-value="initialAvatar"
@update:model-value="submitAvatar($event)"
@delete="avatar = {uuid: null}"
>
{{ t('components.auth.Settings.label.avatar') }}
</attachment-input>
</Layout>
</section>
<section class="ui text container">
<h2 class="ui header">
{{ t('components.auth.Settings.header.changePassword') }}
</h2>
<div class="ui message">
{{ t('components.auth.Settings.description.changePassword.paragraph1') }}&nbsp;{{ t('components.auth.Settings.description.changePassword.paragraph2') }}
</div>
<Layout form
@submit.prevent="submitPassword()"
>
<Alert
v-if="passwordError"
role="alert"
>
<h4 class="header">
{{ t('components.auth.Settings.header.passwordFailure') }}
</h4>
<ul class="list">
<li v-if="passwordError == 'invalid_credentials'">
{{ t('components.auth.Settings.help.changePassword') }}
</li>
</ul>
</Alert>
<div class="field">
<label for="old-password-field">{{ t('components.auth.Settings.label.currentPassword') }}</label>
<password-input
v-model="credentials.oldPassword"
field-id="old-password-field"
required
/>
</div>
<div class="field">
<label for="new-password-field">{{ t('components.auth.Settings.label.newPassword') }}</label>
<password-input
v-model="credentials.newPassword"
field-id="new-password-field"
required
/>
</div>
<dangerous-button
:class="['ui', {'loading': isLoadingPassword}, {disabled: !credentials.newPassword || !credentials.oldPassword}, 'warning', 'button']"
:action="submitPassword"
:title="t('components.auth.Settings.modal.changePassword.header')"
>
{{ t('components.auth.Settings.button.password') }}
<template #modal-content>
<div>
<p>
{{ t('components.auth.Settings.modal.changePassword.content.warning') }}
</p>
<ul>
<li>
{{ t('components.auth.Settings.modal.changePassword.content.logout') }}
</li>
<li>
{{ t('components.auth.Settings.modal.changePassword.content.subsonic') }}
</li>
</ul>
</div>
</template>
<template #modal-confirm>
<div>
{{ t('components.auth.Settings.button.disableSubsonic') }}
</div>
</template>
</dangerous-button>
</Layout>
<div class="ui hidden divider" />
<subsonic-token-form />
</section>
<section
id="content-filters"
class="ui text container"
>
<h2 class="ui header">
<i class="bi bi-eye-slash" />
<div class="content">
{{ t('components.auth.Settings.header.contentFilters') }}
</div>
</h2>
<p>
{{ t('components.auth.Settings.description.contentFilters') }}
</p>
<Button
primary
icon="bi-arrow-clockwise"
@click="store.dispatch('moderation/fetchContentFilters')"
>
{{ t('components.auth.Settings.button.refresh') }}
</Button>
<h3 class="ui header">
{{ t('components.auth.Settings.header.hiddenArtists') }}
</h3>
<table class="ui compact very basic unstackable table">
<thead>
<tr>
<th>
{{ t('components.auth.Settings.table.artists.header.name') }}
</th>
<th>
{{ t('components.auth.Settings.table.artists.header.creationDate') }}
</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="filter in store.getters['moderation/artistFilters']()"
:key="filter.uuid"
>
<td>
<router-link :to="{name: 'library.artists.detail', params: {id: filter.target.id }}">
{{ filter.target.name }}
</router-link>
</td>
<td>
<human-date :date="filter.creation_date" />
</td>
<td>
<Button
secondary
@click="store.dispatch('moderation/deleteContentFilter', filter.uuid)"
>
{{ t('components.auth.Settings.button.delete') }}
</Button>
</td>
</tr>
</tbody>
</table>
</section>
<section
id="grants"
class="ui text container"
>
<div class="ui hidden divider" />
<h2 class="ui header">
<i class="bi bi-unlock-fill" />
<div class="content">
{{ t('components.auth.Settings.header.authorizedApps') }}
</div>
</h2>
<p>
{{ t('components.auth.Settings.description.authorizedApps') }}
</p>
<Button
primary
icon="bi-arrow-clockwise"
:is-loading="isLoadingApps"
@click="fetchApps()"
>
{{ t('components.auth.Settings.button.refresh') }}
</Button>
<table
v-if="apps.length > 0"
class="ui compact very basic unstackable table"
>
<thead>
<tr>
<th>
{{ t('components.auth.Settings.table.authorizedApps.header.application') }}
</th>
<th>
{{ t('components.auth.Settings.table.authorizedApps.header.permissions') }}
</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="app in apps"
:key="app.client_id"
>
<td>
{{ app.name }}
</td>
<td>
{{ app.scopes }}
</td>
<td>
<dangerous-button
:class="['ui', 'tiny', 'danger', { loading: isRevoking.has(app.client_id) }, 'button']"
@confirm="revokeApp(app.client_id)"
:title="t('components.auth.Settings.modal.revokeApp.header', {app: app.name})"
>
{{ t('components.auth.Settings.button.revoke') }}
<template #modal-content>
{{ t('components.auth.Settings.modal.revokeApp.content.warning') }}
</template>
<template #modal-confirm>
{{ t('components.auth.Settings.button.revokeAccess') }}
</template>
</dangerous-button>
</td>
</tr>
</tbody>
</table>
<empty-state v-else>
<template #title>
{{ t('components.auth.Settings.header.noApps') }}
</template>
{{ t('components.auth.Settings.help.noApps') }}
</empty-state>
</section>
<section
id="apps"
class="ui text container"
>
<div class="ui hidden divider" />
<h2 class="ui header">
<i class="bi bi-code-slash" />
<div class="content">
{{ t('components.auth.Settings.header.yourApps') }}
</div>
</h2>
<p>
{{ t('components.auth.Settings.description.yourApps') }}
</p>
<Link
class="ui success button"
:to="{name: 'settings.applications.new'}"
>
{{ t('components.auth.Settings.link.newApp') }}
</Link>
<table
v-if="ownedApps.length > 0"
class="ui compact very basic unstackable table"
>
<thead>
<tr>
<th>
{{ t('components.auth.Settings.table.yourApps.header.application') }}
</th>
<th>
{{ t('components.auth.Settings.table.yourApps.header.scopes') }}
</th>
<th>
{{ t('components.auth.Settings.table.yourApps.header.creationDate') }}
</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="app in ownedApps"
:key="app.client_id"
>
<td>
<router-link :to="{name: 'settings.applications.edit', params: {id: app.client_id}}">
{{ app.name }}
</router-link>
</td>
<td>
{{ app.scopes }}
</td>
<td>
<human-date :date="app.created" />
</td>
<td>
<Link
class="ui tiny success button"
:to="{name: 'settings.applications.edit', params: {id: app.client_id}}"
>
{{ t('components.auth.Settings.button.edit') }}
</Link>
<DangerousButton
:is-loading="isDeleting.has(app.client_id)"
class="tiny"
@confirm="deleteApp(app.client_id)"
:title="t('components.auth.Settings.modal.deleteApp.header', {app: app.name})"
>
{{ t('components.auth.Settings.button.remove') }}
<template #modal-content>
{{ t('components.auth.Settings.modal.deleteApp.content.warning') }}
</template>
<template #modal-confirm>
{{ t('components.auth.Settings.button.removeApp') }}
</template>
</DangerousButton>
</td>
</tr>
</tbody>
</table>
<empty-state v-else>
<template #title>
{{ t('components.auth.Settings.header.noPersonalApps') }}
</template>
{{ t('components.auth.Settings.help.noPersonalApps') }}
</empty-state>
</section>
<section
id="plugins"
class="ui text container"
>
<div class="ui hidden divider" />
<h2 class="ui header">
<i class="bi bi-code" />
<div class="content">
{{ t('components.auth.Settings.header.plugins') }}
</div>
</h2>
<p>
{{ t('components.auth.Settings.description.plugins') }}
</p>
<Button
primary
:to="{name: 'settings.plugins'}"
>
{{ t('components.auth.Settings.link.managePlugins') }}
</Button>
</section>
<section class="ui text container">
<div class="ui hidden divider" />
<h2 class="ui header">
<i class="bi bi-envelope-at" />
<div class="content">
{{ t('components.auth.Settings.header.changeEmail') }}
</div>
</h2>
<p>
{{ t('components.auth.Settings.description.changeEmail') }}
</p>
<p>
{{ t('components.auth.Settings.message.currentEmail', { email: store.state.auth.profile?.email }) }}
</p>
<Layout form
@submit.prevent="changeEmail"
>
<Alert red
v-if="changeEmailErrors.length > 0"
role="alert"
>
<h4 class="header">
{{ t('components.auth.Settings.header.emailFailure') }}
</h4>
<ul class="list">
<li
v-for="(error, key) in changeEmailErrors"
:key="key"
>
{{ error }}
</li>
</ul>
</Alert>
<div class="field">
<label for="new-email">{{ t('components.auth.Settings.label.newEmail') }}</label>
<Input
id="new-email"
v-model="newEmail"
required
type="email"
/>
</div>
<div class="field">
<label for="current-password-field-email">{{ t('components.auth.Settings.label.password') }}</label>
<password-input
v-model="emailPassword"
field-id="current-password-field-email"
required
/>
</div>
<Button
primary
type="submit"
>
{{ t('components.auth.Settings.button.update') }}
</Button>
</Layout>
</section>
<section class="ui text container">
<div class="ui hidden divider" />
<h2 class="ui header">
<i class="bi bi-trash" />
<div class="content">
{{ t('components.auth.Settings.header.deleteAccount') }}
</div>
</h2>
<p>
{{ t('components.auth.Settings.description.deleteAccount') }}
</p>
<Alert yellow
role="alert" role="alert"
> >
{{ t('components.auth.Settings.warning.deleteAccount') }} <h4 class="header">
{{ t('components.auth.Settings.header.avatarFailure') }}
</h4>
<ul class="list">
<li
v-for="(error, key) in avatarErrors"
:key="key"
>
{{ error }}
</li>
</ul>
</Alert> </Alert>
<Layout form> <attachment-input
<Alert red v-model="avatar.uuid"
v-if="accountDeleteErrors.length > 0" :initial-value="initialAvatar"
role="alert" @update:model-value="submitAvatar($event)"
@delete="avatar = {uuid: null}"
>
{{ t('components.auth.Settings.label.avatar') }}
</attachment-input>
</Layout>
</section>
<section class="ui text container">
<h2 class="ui header">
{{ t('components.auth.Settings.header.changePassword') }}
</h2>
<div class="ui message">
{{ t('components.auth.Settings.description.changePassword.paragraph1') }}&nbsp;{{ t('components.auth.Settings.description.changePassword.paragraph2') }}
</div>
<Layout
form
@submit.prevent="submitPassword()"
>
<Alert
v-if="passwordError"
role="alert"
>
<h4 class="header">
{{ t('components.auth.Settings.header.passwordFailure') }}
</h4>
<ul class="list">
<li v-if="passwordError == 'invalid_credentials'">
{{ t('components.auth.Settings.help.changePassword') }}
</li>
</ul>
</Alert>
<div class="field">
<label for="old-password-field">{{ t('components.auth.Settings.label.currentPassword') }}</label>
<password-input
v-model="credentials.oldPassword"
field-id="old-password-field"
required
/>
</div>
<div class="field">
<label for="new-password-field">{{ t('components.auth.Settings.label.newPassword') }}</label>
<password-input
v-model="credentials.newPassword"
field-id="new-password-field"
required
/>
</div>
<dangerous-button
:class="['ui', {'loading': isLoadingPassword}, {disabled: !credentials.newPassword || !credentials.oldPassword}, 'warning', 'button']"
:action="submitPassword"
:title="t('components.auth.Settings.modal.changePassword.header')"
>
{{ t('components.auth.Settings.button.password') }}
<template #modal-content>
<div>
<p>
{{ t('components.auth.Settings.modal.changePassword.content.warning') }}
</p>
<ul>
<li>
{{ t('components.auth.Settings.modal.changePassword.content.logout') }}
</li>
<li>
{{ t('components.auth.Settings.modal.changePassword.content.subsonic') }}
</li>
</ul>
</div>
</template>
<template #modal-confirm>
<div>
{{ t('components.auth.Settings.button.disableSubsonic') }}
</div>
</template>
</dangerous-button>
</Layout>
<div class="ui hidden divider" />
<subsonic-token-form />
</section>
<section
id="content-filters"
class="ui text container"
>
<h2 class="ui header">
<i class="bi bi-eye-slash" />
<div class="content">
{{ t('components.auth.Settings.header.contentFilters') }}
</div>
</h2>
<p>
{{ t('components.auth.Settings.description.contentFilters') }}
</p>
<Button
primary
icon="bi-arrow-clockwise"
@click="store.dispatch('moderation/fetchContentFilters')"
>
{{ t('components.auth.Settings.button.refresh') }}
</Button>
<h3 class="ui header">
{{ t('components.auth.Settings.header.hiddenArtists') }}
</h3>
<table class="ui compact very basic unstackable table">
<thead>
<tr>
<th>
{{ t('components.auth.Settings.table.artists.header.name') }}
</th>
<th>
{{ t('components.auth.Settings.table.artists.header.creationDate') }}
</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="filter in store.getters['moderation/artistFilters']()"
:key="filter.uuid"
> >
<h4 class="header"> <td>
{{ t('components.auth.Settings.header.accountFailure') }} <router-link :to="{name: 'library.artists.detail', params: {id: filter.target.id }}">
</h4> {{ filter.target.name }}
<ul class="list"> </router-link>
<li </td>
v-for="(error, key) in accountDeleteErrors" <td>
:key="key" <human-date :date="filter.creation_date" />
</td>
<td>
<Button
secondary
@click="store.dispatch('moderation/deleteContentFilter', filter.uuid)"
> >
{{ error }} {{ t('components.auth.Settings.button.delete') }}
</li> </Button>
</ul> </td>
</Alert> </tr>
<div class="field"> </tbody>
<label for="current-password-field">{{ t('components.auth.Settings.label.currentPassword') }}</label> </table>
<password-input </section>
v-model="deleteAccountPassword" <section
field-id="current-password-field" id="grants"
required class="ui text container"
/> >
</div> <div class="ui hidden divider" />
<dangerous-button <h2 class="ui header">
:is-loading="isDeletingAccount" <i class="bi bi-unlock-fill" />
:disabled="!deleteAccountPassword || undefined" <div class="content">
:class="{danger: deleteAccountPassword}" {{ t('components.auth.Settings.header.authorizedApps') }}
:action="deleteAccount" </div>
:title="t('components.auth.Settings.modal.deleteAccount.header')" </h2>
<p>
{{ t('components.auth.Settings.description.authorizedApps') }}
</p>
<Button
primary
icon="bi-arrow-clockwise"
:is-loading="isLoadingApps"
@click="fetchApps()"
>
{{ t('components.auth.Settings.button.refresh') }}
</Button>
<table
v-if="apps.length > 0"
class="ui compact very basic unstackable table"
>
<thead>
<tr>
<th>
{{ t('components.auth.Settings.table.authorizedApps.header.application') }}
</th>
<th>
{{ t('components.auth.Settings.table.authorizedApps.header.permissions') }}
</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="app in apps"
:key="app.client_id"
> >
{{ t('components.auth.Settings.button.deleteAccount') }} <td>
<template #modal-content> {{ app.name }}
{{ t('components.auth.Settings.modal.deleteAccount.content.warning') }} </td>
</template> <td>
<template #modal-confirm> {{ app.scopes }}
{{ t('components.auth.Settings.button.deleteAccountConfirm') }} </td>
</template> <td>
</dangerous-button> <dangerous-button
</Layout> :class="['ui', 'tiny', 'danger', { loading: isRevoking.has(app.client_id) }, 'button']"
</section> :title="t('components.auth.Settings.modal.revokeApp.header', {app: app.name})"
@confirm="revokeApp(app.client_id)"
>
{{ t('components.auth.Settings.button.revoke') }}
<template #modal-content>
{{ t('components.auth.Settings.modal.revokeApp.content.warning') }}
</template>
<template #modal-confirm>
{{ t('components.auth.Settings.button.revokeAccess') }}
</template>
</dangerous-button>
</td>
</tr>
</tbody>
</table>
<empty-state v-else>
<template #title>
{{ t('components.auth.Settings.header.noApps') }}
</template>
{{ t('components.auth.Settings.help.noApps') }}
</empty-state>
</section>
<section
id="apps"
class="ui text container"
>
<div class="ui hidden divider" />
<h2 class="ui header">
<i class="bi bi-code-slash" />
<div class="content">
{{ t('components.auth.Settings.header.yourApps') }}
</div>
</h2>
<p>
{{ t('components.auth.Settings.description.yourApps') }}
</p>
<Link
class="ui success button"
:to="{name: 'settings.applications.new'}"
>
{{ t('components.auth.Settings.link.newApp') }}
</Link>
<table
v-if="ownedApps.length > 0"
class="ui compact very basic unstackable table"
>
<thead>
<tr>
<th>
{{ t('components.auth.Settings.table.yourApps.header.application') }}
</th>
<th>
{{ t('components.auth.Settings.table.yourApps.header.scopes') }}
</th>
<th>
{{ t('components.auth.Settings.table.yourApps.header.creationDate') }}
</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="app in ownedApps"
:key="app.client_id"
>
<td>
<router-link :to="{name: 'settings.applications.edit', params: {id: app.client_id}}">
{{ app.name }}
</router-link>
</td>
<td>
{{ app.scopes }}
</td>
<td>
<human-date :date="app.created" />
</td>
<td>
<Link
class="ui tiny success button"
:to="{name: 'settings.applications.edit', params: {id: app.client_id}}"
>
{{ t('components.auth.Settings.button.edit') }}
</Link>
<DangerousButton
:is-loading="isDeleting.has(app.client_id)"
class="tiny"
:title="t('components.auth.Settings.modal.deleteApp.header', {app: app.name})"
@confirm="deleteApp(app.client_id)"
>
{{ t('components.auth.Settings.button.remove') }}
<template #modal-content>
{{ t('components.auth.Settings.modal.deleteApp.content.warning') }}
</template>
<template #modal-confirm>
{{ t('components.auth.Settings.button.removeApp') }}
</template>
</DangerousButton>
</td>
</tr>
</tbody>
</table>
<empty-state v-else>
<template #title>
{{ t('components.auth.Settings.header.noPersonalApps') }}
</template>
{{ t('components.auth.Settings.help.noPersonalApps') }}
</empty-state>
</section>
<section
id="plugins"
class="ui text container"
>
<div class="ui hidden divider" />
<h2 class="ui header">
<i class="bi bi-code" />
<div class="content">
{{ t('components.auth.Settings.header.plugins') }}
</div>
</h2>
<p>
{{ t('components.auth.Settings.description.plugins') }}
</p>
<Button
primary
:to="{name: 'settings.plugins'}"
>
{{ t('components.auth.Settings.link.managePlugins') }}
</Button>
</section>
<section class="ui text container">
<div class="ui hidden divider" />
<h2 class="ui header">
<i class="bi bi-envelope-at" />
<div class="content">
{{ t('components.auth.Settings.header.changeEmail') }}
</div>
</h2>
<p>
{{ t('components.auth.Settings.description.changeEmail') }}
</p>
<p>
{{ t('components.auth.Settings.message.currentEmail', { email: store.state.auth.profile?.email }) }}
</p>
<Layout
form
@submit.prevent="changeEmail"
>
<Alert
v-if="changeEmailErrors.length > 0"
red
role="alert"
>
<h4 class="header">
{{ t('components.auth.Settings.header.emailFailure') }}
</h4>
<ul class="list">
<li
v-for="(error, key) in changeEmailErrors"
:key="key"
>
{{ error }}
</li>
</ul>
</Alert>
<div class="field">
<label for="new-email">{{ t('components.auth.Settings.label.newEmail') }}</label>
<Input
id="new-email"
v-model="newEmail"
required
type="email"
/>
</div>
<div class="field">
<label for="current-password-field-email">{{ t('components.auth.Settings.label.password') }}</label>
<password-input
v-model="emailPassword"
field-id="current-password-field-email"
required
/>
</div>
<Button
primary
type="submit"
>
{{ t('components.auth.Settings.button.update') }}
</Button>
</Layout>
</section>
<section class="ui text container">
<div class="ui hidden divider" />
<h2 class="ui header">
<i class="bi bi-trash" />
<div class="content">
{{ t('components.auth.Settings.header.deleteAccount') }}
</div>
</h2>
<p>
{{ t('components.auth.Settings.description.deleteAccount') }}
</p>
<Alert
yellow
role="alert"
>
{{ t('components.auth.Settings.warning.deleteAccount') }}
</Alert>
<Layout form>
<Alert
v-if="accountDeleteErrors.length > 0"
red
role="alert"
>
<h4 class="header">
{{ t('components.auth.Settings.header.accountFailure') }}
</h4>
<ul class="list">
<li
v-for="(error, key) in accountDeleteErrors"
:key="key"
>
{{ error }}
</li>
</ul>
</Alert>
<div class="field">
<label for="current-password-field">{{ t('components.auth.Settings.label.currentPassword') }}</label>
<password-input
v-model="deleteAccountPassword"
field-id="current-password-field"
required
/>
</div>
<dangerous-button
:is-loading="isDeletingAccount"
:disabled="!deleteAccountPassword || undefined"
:class="{danger: deleteAccountPassword}"
:action="deleteAccount"
:title="t('components.auth.Settings.modal.deleteAccount.header')"
>
{{ t('components.auth.Settings.button.deleteAccount') }}
<template #modal-content>
{{ t('components.auth.Settings.modal.deleteAccount.content.warning') }}
</template>
<template #modal-confirm>
{{ t('components.auth.Settings.button.deleteAccountConfirm') }}
</template>
</dangerous-button>
</Layout>
</section>
</Layout> </Layout>
</template> </template>

View File

@ -92,10 +92,16 @@ fetchInstanceSettings()
<template> <template>
<div v-if="submitted"> <div v-if="submitted">
<Alert yellow v-if="signupRequiresApproval"> <Alert
v-if="signupRequiresApproval"
yellow
>
{{ t('components.auth.SignupForm.message.awaitingReview') }} {{ t('components.auth.SignupForm.message.awaitingReview') }}
</Alert> </Alert>
<Alert green v-else> <Alert
v-else
green
>
{{ t('components.auth.SignupForm.message.accountCreated') }} {{ t('components.auth.SignupForm.message.accountCreated') }}
</Alert> </Alert>
<h2> <h2>
@ -107,18 +113,22 @@ fetchInstanceSettings()
:show-signup="false" :show-signup="false"
/> />
</div> </div>
<Layout form stack <Layout
v-else v-else
form
stack
style="max-width: 600px" style="max-width: 600px"
@submit.prevent="submit()" @submit.prevent="submit()"
> >
<Alert red <Alert
v-if="!store.state.instance.settings.users.registration_enabled.value" v-if="!store.state.instance.settings.users.registration_enabled.value"
red
> >
{{ t('components.auth.SignupForm.message.registrationClosed') }} {{ t('components.auth.SignupForm.message.registrationClosed') }}
</Alert> </Alert>
<Alert yellow <Alert
v-else-if="signupRequiresApproval" v-else-if="signupRequiresApproval"
yellow
> >
{{ t('components.auth.SignupForm.message.requiresReview') }} {{ t('components.auth.SignupForm.message.requiresReview') }}
</Alert> </Alert>
@ -129,8 +139,9 @@ fetchInstanceSettings()
:permissive="true" :permissive="true"
/> />
</template> </template>
<Alert red <Alert
v-if="errors.length > 0" v-if="errors.length > 0"
red
> >
<h4 class="header"> <h4 class="header">
{{ t('components.auth.SignupForm.header.signupFailure') }} {{ t('components.auth.SignupForm.header.signupFailure') }}
@ -144,65 +155,68 @@ fetchInstanceSettings()
</li> </li>
</ul> </ul>
</Alert> </Alert>
<Input <Input
id="username-field" id="username-field"
:label = "t('components.auth.SignupForm.label.username')" ref="username"
ref="username" v-model="payload.username"
v-model="payload.username" :label="t('components.auth.SignupForm.label.username')"
name="username" name="username"
required required
type="text" type="text"
autofocus autofocus
:placeholder="labels.usernamePlaceholder" :placeholder="labels.usernamePlaceholder"
/>
<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"
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 formCustomization?.fields"
v-if="signupRequiresApproval && (formCustomization?.fields.length ?? 0) > 0"
:key="idx"
:class="[{required: field.required}, 'field']"
>
<Textarea
v-if="field.input_type === 'long_text'"
:id="`custom-field-${idx}`"
v-model="payload.request_fields[field.label]"
:label="field.label"
:required="field.required"
rows="5"
/> />
<Input <Input
:label="t('components.auth.SignupForm.label.email')" v-else
id="email-field" :id="`custom-field-${idx}`"
autocomplete="email" v-model="payload.request_fields[field.label]"
ref="email" :label="field.label"
v-model="payload.email"
name="email"
required
type="email"
:placeholder="labels.emailPlaceholder"
/>
<Input password
autocomplete="new-password"
:label="t('components.auth.SignupForm.label.password')"
v-model="payload.password1"
field-id="password-field"
/>
<Input v-if="!store.state.instance.settings.users.registration_enabled.value"
:label="t('components.auth.SignupForm.label.invitation')"
id="invitation-code"
v-model="payload.invitation"
required
type="text" type="text"
name="invitation" :required="field.required"
:placeholder="labels.placeholder"
/> />
<div v-if="signupRequiresApproval && (formCustomization?.fields.length ?? 0) > 0" </div>
v-for="(field, idx) in formCustomization?.fields"
:key="idx"
:class="[{required: field.required}, 'field']"
>
<Textarea
:label="field.label"
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}`"
:label="field.label"
v-model="payload.request_fields[field.label]"
type="text"
:required="field.required"
/>
</div>
<Button <Button
primary primary
auto auto

View File

@ -108,15 +108,17 @@ fetchToken()
{{ t('components.auth.SubsonicTokenForm.link.apps') }} {{ t('components.auth.SubsonicTokenForm.link.apps') }}
</a> </a>
</p> </p>
<Alert green <Alert
v-if="success" v-if="success"
green
> >
<h4 class="header"> <h4 class="header">
{{ successMessage }} {{ successMessage }}
</h4> </h4>
</Alert> </Alert>
<Alert red <Alert
v-if="subsonicEnabled && errors.length > 0" v-if="subsonicEnabled && errors.length > 0"
red
role="alert" role="alert"
> >
<h4 class="header"> <h4 class="header">
@ -164,8 +166,8 @@ fetchToken()
</template> </template>
</DangerousButton> </DangerousButton>
<Button <Button
primary
v-else v-else
primary
:is-loading="isLoading" :is-loading="isLoading"
@click="requestNewToken" @click="requestNewToken"
> >

View File

@ -15,7 +15,7 @@ interface Events {
(e: 'created'): void (e: 'created'): void
} }
const channel = defineModel<Channel>({required: true}) const channel = defineModel<Channel>({ required: true })
const { t } = useI18n() const { t } = useI18n()
@ -53,7 +53,8 @@ defineExpose({
</script> </script>
<template> <template>
<Layout form <Layout
form
:class="['ui', {loading: isLoading}, 'form']" :class="['ui', {loading: isLoading}, 'form']"
@submit.stop.prevent @submit.stop.prevent
> >

View File

@ -14,7 +14,7 @@ import Input from '~/components/ui/Input.vue'
const { t } = useI18n() const { t } = useI18n()
const channel = defineModel<Channel>({required: true}) const channel = defineModel<Channel>({ required: true })
defineEmits(['created']) defineEmits(['created'])
const newAlbumTitle = ref<string>('') const newAlbumTitle = ref<string>('')
@ -22,7 +22,7 @@ const isLoading = ref(false)
const submittable = ref(false) const submittable = ref(false)
const errors = ref<string[]>([]) const errors = ref<string[]>([])
const {isOpen:show} = useModal('album') const { isOpen: show } = useModal('album')
watch(show, () => { watch(show, () => {
isLoading.value = false isLoading.value = false
@ -56,8 +56,9 @@ const submit = async () => {
</script> </script>
<template> <template>
<Modal :title="t(channel?.artist?.content_category === 'podcast' ? 'components.channels.AlbumModal.header.newSeries' : 'components.channels.AlbumModal.header.newAlbum')" <Modal
v-model="show" v-model="show"
:title="t(channel?.artist?.content_category === 'podcast' ? 'components.channels.AlbumModal.header.newSeries' : 'components.channels.AlbumModal.header.newAlbum')"
class="small" class="small"
:cancel="t('components.channels.AlbumModal.button.cancel')" :cancel="t('components.channels.AlbumModal.button.cancel')"
> >
@ -80,7 +81,8 @@ const submit = async () => {
</Alert> </Alert>
</template> </template>
<Layout form <Layout
form
:class="['ui', {loading: isLoading}, 'form']" :class="['ui', {loading: isLoading}, 'form']"
@submit.stop.prevent @submit.stop.prevent
> >

View File

@ -27,15 +27,14 @@ const fetchAlbums = async () => {
} }
}) })
console.log("I found another album with artist", model.value.channel.artist.name, ":", response.data.results) console.log('I found another album with artist', model.value.channel.artist.name, ':', response.data.results)
albums.value = response.data.results albums.value = response.data.results
isLoading.value = false isLoading.value = false
} }
watch(() => model.value.channel, fetchAlbums, { immediate: true }) watch(() => model.value.channel, fetchAlbums, { immediate: true })
watch(albums, (value) => { watch(albums, (value) => {
if (value.length === 1) if (value.length === 1) { selectedAlbumId.value = albums.value[0].id }
selectedAlbumId.value = albums.value[0].id
}) })
</script> </script>
@ -62,7 +61,7 @@ watch(albums, (value) => {
:value="album.id" :value="album.id"
> >
{{ album.title }} {{ album.title }}
{{ t('components.channels.AlbumSelect.meta.tracks', album.tracks_count) }} {{ t('components.channels.AlbumSelect.meta.tracks', album.tracks_count) }}
</option> </option>
</select> </select>
<Layout stack> <Layout stack>
@ -73,11 +72,11 @@ watch(albums, (value) => {
icon="bi-plus" icon="bi-plus"
:to="useModal('album').to" :to="useModal('album').to"
> >
Add Album Add Album
<AlbumModal <AlbumModal
v-model="model.channel" v-model="model.channel"
@created="fetchAlbums" @created="fetchAlbums"
/> />
</Link> </Link>
</Layout> </Layout>
</template> </template>

View File

@ -6,11 +6,11 @@ import { computed, ref } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
const route = useRoute()
import LoginModal from '~/components/common/LoginModal.vue' import LoginModal from '~/components/common/LoginModal.vue'
import Button from '~/components/ui/Button.vue' import Button from '~/components/ui/Button.vue'
const route = useRoute()
interface Events { interface Events {
(e: 'unsubscribed'): void (e: 'unsubscribed'): void
(e: 'subscribed'): void (e: 'subscribed'): void

View File

@ -27,7 +27,6 @@ import Link from '~/components/ui/Link.vue'
import Loader from '~/components/ui/Loader.vue' import Loader from '~/components/ui/Loader.vue'
import Spacer from '~/components/ui/Spacer.vue' import Spacer from '~/components/ui/Spacer.vue'
interface Events { interface Events {
(e: 'status', status: UploadStatus): void (e: 'status', status: UploadStatus): void
} }
@ -106,11 +105,14 @@ albums
const channelDropdownId = ref<Channel['artist']['id'] | null>(null) const channelDropdownId = ref<Channel['artist']['id'] | null>(null)
const isLoading = ref(false) const isLoading = ref(false)
const selectedChannel = computed(()=> const selectedChannel = computed(() =>
props.channel ? props.channel props.channel
: availableChannels.value.length===0 ? (createEmptyChannel(), null) ? props.channel
: availableChannels.value.length===1 ? availableChannels.value[0] : availableChannels.value.length === 0
: availableChannels.value.find(({artist}) => artist.id === channelDropdownId.value) ? (createEmptyChannel(), null)
: availableChannels.value.length === 1
? availableChannels.value[0]
: availableChannels.value.find(({ artist }) => artist.id === channelDropdownId.value)
) )
const emptyChannelCreateRequest:components['schemas']['ChannelCreateRequest'] = { const emptyChannelCreateRequest:components['schemas']['ChannelCreateRequest'] = {
@ -118,7 +120,7 @@ const emptyChannelCreateRequest:components['schemas']['ChannelCreateRequest'] =
username: store.state.auth.username, username: store.state.auth.username,
description: null, description: null,
tags: [], tags: [],
content_category: 'music', content_category: 'music'
} }
const createEmptyChannel = async () => { const createEmptyChannel = async () => {
@ -127,10 +129,10 @@ const createEmptyChannel = async () => {
'channels/', 'channels/',
(emptyChannelCreateRequest satisfies operations['create_channel_2']['requestBody']['content']['application/json']) (emptyChannelCreateRequest satisfies operations['create_channel_2']['requestBody']['content']['application/json'])
) )
console.log("Created Channel: ", response.data) console.log('Created Channel: ', response.data)
} catch (error) { } catch (error) {
errors.value = (error as BackendError).backendErrors errors.value = (error as BackendError).backendErrors
console.log("Error:", error) console.log('Error:', error)
} }
} }
@ -150,12 +152,13 @@ const fetchChannels = async () => {
const albumSelection = ref<{channel: Channel, albumId: Album['id'] | '', albums: Album[]}> const albumSelection = ref<{channel: Channel, albumId: Album['id'] | '', albums: Album[]}>
watch(selectedChannel, (channel) => watch(selectedChannel, (channel) =>
albumSelection.value = albumSelection.value
{ channel: channel, = {
albumId: '', channel,
albums: [] albumId: '',
} albums: []
) }
)
const channelChange = async (channelId) => { const channelChange = async (channelId) => {
selectedChannel.value = channelId selectedChannel.value = channelId
@ -409,7 +412,7 @@ const labels = computed(() => ({
})) }))
const publish = async () => { const publish = async () => {
console.log("starting publish...") console.log('starting publish...')
isLoading.value = true isLoading.value = true
errors.value = [] errors.value = []
@ -432,8 +435,7 @@ const publish = async () => {
// headers: { 'Authorization': `Bearer ${store.state.auth.oauth}` } // headers: { 'Authorization': `Bearer ${store.state.auth.oauth}` }
// }) // })
console.log('Channels Store Before: ', store.state.channels)
console.log("Channels Store Before: ", store.state.channels)
// Tell the store that the uploaded files are pending import // Tell the store that the uploaded files are pending import
store.commit('channels/publish', { store.commit('channels/publish', {
@ -441,17 +443,16 @@ const publish = async () => {
channel: selectedChannel.value channel: selectedChannel.value
}) })
console.log("Channels Store After: ", store.state.channels) console.log('Channels Store After: ', store.state.channels)
} catch (error) { } catch (error) {
// TODO: Use inferred error type instead of typecasting // TODO: Use inferred error type instead of typecasting
errors.value = (error as BackendError).backendErrors errors.value = (error as BackendError).backendErrors
console.log("Error:", error) console.log('Error:', error)
} }
isLoading.value = false isLoading.value = false
console.log("...finished publish") console.log('...finished publish')
} }
defineExpose({ defineExpose({
@ -520,7 +521,6 @@ ChannelCreateRequest: {
*/ */
</script> </script>
<template> <template>
<Layout <Layout
form form
@ -548,19 +548,28 @@ ChannelCreateRequest: {
<!-- Select Album and License --> <!-- Select Album and License -->
<div :class="['ui', 'required', 'field']"> <div :class="['ui', 'required', 'field']">
<label v-if="availableChannels.length === 1" for="channel-dropdown"> <label
v-if="availableChannels.length === 1"
for="channel-dropdown"
>
{{ t('components.channels.UploadForm.label.channel') }}: {{ selectedChannel?.artist.name }} {{ t('components.channels.UploadForm.label.channel') }}: {{ selectedChannel?.artist.name }}
</label> </label>
<label v-else for="channel-dropdown"> <label
v-else
for="channel-dropdown"
>
{{ t('components.channels.UploadForm.label.channel') }} {{ t('components.channels.UploadForm.label.channel') }}
</label> </label>
<select <select
v-if="availableChannels.length > 1" v-if="availableChannels.length > 1"
v-model="channelDropdownId"
id="channel-dropdown" id="channel-dropdown"
v-model="channelDropdownId"
class="dropdown" class="dropdown"
> >
<option v-for="channel in availableChannels" :value="channel.artist.id"> <option
v-for="channel in availableChannels"
:value="channel.artist.id"
>
{{ channel.artist.name }} {{ channel.artist.name }}
</option> </option>
</select> </select>
@ -575,13 +584,13 @@ ChannelCreateRequest: {
v-model="values.license" v-model="values.license"
:class="['ui', 'field']" :class="['ui', 'field']"
/> />
<div class="content"> <div class="content">
<p> <p>
<i class="copyright icon" /> <i class="copyright icon" />
{{ t('components.channels.UploadForm.help.license') }} {{ t('components.channels.UploadForm.help.license') }}
</p> </p>
</div>
</div> </div>
</div>
<!-- Files to upload --> <!-- Files to upload -->
<template v-if="remainingSpace === 0"> <template v-if="remainingSpace === 0">
@ -690,10 +699,14 @@ ChannelCreateRequest: {
v-model:values="uploadImportData[selectedUploadId]" v-model:values="uploadImportData[selectedUploadId]"
:upload="selectedUpload" :upload="selectedUpload"
/> />
<Alert blue <Alert
blue
class="ui message" class="ui message"
> >
<Layout flex gap-8> <Layout
flex
gap-8
>
<i class="bi bi-info-circle-fill" /> <i class="bi bi-info-circle-fill" />
{{ t('components.channels.UploadForm.description.extensions', {extensions: store.state.ui.supportedExtensions.join(', ')}) }} {{ t('components.channels.UploadForm.description.extensions', {extensions: store.state.ui.supportedExtensions.join(', ')}) }}
</Layout> </Layout>
@ -710,10 +723,16 @@ ChannelCreateRequest: {
{{ t('components.channels.UploadForm.message.dragAndDrop') }} {{ t('components.channels.UploadForm.message.dragAndDrop') }}
</div> </div>
<div class="ui very small divider" /> <div class="ui very small divider" />
<Button primary icon="bi-folder2-open"> <Button
primary
icon="bi-folder2-open"
>
{{ t('components.channels.UploadForm.label.openBrowser') }} {{ t('components.channels.UploadForm.label.openBrowser') }}
</Button> </Button>
<Spacer class="divider" :size="32" /> <Spacer
class="divider"
:size="32"
/>
</file-upload-widget> </file-upload-widget>
</Layout> </Layout>
</template> </template>

View File

@ -54,8 +54,8 @@ const open = ref(false)
<template> <template>
<Modal <Modal
:title="t(`components.channels.UploadModal.header.${['', 'publish', 'uploadFiles', 'uploadDetails', 'processing'][step]}`)"
v-model="store.state.channels.showUploadModal" v-model="store.state.channels.showUploadModal"
:title="t(`components.channels.UploadModal.header.${['', 'publish', 'uploadFiles', 'uploadDetails', 'processing'][step]}`)"
class="small" class="small"
> >
<div class="scrolling content"> <div class="scrolling content">

View File

@ -206,11 +206,11 @@ const launchAction = async () => {
<dangerous-button <dangerous-button
v-if="selectAll || currentAction?.isDangerous" v-if="selectAll || currentAction?.isDangerous"
:disabled="checked.length === 0 || undefined" :disabled="checked.length === 0 || undefined"
:isLoading="isLoading" :is-loading="isLoading"
:confirm-color="currentAction?.confirmColor ?? 'success'" :confirm-color="currentAction?.confirmColor ?? 'success'"
:aria-label="labels.performAction" :aria-label="labels.performAction"
@confirm="launchAction"
:title="t('components.common.ActionTable.modal.performAction.header', { action: currentActionName }, affectedObjectsCount)" :title="t('components.common.ActionTable.modal.performAction.header', { action: currentActionName }, affectedObjectsCount)"
@confirm="launchAction"
> >
{{ t('components.common.ActionTable.button.go') }} {{ t('components.common.ActionTable.button.go') }}
<template #modal-content> <template #modal-content>

View File

@ -5,7 +5,7 @@ import { hashCode, intToRGB } from '~/utils/color'
import { computed } from 'vue' import { computed } from 'vue'
interface Props { interface Props {
actor: { full_username : string; preferred_username?:string; icon?:Actor["icon"] } actor: { full_username : string; preferred_username?:string; icon?:Actor['icon'] }
} }
const props = defineProps<Props>() const props = defineProps<Props>()

View File

@ -107,8 +107,9 @@ const getAttachmentUrl = (uuid: string) => {
<template> <template>
<div class="ui form"> <div class="ui form">
<Alert red <Alert
v-if="errors.length > 0" v-if="errors.length > 0"
red
role="alert" role="alert"
> >
<h4 class="header"> <h4 class="header">
@ -160,7 +161,7 @@ const getAttachmentUrl = (uuid: string) => {
type="file" type="file"
accept="image/png,image/jpeg" accept="image/png,image/jpeg"
@change="submit" @change="submit"
/> >
</div> </div>
<div class="ui very small hidden divider" /> <div class="ui very small hidden divider" />
<p> <p>

View File

@ -55,7 +55,7 @@ const remainingChars = computed(() => props.charLimit - props.modelValue.length)
v-model="value" v-model="value"
:required="required || undefined" :required="required || undefined"
:placeholder="labels.placeholder" :placeholder="labels.placeholder"
:autofocus = "autofocus || undefined" :autofocus="autofocus || undefined"
/> />
<span <span
v-if="charLimit" v-if="charLimit"

View File

@ -25,33 +25,33 @@ const { copy, isSupported: canCopy, copied } = useClipboard({ source: value, cop
</script> </script>
<template> <template>
<p <p
v-if="copied" v-if="copied"
class="message" class="message"
> >
{{ t('components.common.CopyInput.message.success') }} {{ t('components.common.CopyInput.message.success') }}
</p> </p>
<Input <Input
:id="id" :id="id"
:value="value" :value="value"
readonly readonly
:name="id" :name="id"
type="text" type="text"
:label="label" :label="label"
> >
<template #input-right> <template #input-right>
<Button <Button
:class="['ui', buttonClasses, 'input-right']" :class="['ui', buttonClasses, 'input-right']"
min-content min-content
secondary secondary
:disabled="!canCopy || undefined" :disabled="!canCopy || undefined"
@click="copy()" @click="copy()"
> >
<i class="bi bi-copy" /> <i class="bi bi-copy" />
{{ t('components.common.CopyInput.button.copy') }} {{ t('components.common.CopyInput.button.copy') }}
</Button> </Button>
</template> </template>
</Input> </Input>
</template> </template>
<style scoped> <style scoped>

View File

@ -13,7 +13,7 @@ interface Events {
// Note that properties such as [disabled] and 'destructive' | 'primary' are inherited. // Note that properties such as [disabled] and 'destructive' | 'primary' are inherited.
const props = defineProps<{ const props = defineProps<{
title?: string title?: string
action?: () => void, action?:() => void,
confirmColor?:'success' | 'danger', confirmColor?:'success' | 'danger',
popoverItem?: boolean popoverItem?: boolean
}>() }>()
@ -32,16 +32,17 @@ const confirm = () => {
</script> </script>
<template> <template>
<component :is="props.popoverItem ? PopoverItem : Button" <component
:is="props.popoverItem ? PopoverItem : Button"
destructive destructive
@click.prevent.stop="showModal = true"
v-bind="$attrs" v-bind="$attrs"
@click.prevent.stop="showModal = true"
> >
<slot /> <slot />
<Modal <Modal
destructive
v-model="showModal" v-model="showModal"
destructive
:title="title || t('components.common.DangerousButton.header.confirm')" :title="title || t('components.common.DangerousButton.header.confirm')"
:cancel="t('components.common.DangerousButton.button.cancel')" :cancel="t('components.common.DangerousButton.button.cancel')"
> >

View File

@ -22,7 +22,10 @@ withDefaults(defineProps<Props>(), {
</script> </script>
<template> <template>
<Alert blue align-items="center"> <Alert
blue
align-items="center"
>
<h4 class="ui header"> <h4 class="ui header">
<div class="content"> <div class="content">
<slot name="title"> <slot name="title">
@ -33,10 +36,10 @@ withDefaults(defineProps<Props>(), {
</h4> </h4>
<div class="inline center aligned text"> <div class="inline center aligned text">
<slot /> <slot />
<Spacer :size="16"/> <Spacer :size="16" />
<Button <Button
primary
v-if="refresh" v-if="refresh"
primary
@click="emit('refresh')" @click="emit('refresh')"
> >
{{ t('components.common.EmptyState.button.refresh') }} {{ t('components.common.EmptyState.button.refresh') }}

View File

@ -36,7 +36,8 @@ const search = () => {
</script> </script>
<template> <template>
<Layout form <Layout
form
@submit.stop.prevent="emit('search', value)" @submit.stop.prevent="emit('search', value)"
> >
<div :class="['ui', 'action', {icon: value}, 'input']"> <div :class="['ui', 'action', {icon: value}, 'input']">

View File

@ -32,7 +32,10 @@ const labels = computed(() => ({
</script> </script>
<template> <template>
<Modal v-model="show" :title="labels.header"> <Modal
v-model="show"
:title="labels.header"
>
<div <div
v-if="cover" v-if="cover"
class="image content" class="image content"

View File

@ -119,8 +119,9 @@ const submit = async () => {
v-if="isUpdating" v-if="isUpdating"
@submit.prevent="submit()" @submit.prevent="submit()"
> >
<Alert red <Alert
v-if="errors.length > 0" v-if="errors.length > 0"
red
title="{{ t('components.common.RenderedDescription.header.failure') }}" title="{{ t('components.common.RenderedDescription.header.failure') }}"
role="alert" role="alert"
> >
@ -139,9 +140,9 @@ const submit = async () => {
/> />
<Button <Button
class="left floated" class="left floated"
@click.prevent="isUpdating = false"
solid solid
secondary secondary
@click.prevent="isUpdating = false"
> >
{{ t('components.common.RenderedDescription.button.cancel') }} {{ t('components.common.RenderedDescription.button.cancel') }}
</Button> </Button>

View File

@ -107,8 +107,12 @@ const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value]
</script> </script>
<template> <template>
<Layout main stack no-gap align-left <Layout
v-title="labels.title" v-title="labels.title"
main
stack
no-gap
align-left
> >
<Header :h1="labels.title"> <Header :h1="labels.title">
<template #action> <template #action>
@ -119,69 +123,87 @@ const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value]
</template> </template>
</Header> </Header>
<Loader v-if="isLoading"/> <Loader v-if="isLoading" />
<Layout <Layout
v-if="store.state.favorites.count > 0" v-if="store.state.favorites.count > 0"
form form
stack stack
:class="['ui', { 'loading': isLoading }, 'form']" :class="['ui', { 'loading': isLoading }, 'form']"
> >
<Spacer :size="16" /> <Spacer :size="16" />
<Layout flex style="justify-content: flex-end;"> <Layout
<Layout stack noGap label for="favorites-ordering"> flex
<span class="label"> style="justify-content: flex-end;"
{{ t('components.favorites.List.ordering.label') }} >
</span> <Layout
<select stack
id="favorites-ordering" no-gap
v-model="ordering" label
class="dropdown" for="favorites-ordering"
> >
<option <span class="label">
v-for="option in orderingOptions" {{ t('components.favorites.List.ordering.label') }}
:key="option[0]" </span>
:value="option[0]" <select
id="favorites-ordering"
v-model="ordering"
class="dropdown"
> >
{{ sharedLabels.filters[option[1]] }} <option
</option> v-for="option in orderingOptions"
</select> :key="option[0]"
</Layout> :value="option[0]"
<Layout stack noGap label for="favorites-ordering-direction"> >
<span class="label"> {{ sharedLabels.filters[option[1]] }}
{{ t('components.favorites.List.ordering.direction.label') }} </option>
</span> </select>
<select </Layout>
id="favorites-ordering-direction" <Layout
v-model="orderingDirection" stack
class="dropdown" no-gap
label
for="favorites-ordering-direction"
> >
<option value="+"> <span class="label">
{{ t('components.favorites.List.ordering.direction.ascending') }} {{ t('components.favorites.List.ordering.direction.label') }}
</option> </span>
<option value="-"> <select
{{ t('components.favorites.List.ordering.direction.descending') }} id="favorites-ordering-direction"
</option> v-model="orderingDirection"
</select> class="dropdown"
</Layout>
<Layout stack noGap 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"
> >
{{ opt }} <option value="+">
</option> {{ t('components.favorites.List.ordering.direction.ascending') }}
</select> </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"
>
{{ opt }}
</option>
</select>
</Layout>
</Layout> </Layout>
</Layout>
<TrackTable <TrackTable
v-if="results" v-if="results"
:search="true" :search="true"
@ -190,13 +212,18 @@ const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value]
:tracks="results" :tracks="results"
/> />
</Layout> </Layout>
<Alert blue align-items="center" <Alert
v-else v-else
blue
align-items="center"
> >
<i class="bi bi-heartbreak-fill" style="font-size: 100px;" /> <i
class="bi bi-heartbreak-fill"
style="font-size: 100px;"
/>
<Spacer /> <Spacer />
{{ t('components.favorites.List.empty.noFavorites') }} {{ t('components.favorites.List.empty.noFavorites') }}
<Spacer :size="32"/> <Spacer :size="32" />
<Link <Link
to="/library" to="/library"
solid solid

View File

@ -55,6 +55,5 @@ const title = computed(() => isFavorite.value
:aria-label="title" :aria-label="title"
:title="title" :title="title"
@click.stop="store.dispatch('favorites/toggle', track.id)" @click.stop="store.dispatch('favorites/toggle', track.id)"
> />
</Button>
</template> </template>

View File

@ -80,8 +80,9 @@ const { start: startPolling } = useTimeoutFn(poll, 1000, { immediate: false })
<div> <div>
<slot /> <slot />
</div> </div>
<Modal :title="t('components.federation.FetchButton.header.refresh')" <Modal
v-model="showModal" v-model="showModal"
:title="t('components.federation.FetchButton.header.refresh')"
class="small" class="small"
:cancel="t('components.federation.FetchButton.button.close')" :cancel="t('components.federation.FetchButton.button.close')"
> >

View File

@ -69,9 +69,13 @@ watch(() => props.url, () => {
align-left align-left
small-items small-items
> >
<Loader v-if="isLoading" style="grid-column: 1 / -1;" /> <Loader
<Alert blue v-if="isLoading"
style="grid-column: 1 / -1;"
/>
<Alert
v-if="!isLoading && libraries.length === 0" v-if="!isLoading && libraries.length === 0"
blue
style="grid-column: 1 / -1;" style="grid-column: 1 / -1;"
> >
{{ t('components.federation.LibraryWidget.empty.noMatch') }} {{ t('components.federation.LibraryWidget.empty.noMatch') }}

View File

@ -45,9 +45,10 @@ const copyPassword = () => {
<template> <template>
<div> <div>
<Input password <Input
:id="fieldId" :id="fieldId"
v-model="value" v-model="value"
password
required required
> >
<template #input-right> <template #input-right>

View File

@ -69,7 +69,7 @@ const labels = computed(() => ({
const { const {
isShuffled, isShuffled,
shuffle, shuffle
} = useQueue() } = useQueue()
const isLoading = ref(false) const isLoading = ref(false)
@ -150,7 +150,10 @@ const remove = async () => {
</script> </script>
<template> <template>
<Layout stack main> <Layout
stack
main
>
<Loader <Loader
v-if="isLoading" v-if="isLoading"
v-title="labels.title" v-title="labels.title"
@ -162,114 +165,129 @@ const remove = async () => {
v-lazy="store.getters['instance/absoluteUrl'](object.cover.urls.large_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](object.cover.urls.large_square_crop)"
:alt="object.title" :alt="object.title"
class="channel-image" class="channel-image"
/> >
<img <img
v-else-if="object.artist_credit && object.artist_credit[0] && object.artist_credit[0].artist.cover" v-else-if="object.artist_credit && object.artist_credit[0] && object.artist_credit[0].artist.cover"
v-lazy="object.artist_credit[0].artist.cover.urls.large_square_crop"
:alt="object.artist_credit[0].artist.name" :alt="object.artist_credit[0].artist.name"
class="channel-image" class="channel-image"
v-lazy="object.artist_credit[0].artist.cover.urls.large_square_crop" >
/>
<img <img
v-else v-else
alt="" alt=""
class="channel-image" class="channel-image"
src="../../assets/audio/default-cover.png" src="../../assets/audio/default-cover.png"
/> >
<!-- ({target}) => target --> <!-- ({target}) => target -->
<!-- Header (TODO: Put into Header component) Hint: Header is heavier fontweight than h1! --> <!-- Header (TODO: Put into Header component) Hint: Header is heavier fontweight than h1! -->
<Layout stack style="flex: 1; gap: 8px;"> <Layout
<h1>{{ object.title }}</h1> stack
<!-- <Header :h1="object.title" /> --> style="flex: 1; gap: 8px;"
<artist-credit-label >
v-if="artistCredit" <h1>{{ object.title }}</h1>
:artist-credit="artistCredit" <!-- <Header :h1="object.title" /> -->
<artist-credit-label
v-if="artistCredit"
:artist-credit="artistCredit"
/>
<!-- Metadata: -->
<div class="meta">
<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 -->
</div>
<Layout flex>
<rendered-description
v-if="object.description"
:content="object.description"
:can-update="true"
/> />
<!-- Metadata: -->
<div class="meta">
<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 -->
</div>
<Layout flex>
<rendered-description
v-if="object.description"
:content="object.description"
:can-update="true"
/>
</Layout>
<Layout flex>
<PlayButton
split
:tracks="object.tracks"
:is-playable="object.is_playable"
/>
<Button
v-if="object.tracks.length > 2"
primary
icon="bi-shuffle"
:aria-label="labels.shuffle"
@click.prevent.stop="shuffle()"
>
{{ labels.shuffle }}
</Button>
<DangerousButton
v-if="artistCredit[0] &&
store.state.auth.authenticated &&
artistCredit[0].artist.channel &&
artistCredit[0].artist.attributed_to?.full_username === store.state.auth.fullUsername"
:is-loading="isLoading"
@confirm="remove()"
icon="bi-trash"
>
{{ t('components.library.AlbumDropdown.button.delete') }}
</DangerousButton>
<Spacer h grow />
<TrackFavoriteIcon v-if="store.state.auth.authenticated" :album="object" />
<TrackPlaylistIcon v-if="store.state.auth.authenticated" :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>
</Layout> </Layout>
<Layout flex>
<PlayButton
split
:tracks="object.tracks"
:is-playable="object.is_playable"
/>
<Button
v-if="object.tracks.length > 2"
primary
icon="bi-shuffle"
:aria-label="labels.shuffle"
@click.prevent.stop="shuffle()"
>
{{ labels.shuffle }}
</Button>
<DangerousButton
v-if="artistCredit[0] &&
store.state.auth.authenticated &&
artistCredit[0].artist.channel &&
artistCredit[0].artist.attributed_to?.full_username === store.state.auth.fullUsername"
:is-loading="isLoading"
icon="bi-trash"
@confirm="remove()"
>
{{ t('components.library.AlbumDropdown.button.delete') }}
</DangerousButton>
<Spacer
h
grow
/>
<TrackFavoriteIcon
v-if="store.state.auth.authenticated"
:album="object"
/>
<TrackPlaylistIcon
v-if="store.state.auth.authenticated"
: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>
</Layout>
</Layout> </Layout>
<div style="flex 1;"> <div style="flex 1;">
<router-view <router-view
v-if="object" v-if="object"
:key="route.fullPath" :key="route.fullPath"
:paginate-by="paginateBy" :paginate-by="paginateBy"
:total-tracks="totalTracks" :total-tracks="totalTracks"
:is-serie="isSerie" :is-serie="isSerie"
:artist-credit="artistCredit" :artist-credit="artistCredit"
:object="object" :object="object"
:is-loading-tracks="isLoadingTracks" :is-loading-tracks="isLoadingTracks"
object-type="album" object-type="album"
@libraries-loaded="libraries = $event" @libraries-loaded="libraries = $event"
/> />
</div> </div>
</template> </template>
</Layout> </Layout>
</template> </template>

View File

@ -139,8 +139,8 @@ const paginatedDiscs = computed(() => props.object.tracks.slice(props.paginateBy
<Spacer /> <Spacer />
<library-widget <library-widget
:url="'albums/' + object.id + '/libraries/'" :url="'albums/' + object.id + '/libraries/'"
@loaded="emit('libraries-loaded', $event)"
:title="t('components.library.AlbumDetail.header.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> </library-widget>

View File

@ -16,7 +16,7 @@ import Button from '~/components/ui/Button.vue'
import Popover from '~/components/ui/Popover.vue' import Popover from '~/components/ui/Popover.vue'
import PopoverItem from '~/components/ui/popover/PopoverItem.vue' import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
import DangerousButton from '~/components/common/DangerousButton.vue' import DangerousButton from '~/components/common/DangerousButton.vue'
import OptionsButton from "~/components/ui/button/Options.vue" import OptionsButton from '~/components/ui/button/Options.vue'
interface Events { interface Events {
(e: 'remove'): void (e: 'remove'): void
@ -60,9 +60,10 @@ const open = ref(false)
<template> <template>
<span> <span>
<Modal :title="t('components.library.AlbumDropdown.modal.embed.header')" <Modal
v-if="isEmbedable" v-if="isEmbedable"
v-model="showEmbedModal" v-model="showEmbedModal"
:title="t('components.library.AlbumDropdown.modal.embed.header')"
:cancel="t('components.library.AlbumDropdown.button.cancel')" :cancel="t('components.library.AlbumDropdown.button.cancel')"
> >
<div class="scrolling content"> <div class="scrolling content">
@ -78,13 +79,13 @@ const open = ref(false)
<template #default="{ toggleOpen }"> <template #default="{ toggleOpen }">
<OptionsButton <OptionsButton
:title="labels.more" :title="labels.more"
isSquare is-square
@click="toggleOpen()" @click="toggleOpen()"
/> />
</template> </template>
<template #items> <template #items>
<PopoverItem <PopoverItem
v-if="domain != store.getters['instance/domain']" v-if="domain != store.getters['instance/domain']"
:to="object.fid" :to="object.fid"
icon="bi-box-arrow-up-right" icon="bi-box-arrow-up-right"
@ -95,8 +96,8 @@ const open = ref(false)
<PopoverItem <PopoverItem
v-if="isEmbedable" v-if="isEmbedable"
@click="showEmbedModal = !showEmbedModal"
icon="bi-code" icon="bi-code"
@click="showEmbedModal = !showEmbedModal"
> >
{{ t('components.library.AlbumDropdown.button.embed') }} {{ t('components.library.AlbumDropdown.button.embed') }}
</PopoverItem> </PopoverItem>
@ -131,14 +132,14 @@ const open = ref(false)
<PopoverItem <PopoverItem
v-if="artistCredit[0] && v-if="artistCredit[0] &&
store.state.auth.authenticated && store.state.auth.authenticated &&
artistCredit[0].artist.channel && artistCredit[0].artist.channel &&
artistCredit[0].artist.attributed_to?.full_username === store.state.auth.fullUsername" artistCredit[0].artist.attributed_to?.full_username === store.state.auth.fullUsername"
> >
<DangerousButton <DangerousButton
:is-loading="isLoading" :is-loading="isLoading"
@confirm="remove()"
icon="bi-trash" icon="bi-trash"
@confirm="remove()"
> >
{{ t('components.library.AlbumDropdown.button.delete') }} {{ t('components.library.AlbumDropdown.button.delete') }}
</DangerousButton> </DangerousButton>
@ -152,8 +153,8 @@ const open = ref(false)
channel: artistCredit[0]?.artist.channel channel: artistCredit[0]?.artist.channel
})" })"
:key="obj.target.type + obj.target.id" :key="obj.target.type + obj.target.id"
@click="report(obj)"
icon="bi-flag" icon="bi-flag"
@click="report(obj)"
> >
{{ obj.label }} {{ obj.label }}
</PopoverItem> </PopoverItem>

View File

@ -131,27 +131,38 @@ const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value]
</script> </script>
<template> <template>
<Layout stack main v-title="labels.title"> <Layout
v-title="labels.title"
stack
main
>
<Header :h1="t('components.library.Albums.header.browse')" /> <Header :h1="t('components.library.Albums.header.browse')" />
<Layout form flex <Layout
form
flex
:class="['ui', {'loading': isLoading}, 'form']" :class="['ui', {'loading': isLoading}, 'form']"
@submit.prevent="search" @submit.prevent="search"
> >
<Input search <Input
id="album-search" id="album-search"
v-model="query" v-model="query"
search
name="search" name="search"
:label="t('components.library.Albums.label.search')" :label="t('components.library.Albums.label.search')"
autofocus autofocus
:placeholder="labels.searchPlaceholder" :placeholder="labels.searchPlaceholder"
> />
</Input>
<Pills <Pills
v-model="tagList" v-model="tagList"
:label="t('components.library.Albums.label.tags')" :label="t('components.library.Albums.label.tags')"
style="max-width: 150px;" style="max-width: 150px;"
/> />
<Layout stack noGap label for="album-ordering"> <Layout
stack
no-gap
label
for="album-ordering"
>
<span class="label"> <span class="label">
{{ t('components.library.Albums.ordering.label') }} {{ t('components.library.Albums.ordering.label') }}
</span> </span>
@ -169,7 +180,12 @@ const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value]
</option> </option>
</select> </select>
</Layout> </Layout>
<Layout stack noGap label for="album-ordering-direction"> <Layout
stack
no-gap
label
for="album-ordering-direction"
>
<span class="label"> <span class="label">
{{ t('components.library.Albums.ordering.direction.label') }} {{ t('components.library.Albums.ordering.direction.label') }}
</span> </span>
@ -186,7 +202,12 @@ const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value]
</option> </option>
</select> </select>
</Layout> </Layout>
<Layout stack noGap label for="album-results"> <Layout
stack
no-gap
label
for="album-results"
>
<span class="label"> <span class="label">
{{ t('components.library.Albums.pagination.results') }} {{ t('components.library.Albums.pagination.results') }}
</span> </span>
@ -205,14 +226,15 @@ const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value]
</select> </select>
</Layout> </Layout>
</Layout> </Layout>
<Loader v-if="isLoading"/> <Loader v-if="isLoading" />
<Pagination <Pagination
v-if="result && result.count > paginateBy" v-if="result && result.count > paginateBy"
v-model:page="page" v-model:page="page"
:pages="Math.ceil((result.count || 0)/paginateBy)" :pages="Math.ceil((result.count || 0)/paginateBy)"
/> />
<Layout grid <Layout
v-if="result && result.results.length > 0" v-if="result && result.results.length > 0"
grid
style="display:flex; flex-wrap:wrap; gap: 32px; margin-top:32px;" style="display:flex; flex-wrap:wrap; gap: 32px; margin-top:32px;"
> >
<AlbumCard <AlbumCard
@ -222,8 +244,8 @@ const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value]
/> />
</Layout> </Layout>
<Layout <Layout
stack
v-else-if="result && result.results.length === 0" v-else-if="result && result.results.length === 0"
stack
> >
<Alert blue> <Alert blue>
<i class="bi bi-disc" /> <i class="bi bi-disc" />
@ -239,7 +261,10 @@ const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value]
:to="useModal('upload').to" :to="useModal('upload').to"
> >
<template #image> <template #image>
<i class="bi bi-upload" style="font-size: 100px; position: relative; top: 50px;" /> <i
class="bi bi-upload"
style="font-size: 100px; position: relative; top: 50px;"
/>
</template> </template>
</Card> </Card>
</Layout> </Layout>

View File

@ -25,7 +25,6 @@ import Layout from '~/components/ui/Layout.vue'
import Modal from '~/components/ui/Modal.vue' import Modal from '~/components/ui/Modal.vue'
import Spacer from '~/components/ui/Spacer.vue' import Spacer from '~/components/ui/Spacer.vue'
interface Props { interface Props {
id: number id: number
} }
@ -56,8 +55,7 @@ const musicbrainzUrl = computed(() => object.value?.mbid ? `https://musicbrainz.
const discogsUrl = computed(() => `https://discogs.com/search/?type=artist&title=${encodeURI(object.value?.name ?? '')}`) const discogsUrl = computed(() => `https://discogs.com/search/?type=artist&title=${encodeURI(object.value?.name ?? '')}`)
const publicLibraries = computed(() => libraries.value?.filter(library => library.privacy_level === 'everyone') ?? []) const publicLibraries = computed(() => libraries.value?.filter(library => library.privacy_level === 'everyone') ?? [])
const cover = computed(() => {
const cover = computed(() => {
const artistCover: Cover | undefined = object.value?.cover const artistCover: Cover | undefined = object.value?.cover
const albumCover: Cover | undefined = object.value?.albums const albumCover: Cover | undefined = object.value?.albums
@ -68,12 +66,12 @@ const cover = computed(() => {
)?.cover )?.cover
const fallback : Cover = { const fallback : Cover = {
uuid: '', uuid: '',
urls: { urls: {
original: `${import.meta.env.BASE_URL}embed-default-cover.jpeg`, original: `${import.meta.env.BASE_URL}embed-default-cover.jpeg`,
medium_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` large_square_crop: `${import.meta.env.BASE_URL}embed-default-cover.jpeg`
} }
} }
return artistCover return artistCover
@ -122,7 +120,11 @@ watch(() => props.id, fetchData, { immediate: true })
</script> </script>
<template> <template>
<Layout stack main v-title="labels.title"> <Layout
v-title="labels.title"
stack
main
>
<Loader v-if="isLoading" /> <Loader v-if="isLoading" />
<template v-if="object && !isLoading"> <template v-if="object && !isLoading">
<Layout flex> <Layout flex>
@ -131,9 +133,16 @@ watch(() => props.id, fetchData, { immediate: true })
:alt="object.name" :alt="object.name"
class="channel-image" class="channel-image"
> >
<Layout stack style="flex: 1; gap: 8px;"> <Layout
stack
style="flex: 1; gap: 8px;"
>
<h1>{{ object.name }}</h1> <h1>{{ object.name }}</h1>
<Layout flex class="meta" style="gap: 0;"> <Layout
flex
class="meta"
style="gap: 0;"
>
<div <div
v-if="albums" v-if="albums"
> >
@ -181,8 +190,8 @@ watch(() => props.id, fetchData, { immediate: true })
<PopoverItem <PopoverItem
v-if="publicLibraries.length > 0" v-if="publicLibraries.length > 0"
@click="showEmbedModal = true"
icon="bi-code-square" icon="bi-code-square"
@click="showEmbedModal = true"
> >
{{ t('components.library.ArtistBase.button.embed') }} {{ t('components.library.ArtistBase.button.embed') }}
</PopoverItem> </PopoverItem>

View File

@ -6,7 +6,6 @@ import { ref, computed, reactive } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import axios from 'axios' import axios from 'axios'
import LibraryWidget from '~/components/federation/LibraryWidget.vue' import LibraryWidget from '~/components/federation/LibraryWidget.vue'
@ -14,7 +13,7 @@ import TrackTable from '~/components/audio/track/Table.vue'
import TagsList from '~/components/tags/List.vue' import TagsList from '~/components/tags/List.vue'
import AlbumCard from '~/components/album/Card.vue' import AlbumCard from '~/components/album/Card.vue'
import Layout from '~/components/ui/Layout.vue' import Layout from '~/components/ui/Layout.vue'
import Heading from "~/components/ui/Heading.vue"; import Heading from '~/components/ui/Heading.vue'
import Loader from '~/components/ui/Loader.vue' import Loader from '~/components/ui/Loader.vue'
import Link from '~/components/ui/Link.vue' import Link from '~/components/ui/Link.vue'
import Spacer from '~/components/ui/Spacer.vue' import Spacer from '~/components/ui/Spacer.vue'
@ -69,14 +68,18 @@ const loadMoreAlbums = async () => {
</script> </script>
<template> <template>
<Layout stack v-if="object"> <Layout
v-if="object"
stack
>
<TagsList <TagsList
v-if="object.tags && object.tags.length > 0" v-if="object.tags && object.tags.length > 0"
style="margin-top: -16px;" style="margin-top: -16px;"
:tags="object.tags" :tags="object.tags"
/> />
<Alert blue <Alert
v-if="contentFilter" v-if="contentFilter"
blue
> >
<p> <p>
{{ t('components.library.ArtistDetail.message.filter') }} {{ t('components.library.ArtistDetail.message.filter') }}
@ -96,7 +99,10 @@ const loadMoreAlbums = async () => {
</Alert> </Alert>
<Loader v-if="isLoadingAlbums" /> <Loader v-if="isLoadingAlbums" />
<template v-else-if="albums && albums.length > 0"> <template v-else-if="albums && albums.length > 0">
<Heading h2 section-heading> <Heading
h2
section-heading
>
{{ t('components.library.ArtistDetail.header.album') }} {{ t('components.library.ArtistDetail.header.album') }}
</Heading> </Heading>
<Layout flex> <Layout flex>
@ -105,7 +111,10 @@ const loadMoreAlbums = async () => {
:key="album.id" :key="album.id"
:album="album" :album="album"
/> />
<Spacer h grow /> <Spacer
h
grow
/>
<Button <Button
v-if="loadMoreAlbumsUrl !== null" v-if="loadMoreAlbumsUrl !== null"
primary primary
@ -117,7 +126,10 @@ const loadMoreAlbums = async () => {
</Layout> </Layout>
</template> </template>
<template v-if="tracks.length > 0"> <template v-if="tracks.length > 0">
<Heading h2 section-heading> <Heading
h2
section-heading
>
{{ t('components.library.ArtistDetail.header.track') }} {{ t('components.library.ArtistDetail.header.track') }}
</Heading> </Heading>
<TrackTable <TrackTable
@ -127,7 +139,10 @@ const loadMoreAlbums = async () => {
:tracks="tracks.slice(0,5)" :tracks="tracks.slice(0,5)"
/> />
</template> </template>
<Heading h2 section-heading> <Heading
h2
section-heading
>
{{ t('components.library.ArtistDetail.header.library') }} {{ t('components.library.ArtistDetail.header.library') }}
</Heading> </Heading>
<LibraryWidget <LibraryWidget

View File

@ -26,23 +26,26 @@ const canEdit = store.state.auth.availablePermissions.library
</script> </script>
<template> <template>
<Layout stack> <Layout stack>
<Spacer /> <Spacer />
<Section no-items alignLeft <Section
:h2="canEdit no-items
? t('components.library.ArtistEdit.header.edit') align-left
: t('components.library.ArtistEdit.header.suggest') :h2="canEdit
" ? t('components.library.ArtistEdit.header.edit')
/> : t('components.library.ArtistEdit.header.suggest')
<Alert yellow "
v-if="!object.is_local" />
> <Alert
{{ t('components.library.ArtistEdit.message.remote') }} v-if="!object.is_local"
</Alert> yellow
<edit-form >
v-else {{ t('components.library.ArtistEdit.message.remote') }}
:object-type="objectType" </Alert>
:object="object" <edit-form
/> v-else
</Layout> :object-type="objectType"
:object="object"
/>
</Layout>
</template> </template>

View File

@ -130,27 +130,38 @@ const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value]
</script> </script>
<template> <template>
<Layout stack main v-title="labels.title"> <Layout
v-title="labels.title"
stack
main
>
<Header :h1="t('components.library.Artists.header.browse')" /> <Header :h1="t('components.library.Artists.header.browse')" />
<Layout form flex <Layout
form
flex
:class="['ui', {'loading': isLoading}, 'form']" :class="['ui', {'loading': isLoading}, 'form']"
@submit.prevent="search" @submit.prevent="search"
> >
<Input search <Input
id="artist-search" id="artist-search"
v-model="query" v-model="query"
search
name="search" name="search"
:label="t('components.library.Artists.label.search')" :label="t('components.library.Artists.label.search')"
autofocus autofocus
:placeholder="labels.searchPlaceholder" :placeholder="labels.searchPlaceholder"
> />
</Input>
<Pills <Pills
v-model="tagList" v-model="tagList"
:label="t('components.library.Artists.label.tags')" :label="t('components.library.Artists.label.tags')"
style="max-width: 150px;" style="max-width: 150px;"
/> />
<Layout stack noGap label for="artist-ordering"> <Layout
stack
no-gap
label
for="artist-ordering"
>
<span class="label"> <span class="label">
{{ t('components.library.Artists.ordering.label') }} {{ t('components.library.Artists.ordering.label') }}
</span> </span>
@ -168,7 +179,12 @@ const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value]
</option> </option>
</select> </select>
</Layout> </Layout>
<Layout stack noGap label for="artist-ordering-direction"> <Layout
stack
no-gap
label
for="artist-ordering-direction"
>
<span class="label"> <span class="label">
{{ t('components.library.Artists.ordering.direction.label') }} {{ t('components.library.Artists.ordering.direction.label') }}
</span> </span>
@ -185,7 +201,12 @@ const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value]
</option> </option>
</select> </select>
</Layout> </Layout>
<Layout stack noGap label for="artist-results"> <Layout
stack
no-gap
label
for="artist-results"
>
<span class="label"> <span class="label">
{{ t('components.library.Artists.pagination.results') }} {{ t('components.library.Artists.pagination.results') }}
</span> </span>
@ -204,22 +225,23 @@ const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value]
</select> </select>
</Layout> </Layout>
<Toggle <Toggle
:label="t('components.library.Artists.label.excludeCompilation')"
id="exclude-compilation" id="exclude-compilation"
v-model="excludeCompilation" v-model="excludeCompilation"
:label="t('components.library.Artists.label.excludeCompilation')"
true-value="true" true-value="true"
false-value="null" false-value="null"
type="checkbox" type="checkbox"
/> />
</Layout> </Layout>
<Loader v-if="isLoading"/> <Loader v-if="isLoading" />
<Pagination <Pagination
v-if="result && result.count > paginateBy" v-if="result && result.count > paginateBy"
v-model:page="page" v-model:page="page"
:pages="Math.ceil(result.count / paginateBy)" :pages="Math.ceil(result.count / paginateBy)"
/> />
<Layout grid <Layout
v-if="result && result.results.length > 0" v-if="result && result.results.length > 0"
grid
style="display:flex; flex-wrap:wrap; gap: 32px; margin-top:32px;" style="display:flex; flex-wrap:wrap; gap: 32px; margin-top:32px;"
> >
<ArtistCard <ArtistCard
@ -229,8 +251,8 @@ const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value]
/> />
</Layout> </Layout>
<Layout <Layout
stack
v-else-if="result && result.results.length === 0" v-else-if="result && result.results.length === 0"
stack
> >
<Alert yellow> <Alert yellow>
<i class="compact disc icon" /> <i class="compact disc icon" />
@ -246,7 +268,10 @@ const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value]
:to="useModal('upload').to" :to="useModal('upload').to"
> >
<template #image> <template #image>
<i class="bi bi-upload" style="font-size: 100px; position: relative; top: 50px;" /> <i
class="bi bi-upload"
style="font-size: 100px; position: relative; top: 50px;"
/>
</template> </template>
</Card> </Card>
</Layout> </Layout>

View File

@ -169,7 +169,7 @@ const alertProps = computed(() => {
<template> <template>
<Card <Card
:alertProps="alertProps" :alert-props="alertProps"
:to="detailUrl" :to="detailUrl"
:title="t('components.library.EditCard.header.modification', {id: obj.uuid.substring(0, 8)})" :title="t('components.library.EditCard.header.modification', {id: obj.uuid.substring(0, 8)})"
> >
@ -183,7 +183,6 @@ const alertProps = computed(() => {
<i class="bi bi-file-music-fill" /> <i class="bi bi-file-music-fill" />
{{ t('components.library.EditCard.link.track', {id: obj.target.id, name: obj.target.repr}) }} {{ t('components.library.EditCard.link.track', {id: obj.target.id, name: obj.target.repr}) }}
</router-link> </router-link>
</div> </div>
</div> </div>
<div <div
@ -194,115 +193,115 @@ const alertProps = computed(() => {
</div> </div>
<template #alert> <template #alert>
<span class="right floated"> <span class="right floated">
<span v-if="obj.is_approved && obj.is_applied"> <span v-if="obj.is_approved && obj.is_applied">
<i class="green bi bi-check"/> <i class="green bi bi-check" />
{{ t('components.library.EditCard.status.applied') }} {{ t('components.library.EditCard.status.applied') }}
</span>
<span v-else-if="obj.is_approved">
<i class="green bi bi-check" />
{{ t('components.library.EditCard.status.approved') }}
</span>
<span v-else-if="obj.is_approved === null">
<i class="yellow bi bi-hourglass" />
{{ t('components.library.EditCard.status.pending') }}
</span>
<span v-else-if="obj.is_approved === false">
<i class="destructive bi bi-x" />
{{ t('components.library.EditCard.status.rejected') }}
</span>
</span> </span>
<table <span v-else-if="obj.is_approved">
v-if="obj.type === 'update'" <i class="green bi bi-check" />
> {{ t('components.library.EditCard.status.approved') }}
<thead> </span>
<tr> <span v-else-if="obj.is_approved === null">
<th> <i class="yellow bi bi-hourglass" />
{{ t('components.library.EditCard.table.update.header.field') }} {{ t('components.library.EditCard.status.pending') }}
</th> </span>
<th> <span v-else-if="obj.is_approved === false">
{{ t('components.library.EditCard.table.update.header.oldValue') }} <i class="destructive bi bi-x" />
</th> {{ t('components.library.EditCard.status.rejected') }}
<th> </span>
{{ t('components.library.EditCard.table.update.header.newValue') }} </span>
</th> <table
</tr> v-if="obj.type === 'update'"
</thead> >
<tbody> <thead>
<tr <tr>
v-for="field in updatedFields" <th>
:key="field.id" {{ t('components.library.EditCard.table.update.header.field') }}
</th>
<th>
{{ t('components.library.EditCard.table.update.header.oldValue') }}
</th>
<th>
{{ t('components.library.EditCard.table.update.header.newValue') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="field in updatedFields"
:key="field.id"
>
<td>{{ field.id }}</td>
<td v-if="field.diff">
<template v-if="field.config?.type === 'attachment' && field.oldRepr">
<img
class="image"
alt=""
:src="store.getters['instance/absoluteUrl'](`api/v1/attachments/${field.oldRepr}/proxy?next=medium_square_crop`)"
>
</template>
<template v-else>
<span
v-for="(part, key) in field.diff.filter(p => !p.added)"
:key="key"
:class="['diff', {removed: part.removed}]"
>
{{ part.value }}
</span>
</template>
</td>
<td v-else>
{{ t('components.library.EditCard.table.update.notApplicable') }}
</td>
<td
v-if="field.diff"
:title="field.newRepr"
> >
<td>{{ field.id }}</td> <template v-if="field.config?.type === 'attachment' && field.newRepr">
<img
<td v-if="field.diff"> class="ui image"
<template v-if="field.config?.type === 'attachment' && field.oldRepr"> alt=""
<img :src="store.getters['instance/absoluteUrl'](`api/v1/attachments/${field.newRepr}/proxy?next=medium_square_crop`)"
class="image" >
alt="" </template>
:src="store.getters['instance/absoluteUrl'](`api/v1/attachments/${field.oldRepr}/proxy?next=medium_square_crop`)" <template v-else>
> <span
</template> v-for="(part, key) in field.diff.filter(p => !p.removed)"
<template v-else> :key="key"
<span :class="['diff', {added: part.added}]"
v-for="(part, key) in field.diff.filter(p => !p.added)" >
:key="key" {{ part.value }}
:class="['diff', {removed: part.removed}]" </span>
> </template>
{{ part.value }} </td>
</span> <td
</template> v-else
</td> :title="field.newRepr"
<td v-else> >
{{ t('components.library.EditCard.table.update.notApplicable') }} <template v-if="field.config?.type === 'attachment' && field.newRepr">
</td> <img
class="ui image"
<td alt=""
v-if="field.diff" :src="store.getters['instance/absoluteUrl'](`api/v1/attachments/${field.newRepr}/proxy?next=medium_square_crop`)"
:title="field.newRepr" >
> </template>
<template v-if="field.config?.type === 'attachment' && field.newRepr"> <template v-else>
<img {{ field.newRepr }}
class="ui image" </template>
alt="" </td>
:src="store.getters['instance/absoluteUrl'](`api/v1/attachments/${field.newRepr}/proxy?next=medium_square_crop`)" </tr>
> </tbody>
</template> </table>
<template v-else>
<span
v-for="(part, key) in field.diff.filter(p => !p.removed)"
:key="key"
:class="['diff', {added: part.added}]"
>
{{ part.value }}
</span>
</template>
</td>
<td
v-else
:title="field.newRepr"
>
<template v-if="field.config?.type === 'attachment' && field.newRepr">
<img
class="ui image"
alt=""
:src="store.getters['instance/absoluteUrl'](`api/v1/attachments/${field.newRepr}/proxy?next=medium_square_crop`)"
>
</template>
<template v-else>
{{ field.newRepr }}
</template>
</td>
</tr>
</tbody>
</table>
</template> </template>
<div <div
v-if="obj.created_by" v-if="obj.created_by"
class="extra content" class="extra content"
> >
<Spacer :size="8"/> <Spacer :size="8" />
<actor-link :actor="obj.created_by" /> <actor-link :actor="obj.created_by" />
</div> </div>
@ -313,13 +312,14 @@ const alertProps = computed(() => {
/> />
</template> </template>
<template #action <template
v-if="canDelete || canApprove" v-if="canDelete || canApprove"
#action
> >
<Button <Button
v-if="canApprove && obj.is_approved !== true" v-if="canApprove && obj.is_approved !== true"
primary primary
:isLoading="isLoading" :is-loading="isLoading"
@click="approve(true)" @click="approve(true)"
> >
{{ t('components.library.EditCard.button.approve') }} {{ t('components.library.EditCard.button.approve') }}
@ -327,7 +327,7 @@ const alertProps = computed(() => {
<Button <Button
v-if="canApprove && obj.is_approved === null" v-if="canApprove && obj.is_approved === null"
destructive destructive
:isLoading="isLoading" :is-loading="isLoading"
@click="approve(false)" @click="approve(false)"
> >
{{ t('components.library.EditCard.button.reject') }} {{ t('components.library.EditCard.button.reject') }}
@ -335,7 +335,7 @@ const alertProps = computed(() => {
<!--TODO: Make Dangerous Button hand through isLoading prop --> <!--TODO: Make Dangerous Button hand through isLoading prop -->
<dangerous-button <dangerous-button
v-if="canDelete" v-if="canDelete"
:isLoading="isLoading" :is-loading="isLoading"
:action="remove" :action="remove"
:title="t('components.library.EditCard.modal.delete.header')" :title="t('components.library.EditCard.modal.delete.header')"
> >

View File

@ -48,10 +48,10 @@ fetchData()
<template> <template>
<section :class="{ loading: isLoading }"> <section :class="{ loading: isLoading }">
<edit-card <edit-card
v-if="obj" v-if="obj"
:obj="obj" :obj="obj"
:current-state="currentState" :current-state="currentState"
/> />
</section> </section>
</template> </template>

View File

@ -13,9 +13,9 @@ import Layout from '~/components/ui/Layout.vue'
import Button from '~/components/ui/Button.vue' import Button from '~/components/ui/Button.vue'
import Spacer from '~/components/ui/Spacer.vue' import Spacer from '~/components/ui/Spacer.vue'
import Input from '~/components/ui/Input.vue' import Input from '~/components/ui/Input.vue'
import Textarea from "~/components/ui/Textarea.vue" import Textarea from '~/components/ui/Textarea.vue'
import Pills from "~/components/ui/Pills.vue" import Pills from '~/components/ui/Pills.vue'
import Alert from "~/components/ui/Alert.vue" import Alert from '~/components/ui/Alert.vue'
import AttachmentInput from '~/components/common/AttachmentInput.vue' import AttachmentInput from '~/components/common/AttachmentInput.vue'
import useEditConfigs from '~/composables/moderation/useEditConfigs' import useEditConfigs from '~/composables/moderation/useEditConfigs'
@ -137,23 +137,29 @@ const resetField = (fieldId: string) => {
</script> </script>
<template> <template>
<Alert green v-if="submittedMutation"> <Alert
<h4 class="header"> v-if="submittedMutation"
{{ t('components.library.EditForm.header.success') }} green
</h4> >
<h4 class="header">
{{ t('components.library.EditForm.header.success') }}
</h4>
<edit-card <edit-card
:obj="submittedMutation" :obj="submittedMutation"
:current-state="currentState" :current-state="currentState"
/> />
<Button <Button
solid primary solid
primary
@click.prevent="submittedMutation = null" @click.prevent="submittedMutation = null"
> >
{{ t('components.library.EditForm.button.new') }} {{ t('components.library.EditForm.button.new') }}
</Button> </Button>
</Alert> </Alert>
<Layout gap-32 v-else> <Layout
v-else
gap-32
>
<!-- Previous edits --> <!-- Previous edits -->
<edit-list <edit-list
@ -163,7 +169,7 @@ const resetField = (fieldId: string) => {
:current-state="currentState" :current-state="currentState"
> >
<div> <div>
<!--TODO: Use Section component with conditional headlines and action buttons--> <!--TODO: Use Section component with conditional headlines and action buttons-->
<template v-if="showPendingReview"> <template v-if="showPendingReview">
{{ t('components.library.EditForm.header.unreviewed') }} {{ t('components.library.EditForm.header.unreviewed') }}
<Button <Button
@ -196,11 +202,14 @@ const resetField = (fieldId: string) => {
<!-- Add new edits --> <!-- Add new edits -->
<form class="ui form" style="display: contents;" <form
class="ui form"
style="display: contents;"
@submit.prevent="submit()" @submit.prevent="submit()"
> >
<Alert red <Alert
v-if="errors.length > 0" v-if="errors.length > 0"
red
role="alert" role="alert"
> >
<h4 class="header"> <h4 class="header">
@ -215,15 +224,18 @@ const resetField = (fieldId: string) => {
</li> </li>
</ul> </ul>
</Alert> </Alert>
<Alert red <Alert
v-if="!canEdit" v-if="!canEdit"
red
> >
{{ t('components.library.EditForm.message.noPermission') }} {{ t('components.library.EditForm.message.noPermission') }}
</Alert> </Alert>
<Layout stack gap-8 <Layout
v-if="values"
v-for="fieldConfig in config.fields" v-for="fieldConfig in config.fields"
v-if="values"
:key="fieldConfig.id" :key="fieldConfig.id"
stack
gap-8
class="ui field" class="ui field"
> >
<template v-if="fieldConfig.type === 'text'"> <template v-if="fieldConfig.type === 'text'">
@ -259,10 +271,10 @@ const resetField = (fieldId: string) => {
</template> </template>
<template v-else-if="fieldConfig.type === 'content'"> <template v-else-if="fieldConfig.type === 'content'">
<Textarea <Textarea
:label="fieldConfig.label"
v-model="values[fieldConfig.id].text" v-model="values[fieldConfig.id].text"
:label="fieldConfig.label"
:field-id="fieldConfig.id" :field-id="fieldConfig.id"
initialLines="3" initial-lines="3"
/> />
</template> </template>
<!-- TODO: Style Attachment Input --> <!-- TODO: Style Attachment Input -->
@ -281,9 +293,9 @@ const resetField = (fieldId: string) => {
<template v-else-if="fieldConfig.type === 'tags'"> <template v-else-if="fieldConfig.type === 'tags'">
<Pills <Pills
:id="fieldConfig.id" :id="fieldConfig.id"
:label="fieldConfig.label"
ref="tags" ref="tags"
v-model="values[fieldConfig.id]" v-model="values[fieldConfig.id]"
:label="fieldConfig.label"
required="fieldConfig.required" required="fieldConfig.required"
> >
<Button <Button
@ -298,7 +310,7 @@ const resetField = (fieldId: string) => {
<Button <Button
low-height low-height
secondary secondary
alignSelf="end" align-self="end"
icon="bi-arrow-counterclockwise" icon="bi-arrow-counterclockwise"
form="noop" form="noop"
:disabled="fieldValuesChanged(fieldConfig.id) ? undefined : true" :disabled="fieldValuesChanged(fieldConfig.id) ? undefined : true"
@ -307,12 +319,12 @@ const resetField = (fieldId: string) => {
{{ t('components.library.EditForm.button.reset') }} {{ t('components.library.EditForm.button.reset') }}
</Button> </Button>
</Layout> </Layout>
<Spacer/> <Spacer />
<Textarea <Textarea
id="change-summary" id="change-summary"
v-model="summary" v-model="summary"
name="change-summary" name="change-summary"
initialLines="3" initial-lines="3"
:label="t('components.library.EditForm.label.summary')" :label="t('components.library.EditForm.label.summary')"
:placeholder="labels.summaryPlaceholder" :placeholder="labels.summaryPlaceholder"
> >
@ -327,7 +339,7 @@ const resetField = (fieldId: string) => {
</Button> </Button>
</Textarea> </Textarea>
<Button <Button
:isLoading="isLoading" :is-loading="isLoading"
primary primary
:disabled="isLoading || !mutationPayload" :disabled="isLoading || !mutationPayload"
> >

View File

@ -61,11 +61,10 @@ watchEffect(() => fetchData())
:disabled="!previousPage" :disabled="!previousPage"
primary primary
round round
alignSelf="center" align-self="center"
icon="bi-chevron-left" icon="bi-chevron-left"
@click="fetchData(previousPage)" @click="fetchData(previousPage)"
> />
</Button>
<Loader v-if="isLoading" /> <Loader v-if="isLoading" />
<edit-card <edit-card
v-for="obj in objects" v-for="obj in objects"
@ -78,12 +77,11 @@ watchEffect(() => fetchData())
<Button <Button
v-if="nextPage || previousPage" v-if="nextPage || previousPage"
:disabled="!nextPage" :disabled="!nextPage"
alignSelf="center" align-self="center"
primary primary
round round
icon="bi-chevron-right" icon="bi-chevron-right"
@click="fetchData(nextPage)" @click="fetchData(nextPage)"
> />
</Button>
</Layout> </Layout>
</template> </template>

View File

@ -97,19 +97,20 @@ const library = ref<Library>()
// Old implementation: // Old implementation:
watch(privacyLevel, async(newValue) => { try { watch(privacyLevel, async (newValue) => {
const response = await axios.get<paths['/api/v2/libraries/']['get']['responses']['200']['content']['application/json']>('libraries/', { try {
params: { const response = await axios.get<paths['/api/v2/libraries/']['get']['responses']['200']['content']['application/json']>('libraries/', {
privacy_level: privacyLevel.value, params: {
scope: 'me' privacy_level: privacyLevel.value,
} scope: 'me'
}) }
})
library.value = response.data.results.find(({name})=>name===privacyLevel.value)
} catch (error) {
useErrorHandler(error as Error)
}}, { immediate: true })
library.value = response.data.results.find(({ name }) => name === privacyLevel.value)
} catch (error) {
useErrorHandler(error as Error)
}
}, { immediate: true })
// //
// File counts // File counts
@ -355,15 +356,22 @@ useEventListener(window, 'beforeunload', (event) => {
</div> </div>
</div> </div>
</div> </div>
<Slider :options="options" v-model="privacyLevel" :label="t('components.manage.library.UploadsTable.label.visibility')" /> <Slider
v-model="privacyLevel"
:options="options"
:label="t('components.manage.library.UploadsTable.label.visibility')"
/>
<file-upload-widget <file-upload-widget
ref="upload" ref="upload"
v-model="files" v-model="files"
:data="uploadData" :data="uploadData"
@input-file="inputFile" @input-file="inputFile"
> >
<Button primary icon="bi bi-upload" > <Button
{{ t('components.library.FileUpload.label.uploadWidget') }} primary
icon="bi bi-upload"
>
{{ t('components.library.FileUpload.label.uploadWidget') }}
</Button> </Button>
<p> <p>
{{ t('components.library.FileUpload.label.extensions', {extensions: supportedExtensions.join(', ')}) }} {{ t('components.library.FileUpload.label.extensions', {extensions: supportedExtensions.join(', ')}) }}
@ -602,11 +610,10 @@ useEventListener(window, 'beforeunload', (event) => {
<template v-else-if="!file.success"> <template v-else-if="!file.success">
<Button <Button
tiny tiny
@click.prevent="upload.remove(file)"
style="float: right;" style="float: right;"
icon="bi-trash-fill" icon="bi-trash-fill"
> @click.prevent="upload.remove(file)"
</Button> />
</template> </template>
</td> </td>
</tr> </tr>

View File

@ -9,7 +9,6 @@ import Layout from '~/components/ui/Layout.vue'
import Button from '~/components/ui/Button.vue' import Button from '~/components/ui/Button.vue'
import Input from '~/components/ui/Input.vue' import Input from '~/components/ui/Input.vue'
interface Events { interface Events {
(e: 'update:modelValue', value: string[]): void (e: 'update:modelValue', value: string[]): void
(e: 'import'): void (e: 'import'): void
@ -37,7 +36,7 @@ const handleClick = (entry: FSEntry) => {
value.value.push(entry.name) value.value.push(entry.name)
} }
const path = computed (() => props.data.root + '/' + value.value.join('/')) const path = computed(() => props.data.root + '/' + value.value.join('/'))
</script> </script>
<template> <template>

View File

@ -2,7 +2,6 @@
import type { FSLogs } from '~/types' import type { FSLogs } from '~/types'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
interface Props { interface Props {
data: FSLogs data: FSLogs
} }

View File

@ -62,43 +62,45 @@ fetchData()
</script> </script>
<template> <template>
<Layout main stack <Layout
:key="route?.name ?? undefined" :key="route?.name ?? undefined"
v-title="labels.title" v-title="labels.title"
main
stack
> >
<Header :h1="t('components.Sidebar.header.explore')" /> <Header :h1="t('components.Sidebar.header.explore')" />
<playlist-widget <playlist-widget
:url="'playlists/'" :url="'playlists/'"
:filters="{scope: scope, playable: true, ordering: '-modification_date'}" :filters="{scope: scope, playable: true, ordering: '-modification_date'}"
:title="t('components.library.Home.header.playlists')" :title="t('components.library.Home.header.playlists')"
:limit="12" :limit="12"
/> />
<Spacer /> <Spacer />
<channels-widget <channels-widget
v-if="scope === 'all'" v-if="scope === 'all'"
:show-modification-date="true" :show-modification-date="true"
:filters="{ordering: '-creation_date'}" :filters="{ordering: '-creation_date'}"
:limit="8" :limit="8"
:title="t('components.library.Home.header.newChannels')" :title="t('components.library.Home.header.newChannels')"
/> />
<Spacer /> <Spacer />
<track-widget <track-widget
:title="t('components.library.Home.header.recentlyListened')" :title="t('components.library.Home.header.recentlyListened')"
:url="'history/listenings/'" :url="'history/listenings/'"
:filters="{ scope, ordering: '-creation_date', ...qualityFilters }" :filters="{ scope, ordering: '-creation_date', ...qualityFilters }"
:websocket-handlers="['Listen']" :websocket-handlers="['Listen']"
/> />
<Spacer /> <Spacer />
<track-widget <track-widget
:title="t('components.library.Home.header.recentlyFavorited')" :title="t('components.library.Home.header.recentlyFavorited')"
:url="'favorites/tracks/'" :url="'favorites/tracks/'"
:filters="{scope: scope, ordering: '-creation_date'}" :filters="{scope: scope, ordering: '-creation_date'}"
/> />
<Spacer /> <Spacer />
<album-widget <album-widget
:filters="{scope: scope, playable: true, ordering: '-creation_date', ...qualityFilters}" :filters="{scope: scope, playable: true, ordering: '-creation_date', ...qualityFilters}"
:limit="12" :limit="12"
:title="t('components.library.Home.header.recentlyAdded')" :title="t('components.library.Home.header.recentlyAdded')"
/> />
</Layout> </Layout>
</template> </template>

View File

@ -76,8 +76,9 @@ const getErrorData = (upload: Upload) => {
</script> </script>
<template> <template>
<Modal :title="t('components.library.ImportStatusModal.header.importDetail')" <Modal
v-model="show" v-model="show"
:title="t('components.library.ImportStatusModal.header.importDetail')"
:cancel="t('components.library.ImportStatusModal.button.close')" :cancel="t('components.library.ImportStatusModal.button.close')"
> >
<div <div

View File

@ -128,34 +128,44 @@ const labels = computed(() => ({
const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value].sort((a, b) => a - b))) const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value].sort((a, b) => a - b)))
const { isOpen:subscribeIsOpen, to:subscribe } = useModal('subscribe') const { isOpen: subscribeIsOpen, to: subscribe } = useModal('subscribe')
const { to:upload } = useModal('upload') const { to: upload } = useModal('upload')
</script> </script>
<template> <template>
<Layout stack main> <Layout
stack
main
>
<Header <Header
:h1="t('components.library.Podcasts.header.browse')" :h1="t('components.library.Podcasts.header.browse')"
/> />
<Layout form flex <Layout
form
flex
:class="['ui', {'loading': isLoading}, 'form']" :class="['ui', {'loading': isLoading}, 'form']"
@submit.prevent="search" @submit.prevent="search"
> >
<Input search <Input
id="artist-search" id="artist-search"
v-model="query" v-model="query"
search
name="search" name="search"
:label="t('components.library.Podcasts.label.search')" :label="t('components.library.Podcasts.label.search')"
autofocus autofocus
:placeholder="labels.searchPlaceholder" :placeholder="labels.searchPlaceholder"
> />
</Input>
<Pills <Pills
v-model="tagList" v-model="tagList"
:label="t('components.library.Podcasts.label.tags')" :label="t('components.library.Podcasts.label.tags')"
style="max-width: 150px;" style="max-width: 150px;"
/> />
<Layout stack noGap label for="artist-ordering"> <Layout
stack
no-gap
label
for="artist-ordering"
>
<span class="label"> <span class="label">
{{ t('components.library.Podcasts.ordering.label') }} {{ t('components.library.Podcasts.ordering.label') }}
</span> </span>
@ -173,7 +183,12 @@ const { to:upload } = useModal('upload')
</option> </option>
</select> </select>
</Layout> </Layout>
<Layout stack noGap label for="artist-ordering-direction"> <Layout
stack
no-gap
label
for="artist-ordering-direction"
>
<span class="label"> <span class="label">
{{ t('components.library.Podcasts.ordering.direction.label') }} {{ t('components.library.Podcasts.ordering.direction.label') }}
</span> </span>
@ -190,7 +205,12 @@ const { to:upload } = useModal('upload')
</option> </option>
</select> </select>
</Layout> </Layout>
<Layout stack noGap label for="artist-results"> <Layout
stack
no-gap
label
for="artist-results"
>
<span class="label"> <span class="label">
{{ t('components.library.Podcasts.pagination.results') }} {{ t('components.library.Podcasts.pagination.results') }}
</span> </span>
@ -209,11 +229,12 @@ const { to:upload } = useModal('upload')
</select> </select>
</Layout> </Layout>
</Layout> </Layout>
<Layout grid <Layout
v-if="result && result.results.length > 0" v-if="result && result.results.length > 0"
grid
style="display:flex; flex-wrap:wrap; gap: 32px; margin-top:32px;" style="display:flex; flex-wrap:wrap; gap: 32px; margin-top:32px;"
> >
<Loader v-if="isLoading"/> <Loader v-if="isLoading" />
<artist-card <artist-card
v-for="artist in result.results" v-for="artist in result.results"
:key="artist.id" :key="artist.id"
@ -221,8 +242,8 @@ const { to:upload } = useModal('upload')
/> />
</Layout> </Layout>
<Layout <Layout
stack
v-else-if="result && result.results.length === 0" v-else-if="result && result.results.length === 0"
stack
> >
<Alert yellow> <Alert yellow>
{{ t('components.library.Podcasts.empty.noResults') }} {{ t('components.library.Podcasts.empty.noResults') }}
@ -238,7 +259,10 @@ const { to:upload } = useModal('upload')
:to="subscribe" :to="subscribe"
> >
<template #image> <template #image>
<i class="bi bi-plus" style="font-size: 100px; position: relative; top: 50px;" /> <i
class="bi bi-plus"
style="font-size: 100px; position: relative; top: 50px;"
/>
</template> </template>
</Card> </Card>
<Card <Card
@ -251,7 +275,10 @@ const { to:upload } = useModal('upload')
:to="upload" :to="upload"
> >
<template #image> <template #image>
<i class="bi bi-upload" style="font-size: 100px; position: relative; top: 50px;" /> <i
class="bi bi-upload"
style="font-size: 100px; position: relative; top: 50px;"
/>
</template> </template>
</Card> </Card>
</Layout> </Layout>
@ -262,35 +289,33 @@ const { to:upload } = useModal('upload')
:page="page" :page="page"
:pages="Math.ceil((result?.results.length || 0)/paginateBy)" :pages="Math.ceil((result?.results.length || 0)/paginateBy)"
/> />
<Modal <Modal
v-model="subscribeIsOpen" v-model="subscribeIsOpen"
:title="t('components.library.Podcasts.modal.subscription.header')" :title="t('components.library.Podcasts.modal.subscription.header')"
:cancel="t('components.library.Podcasts.button.cancel')" :cancel="t('components.library.Podcasts.button.cancel')"
>
<div
ref="modalContent"
class="scrolling content"
> >
<remote-search-form <div
initial-type="both" ref="modalContent"
:show-submit="false" class="scrolling content"
:standalone="false"
:redirect="true"
@subscribed="subscribeIsOpen = false; fetchData()"
/>
</div>
<template #actions>
<Button
primary
form="remote-search"
type="submit"
> >
<i class="bookmark icon" /> <remote-search-form
{{ t('components.library.Podcasts.button.subscribe') }} initial-type="both"
</Button> :show-submit="false"
</template> :standalone="false"
</Modal> :redirect="true"
@subscribed="subscribeIsOpen = false; fetchData()"
/>
</div>
<template #actions>
<Button
primary
form="remote-search"
type="submit"
>
<i class="bookmark icon" />
{{ t('components.library.Podcasts.button.subscribe') }}
</Button>
</template>
</Modal>
</Layout> </Layout>
</template> </template>

View File

@ -114,35 +114,42 @@ const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value]
</script> </script>
<template> <template>
<Layout main stack gap-64> <Layout
main
stack
gap-64
>
<Header :h1="t('components.library.Radios.header.browse')" /> <Header :h1="t('components.library.Radios.header.browse')" />
<Section alignLeft :h2="t('components.library.Radios.header.instance')"> <Section
<radio-card align-left
v-if="isAuthenticated" :h2="t('components.library.Radios.header.instance')"
:type="'actor-content'" >
:object-id="store.state.auth.fullUsername" <radio-card
/> v-if="isAuthenticated"
<radio-card :type="'actor-content'"
v-if="isAuthenticated && hasFavorites" :object-id="store.state.auth.fullUsername"
:type="'favorites'" />
/> <radio-card
<radio-card v-if="isAuthenticated && hasFavorites"
v-if="scope === 'all'" :type="'favorites'"
:type="'random'" />
/> <radio-card
<radio-card v-if="scope === 'all'"
v-if="scope === 'me'" :type="'random'"
:type="'random_library'" />
/> <radio-card
<radio-card :type="'recently-added'" /> v-if="scope === 'me'"
<radio-card :type="'random_library'"
v-if="store.state.auth.authenticated && scope === 'all'" />
:type="'less-listened'" <radio-card :type="'recently-added'" />
/> <radio-card
<radio-card v-if="store.state.auth.authenticated && scope === 'all'"
v-if="store.state.auth.authenticated && scope === 'me'" :type="'less-listened'"
:type="'less-listened_library'" />
/> <radio-card
v-if="store.state.auth.authenticated && scope === 'me'"
:type="'less-listened_library'"
/>
</Section> </Section>
<h2> <h2>
{{ t('components.library.Radios.header.user') }} {{ t('components.library.Radios.header.user') }}
@ -157,18 +164,26 @@ const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value]
{{ t('components.library.Radios.button.create') }} {{ t('components.library.Radios.button.create') }}
</Link> </Link>
</h2> </h2>
<Layout flex form <Layout
flex
form
:class="['ui', {'loading': isLoading}, 'form']" :class="['ui', {'loading': isLoading}, 'form']"
@submit.prevent="search" @submit.prevent="search"
> >
<Input search <Input
id="radios-search" id="radios-search"
v-model="query" v-model="query"
search
name="search" name="search"
:label="t('components.library.Radios.label.search')" :label="t('components.library.Radios.label.search')"
:placeholder="labels.searchPlaceholder" :placeholder="labels.searchPlaceholder"
/> />
<Layout stack noGap label for="radios-ordering"> <Layout
stack
no-gap
label
for="radios-ordering"
>
<span class="label"> <span class="label">
{{ t('components.library.Radios.ordering.label') }} {{ t('components.library.Radios.ordering.label') }}
</span> </span>
@ -186,7 +201,12 @@ const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value]
</option> </option>
</select> </select>
</Layout> </Layout>
<Layout stack noGap label for="radios-ordering-direction"> <Layout
stack
no-gap
label
for="radios-ordering-direction"
>
<span class="label"> <span class="label">
{{ t('components.library.Radios.ordering.direction.label') }} {{ t('components.library.Radios.ordering.direction.label') }}
</span> </span>
@ -203,7 +223,12 @@ const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value]
</option> </option>
</select> </select>
</Layout> </Layout>
<Layout stack noGap label for="radios-results"> <Layout
stack
no-gap
label
for="radios-results"
>
<span class="label"> <span class="label">
{{ t('components.library.Radios.pagination.results') }} {{ t('components.library.Radios.pagination.results') }}
</span> </span>
@ -223,11 +248,14 @@ const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value]
</Layout> </Layout>
</Layout> </Layout>
<Alert <Alert
v-if="result && result.results.length === 0"
blue blue
style="align-items: center;" style="align-items: center;"
v-if="result && result.results.length === 0"
> >
<i class="bi bi-broadcast-pin" style="font-size: 80px" /> <i
class="bi bi-broadcast-pin"
style="font-size: 80px"
/>
<Spacer /> <Spacer />
{{ t('components.library.Radios.empty.noResults') }} {{ t('components.library.Radios.empty.noResults') }}
<Spacer /> <Spacer />
@ -241,8 +269,9 @@ const paginateOptions = computed(() => sortedUniq([12, 25, 50, paginateBy.value]
{{ t('components.library.Radios.button.add') }} {{ t('components.library.Radios.button.add') }}
</Button> </Button>
</Alert> </Alert>
<Layout flex <Layout
v-if="result && result.results.length > 0" v-if="result && result.results.length > 0"
flex
> >
<Pagination <Pagination
v-if="result && result.count > paginateBy" v-if="result && result.count > paginateBy"

View File

@ -27,15 +27,22 @@ const labels = computed(() => ({
</script> </script>
<template> <template>
<Layout main stack v-title="labels.title"> <Layout
v-title="labels.title"
main
stack
>
<h1 class="ui header"> <h1 class="ui header">
<span class="funkwhale solid raised secondary pill"> <span class="funkwhale solid raised secondary pill">
<span class="pill-content"> <span class="pill-content">
{{ labels.title }} {{ labels.title }}
</span> </span>
</span> </span>
</h1> </h1>
<Layout flex class="buttons"> <Layout
flex
class="buttons"
>
<radio-button <radio-button
type="tag" type="tag"
:object-id="id" :object-id="id"

View File

@ -76,7 +76,7 @@ const attributedToUrl = computed(() => router.resolve({
} }
})?.href) })?.href)
const totalDuration = computed(() => track.value?.uploads[0]?.duration ?? 0) const totalDuration = computed(() => track.value?.uploads[0]?.duration ?? 0)
const { t } = useI18n() const { t } = useI18n()
const labels = computed(() => ({ const labels = computed(() => ({
@ -126,250 +126,271 @@ watch(showDeleteModal, (newValue) => {
</script> </script>
<template> <template>
<Layout stack main> <Layout
<Loader stack
v-if="isLoading" main
v-title="labels.title" >
/> <Loader
<template v-if="track"> v-if="isLoading"
<Layout flex> v-title="labels.title"
<img />
v-if="track.cover" <template v-if="track">
v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.large_square_crop)" <Layout flex>
alt="" <img
class="channel-image" v-if="track.cover"
> v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.large_square_crop)"
<img alt=""
v-if="track.album && track.album.cover" class="channel-image"
v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.large_square_crop)" >
alt="" <img
class="channel-image" v-if="track.album && track.album.cover"
> v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.large_square_crop)"
<img alt=""
v-else class="channel-image"
alt="" >
class="channel-image" <img
src="../../assets/audio/default-cover.png" v-else
> alt=""
class="channel-image"
src="../../assets/audio/default-cover.png"
>
<Layout stack style="flex: 1; gap: 8px;"> <Layout
<Layout flex no-gap style="align-items: baseline; margin-bottom: 24px;"> stack
<h1>{{ track.title }}</h1> style="flex: 1; gap: 8px;"
<Spacer grow /> >
<Button <Layout
v-if="upload" flex
:aria-label="labels.download" no-gap
:to="downloadUrl" style="align-items: baseline; margin-bottom: 24px;"
target="_blank"
primary
icon="bi-download"
:title="labels.download"
> >
{{ labels.download }} <h1>{{ track.title }}</h1>
</Button> <Spacer grow />
</Layout> <Button
<div class="meta"> v-if="upload"
:aria-label="labels.download"
:to="downloadUrl"
target="_blank"
primary
icon="bi-download"
:title="labels.download"
>
{{ labels.download }}
</Button>
</Layout>
<div class="meta">
<span>{{ t('components.library.TrackBase.title') }}</span> <span>{{ t('components.library.TrackBase.title') }}</span>
<i class="bi bi-dot" /> <i class="bi bi-dot" />
<span>{{ track.album.title }}</span> <span>{{ track.album.title }}</span>
<i v-if="totalDuration > 0" class="bi bi-dot" /> <i
v-if="totalDuration > 0"
class="bi bi-dot"
/>
<human-duration <human-duration
v-if="totalDuration > 0" v-if="totalDuration > 0"
:duration="totalDuration" :duration="totalDuration"
/> />
</div> </div>
<Layout flex> <Layout flex>
<PlayButton <PlayButton
:is-playable="track.is_playable" :is-playable="track.is_playable"
class="vibrant" class="vibrant"
split split
:track="track" :track="track"
/> />
<Spacer h grow /> <Spacer
h
grow
/>
<TrackFavoriteIcon v-if="store.state.auth.authenticated" :track="track" /> <TrackFavoriteIcon
<TrackPlaylistIcon v-if="store.state.auth.authenticated" :track="track" /> v-if="store.state.auth.authenticated"
<Popover v-model:open="open"> :track="track"
<template #default="{ toggleOpen }"> />
<OptionsButton @click="toggleOpen" /> <TrackPlaylistIcon
</template> v-if="store.state.auth.authenticated"
<template #items> :track="track"
<PopoverItem />
v-if="domain != store.getters['instance/domain']" <Popover v-model:open="open">
:to="track.fid" <template #default="{ toggleOpen }">
target="_blank" <OptionsButton @click="toggleOpen" />
icon="bi-box-arrow-up-right" </template>
> <template #items>
{{ t('components.library.TrackBase.link.domain', { domain }) }} <PopoverItem
</PopoverItem> v-if="domain != store.getters['instance/domain']"
:to="track.fid"
target="_blank"
icon="bi-box-arrow-up-right"
>
{{ t('components.library.TrackBase.link.domain', { domain }) }}
</PopoverItem>
<PopoverItem <PopoverItem
v-if="isEmbedable" v-if="isEmbedable"
@click="showEmbedModal = !showEmbedModal" icon="bi-code-slash"
icon="bi-code-slash" @click="showEmbedModal = !showEmbedModal"
> >
{{ t('components.library.TrackBase.button.embed') }} {{ t('components.library.TrackBase.button.embed') }}
</PopoverItem> </PopoverItem>
<PopoverItem <PopoverItem
:to="wikipediaUrl" :to="wikipediaUrl"
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
icon="bi-wikipedia" icon="bi-wikipedia"
> >
{{ t('components.library.TrackBase.link.wikipedia') }} {{ t('components.library.TrackBase.link.wikipedia') }}
</PopoverItem> </PopoverItem>
<PopoverItem <PopoverItem
v-if="discogsUrl" v-if="discogsUrl"
:to="discogsUrl" :to="discogsUrl"
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
icon="bi-box-arrow-up-right" icon="bi-box-arrow-up-right"
> >
{{ t('components.library.TrackBase.link.discogs') }} {{ t('components.library.TrackBase.link.discogs') }}
</PopoverItem> </PopoverItem>
<PopoverItem <PopoverItem
v-if="track.is_local" v-if="track.is_local"
icon="bi-pencil-fill" icon="bi-pencil-fill"
:to="{ name: 'library.tracks.edit', params: { id: track.id } }" :to="{ name: 'library.tracks.edit', params: { id: track.id } }"
> >
{{ t('components.library.TrackBase.button.edit') }} {{ t('components.library.TrackBase.button.edit') }}
</PopoverItem> </PopoverItem>
<PopoverItem <PopoverItem
v-if="artist && v-if="artist &&
store.state.auth.authenticated && store.state.auth.authenticated &&
artist.channel && artist.channel &&
artist.attributed_to.full_username === store.state.auth.fullUsername" artist.attributed_to.full_username === store.state.auth.fullUsername"
@click="showDeleteModal = true" icon="bi-trash"
icon="bi-trash" @click="showDeleteModal = true"
> >
{{ t('components.library.TrackBase.button.delete') }} {{ t('components.library.TrackBase.button.delete') }}
</PopoverItem> </PopoverItem>
<hr> <hr>
<PopoverItem <PopoverItem
v-for="obj in getReportableObjects({ track })" v-for="obj in getReportableObjects({ track })"
:key="obj.target.type + obj.target.id" :key="obj.target.type + obj.target.id"
@click="report(obj)" icon="bi-flag"
icon="bi-flag" @click="report(obj)"
> >
{{ obj.label }} {{ obj.label }}
</PopoverItem> </PopoverItem>
<hr> <hr>
<PopoverItem <PopoverItem
v-if="store.state.auth.availablePermissions['library']" v-if="store.state.auth.availablePermissions['library']"
:to="{ :to="{
name: 'manage.library.tracks.detail', name: 'manage.library.tracks.detail',
params: { id: track.id } params: { id: track.id }
}" }"
icon="bi-wrench" icon="bi-wrench"
> >
{{ t('components.library.TrackBase.link.moderation') }} {{ t('components.library.TrackBase.link.moderation') }}
</PopoverItem> </PopoverItem>
<PopoverItem <PopoverItem
v-if="store.state.auth.profile?.is_superuser" v-if="store.state.auth.profile?.is_superuser"
:to="store.getters['instance/absoluteUrl'](`/api/admin/music/track/${track.id}`)" :to="store.getters['instance/absoluteUrl'](`/api/admin/music/track/${track.id}`)"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
icon="bi-wrench" icon="bi-wrench"
> >
{{ t('components.library.TrackBase.link.django') }} {{ t('components.library.TrackBase.link.django') }}
</PopoverItem> </PopoverItem>
</template> </template>
</Popover> </Popover>
</Layout>
</Layout> </Layout>
</Layout> </Layout>
</Layout>
<hr> <hr>
<Layout flex> <Layout flex>
<div> <div>
<span v-if="track.attributed_to"> <span v-if="track.attributed_to">
{{ t('components.library.TrackBase.subtitle.with-uploader') }} {{ t('components.library.TrackBase.subtitle.with-uploader') }}
</span> </span>
<span v-else> <span v-else>
{{ t('components.library.TrackBase.subtitle.without-uploader') }} {{ t('components.library.TrackBase.subtitle.without-uploader') }}
</span> </span>
<ActorLink <ActorLink
v-if="track.attributed_to" v-if="track.attributed_to"
:actor="track.attributed_to" :actor="track.attributed_to"
:avatar="false" :avatar="false"
/>
<time
:title="track.creation_date"
:datetime="track.creation_date"
>
{{ momentFormat(new Date(track.creation_date), 'LL') }}
</time>
</div>
</Layout>
<Modal
v-if="isEmbedable"
v-model="showEmbedModal"
:title="t('components.library.TrackBase.modal.embed.header')"
>
<embed-wizard
:id="track.id"
type="track"
/> />
<time <template #actions>
:title="track.creation_date" <Button
:datetime="track.creation_date" secondary
> @click="showEmbedModal = false"
{{ momentFormat(new Date(track.creation_date), 'LL') }} >
</time> {{ t('components.library.TrackBase.button.cancel') }}
</div> </Button>
</Layout> </template>
</Modal>
<Modal
v-model="showDeleteModal"
:title="t('components.library.TrackBase.modal.delete.header')"
destructive
>
<template #alert>
<Alert red>
{{ t('components.library.TrackBase.modal.delete.content.warning') }}
</Alert>
</template>
<Modal <template #actions>
v-if="isEmbedable" <Button
v-model="showEmbedModal" secondary
:title="t('components.library.TrackBase.modal.embed.header')" @click="showDeleteModal = false"
> >
<embed-wizard {{ t('components.library.TrackBase.button.cancel') }}
:id="track.id" </Button>
type="track" <Button
destructive
:is-loading="isLoading"
@click="remove()"
>
{{ t('components.library.TrackBase.button.delete') }}
</Button>
</template>
</Modal>
<router-view
v-if="track"
:key="route.fullPath"
:track="track"
:object="track"
object-type="track"
@libraries-loaded="libraries = $event"
/> />
</template>
<template #actions>
<Button
secondary
@click="showEmbedModal = false"
>
{{ t('components.library.TrackBase.button.cancel') }}
</Button>
</template>
</Modal>
<Modal
v-model="showDeleteModal"
:title="t('components.library.TrackBase.modal.delete.header')"
destructive
>
<template #alert>
<Alert red>
{{ t('components.library.TrackBase.modal.delete.content.warning') }}
</Alert>
</template>
<template #actions>
<Button
secondary
@click="showDeleteModal = false"
>
{{ t('components.library.TrackBase.button.cancel') }}
</Button>
<Button
destructive
:is-loading="isLoading"
@click="remove()"
>
{{ t('components.library.TrackBase.button.delete') }}
</Button>
</template>
</Modal>
<router-view
v-if="track"
:key="route.fullPath"
:track="track"
:object="track"
object-type="track"
@libraries-loaded="libraries = $event"
/>
</template>
</Layout> </Layout>
</template> </template>

View File

@ -98,7 +98,7 @@ const release_details: {
}, },
{ {
label: t('components.library.TrackDetail.table.release.license'), label: t('components.library.TrackDetail.table.release.license'),
release_value: license?.name || t('components.library.TrackDetail.notApplicable'), release_value: license.value?.name || t('components.library.TrackDetail.notApplicable')
} }
] ]
@ -124,7 +124,7 @@ const track_details: {
label: label:
t('components.library.TrackDetail.table.track.bitrate.label'), t('components.library.TrackDetail.table.track.bitrate.label'),
track_value: upload?.value.bitrate track_value: upload?.value.bitrate
? t('components.library.TrackDetail.table.track.bitrate.value', {bitrate: humanSize(upload.value.bitrate)}) ? t('components.library.TrackDetail.table.track.bitrate.value', { bitrate: humanSize(upload.value.bitrate) })
: t('components.library.TrackDetail.notApplicable') : t('components.library.TrackDetail.notApplicable')
}, },
{ {
@ -135,17 +135,26 @@ const track_details: {
</script> </script>
<template> <template>
<Layout stack v-if="track"> <Layout
v-if="track"
stack
>
<TagsList <TagsList
v-if="track.tags && track.tags.length > 0" v-if="track.tags && track.tags.length > 0"
style="margin-top: -16px;" style="margin-top: -16px;"
:tags="track.tags" :tags="track.tags"
/> />
<Layout flex style="gap: 24px;"> <Layout
<Layout stack style="flex: 1; gap: 0;"> flex
style="gap: 24px;"
>
<Layout
stack
style="flex: 1; gap: 0;"
>
<Section <Section
alignLeft align-left
h2="Release Details" h2="Release Details"
:action="{ :action="{
text:'View on MusicBrainz', text:'View on MusicBrainz',
@ -153,28 +162,63 @@ const track_details: {
}" }"
icon="bi-box-arrow-up-right" icon="bi-box-arrow-up-right"
/> />
<Layout flex class="details" v-for="item in release_details" key="label" <Layout
v-for="item in release_details"
key="label"
flex
class="details"
style="min-width: 120px;" style="min-width: 120px;"
> >
<span class="label">{{ item.label }}</span> <span class="label">{{ item.label }}</span>
<Spacer h grow /> <Spacer
<Link v-if="item.link" class="value" :to="item.link">{{ item.release_value }}</Link> h
<span v-else class="value">{{ item.release_value }}</span> grow
/>
<Link
v-if="item.link"
class="value"
:to="item.link"
>
{{ item.release_value }}
</Link>
<span
v-else
class="value"
>{{ item.release_value }}</span>
</Layout> </Layout>
</Layout> </Layout>
<Layout stack style="flex: 1; gap: 0;"> <Layout
stack
style="flex: 1; gap: 0;"
>
<Section <Section
alignLeft align-left
h2="Track Details" h2="Track Details"
/> />
<Layout flex class="details" v-for="item in track_details" key="label" <Layout
v-for="item in track_details"
key="label"
flex
class="details"
style="min-width: 120px;" style="min-width: 120px;"
> >
<span class="label">{{ item.label }}</span> <span class="label">{{ item.label }}</span>
<Spacer h grow /> <Spacer
<Link v-if="item.link" class="value" :to="item.link">{{ item.track_value }}</Link> h
<span v-else class="value">{{ item.track_value }}</span> grow
/>
<Link
v-if="item.link"
class="value"
:to="item.link"
>
{{ item.track_value }}
</Link>
<span
v-else
class="value"
>{{ item.track_value }}</span>
</Layout> </Layout>
</Layout> </Layout>
</Layout> </Layout>

View File

@ -5,7 +5,6 @@ import type { Library } from '~/types'
import { ref } from 'vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import store from '~/store' import store from '~/store'
import axios from 'axios' import axios from 'axios'
@ -50,8 +49,9 @@ fetchLicenses()
<template> <template>
<Header :h2="canEdit ? t('components.library.TrackEdit.header.edit') : t('components.library.TrackEdit.header.suggest')" /> <Header :h2="canEdit ? t('components.library.TrackEdit.header.edit') : t('components.library.TrackEdit.header.suggest')" />
<Alert yellow <Alert
v-if="!object.is_local" v-if="!object.is_local"
yellow
> >
{{ t('components.library.TrackEdit.message.remote') }} {{ t('components.library.TrackEdit.message.remote') }}
</Alert> </Alert>

View File

@ -195,9 +195,9 @@ const save = async () => {
<template> <template>
<Layout <Layout
v-title="labels.title"
stack stack
main main
v-title="labels.title"
> >
<section> <section>
<h1> <h1>

View File

@ -186,9 +186,10 @@ fetchCandidates()
> >
{{ t('components.library.radios.Filter.matchingTracks', checkResult.candidates.count) }} {{ t('components.library.radios.Filter.matchingTracks', checkResult.candidates.count) }}
</a> </a>
<Modal :title="t('components.library.radios.Filter.matchingTracksModalHeader')" <Modal
v-if="checkResult" v-if="checkResult"
v-model:show="showCandidadesModal" v-model:show="showCandidadesModal"
:title="t('components.library.radios.Filter.matchingTracksModalHeader')"
> >
<div class="content"> <div class="content">
<div class="description"> <div class="description">

View File

@ -102,7 +102,7 @@ const labels = computed(() => ({
<template> <template>
<div> <div>
<div class="ui inline form"> <div class="ui inline form">
/front/src/components/manage/library/AlbumsTable.vue /front/src/components/manage/library/AlbumsTable.vue
<div class="fields"> <div class="fields">
<div class="ui six wide field"> <div class="ui six wide field">
<label for="albums-search">{{ t('components.manage.library.AlbumsTable.label.search') }}</label> <label for="albums-search">{{ t('components.manage.library.AlbumsTable.label.search') }}</label>

View File

@ -340,8 +340,7 @@ const getPrivacyLevelChoice = (privacyLevel: PrivacyLevel) => {
icon="bi-question-circle" icon="bi-question-circle"
:title="sharedLabels.fields.import_status.label" :title="sharedLabels.fields.import_status.label"
@click="detailedUpload = scope.obj; showUploadDetailModal = true" @click="detailedUpload = scope.obj; showUploadDetailModal = true"
> />
</Button>
</td> </td>
<td> <td>
<span v-if="scope.obj.size">{{ humanSize(scope.obj.size) }}</span> <span v-if="scope.obj.size">{{ humanSize(scope.obj.size) }}</span>

View File

@ -241,8 +241,8 @@ const remove = async () => {
<dangerous-button <dangerous-button
v-if="object" v-if="object"
style="float: right;" style="float: right;"
@confirm="remove"
:title="t('components.manage.moderation.InstancePolicyForm.modal.delete.header')" :title="t('components.manage.moderation.InstancePolicyForm.modal.delete.header')"
@confirm="remove"
> >
{{ t('components.manage.moderation.InstancePolicyForm.button.delete') }} {{ t('components.manage.moderation.InstancePolicyForm.button.delete') }}
<template #modal-content> <template #modal-content>

View File

@ -30,7 +30,7 @@ const obj = computed(() => result.value?.results[0] ?? null)
const isLoading = ref(false) const isLoading = ref(false)
watch (show, (newValue) => { watch(show, (newValue) => {
if (newValue) fetchData() if (newValue) fetchData()
}) })
@ -72,8 +72,9 @@ const fetchData = async () => {
<slot> <slot>
{{ t('components.manage.moderation.InstancePolicyModal.button.show') }} {{ t('components.manage.moderation.InstancePolicyModal.button.show') }}
</slot> </slot>
<Modal :title="t('components.manage.moderation.InstancePolicyModal.modal.manage.header', {obj: target})" <Modal
v-model="show" v-model="show"
:title="t('components.manage.moderation.InstancePolicyModal.modal.manage.header', {obj: target})"
:cancel="t('components.manage.moderation.InstancePolicyModal.button.close')" :cancel="t('components.manage.moderation.InstancePolicyModal.button.close')"
> >
<div class="content"> <div class="content">

View File

@ -67,9 +67,9 @@ const remove = async (note: Note) => {
<dangerous-button <dangerous-button
:is-loading="isLoading" :is-loading="isLoading"
low-height low-height
@confirm="remove(note)"
icon="bi-trash" icon="bi-trash"
:title="t('components.manage.moderation.NotesThread.modal.delete.header')" :title="t('components.manage.moderation.NotesThread.modal.delete.header')"
@confirm="remove(note)"
> >
{{ t('components.manage.moderation.NotesThread.button.delete') }} {{ t('components.manage.moderation.NotesThread.button.delete') }}

View File

@ -5,7 +5,6 @@ import { useStore } from '~/store'
import { ref } from 'vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import axios from 'axios' import axios from 'axios'
import NotesThread from '~/components/manage/moderation/NotesThread.vue' import NotesThread from '~/components/manage/moderation/NotesThread.vue'

View File

@ -60,14 +60,16 @@ const hide = async () => {
</script> </script>
<template> <template>
<Modal v-model="show" <Modal
v-model="show"
:title="type==='artist' ? t('components.moderation.FilterModal.header.modal', {name: target?.name}) : errors.length > 0 ? t('components.moderation.FilterModal.header.failure') : ''" :title="type==='artist' ? t('components.moderation.FilterModal.header.modal', {name: target?.name}) : errors.length > 0 ? t('components.moderation.FilterModal.header.failure') : ''"
:cancel="t('components.moderation.FilterModal.button.cancel')" :cancel="t('components.moderation.FilterModal.button.cancel')"
> >
<div class="scrolling content"> <div class="scrolling content">
<div class="description"> <div class="description">
<Alert red <Alert
v-if="errors.length > 0" v-if="errors.length > 0"
red
> >
<ul class="list"> <ul class="list">
<li <li

View File

@ -125,8 +125,9 @@ watchEffect(async () => {
</script> </script>
<template> <template>
<Modal :title="target ? t('components.moderation.ReportModal.header.modal') : errors.length > 0 ? t('components.moderation.ReportModal.header.submissionFailure') : ''" <Modal
v-model="show" v-model="show"
:title="target ? t('components.moderation.ReportModal.header.modal') : errors.length > 0 ? t('components.moderation.ReportModal.header.submissionFailure') : ''"
:cancel="t('components.moderation.ReportModal.button.cancel')" :cancel="t('components.moderation.ReportModal.button.cancel')"
> >
<h2 <h2
@ -141,8 +142,9 @@ watchEffect(async () => {
</h2> </h2>
<div class="scrolling content"> <div class="scrolling content">
<div class="description"> <div class="description">
<Alert red <Alert
v-if="errors.length > 0" v-if="errors.length > 0"
red
> >
<ul class="list"> <ul class="list">
<li <li
@ -241,8 +243,9 @@ watchEffect(async () => {
</div> </div>
</div> </div>
<template #actions> <template #actions>
<Button destructive <Button
v-if="canSubmit" v-if="canSubmit"
destructive
:is-loading="isLoading" :is-loading="isLoading"
type="submit" type="submit"
form="report-form" form="report-form"

View File

@ -11,7 +11,7 @@ import type { Playlist } from '~/types'
const { t } = useI18n() const { t } = useI18n()
const play = defineEmit<[playlist: Playlist]>() const play = defineEmit<[playlist: Playlist]>()
const {playlist} = defineProps<{playlist: Playlist}>() const { playlist } = defineProps<{playlist: Playlist}>()
const covers = computed(() => playlist.album_covers const covers = computed(() => playlist.album_covers
.filter((src, index, array) => array.indexOf(src) === index) .filter((src, index, array) => array.indexOf(src) === index)
@ -36,15 +36,15 @@ if (import.meta.env.PROD) {
<template> <template>
<Card <Card
:title="playlist.name" :title="playlist.name"
@click="navigate('playlist')"
class="playlist-card" class="playlist-card"
@click="navigate('playlist')"
> >
<template #image> <template #image>
<img <img
v-for="src in covers" v-for="src in covers"
:key="src" :key="src"
:src="src" :src="src"
/> >
<div <div
v-for="i in Math.max(0, 4 - covers.length)" v-for="i in Math.max(0, 4 - covers.length)"
:key="i" :key="i"
@ -54,8 +54,8 @@ if (import.meta.env.PROD) {
<PlayButton @play="play(playlist)" /> <PlayButton @play="play(playlist)" />
<a <a
@click.stop="navigate('user')"
class="funkwhale link" class="funkwhale link"
@click.stop="navigate('user')"
> >
{{ t('vui.by-user', playlist.actor.full_username) }} {{ t('vui.by-user', playlist.actor.full_username) }}
</a> </a>

View File

@ -45,16 +45,16 @@ const bgcolors = ref([
'#292525', '#292525',
'#403a3b', '#403a3b',
'#322f2f' '#322f2f'
]); ])
function shuffleArray(array: string[]): string[] { function shuffleArray (array: string[]): string[] {
return [...array].sort(() => Math.random() - 0.5); return [...array].sort(() => Math.random() - 0.5)
} }
const randomizedColors = computed(() => shuffleArray(bgcolors.value)); const randomizedColors = computed(() => shuffleArray(bgcolors.value))
const goToPlaylist = () => { const goToPlaylist = () => {
router.push({name: 'library.playlists.detail', params: {id: props.playlist.id}}) router.push({ name: 'library.playlists.detail', params: { id: props.playlist.id } })
} }
const updatedTitle = computed(() => { const updatedTitle = computed(() => {
@ -72,7 +72,7 @@ const updatedAgo = computed(() => moment(props.playlist.modification_date).fromN
> >
<template #topright> <template #topright>
<PlayButton <PlayButton
iconOnly icon-only
:is-playable="playlist.is_playable" :is-playable="playlist.is_playable"
:playlist="playlist" :playlist="playlist"
/> />
@ -86,7 +86,7 @@ const updatedAgo = computed(() => moment(props.playlist.modification_date).fromN
v-lazy="url" v-lazy="url"
:alt="playlist.name" :alt="playlist.name"
:style="{ backgroundColor: randomizedColors[idx % randomizedColors.length] }" :style="{ backgroundColor: randomizedColors[idx % randomizedColors.length] }"
/> >
</div> </div>
</template> </template>

View File

@ -12,7 +12,10 @@ defineProps<Props>()
</script> </script>
<template> <template>
<Layout grid v-if="playlists.length > 0"> <Layout
v-if="playlists.length > 0"
grid
>
<PlaylistsCard <PlaylistsCard
v-for="playlist in playlists" v-for="playlist in playlists"
:key="playlist.id" :key="playlist.id"

View File

@ -176,143 +176,143 @@ const insertMany = async (insertedTracks: number[], allowDuplicates: boolean) =>
<h3 class="ui top attached header"> <h3 class="ui top attached header">
{{ t('components.playlists.Editor.header.editor') }} {{ t('components.playlists.Editor.header.editor') }}
</h3> </h3>
<template v-if="status === 'loading'"> <template v-if="status === 'loading'">
<div class="ui active tiny inline loader" /> <div class="ui active tiny inline loader" />
{{ t('components.playlists.Editor.loading.sync') }} {{ t('components.playlists.Editor.loading.sync') }}
</template> </template>
<template v-else-if="status === 'errored'"> <template v-else-if="status === 'errored'">
<i class="dangerclose icon" /> <i class="dangerclose icon" />
{{ t('components.playlists.Editor.error.sync') }} {{ t('components.playlists.Editor.error.sync') }}
<Alert
red
v-if="errors.length > 0"
role="alert"
>
<ul class="list">
<li
v-for="error in errors"
:key="error"
>
{{ error }}
</li>
</ul>
</Alert>
</template>
<Alert <Alert
v-if="errors.length > 0"
red red
v-else-if="status === 'confirmDuplicateAdd'"
role="alert" role="alert"
> >
<p> <ul class="list">
{{ t('components.playlists.Editor.warning.duplicate') }}
</p>
<ul class="ui relaxed divided list duplicate-tracks-list">
<li <li
v-for="track in duplicateTrackAddInfo?.tracks ?? []" v-for="error in errors"
:key="track" :key="error"
class="ui item"
> >
{{ track }} {{ error }}
</li> </li>
</ul> </ul>
<Button
destructive
@click="insertMany(queueTracks, true)"
>
{{ t('components.playlists.Editor.button.addDuplicate') }}
</Button>
</Alert> </Alert>
<Alert </template>
v-else-if="status === 'saved'" <Alert
green v-else-if="status === 'confirmDuplicateAdd'"
align-content="center" red
role="alert"
>
<p>
{{ t('components.playlists.Editor.warning.duplicate') }}
</p>
<ul class="ui relaxed divided list duplicate-tracks-list">
<li
v-for="track in duplicateTrackAddInfo?.tracks ?? []"
:key="track"
class="ui item"
>
{{ track }}
</li>
</ul>
<Button
destructive
@click="insertMany(queueTracks, true)"
> >
<span> {{ t('components.playlists.Editor.button.addDuplicate') }}
<i class="bi bi-check" /> </Button>
{{ t('components.playlists.Editor.message.sync') }} </Alert>
</span> <Alert
</Alert> v-else-if="status === 'saved'"
<Layout flex> green
<Button align-content="center"
:disabled="queueTracks.length === 0" >
primary <span>
:class="['ui', {disabled: queueTracks.length === 0}, 'labeled', 'icon', 'button']" <i class="bi bi-check" />
:title="labels.copyTitle" {{ t('components.playlists.Editor.message.sync') }}
icon="bi-plus" </span>
@click="insertMany(queueTracks, false)" </Alert>
> <Layout flex>
{{ t('components.playlists.Editor.button.insertFromQueue', queueTracks.length) }} <Button
</Button> :disabled="queueTracks.length === 0"
primary
:class="['ui', {disabled: queueTracks.length === 0}, 'labeled', 'icon', 'button']"
:title="labels.copyTitle"
icon="bi-plus"
@click="insertMany(queueTracks, false)"
>
{{ t('components.playlists.Editor.button.insertFromQueue', queueTracks.length) }}
</Button>
<dangerous-button <dangerous-button
:disabled="tracks.length === 0" :disabled="tracks.length === 0"
icon="bi-eraser-fill" icon="bi-eraser-fill"
style="float: right;" style="float: right;"
:action="clearPlaylist" :action="clearPlaylist"
:title="t('components.playlists.Editor.modal.clearPlaylist.header', { playlist: playlist?.name })" :title="t('components.playlists.Editor.modal.clearPlaylist.header', { playlist: playlist?.name })"
> >
{{ t('components.playlists.Editor.button.clear') }}
<template #modal-content>
{{ t('components.playlists.Editor.modal.clearPlaylist.content.warning') }}
</template>
<template #modal-confirm>
{{ t('components.playlists.Editor.button.clear') }} {{ t('components.playlists.Editor.button.clear') }}
<template #modal-content> </template>
{{ t('components.playlists.Editor.modal.clearPlaylist.content.warning') }} </dangerous-button>
</template> </Layout>
<template #modal-confirm> <template v-if="tracks.length > 0">
{{ t('components.playlists.Editor.button.clear') }} <p>
</template> {{ t('components.playlists.Editor.help.reorder') }}
</dangerous-button> </p>
</Layout> <div class="table-wrapper">
<template v-if="tracks.length > 0">
<p>
{{ t('components.playlists.Editor.help.reorder') }}
</p>
<div class="table-wrapper">
<!-- TODO: Use activity.vue --> <!-- TODO: Use activity.vue -->
<table class="ui compact very basic unstackable table"> <table class="ui compact very basic unstackable table">
<draggable <draggable
v-model="tracks" v-model="tracks"
tag="tbody" tag="tbody"
item-key="_id" item-key="_id"
@update="reorder" @update="reorder"
> >
<template #item="{ element: plt, index }"> <template #item="{ element: plt, index }">
<tr> <tr>
<td class="left aligned"> <td class="left aligned">
{{ plt.index + 1 }} {{ plt.index + 1 }}
</td> </td>
<td class="center aligned"> <td class="center aligned">
<img <img
v-if="plt.track.album && plt.track.album.cover && plt.track.album.cover.urls.original" v-if="plt.track.album && plt.track.album.cover && plt.track.album.cover.urls.original"
v-lazy="store.getters['instance/absoluteUrl'](plt.track.album.cover.urls.medium_square_crop)" v-lazy="store.getters['instance/absoluteUrl'](plt.track.album.cover.urls.medium_square_crop)"
alt="" alt=""
style="width: 40px;" style="width: 40px;"
> >
<img <img
v-else v-else
alt="" alt=""
style="width: 40px;" style="width: 40px;"
src="../../assets/audio/default-cover.png" src="../../assets/audio/default-cover.png"
> >
</td> </td>
<td colspan="4"> <td colspan="4">
<strong>{{ plt.track.title }}</strong><br> <strong>{{ plt.track.title }}</strong><br>
{{ generateTrackCreditString(plt.track) }} {{ generateTrackCreditString(plt.track) }}
</td> </td>
<td class="right aligned"> <td class="right aligned">
<Button <Button
square-small square-small
round round
destructive destructive
@click.stop="removePlaylistTrack(index)" @click.stop="removePlaylistTrack(index)"
> >
<i <i
class="bi bi-trash" class="bi bi-trash"
/> />
</Button> </Button>
</td> </td>
</tr> </tr>
</template> </template>
</draggable> </draggable>
</table> </table>
</div> </div>
</template> </template>
</Layout> </Layout>
</template> </template>

View File

@ -55,7 +55,7 @@ const privacyLevelChoices = {
me: sharedLabels.fields.privacy_level.choices.me, me: sharedLabels.fields.privacy_level.choices.me,
instance: sharedLabels.fields.privacy_level.choices.instance, instance: sharedLabels.fields.privacy_level.choices.instance,
everyone: sharedLabels.fields.privacy_level.choices.everyone everyone: sharedLabels.fields.privacy_level.choices.everyone
} as const satisfies Record<PrivacyLevel, string>; } as const satisfies Record<PrivacyLevel, string>
const el = useCurrentElement() const el = useCurrentElement()
@ -95,7 +95,8 @@ const submit = async () => {
</script> </script>
<template> <template>
<Layout form <Layout
form
@submit.prevent="submit()" @submit.prevent="submit()"
> >
<h3 <h3
@ -145,7 +146,11 @@ const submit = async () => {
/> />
</div> </div>
<div class="field"> <div class="field">
<Slider :options="privacyLevelChoices" v-model="privacyLevel" :label="t('components.playlists.Form.label.visibility')" /> <Slider
v-model="privacyLevel"
:options="privacyLevelChoices"
:label="t('components.playlists.Form.label.visibility')"
/>
</div> </div>
<div class="field"> <div class="field">
<span id="updatePlaylistLabel" /> <span id="updatePlaylistLabel" />

View File

@ -102,42 +102,43 @@ const showDeleteModal = ref(false)
</script> </script>
<template> <template>
<span> <span>
<Popover v-model:open="open"> <Popover v-model:open="open">
<template #default="{ toggleOpen }"> <template #default="{ toggleOpen }">
<OptionsButton @click="toggleOpen" /> <OptionsButton @click="toggleOpen" />
</template> </template>
<template #items> <template #items>
<PopoverItem <PopoverItem
v-if="playlist.privacy_level === 'everyone' && playlist.is_playable" v-if="playlist.privacy_level === 'everyone' && playlist.is_playable"
icon="bi-code-slash" icon="bi-code-slash"
@click="showEmbedModal = !showEmbedModal" @click="showEmbedModal = !showEmbedModal"
> >
{{ t('views.playlists.Detail.button.embed') }} {{ t('views.playlists.Detail.button.embed') }}
</PopoverItem> </PopoverItem>
<PopoverItem destructive <PopoverItem
v-if="store.state.auth.profile && playlist.actor.full_username === store.state.auth.fullUsername" v-if="store.state.auth.profile && playlist.actor.full_username === store.state.auth.fullUsername"
icon="bi-trash" destructive
:action="deletePlaylist" icon="bi-trash"
@click="showDeleteModal = !showDeleteModal" :action="deletePlaylist"
> @click="showDeleteModal = !showDeleteModal"
{{ t('views.playlists.Detail.button.delete') }} >
</PopoverItem> {{ t('views.playlists.Detail.button.delete') }}
<PopoverItem </PopoverItem>
icon="bi-download" <PopoverItem
@click="exportPlaylist" icon="bi-download"
> @click="exportPlaylist"
{{ labels.export }} >
</PopoverItem> {{ labels.export }}
</PopoverItem>
<PopoverItem <PopoverItem
v-if="store.state.auth.authenticated && playlist.actor.full_username === store.state.auth.fullUsername" v-if="store.state.auth.authenticated && playlist.actor.full_username === store.state.auth.fullUsername"
icon="bi-upload" icon="bi-upload"
@click="triggerFileInput" @click="triggerFileInput"
> >
{{ labels.import }} {{ labels.import }}
</PopoverItem> </PopoverItem>
</template> </template>
</Popover> </Popover>
<!-- Hidden file input, triggered by the button click --> <!-- Hidden file input, triggered by the button click -->
<input <input
@ -182,7 +183,7 @@ const showDeleteModal = ref(false)
</template> </template>
<template #actions> <template #actions>
<Button @click="showDeleteModal = false"> <Button @click="showDeleteModal = false">
{{ t('views.playlists.Detail.button.cancel') }} {{ t('views.playlists.Detail.button.cancel') }}
</Button> </Button>
<Button <Button
destructive destructive
@ -192,5 +193,4 @@ const showDeleteModal = ref(false)
</Button> </Button>
</template> </template>
</Modal> </Modal>
</template> </template>

View File

@ -91,7 +91,7 @@ store.dispatch('playlists/fetchOwn')
v-model="store.state.playlists.showModal" v-model="store.state.playlists.showModal"
:title="t('components.playlists.PlaylistModal.header.addToPlaylist')" :title="t('components.playlists.PlaylistModal.header.addToPlaylist')"
:cancel="t('components.playlists.PlaylistModal.button.cancel')" :cancel="t('components.playlists.PlaylistModal.button.cancel')"
overPopover over-popover
> >
<template v-if="track"> <template v-if="track">
<h3 class="ui header"> <h3 class="ui header">
@ -206,7 +206,7 @@ store.dispatch('playlists/fetchOwn')
</tbody> </tbody>
</table> </table>
<template v-else> <template v-else>
<Spacer /> <Spacer />
<Alert blue> <Alert blue>
<span> <span>
{{ t('components.playlists.PlaylistModal.header.noResults') }} {{ t('components.playlists.PlaylistModal.header.noResults') }}

View File

@ -45,6 +45,5 @@ const labels = computed(() => ({
:aria-label="labels.addToPlaylist" :aria-label="labels.addToPlaylist"
:title="labels.addToPlaylist" :title="labels.addToPlaylist"
@click.stop="store.commit('playlists/chooseTrack', track)" @click.stop="store.commit('playlists/chooseTrack', track)"
> />
</Button>
</template> </template>

View File

@ -5,7 +5,6 @@ import { ref, reactive, watch } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import axios from 'axios' import axios from 'axios'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
@ -74,7 +73,10 @@ watch(
small-items small-items
:h2="title" :h2="title"
> >
<Loader v-if="isLoading" style="grid-column: 1 / -1;" /> <Loader
v-if="isLoading"
style="grid-column: 1 / -1;"
/>
<Alert <Alert
v-if="!isLoading && objects.length === 0" v-if="!isLoading && objects.length === 0"
style="grid-column: 1 / -1;" style="grid-column: 1 / -1;"

View File

@ -15,12 +15,12 @@ const timeAgo = useTimeAgo(new Date(podcast.artist?.modification_date ?? new Dat
<Card <Card
:title="podcast.uuid" :title="podcast.uuid"
:image="podcast.artist?.cover?.urls.original" :image="podcast.artist?.cover?.urls.original"
@click="navigate"
class="podcast-card" class="podcast-card"
@click="navigate"
> >
<a <a
@click.stop="navigate"
class="funkwhale link" class="funkwhale link"
@click.stop="navigate"
> >
{{ podcast.artist?.name }} {{ podcast.artist?.name }}
</a> </a>

View File

@ -23,8 +23,8 @@ const pastel = usePastel(() => props.color)
<fw-card <fw-card
:title="radio.name" :title="radio.name"
:class="pastel" :class="pastel"
@click="navigate"
class="radio-card" class="radio-card"
@click="navigate"
> >
<template #image> <template #image>
<div class="cover-name"> <div class="cover-name">

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