Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • 1.4.1-upgrade-release
  • 1121-download
  • 1218-smartplaylist_backend
  • 1373-login-form-move-reset-your-password-link
  • 1381-progress-bars
  • 1481
  • 1518-update-django-allauth
  • 1645
  • 1675-widget-improperly-configured-missing-resource-id
  • 1675-widget-improperly-configured-missing-resource-id-2
  • 1704-required-props-are-not-always-passed
  • 1716-add-frontend-tests-again
  • 1749-smtp-uri-configuration
  • 1930-first-upload-in-a-batch-always-fails
  • 1976-update-documentation-links-in-readme-files
  • 2054-player-layout
  • 2063-funkwhale-connection-interrupted-every-so-often-requires-network-reset-page-refresh
  • 2091-iii-6-improve-visuals-layout
  • 2151-refused-to-load-spa-manifest-json-2
  • 2154-add-to-playlist-pop-up-hidden-by-now-playing-screen
  • 2155-can-t-see-the-episode-list-of-a-podcast-as-an-anonymous-user-with-anonymous-access-enabled
  • 2156-add-management-command-to-change-file-ref-for-in-place-imported-files-to-s3
  • 2192-clear-queue-bug-when-random-shuffle-is-enabled
  • 2205-channel-page-pagination-link-dont-working
  • 2215-custom-logger-does-not-work-at-all-with-webkit-and-blink-based-browsers
  • 2228-troi-real-world-review
  • 2274-implement-new-upload-api
  • 2303-allow-users-to-own-tagged-items
  • 2395-far-right-filter
  • 2405-front-buttont-trigger-third-party-hook
  • 2408-troi-create-missing-tracks
  • 2416-revert-library-drop
  • 2429-fix-popover-auto-close
  • 2448-complete-tags
  • 2452-fetch-third-party-metadata
  • 2467-fix-radio-builder
  • 2469-Fix-search-bar-in-ManageUploads
  • 2476-deep-upload-links
  • 2480-add-notification-number-badges
  • 2482-upgrade-about-page-to-use-new-ui
  • 2487-fix-accessibility-according-to-WCAG
  • 2490-experiment-use-rstore
  • 2490-experimental-use-simple-data-store
  • 2490-fix-search-modal
  • 2490-search-modal
  • 2501-fix-compatibility-with-older-browsers
  • 2502-drop-uno-and-jquery
  • 2533-allow-followers-in-user-activiy-privacy-level
  • 2539-drop-ansible-installation-method-in-favor-of-docker
  • 2550-22-user-interfaces-for-federation
  • 2560-default-modal-width
  • 623-test
  • 653-enable-starting-embedded-player-at-a-specific-position-in-track
  • activitypub-overview
  • album-sliders
  • arne/2091-improve-visuals
  • back-option-for-edits
  • chore/2406-compose-modularity-scope
  • develop
  • develop-password-reset
  • env-file-cleanup
  • feat/2091-improve-visuals
  • feature/2481-vui-translations
  • fix-amd64-docker-build-gfortran
  • fix-channel-creation
  • fix-front-node-version
  • fix-gitpod
  • fix-plugins-dev-setup
  • fix-rate-limit-serializer
  • fix-schema-channel-metadata-choices
  • flupsi/2803-improve-visuals
  • flupsi/2804-new-upload-process
  • funkwhale-fix_pwa_manifest
  • funkwhale-petitminion-2136-bug-fix-prune-skipped-upload
  • funkwhale-ui-buttons
  • georg/add-typescript
  • gitpod/test-1866
  • global-button-experiment
  • global-buttons
  • juniorjpdj/pkg-repo
  • manage-py-reference
  • merge-review
  • minimal-python-version
  • petitminion-develop-patch-84496
  • pin-mutagen-to-1.46
  • pipenv
  • plugins
  • plugins-v2
  • plugins-v3
  • pre-release/1.3.0
  • prune_skipped_uploads_docs
  • refactor/homepage
  • renovate/front-all-dependencies
  • renovate/front-major-all-dependencies
  • schema-updates
  • small-gitpod-improvements
  • spectacular_schema
  • stable
  • tempArne
  • ui-buttons
  • 0.1
  • 0.10
  • 0.11
  • 0.12
  • 0.13
  • 0.14
  • 0.14.1
  • 0.14.2
  • 0.15
  • 0.16
  • 0.16.1
  • 0.16.2
  • 0.16.3
  • 0.17
  • 0.18
  • 0.18.1
  • 0.18.2
  • 0.18.3
  • 0.19.0
  • 0.19.0-rc1
  • 0.19.0-rc2
  • 0.19.1
  • 0.2
  • 0.2.1
  • 0.2.2
  • 0.2.3
  • 0.2.4
  • 0.2.5
  • 0.2.6
  • 0.20.0
  • 0.20.0-rc1
  • 0.20.1
  • 0.21
  • 0.21-rc1
  • 0.21-rc2
  • 0.21.1
  • 0.21.2
  • 0.3
  • 0.3.1
  • 0.3.2
  • 0.3.3
  • 0.3.4
  • 0.3.5
  • 0.4
  • 0.5
  • 0.5.1
  • 0.5.2
  • 0.5.3
  • 0.5.4
  • 0.6
  • 0.6.1
  • 0.7
  • 0.8
  • 0.9
  • 0.9.1
  • 1.0
  • 1.0-rc1
  • 1.0.1
  • 1.1
  • 1.1-rc1
  • 1.1-rc2
  • 1.1.1
  • 1.1.2
  • 1.1.3
  • 1.1.4
  • 1.2.0
  • 1.2.0-rc1
  • 1.2.0-rc2
  • 1.2.0-testing
  • 1.2.0-testing2
  • 1.2.0-testing3
  • 1.2.0-testing4
  • 1.2.1
  • 1.2.10
  • 1.2.2
  • 1.2.3
  • 1.2.4
  • 1.2.5
  • 1.2.6
  • 1.2.6-1
  • 1.2.7
  • 1.2.8
  • 1.2.9
  • 1.3.0
  • 1.3.0-rc1
  • 1.3.0-rc2
  • 1.3.0-rc3
  • 1.3.0-rc4
  • 1.3.0-rc5
  • 1.3.0-rc6
  • 1.3.1
  • 1.3.2
  • 1.3.3
  • 1.3.4
  • 1.4.0
  • 1.4.0-rc1
  • 1.4.0-rc2
  • 1.4.1
  • 2.0.0-alpha.1
  • 2.0.0-alpha.2
200 results

Target

Select target project
  • funkwhale/funkwhale
  • Luclu7/funkwhale
  • mbothorel/funkwhale
  • EorlBruder/funkwhale
  • tcit/funkwhale
  • JocelynDelalande/funkwhale
  • eneiluj/funkwhale
  • reg/funkwhale
  • ButterflyOfFire/funkwhale
  • m4sk1n/funkwhale
  • wxcafe/funkwhale
  • andybalaam/funkwhale
  • jcgruenhage/funkwhale
  • pblayo/funkwhale
  • joshuaboniface/funkwhale
  • n3ddy/funkwhale
  • gegeweb/funkwhale
  • tohojo/funkwhale
  • emillumine/funkwhale
  • Te-k/funkwhale
  • asaintgenis/funkwhale
  • anoadragon453/funkwhale
  • Sakada/funkwhale
  • ilianaw/funkwhale
  • l4p1n/funkwhale
  • pnizet/funkwhale
  • dante383/funkwhale
  • interfect/funkwhale
  • akhardya/funkwhale
  • svfusion/funkwhale
  • noplanman/funkwhale
  • nykopol/funkwhale
  • roipoussiere/funkwhale
  • Von/funkwhale
  • aurieh/funkwhale
  • icaria36/funkwhale
  • floreal/funkwhale
  • paulwalko/funkwhale
  • comradekingu/funkwhale
  • FurryJulie/funkwhale
  • Legolars99/funkwhale
  • Vierkantor/funkwhale
  • zachhats/funkwhale
  • heyjake/funkwhale
  • sn0w/funkwhale
  • jvoisin/funkwhale
  • gordon/funkwhale
  • Alexander/funkwhale
  • bignose/funkwhale
  • qasim.ali/funkwhale
  • fakegit/funkwhale
  • Kxze/funkwhale
  • stenstad/funkwhale
  • creak/funkwhale
  • Kaze/funkwhale
  • Tixie/funkwhale
  • IISergII/funkwhale
  • lfuelling/funkwhale
  • nhaddag/funkwhale
  • yoasif/funkwhale
  • ifischer/funkwhale
  • keslerm/funkwhale
  • flupe/funkwhale
  • petitminion/funkwhale
  • ariasuni/funkwhale
  • ollie/funkwhale
  • ngaumont/funkwhale
  • techknowlogick/funkwhale
  • Shleeble/funkwhale
  • theflyingfrog/funkwhale
  • jonatron/funkwhale
  • neobrain/funkwhale
  • eorn/funkwhale
  • KokaKiwi/funkwhale
  • u1-liquid/funkwhale
  • marzzzello/funkwhale
  • sirenwatcher/funkwhale
  • newer027/funkwhale
  • codl/funkwhale
  • Zwordi/funkwhale
  • gisforgabriel/funkwhale
  • iuriatan/funkwhale
  • simon/funkwhale
  • bheesham/funkwhale
  • zeoses/funkwhale
  • accraze/funkwhale
  • meliurwen/funkwhale
  • divadsn/funkwhale
  • Etua/funkwhale
  • sdrik/funkwhale
  • Soran/funkwhale
  • kuba-orlik/funkwhale
  • cristianvogel/funkwhale
  • Forceu/funkwhale
  • jeff/funkwhale
  • der_scheibenhacker/funkwhale
  • owlnical/funkwhale
  • jovuit/funkwhale
  • SilverFox15/funkwhale
  • phw/funkwhale
  • mayhem/funkwhale
  • sridhar/funkwhale
  • stromlin/funkwhale
  • rrrnld/funkwhale
  • nitaibezerra/funkwhale
  • jaller94/funkwhale
  • pcouy/funkwhale
  • eduxstad/funkwhale
  • codingHahn/funkwhale
  • captain/funkwhale
  • polyedre/funkwhale
  • leishenailong/funkwhale
  • ccritter/funkwhale
  • lnceballosz/funkwhale
  • fpiesche/funkwhale
  • Fanyx/funkwhale
  • markusblogde/funkwhale
  • Firobe/funkwhale
  • devilcius/funkwhale
  • freaktechnik/funkwhale
  • blopware/funkwhale
  • cone/funkwhale
  • thanksd/funkwhale
  • vachan-maker/funkwhale
  • bbenti/funkwhale
  • tarator/funkwhale
  • prplecake/funkwhale
  • DMarzal/funkwhale
  • lullis/funkwhale
  • hanacgr/funkwhale
  • albjeremias/funkwhale
  • xeruf/funkwhale
  • llelite/funkwhale
  • RoiArthurB/funkwhale
  • cloo/funkwhale
  • nztvar/funkwhale
  • Keunes/funkwhale
  • petitminion/funkwhale-petitminion
  • m-idler/funkwhale
  • SkyLeite/funkwhale
140 results
Select Git revision
  • 1121-download
  • 1218-smartplaylist_backend
  • 1288-user-me-can-be-created-but-cannot-be-edited
  • 1381-progress-bars
  • 1392-update-actor-cache-task
  • 1434-update-pyld
  • 1481
  • 1515-update-click
  • 1518-update-django-allauth
  • 1645
  • 1674-recently-added-radio-repair
  • 1711-ping_remote_instance
  • 1714-resolve-timeouts-on-domain-nodeinfo-fetch
  • 1717-stop-player-when-stop-radio
  • 1798-bulk-fetch-actor-data
  • 2010-fix_i18n_globally
  • 2136-bug-fix-prune-skipped-upload
  • 2275-quality-filter-backend
  • 2322-troi-frontend
  • 623-test
  • 639-ThirdPartyStream-poc
  • 653-enable-starting-embedded-player-at-a-specific-position-in-track
  • 762-domain_follow
  • album-sliders
  • back-option-for-edits
  • develop
  • feat/2091-improve-visuals
  • funkwhale-activityPub-overview
  • generate-swagger
  • master
  • pipenv
  • plugins
  • plugins-v2
  • plugins-v3
  • poetry
  • renovate/configure
  • spec-domain-follow
  • spec_test_for_issue_1
  • stable
  • test_typesens
  • testbranch
  • troi-recommendation-system-with-typenses
  • update-boto3
  • update-frontend-dependencies
  • update-uvicorn
  • 0.1
  • 0.10
  • 0.11
  • 0.12
  • 0.13
  • 0.14
  • 0.14.1
  • 0.14.2
  • 0.15
  • 0.16
  • 0.16.1
  • 0.16.2
  • 0.16.3
  • 0.17
  • 0.18
  • 0.18.1
  • 0.18.2
  • 0.18.3
  • 0.19.0
  • 0.19.0-rc1
  • 0.19.0-rc2
  • 0.19.1
  • 0.2
  • 0.2.1
  • 0.2.2
  • 0.2.3
  • 0.2.4
  • 0.2.5
  • 0.2.6
  • 0.20.0
  • 0.20.0-rc1
  • 0.20.1
  • 0.21
  • 0.21-rc1
  • 0.21-rc2
  • 0.21.1
  • 0.21.2
  • 0.3
  • 0.3.1
  • 0.3.2
  • 0.3.3
  • 0.3.4
  • 0.3.5
  • 0.4
  • 0.5
  • 0.5.1
  • 0.5.2
  • 0.5.3
  • 0.5.4
  • 0.6
  • 0.6.1
  • 0.7
  • 0.8
  • 0.9
  • 0.9.1
  • 1.0
  • 1.0-rc1
  • 1.0.1
  • 1.1
  • 1.1-rc1
  • 1.1-rc2
  • 1.1.1
  • 1.1.2
  • 1.1.3
  • 1.1.4
  • 1.2.0
  • 1.2.0-rc1
  • 1.2.0-rc2
  • 1.2.0-testing
  • 1.2.0-testing2
  • 1.2.0-testing3
  • 1.2.0-testing4
  • 1.2.1
118 results
Show changes
Showing
with 1213 additions and 1305 deletions
......@@ -4,7 +4,7 @@ interface Props {
}
withDefaults(defineProps<Props>(), {
fill: '#222222'
fill: 'var(--color)'
})
</script>
......
......@@ -12,7 +12,7 @@ const labels = computed(() => ({
<template>
<main
class="main pusher"
class="main"
:v-title="labels.title"
>
<section class="ui vertical stripe segment">
......@@ -20,11 +20,11 @@ const labels = computed(() => ({
<h1 class="ui huge header">
<i class="warning icon" />
<div class="content">
{{ $t('components.PageNotFound.header.pageNotFound') }}
{{ t('components.PageNotFound.header.pageNotFound') }}
</div>
</h1>
<p>
{{ $t('components.PageNotFound.message.pageNotFound') }}
{{ t('components.PageNotFound.message.pageNotFound') }}
</p>
<a :href="path">{{ path }}</a>
<div class="ui hidden divider" />
......@@ -32,7 +32,7 @@ const labels = computed(() => ({
class="ui icon labeled right button"
to="/"
>
{{ $t('components.PageNotFound.link.home') }}
{{ t('components.PageNotFound.link.home') }}
<i class="right arrow icon" />
</router-link>
</div>
......
......@@ -5,7 +5,6 @@ import { whenever, watchDebounced, useCurrentElement, useScrollLock, useFullscre
import { nextTick, ref, computed, watchEffect, defineAsyncComponent } from 'vue'
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useStore } from '~/store'
import { usePlayer } from '~/composables/audio/player'
......@@ -14,6 +13,8 @@ import { useQueue } from '~/composables/audio/queue'
import time from '~/utils/time'
import { useI18n } from 'vue-i18n'
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
import PlayerControls from '~/components/audio/PlayerControls.vue'
......@@ -21,6 +22,12 @@ import PlayerControls from '~/components/audio/PlayerControls.vue'
import VirtualList from '~/components/vui/list/VirtualList.vue'
import QueueItem from '~/components/QueueItem.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Link from '~/components/ui/Link.vue'
import Button from '~/components/ui/Button.vue'
import ArtistCreditLabel from '~/components/audio/ArtistCreditLabel.vue'
const MilkDrop = defineAsyncComponent(() => import('~/components/audio/visualizer/MilkDrop.vue'))
const {
......@@ -83,12 +90,14 @@ watchEffect(async () => {
const list = ref()
const el = useCurrentElement()
const scrollToCurrent = (behavior: ScrollBehavior = 'smooth') => {
const item = el.value?.querySelector('.queue-item.active')
if (el.value != null && 'querySelector' in el.value) {
const item = el.value.querySelector('.queue-item.active')
item?.scrollIntoView({
behavior,
block: 'center'
})
}
}
watchDebounced(currentTrack, () => scrollToCurrent(), { debounce: 100 })
......@@ -131,12 +140,13 @@ const reorderTracks = async (from: number, to: number) => {
}
}
const hideArtist = () => {
if (currentTrack.value.artistId !== -1 && currentTrack.value.artistCredit) {
const { value } = currentTrack
if (value != null && value.artistId !== -1 && value.artistCredit) {
return store.dispatch('moderation/hide', {
type: 'artist',
target: {
id: currentTrack.value.artistCredit[0].artist.id,
name: currentTrack.value.artistCredit[0].artist.name
id: value.artistCredit[0]?.artist.id,
name: value.artistCredit[0]?.artist.name
}
})
}
......@@ -173,7 +183,7 @@ if (!isWebGLSupported) {
<template>
<section
class="main with-background component-queue"
class="main opaque component-queue default solid"
:aria-label="labels.queue"
>
<div
......@@ -194,12 +204,12 @@ if (!isWebGLSupported) {
<img
v-if="fullscreen"
class="cover-shadow"
:src="$store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
:src="store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
>
<img
ref="cover"
alt=""
:src="$store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
:src="store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
>
</template>
<milk-drop
......@@ -212,47 +222,40 @@ if (!isWebGLSupported) {
v-if="!fullscreen || !idle"
class="cover-buttons"
>
<tooltip :content="!isWebGLSupported && $t('components.Queue.message.webglUnsupported')">
<button
<tooltip :content="!isWebGLSupported && t('components.Queue.message.webglUnsupported')">
<Button
v-if="coverType === CoverType.COVER_ART"
class="ui secondary button"
:aria-label="labels.showVisualizer"
:title="labels.showVisualizer"
:disabled="!isWebGLSupported"
icon="bi-display"
@click="coverType = CoverType.MILK_DROP"
>
<i class="icon signal" />
</button>
<button
/>
<Button
v-else-if="coverType === CoverType.MILK_DROP"
class="ui secondary button"
:aria-label="labels.showCoverArt"
:title="labels.showCoverArt"
:disabled="!isWebGLSupported"
icon="bi-image-fill"
@click="coverType = CoverType.COVER_ART"
>
<i class="icon image outline" />
</button>
/>
</tooltip>
<button
<Button
v-if="!fullscreen"
class="ui secondary button"
:aria-label="labels.fullscreen"
:title="labels.fullscreen"
icon="bi-arrows-fullscreen"
@click="enter"
>
<i class="icon expand" />
</button>
<button
/>
<Button
v-else
class="ui secondary button"
secondary
:aria-label="labels.exitFullscreen"
:title="labels.exitFullscreen"
icon="bi-fullscreen-exit"
@click="exit"
>
<i class="icon compress" />
</button>
/>
</div>
</Transition>
<Transition name="queue">
......@@ -267,65 +270,53 @@ if (!isWebGLSupported) {
v-for="ac in currentTrack.artistCredit"
:key="ac.artist.id"
>
{{ ac.credit ?? $t('components.Queue.meta.unknownArtist') }}
{{ ac.credit ?? t('components.Queue.meta.unknownArtist') }}
<span>{{ ac.joinphrase }}</span>
</div>
<span class="symbol hyphen middle" />
{{ currentTrack.albumTitle ?? $t('components.Queue.meta.unknownAlbum') }}
{{ currentTrack.albumTitle ?? t('components.Queue.meta.unknownAlbum') }}
</h2>
</div>
</Transition>
</div>
</div>
<h1 class="ui header">
<div class="content ellipsis">
<router-link
class="small header discrete link track"
<Link
class="track"
:to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}"
>
{{ currentTrack.title }}
</router-link>
<div class="sub header ellipsis">
<span>
<template
v-for="ac in currentTrack.artistCredit"
:key="ac.artist.id"
>
<router-link
class="discrete link"
:to="{name: 'library.artists.detail', params: {id: ac.artist.id }}"
@click.stop.prevent=""
>
{{ ac.credit ?? $t('components.Queue.meta.unknownArtist') }}
</router-link>
<span>{{ ac.joinphrase }}</span>
</template>
</span>
</Link>
</h1>
<h2>
<template v-if="currentTrack.albumId !== -1">
<span class="middle slash symbol" />
<router-link
class="discrete link album"
<Link
class="album"
:to="{name: 'library.albums.detail', params: {id: currentTrack.albumId }}"
>
{{ currentTrack.albumTitle ?? $t('components.Queue.meta.unknownAlbum') }}
</router-link>
{{ currentTrack.albumTitle ?? t('components.Queue.meta.unknownAlbum') }}
</Link>
</template>
</div>
</div>
</h1>
</h2>
<span>
<ArtistCreditLabel
v-if="currentTrack.artistCredit"
:artist-credit="currentTrack.artistCredit"
/>
</span>
<div
v-if="currentTrack && errored"
class="ui small warning message"
>
<h3 class="header">
{{ $t('components.Queue.header.failure') }}
{{ t('components.Queue.header.failure') }}
</h3>
<p v-if="hasNext && isPlaying">
{{ $t('components.Queue.message.automaticPlay') }}
{{ t('components.Queue.message.automaticPlay') }}
<i class="loading spinner icon" />
</p>
<p>
{{ $t('components.Queue.warning.connectivity') }}
{{ t('components.Queue.warning.connectivity') }}
</p>
</div>
<div
......@@ -333,32 +324,40 @@ if (!isWebGLSupported) {
class="ui small warning message"
>
<h3 class="header">
{{ $t('components.Queue.header.noSources') }}
{{ t('components.Queue.header.noSources') }}
</h3>
<p v-if="hasNext && isPlaying">
{{ $t('components.Queue.message.automaticPlay') }}
{{ t('components.Queue.message.automaticPlay') }}
<i class="loading spinner icon" />
</p>
</div>
<div class="additional-controls desktop-and-below">
<Spacer
:size="16"
class="desktop-and-below"
/>
<Layout
flex
class="additional-controls desktop-and-below"
>
<track-favorite-icon
v-if="$store.state.auth.authenticated"
v-if="store.state.auth.authenticated"
:track="currentTrack"
ghost
/>
<track-playlist-icon
v-if="$store.state.auth.authenticated"
v-if="store.state.auth.authenticated"
:track="currentTrack"
ghost
/>
<button
v-if="$store.state.auth.authenticated"
:class="['ui', 'really', 'basic', 'circular', 'icon', 'button']"
<Button
v-if="store.state.auth.authenticated"
ghost
icon="bi-eye-slash"
:aria-label="labels.addArtistContentFilter"
:title="labels.addArtistContentFilter"
@click="hideArtist"
>
<i :class="['eye slash outline', 'basic', 'icon']" />
</button>
</div>
/>
</Layout>
<div class="progress-wrapper">
<div class="progress-area">
<div
......@@ -386,28 +385,33 @@ if (!isWebGLSupported) {
<span class="right floated timer total">{{ time.parse(Math.round(duration)) }}</span>
</template>
<template v-else>
<span class="left floated timer">{{ $t('components.Queue.meta.startTime') }}</span>
<span class="right floated timer">{{ $t('components.Queue.meta.startTime') }}</span>
<span class="left floated timer">{{ t('components.Queue.meta.startTime') }}</span>
<span class="right floated timer">{{ t('components.Queue.meta.startTime') }}</span>
</template>
</div>
</div>
<player-controls class="desktop-and-below" />
<player-controls class="desktop-and-below queue-controls" />
</template>
</div>
<div id="queue">
<div class="ui basic clearing segment">
<h2 class="ui header">
<div class="content">
<button
v-t="'components.Queue.button.close'"
class="ui right floated basic button"
@click="$store.commit('ui/queueFocused', null)"
<Button
ghost
icon="bi-chevron-down"
style="float: right; margin-right: 24px;"
@click="store.commit('ui/queueFocused', null)"
/>
<button
v-t="'components.Queue.button.clear'"
class="ui right floated basic button danger"
<Button
destructive
outline
icon="bi-trash-fill"
style="float: right; margin-right: 16px;"
@click="clear"
/>
>
{{ t('components.Queue.button.clear') }}
</Button>
{{ labels.queue }}
<div class="sub header">
<div>
......@@ -420,7 +424,10 @@ if (!isWebGLSupported) {
</template>
</i18n-t>
<span class="middle pipe symbol" />
<span v-t="'components.Queue.meta.end'" />
<span
t="'components.Queue.meta.end'"
style="margin-right: 8px;"
/>
<span :title="labels.duration">
{{ endsIn }}
</span>
......@@ -451,30 +458,31 @@ if (!isWebGLSupported) {
</template>
<template #footer>
<div
v-if="$store.state.radios.populating"
v-if="store.state.radios.populating"
class="radio-populating"
>
<i class="loading spinner icon" />
{{ labels.populating }}
</div>
<div
v-if="$store.state.radios.running"
v-if="store.state.radios.running"
class="ui info message radio-message"
>
<div class="content">
<h3 class="header">
<i class="feed icon" />
{{ $t('components.Queue.header.radio') }}
<i class="bi bi-boombox-fill" />
{{ t('components.Queue.header.radio') }}
</h3>
<p>
{{ $t('components.Queue.message.radio') }}
{{ t('components.Queue.message.radio') }}
</p>
<button
class="ui basic primary button"
@click="$store.dispatch('radios/stop')"
<Button
primary
icon="bi-stop-fill"
@click="store.dispatch('radios/stop')"
>
{{ $t('components.Queue.button.stopRadio') }}
</button>
{{ t('components.Queue.button.stopRadio') }}
</Button>
</div>
</div>
</template>
......
......@@ -3,6 +3,11 @@ import type { QueueItemSource } from '~/types'
import time from '~/utils/time'
import { generateTrackCreditStringFromQueue } from '~/utils/utils'
import { useStore } from '~/store'
import Button from '~/components/ui/Button.vue'
const store = useStore()
interface Events {
(e: 'play', index: number): void
......@@ -20,11 +25,11 @@ defineProps<Props>()
<template>
<div
class="queue-item"
class="queue-item interactive ghost solid default raised "
tabindex="0"
>
<div class="handle">
<i class="grip lines icon" />
<i class="bi bi-list" />
</div>
<div
class="image-cell"
......@@ -37,7 +42,7 @@ defineProps<Props>()
>
</div>
<div @click="$emit('play', index)">
<button
<div
class="title reset ellipsis"
:title="source.title"
:aria-label="source.labels.selectTrack"
......@@ -46,34 +51,36 @@ defineProps<Props>()
<span>
{{ generateTrackCreditStringFromQueue(source) }}
</span>
</button>
</div>
</div>
<div class="duration-cell">
<template v-if="source.sources.length > 0">
{{ time.parse(Math.round(source.sources[0].duration ?? 0)) }}
{{ time.parse(Math.round(source.sources[0]?.duration ?? 0)) }}
</template>
</div>
<div class="controls">
<button
v-if="$store.state.auth.authenticated"
<Button
v-if="store.state.auth.authenticated"
:aria-label="source.labels.favorite"
:title="source.labels.favorite"
class="ui really basic circular icon button"
@click.stop="$store.dispatch('favorites/toggle', source.id)"
>
<i
:class="$store.getters['favorites/isFavorite'](source.id) ? 'pink' : ''"
class="heart icon"
:icon="store.getters['favorites/isFavorite'](source.id) ? 'bi-heart-fill' : 'bi-heart'"
round
ghost
square-small
style="align-self: center;"
:class="store.getters['favorites/isFavorite'](source.id) ? 'pink' : ''"
@click.stop="store.dispatch('favorites/toggle', source.id)"
/>
</button>
<button
<Button
:aria-label="source.labels.remove"
:title="source.labels.remove"
class="ui really tiny basic circular icon button"
icon="bi-x"
round
ghost
square-small
style="align-self: center;"
@click.stop="$emit('remove', index)"
>
<i class="x icon" />
</button>
/>
</div>
</div>
</template>
......@@ -8,6 +8,11 @@ import { useStore } from '~/store'
import axios from 'axios'
import Layout from '~/components/ui/Layout.vue'
import Button from '~/components/ui/Button.vue'
import Input from '~/components/ui/Input.vue'
import Alert from '~/components/ui/Alert.vue'
import updateQueryString from '~/composables/updateQueryString'
import useLogger from '~/composables/useLogger'
......@@ -115,7 +120,7 @@ const createFetch = async () => {
isLoading.value = true
try {
const response = await axios.post('federation/fetches/', { object: id.value })
const response = await axios.post('federation/fetches/', { object_uri: id.value })
obj.value = response.data
if (response.data.status === 'errored' || response.data.status === 'skipped') {
......@@ -165,41 +170,42 @@ watch(() => props.initialId, () => {
</script>
<template>
<div
<Layout
v-if="type === 'both'"
class="two ui buttons"
stack
>
<button
class="ui left floated labeled icon button"
<Button
secondary
raised
split
round
icon="bi-rss-fill"
split-icon="bi-globe"
style="align-self: center;"
:split-title="t('components.RemoteSearchForm.button.fediverse')"
@click.prevent="type = 'rss'"
@split-click.prevent="type = 'artists'"
>
<i class="feed icon" />
{{ $t('components.RemoteSearchForm.button.rss') }}
</button>
<div class="or" />
<button
class="ui right floated right labeled icon button"
@click.prevent="type = 'artists'"
>
<i class="globe icon" />
{{ $t('components.RemoteSearchForm.button.fediverse') }}
</button>
</div>
<div v-else>
<form
{{ t('components.RemoteSearchForm.button.rss') }}
</Button>
</Layout>
<Layout
v-else
id="remote-search"
form
:class="['ui', {loading: isLoading}, 'form']"
@submit.stop.prevent="submit"
>
<div
<Alert
v-if="errors.length > 0"
red
role="alert"
class="ui negative message"
title="t('components.RemoteSearchForm.header.fetchFailed')"
>
<ul
v-if="errors.length > 1"
class="list"
>
<h3 class="header">
{{ $t('components.RemoteSearchForm.header.fetchFailed') }}
</h3>
<ul class="list">
<li
v-for="(error, key) in errors"
:key="key"
......@@ -207,43 +213,42 @@ watch(() => props.initialId, () => {
{{ error }}
</li>
</ul>
</div>
<div class="ui required field">
<label for="object-id">
{{ labels.fieldLabel }}
</label>
<p v-else>
{{ errors[0] }}
</p>
</Alert>
<p v-if="type === 'rss'">
{{ $t('components.RemoteSearchForm.description.rss') }}
{{ t('components.RemoteSearchForm.description.rss') }}
</p>
<p v-else-if="type === 'artists'">
{{ $t('components.RemoteSearchForm.description.fediverse') }}
{{ t('components.RemoteSearchForm.description.fediverse') }}
</p>
<input
<Input
id="object-id"
v-model="id"
type="text"
name="object-id"
:label="labels.fieldLabel"
:placeholder="labels.fieldPlaceholder"
style="width: 100%;"
required
>
</div>
<button
/>
<Button
v-if="showSubmit"
primary
type="submit"
:class="['ui', 'primary', {loading: isLoading}, 'button']"
:class="{loading: isLoading}"
:disabled="isLoading || !id || id.length === 0"
>
{{ $t('components.RemoteSearchForm.button.search') }}
</button>
</form>
<div
{{ t('components.RemoteSearchForm.button.search') }}
</Button>
</Layout>
<Alert
v-if="!isLoading && obj?.status === 'finished' && !redirectRoute"
role="alert"
class="ui warning message"
red
>
<p>
{{ $t('components.RemoteSearchForm.warning.unsupported') }}
</p>
</div>
</div>
{{ t('components.RemoteSearchForm.warning.unsupported') }}
</Alert>
</template>
<script setup lang="ts">
import { useStore } from '~/store'
const store = useStore()
</script>
<template>
<div class="ui toast-container">
<message
v-for="message in $store.state.ui.messages"
v-for="message in store.state.ui.messages"
:key="message.key"
:message="message"
/>
......
<script setup lang="ts">
import Modal from '~/components/ui/Modal.vue'
import axios from 'axios'
import { uniq } from 'lodash-es'
import { useVModel } from '@vueuse/core'
import { ref, computed, watch, nextTick } from 'vue'
import { useStore } from '~/store'
import { useI18n } from 'vue-i18n'
// TODO: Delete this file?
const { t } = useI18n()
interface Props {
show: boolean
}
const props = defineProps<Props>()
const emit = defineEmits(['update:show'])
const show = useVModel(props, 'show', emit)
const instanceUrl = ref('')
const store = useStore()
const suggestedInstances = computed(() => {
const serverUrl = store.state.instance.frontSettings.defaultServerUrl
return uniq([
store.state.instance.instanceUrl,
...store.state.instance.knownInstances,
serverUrl.endsWith('/') ? serverUrl : serverUrl + '/',
store.getters['instance/defaultInstance']
]).slice(1)
})
watch(() => store.state.instance.instanceUrl, () => store.dispatch('instance/fetchSettings'))
// TODO: replace translation mechanism { $pgettext } with { t }
// const { $pgettext } = useGettext()
const isError = ref(false)
const isLoading = ref(false)
const checkAndSwitch = async (url: string) => {
isError.value = false
isLoading.value = true
try {
const instanceUrl = new URL(url.startsWith('https://') || url.startsWith('http://') ? url : `https://${url}`).origin
await axios.get(instanceUrl + '/api/v1/instance/nodeinfo/2.0/')
show.value = false
store.commit('ui/addMessage', {
content: 'You are now using the Funkwhale instance at %{ url }',
// $pgettext('*/Instance/Message', 'You are now using the Funkwhale instance at %{ url }', { url: instanceUrl }),
date: new Date()
})
await nextTick()
store.dispatch('instance/setUrl', instanceUrl)
} catch (error) {
isError.value = true
}
isLoading.value = false
}
</script>
<template>
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<Modal
v-model="show"
:title="t('views.ChooseInstance.header.chooseInstance')"
@update="isError = false"
>
<h3 class="header">
<!-- TODO: translate -->
<!-- <translate translate-context="Popup/Instance/Title">
</translate> -->
</h3>
<div class="scrolling content">
<div
v-if="isError"
role="alert"
class="ui negative message"
>
<h4 class="header">
<!-- TODO: translate -->
It is not possible to connect to the given URL
<!-- <translate translate-context="Popup/Instance/Error message.Title">
</translate> -->
</h4>
<ul class="list">
<li>
<!-- TODO: translate -->
The server might be down
<!-- <translate translate-context="Popup/Instance/Error message.List item">
</translate> -->
</li>
<li>
<!-- TODO: translate -->
The given address is not a Funkwhale server
<!-- <translate translate-context="Popup/Instance/Error message.List item">
</translate> -->
</li>
</ul>
</div>
<form
class="ui form"
@submit.prevent="checkAndSwitch(instanceUrl)"
>
<p
v-if="store.state.instance.instanceUrl"
v-translate="{url: store.state.instance.instanceUrl, hostname: store.getters['instance/domain'] }"
class="description"
translate-context="Popup/Login/Paragraph"
>
You are currently connected to <a
href="%{ url }"
target="_blank"
>%{ hostname }&nbsp;<i class="external icon" /></a>. If you continue, you will be disconnected from your current instance and all your local data will be deleted.
</p>
<p v-else>
<!-- TODO: translate -->
To continue, please select the Funkwhale instance you want to connect to. Enter the address directly, or select one of the suggested choices.
<!-- <translate translate-context="Popup/Instance/Paragraph">
</translate> -->
</p>
<div class="field">
<label for="instance-picker">
<!-- TODO: translate -->
<!-- <translate translate-context="Popup/Instance/Input.Label/Noun">Instance URL</translate>
-->
</label>
<div class="ui action input">
<input
id="instance-picker"
v-model="instanceUrl"
type="text"
placeholder="https://funkwhale.server"
>
<button
type="submit"
:class="['ui', 'icon', {loading: isLoading}, 'button']"
>
<!-- TODO: translate -->
Submit
<!-- <translate translate-context="*/*/Button.Label/Verb">
</translate> -->
</button>
</div>
</div>
</form>
<div class="ui hidden divider" />
<form
class="ui form"
@submit.prevent=""
>
<div class="field">
<h4>
<!-- TODO: translate -->
Suggested choices
<!-- <translate translate-context="Popup/Instance/List.Label">
</translate> -->
</h4>
<button
v-for="(url, key) in suggestedInstances"
:key="key"
class="ui basic button"
@click="checkAndSwitch(url)"
>
{{ url }}
</button>
</div>
</form>
</div>
<div class="actions">
<button class="ui basic cancel button">
<!-- TODO: translate -->
Cancel
<!-- <translate translate-context="*/*/Button.Label/Verb">
</translate> -->
</button>
</div>
</Modal>
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
</template>
<script setup lang="ts">
import type { RouteRecordName } from 'vue-router'
import { computed, ref, watch, watchEffect, onMounted } from 'vue'
import { setI18nLanguage, SUPPORTED_LOCALES } from '~/init/locale'
import { useCurrentElement } from '@vueuse/core'
import { setupDropdown } from '~/utils/fomantic'
import { useRoute } from 'vue-router'
import { useStore } from '~/store'
import { useI18n } from 'vue-i18n'
import SemanticModal from '~/components/semantic/Modal.vue'
import UserModal from '~/components/common/UserModal.vue'
import SearchBar from '~/components/audio/SearchBar.vue'
import UserMenu from '~/components/common/UserMenu.vue'
import Logo from '~/components/Logo.vue'
import useThemeList from '~/composables/useThemeList'
import useTheme from '~/composables/useTheme'
import { isTauri as checkTauri } from '~/composables/tauri'
interface Props {
width: number
}
defineProps<Props>()
const store = useStore()
const { theme } = useTheme()
const themes = useThemeList()
const { t, locale: i18nLocale } = useI18n()
const route = useRoute()
const isCollapsed = ref(true)
watch(() => route.path, () => (isCollapsed.value = true))
const additionalNotifications = computed(() => store.getters['ui/additionalNotifications'])
const logoUrl = computed(() => store.state.auth.authenticated ? 'library.index' : 'index')
const labels = computed(() => ({
mainMenu: t('components.Sidebar.label.main'),
selectTrack: t('components.Sidebar.label.play'),
pendingFollows: t('components.Sidebar.label.follows'),
pendingReviewEdits: t('components.Sidebar.label.edits'),
pendingReviewReports: t('components.Sidebar.label.reports'),
language: t('components.Sidebar.label.language'),
theme: t('components.Sidebar.label.theme'),
addContent: t('components.Sidebar.label.add'),
administration: t('components.Sidebar.label.administration')
}))
type SidebarMenuTabs = 'explore' | 'myLibrary'
const expanded = ref<SidebarMenuTabs>('explore')
const ROUTE_MAPPINGS: Record<SidebarMenuTabs, RouteRecordName[]> = {
explore: [
'search',
'library.index',
'library.podcasts.browse',
'library.albums.browse',
'library.albums.detail',
'library.artists.browse',
'library.artists.detail',
'library.tracks.detail',
'library.playlists.browse',
'library.playlists.detail',
'library.radios.browse',
'library.radios.detail'
],
myLibrary: [
'library.me',
'library.albums.me',
'library.artists.me',
'library.playlists.me',
'library.radios.me',
'favorites'
]
}
watchEffect(() => {
if (ROUTE_MAPPINGS.explore.includes(route.name as RouteRecordName)) {
expanded.value = 'explore'
return
}
if (ROUTE_MAPPINGS.myLibrary.includes(route.name as RouteRecordName)) {
expanded.value = 'myLibrary'
return
}
expanded.value = store.state.auth.authenticated ? 'myLibrary' : 'explore'
})
const moderationNotifications = computed(() =>
store.state.ui.notifications.pendingReviewEdits
+ store.state.ui.notifications.pendingReviewReports
+ store.state.ui.notifications.pendingReviewRequests
)
const showLanguageModal = ref(false)
const locale = ref(i18nLocale.value)
watch(locale, (locale) => {
setI18nLanguage(locale)
})
const isProduction = import.meta.env.PROD
const isTauri = checkTauri()
const showUserModal = ref(false)
const showThemeModal = ref(false)
const el = useCurrentElement()
watchEffect(() => {
if (store.state.auth.authenticated) {
setupDropdown('.admin-dropdown', el.value)
}
setupDropdown('.user-dropdown', el.value)
})
onMounted(() => {
document.getElementById('fake-sidebar')?.classList.add('loaded')
})
</script>
<template>
<aside :class="['ui', 'vertical', 'left', 'visible', 'wide', {'collapsed': isCollapsed}, 'sidebar', 'component-sidebar']">
<header class="ui basic segment header-wrapper">
<router-link
:title="'Funkwhale'"
:to="{name: logoUrl}"
>
<i class="logo bordered inverted vibrant big icon">
<logo class="logo" />
<span class="visually-hidden">{{ $t('components.Sidebar.link.home') }}</span>
</i>
</router-link>
<nav class="top ui compact right aligned inverted text menu">
<div class="right menu">
<div
v-if="$store.state.auth.availablePermissions['settings'] || $store.state.auth.availablePermissions['moderation']"
class="item"
:title="labels.administration"
>
<div class="item ui inline admin-dropdown dropdown">
<i class="wrench icon" />
<div
v-if="moderationNotifications > 0"
:class="['ui', 'accent', 'mini', 'bottom floating', 'circular', 'label']"
>
{{ moderationNotifications }}
</div>
<div class="menu">
<h3 class="header">
{{ $t('components.Sidebar.header.administration') }}
</h3>
<div class="divider" />
<router-link
v-if="$store.state.auth.availablePermissions['library']"
class="item"
:to="{name: 'manage.library.edits', query: {q: 'is_approved:null'}}"
>
<div
v-if="$store.state.ui.notifications.pendingReviewEdits > 0"
:title="labels.pendingReviewEdits"
:class="['ui', 'circular', 'mini', 'right floated', 'accent', 'label']"
>
{{ $store.state.ui.notifications.pendingReviewEdits }}
</div>
{{ $t('components.Sidebar.link.library') }}
</router-link>
<router-link
v-if="$store.state.auth.availablePermissions['moderation']"
class="item"
:to="{name: 'manage.moderation.reports.list', query: {q: 'resolved:no'}}"
>
<div
v-if="$store.state.ui.notifications.pendingReviewReports + $store.state.ui.notifications.pendingReviewRequests > 0"
:title="labels.pendingReviewReports"
:class="['ui', 'circular', 'mini', 'right floated', 'accent', 'label']"
>
{{ $store.state.ui.notifications.pendingReviewReports + $store.state.ui.notifications.pendingReviewRequests }}
</div>
{{ $t('components.Sidebar.link.moderation') }}
</router-link>
<router-link
v-if="$store.state.auth.availablePermissions['settings']"
class="item"
:to="{name: 'manage.users.users.list'}"
>
{{ $t('components.Sidebar.link.users') }}
</router-link>
<router-link
v-if="$store.state.auth.availablePermissions['settings']"
class="item"
:to="{path: '/manage/settings'}"
>
{{ $t('components.Sidebar.link.settings') }}
</router-link>
</div>
</div>
</div>
</div>
<router-link
v-if="$store.state.auth.authenticated"
class="item"
:to="{name: 'content.index'}"
>
<i class="upload icon" />
<span class="visually-hidden">{{ labels.addContent }}</span>
</router-link>
<template v-if="width > 768">
<div class="item">
<div class="ui user-dropdown dropdown">
<img
v-if="$store.state.auth.authenticated && $store.state.auth.profile?.avatar && $store.state.auth.profile?.avatar.urls.medium_square_crop"
class="ui avatar image"
alt=""
:src="$store.getters['instance/absoluteUrl']($store.state.auth.profile?.avatar.urls.medium_square_crop)"
>
<actor-avatar
v-else-if="$store.state.auth.authenticated"
:actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username,}"
/>
<i
v-else
class="cog icon"
/>
<div
v-if="$store.state.ui.notifications.inbox + additionalNotifications > 0"
:class="['ui', 'accent', 'mini', 'bottom floating', 'circular', 'label']"
>
{{ $store.state.ui.notifications.inbox + additionalNotifications }}
</div>
<user-menu
v-bind="$attrs"
:width="width"
/>
</div>
</div>
</template>
<template v-else>
<a
href=""
class="item"
@click.prevent.exact="showUserModal = !showUserModal"
>
<img
v-if="$store.state.auth.authenticated && $store.state.auth.profile?.avatar?.urls.medium_square_crop"
class="ui avatar image"
alt=""
:src="$store.getters['instance/absoluteUrl']($store.state.auth.profile?.avatar.urls.medium_square_crop)"
>
<actor-avatar
v-else-if="$store.state.auth.authenticated"
:actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username,}"
/>
<i
v-else
class="cog icon"
/>
<div
v-if="$store.state.ui.notifications.inbox + additionalNotifications > 0"
:class="['ui', 'accent', 'mini', 'bottom floating', 'circular', 'label']"
>
{{ $store.state.ui.notifications.inbox + additionalNotifications }}
</div>
</a>
</template>
<user-modal
v-model:show="showUserModal"
@show-theme-modal-event="showThemeModal=true"
@show-language-modal-event="showLanguageModal=true"
/>
<semantic-modal
ref="languageModal"
v-model:show="showLanguageModal"
:fullscreen="false"
>
<i
role="button"
class="left chevron back inside icon"
@click.prevent.exact="showUserModal = !showUserModal"
/>
<div class="header">
<h3 class="title">
{{ labels.language }}
</h3>
</div>
<div class="content">
<fieldset
v-for="(language, key) in SUPPORTED_LOCALES"
:key="key"
>
<input
:id="`${key}`"
v-model="locale"
type="radio"
name="language"
:value="key"
>
<label :for="`${key}`">{{ language }}</label>
</fieldset>
</div>
</semantic-modal>
<semantic-modal
ref="themeModal"
v-model:show="showThemeModal"
:fullscreen="false"
>
<i
role="button"
class="left chevron back inside icon"
@click.prevent.exact="showUserModal = !showUserModal"
/>
<div class="header">
<h3 class="title">
{{ labels.theme }}
</h3>
</div>
<div class="content">
<fieldset
v-for="th in themes"
:key="th.key"
>
<input
:id="th.key"
v-model="theme"
type="radio"
name="theme"
:value="th.key"
>
<label :for="th.key">{{ th.name }}</label>
</fieldset>
</div>
</semantic-modal>
<div class="item collapse-button-wrapper">
<button
:class="['ui', 'basic', 'big', {'vibrant': !isCollapsed}, 'inverted icon', 'collapse', 'button']"
@click="isCollapsed = !isCollapsed"
>
<i class="sidebar icon" />
</button>
</div>
</nav>
</header>
<div class="ui basic search-wrapper segment">
<search-bar @search="isCollapsed = false" />
</div>
<div
v-if="!$store.state.auth.authenticated"
class="ui basic signup segment"
>
<router-link
class="ui fluid tiny primary button"
:to="{name: 'login'}"
>
{{ $t('components.Sidebar.link.login') }}
</router-link>
<div class="ui small hidden divider" />
<router-link
class="ui fluid tiny button"
:to="{path: '/signup'}"
>
{{ $t('components.Sidebar.link.createAccount') }}
</router-link>
</div>
<nav
class="secondary"
role="navigation"
aria-labelledby="navigation-label"
>
<h1
id="navigation-label"
class="visually-hidden"
>
{{ $t('components.Sidebar.header.main') }}
</h1>
<div class="ui small hidden divider" />
<section
:aria-label="labels.mainMenu"
class="ui bottom attached active tab"
>
<nav
class="ui vertical large fluid inverted menu"
role="navigation"
:aria-label="labels.mainMenu"
>
<div :class="[{ collapsed: expanded !== 'explore' }, 'collapsible item']">
<h2
class="header"
role="button"
tabindex="0"
@click="expanded = 'explore'"
@focus="expanded = 'explore'"
>
{{ $t('components.Sidebar.header.explore') }}
<i
v-if="expanded !== 'explore'"
class="angle right icon"
/>
</h2>
<div class="menu">
<router-link
class="item"
:to="{name: 'search'}"
>
<i class="search icon" />
{{ $t('components.Sidebar.link.search') }}
</router-link>
<router-link
class="item"
:to="{name: 'library.index'}"
active-class="_active"
>
<i class="music icon" />
{{ $t('components.Sidebar.link.browse') }}
</router-link>
<router-link
class="item"
:to="{name: 'library.podcasts.browse'}"
>
<i class="podcast icon" />
{{ $t('components.Sidebar.link.podcasts') }}
</router-link>
<router-link
class="item"
:to="{name: 'library.albums.browse'}"
>
<i class="compact disc icon" />
{{ $t('components.Sidebar.link.albums') }}
</router-link>
<router-link
class="item"
:to="{name: 'library.artists.browse'}"
>
<i class="user icon" />
{{ $t('components.Sidebar.link.artists') }}
</router-link>
<router-link
class="item"
:to="{name: 'library.playlists.browse'}"
>
<i class="list icon" />
{{ $t('components.Sidebar.link.playlists') }}
</router-link>
<router-link
class="item"
:to="{name: 'library.radios.browse'}"
>
<i class="feed icon" />
{{ $t('components.Sidebar.link.radios') }}
</router-link>
</div>
</div>
<div
v-if="$store.state.auth.authenticated"
:class="[{ collapsed: expanded !== 'myLibrary' }, 'collapsible item']"
>
<h3
class="header"
role="button"
tabindex="0"
@click="expanded = 'myLibrary'"
@focus="expanded = 'myLibrary'"
>
{{ $t('components.Sidebar.header.library') }}
<i
v-if="expanded !== 'myLibrary'"
class="angle right icon"
/>
</h3>
<div class="menu">
<router-link
class="item"
:to="{name: 'library.me'}"
>
<i class="music icon" />
{{ $t('components.Sidebar.link.browse') }}
</router-link>
<router-link
class="item"
:to="{name: 'library.albums.me'}"
>
<i class="compact disc icon" />
{{ $t('components.Sidebar.link.albums') }}
</router-link>
<router-link
class="item"
:to="{name: 'library.artists.me'}"
>
<i class="user icon" />
{{ $t('components.Sidebar.link.artists') }}
</router-link>
<router-link
class="item"
:to="{name: 'library.playlists.me'}"
>
<i class="list icon" />
{{ $t('components.Sidebar.link.playlists') }}
</router-link>
<router-link
class="item"
:to="{name: 'library.radios.me'}"
>
<i class="feed icon" />
{{ $t('components.Sidebar.link.radios') }}
</router-link>
<router-link
class="item"
:to="{name: 'favorites'}"
>
<i class="heart icon" />
{{ $t('components.Sidebar.link.favorites') }}
</router-link>
</div>
</div>
<router-link
v-if="$store.state.auth.authenticated"
class="header item"
:to="{name: 'subscriptions'}"
>
{{ $t('components.Sidebar.link.channels') }}
</router-link>
<div class="item">
<h3 class="header">
{{ $t('components.Sidebar.header.more') }}
</h3>
<div class="menu">
<router-link
class="item"
to="/about"
active-class="router-link-exact-active active"
>
<i class="info icon" />
{{ $t('components.Sidebar.link.about') }}
</router-link>
</div>
</div>
<div
v-if="!isProduction || isTauri"
class="item"
>
<router-link
to="/instance-chooser"
class="link item"
>
{{ $t('components.Sidebar.link.switchInstance') }}
</router-link>
</div>
</nav>
</section>
</nav>
</aside>
</template>
<style>
[type="radio"] {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
[type="radio"] + label::after {
content: "";
font-size: 1.4em;
}
[type="radio"]:checked + label::after {
margin-left: 10px;
content: "\2713"; /* Checkmark */
font-size: 1.4em;
}
[type="radio"]:checked + label {
font-weight: bold;
}
fieldset {
border: none;
}
.back {
font-size: 1.25em !important;
position: absolute;
top: 0.5rem;
left: 0.5rem;
width: 2.25rem !important;
height: 2.25rem !important;
padding: 0.625rem 0 0 0;
}
</style>
<script setup lang="ts">
import type { BackendError, SettingsGroup, SettingsDataEntry, FunctionRef, Form } from '~/types'
import type { BackendError, SettingsGroup, SettingsDataEntry, FunctionRef, Form, SettingsField } from '~/types'
import axios from 'axios'
import SignupFormBuilder from '~/components/admin/SignupFormBuilder.vue'
import useFormData from '~/composables/useFormData'
import { ref, computed, reactive } from 'vue'
import { useStore } from '~/store'
import useLogger from '~/composables/useLogger'
import { useI18n } from 'vue-i18n'
import Section from '~/components/ui/Section.vue'
import Layout from '~/components/ui/Layout.vue'
import Toggle from '~/components/ui/Toggle.vue'
import Input from '~/components/ui/Input.vue'
import Alert from '~/components/ui/Alert.vue'
import Button from '~/components/ui/Button.vue'
import Spacer from '~/components/ui/Spacer.vue'
const { t } = useI18n()
interface Props {
group: SettingsGroup
......@@ -34,16 +45,20 @@ const settings = computed(() => {
return acc
}, {} as Record<string, SettingsDataEntry>)
return props.group.settings.map(entry => {
return { ...byIdentifier[entry.name], fieldType: entry.fieldType, fieldParams: entry.fieldParams || {} }
})
return props.group.settings.map(entry => ({
...byIdentifier[entry.name],
fieldType: entry.fieldType,
fieldParams: entry.fieldParams || {}
} as SettingsDataEntry & Pick<SettingsField, 'fieldType' | 'fieldParams'>))
})
const fileSettings = computed(() => settings.value.filter(setting => setting.field.widget.class === 'ImageWidget'))
const fileSettings = computed(() => settings.value.filter(setting => setting.field?.widget.class === 'ImageWidget'))
for (const setting of settings.value) {
if (setting.identifier != null) {
values[setting.identifier] = setting.value
}
}
const isLoading = ref(false)
const save = async () => {
......@@ -55,22 +70,25 @@ const save = async () => {
if (fileSettings.value.length > 0) {
const fileSettingsIDs = fileSettings.value.map((setting) => setting.identifier)
const data = settings.value.reduce((data, setting) => {
const data: Record<string, string | File> = {}
for (const setting of settings.value) {
if (setting.identifier == null) {
return data
}
if (fileSettingsIDs.includes(setting.identifier)) {
const input = fileRefs[setting.identifier]
const { files } = input
const { files } = (input as HTMLInputElement)
logger.debug('ref', input, files)
if (files && files.length > 0) {
if (files && files.length > 0 && files[0] != null) {
data[setting.identifier] = files[0]
}
} else {
data[setting.identifier] = values[setting.identifier] as string
}
return data
}, {} as Record<string, string | File>)
}
contentType = 'multipart/form-data'
postData = useFormData(data)
......@@ -96,42 +114,24 @@ const save = async () => {
</script>
<template>
<!-- TODO: type the different values in `settings` (use generics) -->
<!-- eslint-disable vue/valid-v-model -->
<Section
align-left
:h2="group.label"
large-section-heading
>
<form
:id="group.id"
class="ui form component-settings-group"
style="grid-column: 1 / -1;"
@submit.prevent="save"
>
<div class="ui divider" />
<h3 class="ui header">
{{ group.label }}
</h3>
<div
v-if="errors.length > 0"
role="alert"
class="ui negative message"
>
<h4 class="header">
{{ $t('components.admin.SettingsGroup.header.error') }}
</h4>
<ul class="list">
<li
v-for="(error, key) in errors"
:key="key"
>
{{ error }}
</li>
</ul>
</div>
<div
v-if="result"
class="ui positive message"
>
{{ $t('components.admin.SettingsGroup.message.success') }}
</div>
<Spacer :size="16" />
<div
v-for="(setting, key) in settings"
:key="key"
class="ui field"
:class="[$style.field, 'ui', 'field']"
>
<template v-if="setting.field.widget.class !== 'CheckboxInput'">
<label :for="setting.identifier">{{ setting.verbose_name }}</label>
......@@ -144,60 +144,46 @@ const save = async () => {
v-bind="setting.fieldParams"
v-model="values[setting.identifier]"
/>
<!-- eslint-disable vue/valid-v-model -->
<signup-form-builder
v-else-if="setting.fieldType === 'formBuilder'"
v-model="values[setting.identifier] as Form"
:signup-approval-enabled="!!values.moderation__signup_approval_enabled"
/>
<!-- eslint-enable vue/valid-v-model -->
<input
<Input
v-else-if="setting.field.widget.class === 'PasswordInput'"
:id="setting.identifier"
v-model="values[setting.identifier]"
:name="setting.identifier"
v-model="values[setting.identifier] as string"
password
type="password"
class="ui input"
>
<input
/>
<Input
v-else-if="setting.field.widget.class === 'TextInput'"
:id="setting.identifier"
v-model="values[setting.identifier]"
:name="setting.identifier"
v-model="values[setting.identifier] as string"
type="text"
class="ui input"
>
<input
/>
<Input
v-else-if="setting.field.class === 'IntegerField'"
:id="setting.identifier"
v-model.number="values[setting.identifier]"
:name="setting.identifier"
v-model.number="values[setting.identifier] as number"
type="number"
class="ui input"
>
<!-- eslint-disable vue/valid-v-model -->
/>
<textarea
v-else-if="setting.field.widget.class === 'Textarea'"
:id="setting.identifier"
v-model="values[setting.identifier] as string"
:name="setting.identifier"
type="text"
class="ui input"
/>
<!-- eslint-enable vue/valid-v-model -->
<div
v-else-if="setting.field.widget.class === 'CheckboxInput'"
class="ui toggle checkbox"
>
<!-- eslint-disable vue/valid-v-model -->
<input
:id="setting.identifier"
<Toggle
v-model="values[setting.identifier] as boolean"
:name="setting.identifier"
type="checkbox"
>
<!-- eslint-enable vue/valid-v-model -->
<label :for="setting.identifier">{{ setting.verbose_name }}</label>
big
:label="setting.verbose_name"
/>
<Spacer :size="8" />
<p v-if="setting.help_text">
{{ setting.help_text }}
</p>
......@@ -208,6 +194,7 @@ const save = async () => {
v-model="values[setting.identifier]"
multiple
class="ui search selection dropdown"
style="height: 150px;"
>
<option
v-for="v in setting.additional_data?.choices"
......@@ -232,30 +219,75 @@ const save = async () => {
</option>
</select>
<div v-else-if="setting.field.widget.class === 'ImageWidget'">
<input
<!-- TODO: Implement image input -->
<!-- @vue-ignore -->
<Input
:id="setting.identifier"
:ref="setFileRef(setting.identifier)"
type="file"
>
/>
<div v-if="values[setting.identifier]">
<div class="ui hidden divider" />
<h3 class="ui header">
{{ $t('components.admin.SettingsGroup.header.image') }}
{{ t('components.admin.SettingsGroup.header.image') }}
</h3>
<img
v-if="values[setting.identifier]"
class="ui image"
alt=""
:src="$store.getters['instance/absoluteUrl'](values[setting.identifier])"
:src="store.getters['instance/absoluteUrl'](values[setting.identifier])"
>
</div>
</div>
<Spacer />
</div>
<button
<Layout flex>
<Spacer grow />
<Button
type="submit"
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'success', 'button']"
:class="[{'loading': isLoading}]"
primary
>
{{ t('components.admin.SettingsGroup.button.save') }}
</Button>
</Layout>
<Spacer />
<Alert
v-if="errors.length > 0"
red
>
<h4 class="header">
{{ t('components.admin.SettingsGroup.header.error', {label: group.label}) }}
</h4>
<ul class="list">
<li
v-for="(error, key) in errors"
:key="key"
>
{{ $t('components.admin.SettingsGroup.button.save') }}
</button>
{{ error }}
</li>
</ul>
</Alert>
<Alert
v-if="result"
green
>
{{ t('components.admin.SettingsGroup.message.success') }}
</Alert>
</form>
</Section>
<hr :class="$style.separator">
<Spacer size-64 />
<!-- eslint-enable vue/valid-v-model -->
</template>
<style module>
.field > div {
display: flex;
flex-direction: column;
}
.separator:last-of-type {
display: none;
}
</style>
......@@ -2,6 +2,8 @@
import type { Form } from '~/types'
import SignupForm from '~/components/auth/SignupForm.vue'
import Button from '~/components/ui/Button.vue'
import { useVModel } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
......@@ -65,18 +67,20 @@ const move = (idx: number, increment: number) => {
<template>
<div>
<div class="ui top attached tabular menu">
<button
<Button
color="primary"
:class="[{active: !isPreviewing}, 'item']"
@click.stop.prevent="isPreviewing = false"
>
{{ $t('components.admin.SignupFormBuilder.button.edit') }}
</button>
<button
{{ t('components.admin.SignupFormBuilder.button.edit') }}
</Button>
<Button
color="primary"
:class="[{active: isPreviewing}, 'item']"
@click.stop.prevent="isPreviewing = true"
>
{{ $t('components.admin.SignupFormBuilder.button.preview') }}
</button>
{{ t('components.admin.SignupFormBuilder.button.preview') }}
</Button>
</div>
<div
v-if="isPreviewing"
......@@ -95,10 +99,10 @@ const move = (idx: number, increment: number) => {
>
<div class="field">
<label for="help-text">
{{ $t('components.admin.SignupFormBuilder.label.helpText') }}
{{ t('components.admin.SignupFormBuilder.label.helpText') }}
</label>
<p>
{{ $t('components.admin.SignupFormBuilder.help.helpText') }}
{{ t('components.admin.SignupFormBuilder.help.helpText') }}
</p>
<content-form
v-if="value.help_text"
......@@ -109,24 +113,24 @@ const move = (idx: number, increment: number) => {
</div>
<div class="field">
<label>
{{ $t('components.admin.SignupFormBuilder.label.additionalFields') }}
{{ t('components.admin.SignupFormBuilder.label.additionalFields') }}
</label>
<p>
{{ $t('components.admin.SignupFormBuilder.help.additionalFields') }}
{{ t('components.admin.SignupFormBuilder.help.additionalFields') }}
</p>
<table v-if="value.fields?.length > 0">
<thead>
<tr>
<th>
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.header.label') }}
{{ t('components.admin.SignupFormBuilder.table.additionalFields.header.label') }}
</th>
<th>
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.header.type') }}
{{ t('components.admin.SignupFormBuilder.table.additionalFields.header.type') }}
</th>
<th>
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.header.required') }}
{{ t('components.admin.SignupFormBuilder.table.additionalFields.header.required') }}
</th>
<th><span class="visually-hidden">{{ $t('components.admin.SignupFormBuilder.table.additionalFields.header.actions') }}</span></th>
<th><span class="visually-hidden">{{ t('components.admin.SignupFormBuilder.table.additionalFields.header.actions') }}</span></th>
</tr>
</thead>
<tbody>
......@@ -144,20 +148,20 @@ const move = (idx: number, increment: number) => {
<td>
<select v-model="field.input_type">
<option value="short_text">
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.type.short') }}
{{ t('components.admin.SignupFormBuilder.table.additionalFields.type.short') }}
</option>
<option value="long_text">
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.type.long') }}
{{ t('components.admin.SignupFormBuilder.table.additionalFields.type.long') }}
</option>
</select>
</td>
<td>
<select v-model="field.required">
<option :value="true">
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.required.true') }}
{{ t('components.admin.SignupFormBuilder.table.additionalFields.required.true') }}
</option>
<option :value="false">
{{ $t('components.admin.SignupFormBuilder.table.additionalFields.required.false') }}
{{ t('components.admin.SignupFormBuilder.table.additionalFields.required.false') }}
</option>
</select>
</td>
......@@ -187,13 +191,13 @@ const move = (idx: number, increment: number) => {
</tbody>
</table>
<div class="ui hidden divider" />
<button
<Button
v-if="value.fields?.length < maxFields"
class="ui basic button"
color="primary"
@click.stop.prevent="addField"
>
{{ $t('components.admin.SignupFormBuilder.button.add') }}
</button>
{{ t('components.admin.SignupFormBuilder.button.add') }}
</Button>
</div>
</div>
<div class="ui hidden divider" />
......
<script setup lang="ts">
import { computed } from 'vue'
import { useStore } from '~/store'
import { useI18n } from 'vue-i18n'
import { momentFormat } from '~/utils/filters'
import defaultCover from '~/assets/audio/default-cover.png'
import PlayButton from '~/components/audio/PlayButton.vue'
import Layout from '~/components/ui/Layout.vue'
import Card from '~/components/ui/Card.vue'
import Link from '~/components/ui/Link.vue'
import Spacer from '~/components/ui/Spacer.vue'
import { type Album } from '~/types'
interface Props {
album: Album;
}
const { t } = useI18n()
const props = defineProps<Props>()
const { album } = props
const artistCredit = album.artist_credit || []
const store = useStore()
const imageUrl = computed(() => props.album.cover?.urls.original
? store.getters['instance/absoluteUrl'](props.album.cover?.urls.medium_square_crop)
: defaultCover
)
</script>
<template>
<Card
:title="album.title"
:image="imageUrl"
:tags="album.tags"
:to="{ name: 'library.albums.detail', params: { id: album.id } }"
small
>
<template #topright>
<PlayButton
icon-only
:is-playable="album.is_playable"
:album="album"
/>
</template>
<Layout
flex
gap-4
style="overflow: hidden;"
>
<template
v-for="ac in artistCredit"
:key="ac.artist.id"
>
<Link
align-text="start"
:to="{ name: 'library.artists.detail', params: { id: ac.artist.id } }"
>
{{ ac.credit ?? t('components.Queue.meta.unknownArtist') }}
</Link>
<span style="font-weight: 600;">{{ ac.joinphrase }}</span>
</template>
</Layout>
<template #footer>
<span v-if="album.release_date">
{{ momentFormat(new Date(album.release_date), 'Y') }}
</span>
<i class="bi bi-dot" />
<span>
{{ t('components.audio.album.Card.meta.tracks', album.tracks_count) }}
</span>
<Spacer
h
grow
/>
<PlayButton
:dropdown-only="true"
discrete
:is-playable="album.is_playable"
:album="album"
/>
</template>
</Card>
</template>
<style scoped>
.play-button {
top: 16px;
right: 16px;
}
</style>
......@@ -6,21 +6,27 @@ import { useStore } from '~/store'
import axios from 'axios'
import AlbumCard from '~/components/audio/album/Card.vue'
import useErrorHandler from '~/composables/useErrorHandler'
import AlbumCard from '~/components/album/Card.vue'
import Section from '~/components/ui/Section.vue'
import Loader from '~/components/ui/Loader.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Pagination from '~/components/ui/Pagination.vue'
interface Props {
filters: Record<string, string | boolean>
showCount?: boolean
search?: boolean
limit?: number
title?: string
}
const props = withDefaults(defineProps<Props>(), {
showCount: false,
search: false,
limit: 12
limit: 12,
title: undefined
})
const store = useStore()
......@@ -28,6 +34,7 @@ const store = useStore()
const query = ref('')
const albums = reactive([] as Album[])
const count = ref(0)
const page = ref(1)
const nextPage = ref()
const isLoading = ref(false)
......@@ -38,13 +45,14 @@ const fetchData = async (url = 'albums/') => {
const params = {
q: query.value,
...props.filters,
page: page.value,
page_size: props.limit
}
const response = await axios.get(url, { params })
nextPage.value = response.data.next
count.value = response.data.count
albums.push(...response.data.results)
albums.splice(0, albums.length, ...response.data.results)
} catch (error) {
useErrorHandler(error as Error)
}
......@@ -52,68 +60,59 @@ const fetchData = async (url = 'albums/') => {
isLoading.value = false
}
setTimeout(fetchData, 1000)
const performSearch = () => {
albums.length = 0
fetchData()
}
watch(
() => store.state.moderation.lastUpdate,
() => [store.state.moderation.lastUpdate, page.value],
() => fetchData(),
{ immediate: true }
)
</script>
<template>
<div class="wrapper">
<h3
v-if="!!$slots.title"
class="ui header"
<Section
align-left
:h2="title"
:columns-per-item="1"
>
<slot name="title" />
<span
v-if="showCount"
class="ui tiny circular label"
>{{ count }}</span>
</h3>
<slot />
<inline-search-bar
v-if="search"
v-model="query"
style="grid-column: 1 / -1;"
@search="performSearch"
/>
<div class="ui hidden divider" />
<div class="ui app-cards cards">
<div
<Loader
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div>
style="grid-column: 1 / -1;"
/>
<template v-if="!isLoading && albums.length > 0">
<album-card
v-for="album in albums"
:key="album.id"
:album="album"
/>
</div>
</template>
<slot
v-if="!isLoading && albums.length === 0"
name="empty-state"
>
<empty-state
:refresh="true"
style="grid-column: 1 / -1;"
@refresh="fetchData"
/>
</slot>
<template v-if="nextPage">
<div class="ui hidden divider" />
<button
v-if="nextPage"
:class="['ui', 'basic', 'button']"
@click="fetchData(nextPage)"
>
{{ $t('components.audio.album.Widget.button.more') }}
</button>
</template>
</div>
<Spacer grow />
<Pagination
v-if="page && albums && count > props.limit"
v-model:page="page"
:pages="Math.ceil((count || 0) / props.limit)"
style="grid-column: 1 / -1;"
/>
</Section>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { components } from '~/generated/types.ts'
import PlayButton from '~/components/audio/PlayButton.vue'
import Card from '~/components/ui/Card.vue'
import Spacer from '~/components/ui/Spacer.vue'
import type { Artist, Album } from '~/types'
const albums = ref([] as Album[])
interface Props {
artist: Artist | components['schemas']['ArtistWithAlbums'];
}
const { t } = useI18n()
const props = defineProps<Props>()
const { artist } = props
if ('albums' in artist && Array.isArray(artist.albums)) {
albums.value = artist.albums
}
</script>
<template>
<Card
:title="artist.name"
class="artist-card"
:tags="artist.tags"
:to="{name: 'library.artists.detail', params: {id: artist.id}}"
small
>
<template #topright>
<PlayButton
icon-only
:is-playable="true"
:artist="artist"
/>
</template>
<template #image>
<img
v-if="artist.cover"
v-lazy="artist.cover.urls.medium_square_crop"
:alt="artist.name"
:class="[artist.content_category === 'podcast' ? 'podcast-image' : 'channel-image']"
>
<i
v-else
class="bi bi-person-circle"
style="font-size: 167px; margin: 16px;"
/>
</template>
<template #footer>
<span v-if="artist.content_category === 'music' && 'tracks_count' in artist">
{{ t('components.audio.artist.Card.meta.tracks', artist.tracks_count) }}
</span>
<span v-else-if="'tracks_count' in artist">
{{ t('components.audio.artist.Card.meta.episodes', artist.tracks_count) }}
</span>
<i
v-if="albums"
class="bi bi-dot"
/>
<span v-if="albums">
{{ t('components.audio.artist.Card.meta.albums', albums.length) }}
</span>
<Spacer style="flex-grow: 1" />
<PlayButton
:dropdown-only="true"
:is-playable="Boolean(albums.find(album => album.is_playable))"
:artist="artist"
discrete
/>
</template>
</Card>
</template>
<style lang="scss" scoped>
.channel-image {
border-radius: 50%;
width: 168px;
height: 168px;
margin: 16px;
}
.podcast-image {
width: 168px;
height: 168px;
margin: 16px;
}
.play-button {
top: 16px;
right: 16px;
}
</style>
<script setup lang="ts">
import type { Artist } from '~/types'
import { reactive, ref, watch } from 'vue'
import { reactive, ref, watch, onMounted } from 'vue'
import { useStore } from '~/store'
import axios from 'axios'
import ArtistCard from '~/components/audio/artist/Card.vue'
import useErrorHandler from '~/composables/useErrorHandler'
import ArtistCard from '~/components/artist/Card.vue'
import Section from '~/components/ui/Section.vue'
import Pagination from '~/components/ui/Pagination.vue'
import Loader from '~/components/ui/Loader.vue'
interface Props {
filters: Record<string, string | boolean>
search?: boolean
header?: boolean
limit?: number
title?: string
}
const props = withDefaults(defineProps<Props>(), {
search: false,
header: true,
limit: 12
limit: 12,
title: undefined
})
const store = useStore()
......@@ -28,6 +33,7 @@ const store = useStore()
const query = ref('')
const artists = reactive([] as Artist[])
const count = ref(0)
const page = ref(1)
const nextPage = ref()
const isLoading = ref(false)
......@@ -38,13 +44,14 @@ const fetchData = async (url = 'artists/') => {
const params = {
q: query.value,
...props.filters,
page: page.value,
page_size: props.limit
}
const response = await axios.get(url, { params })
nextPage.value = response.data.next
count.value = response.data.count
artists.push(...response.data.results)
artists.splice(0, artists.length, ...response.data.results)
} catch (error) {
useErrorHandler(error as Error)
}
......@@ -52,64 +59,58 @@ const fetchData = async (url = 'artists/') => {
isLoading.value = false
}
onMounted(() => {
setTimeout(fetchData, 1000)
})
const performSearch = () => {
artists.length = 0
fetchData()
}
watch(
() => store.state.moderation.lastUpdate,
[() => store.state.moderation.lastUpdate, page],
() => fetchData(),
{ immediate: true }
)
</script>
<template>
<div class="wrapper">
<h3
v-if="header"
class="ui header"
<Section
align-left
:columns-per-item="3"
:h2="title"
>
<slot name="title" />
<span class="ui tiny circular label">{{ count }}</span>
</h3>
<inline-search-bar
v-if="search"
v-model="query"
@search="performSearch"
/>
<div class="ui hidden divider" />
<div class="ui five app-cards cards">
<div
<Loader
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div>
<artist-card
v-for="artist in artists"
:key="artist.id"
:artist="artist"
style="grid-column: 1 / -1;"
/>
</div>
<slot
v-if="!isLoading && artists.length === 0"
name="empty-state"
>
<empty-state
style="grid-column: 1 / -1;"
:refresh="true"
@refresh="fetchData"
/>
</slot>
<template v-if="nextPage">
<div class="ui hidden divider" />
<button
v-if="nextPage"
:class="['ui', 'basic', 'button']"
@click="fetchData(nextPage)"
>
{{ $t('components.audio.artist.Widget.button.more') }}
</button>
</template>
</div>
<inline-search-bar
v-if="!isLoading && search"
v-model="query"
style="grid-column: 1 / -1;"
@search="performSearch"
/>
<artist-card
v-for="artist in artists"
:key="artist.id"
:artist="artist"
/>
<Pagination
v-if="page && artists && count > limit"
v-model:page="page"
style="grid-column: 1 / -1;"
:pages="Math.ceil((count || 0) / limit)"
/>
</Section>
</template>
<script setup lang="ts">
import type { ArtistCredit } from '~/types'
import { useStore } from '~/store'
import Layout from '~/components/ui/Layout.vue'
import Pill from '~/components/ui/Pill.vue'
const store = useStore()
interface Props {
artistCredit: ArtistCredit[]
}
const props = defineProps<Props>()
const getRoute = (ac: ArtistCredit) => {
return {
name: ac.artist.channel ? 'channels.detail' : 'library.artists.detail',
params: {
id: ac.artist.id.toString()
}
}
}
</script>
<template>
<div class="artist-label ui image label">
<Layout
flex
gap-8
style="/*2px typographic overshoot compensation+ 4px pill paddings */ margin: 0 -6px;"
>
<template
v-for="ac in props.artistCredit"
:key="ac.artist.id"
>
<router-link
:to="getRoute(ac)"
:to="{name: 'library.artists.detail', params: {id: ac.artist.id }}"
class="username"
@click.stop.prevent=""
>
<Pill v-bind="$attrs">
<template #image>
<img
v-if="ac.index === 0 && ac.artist.cover && ac.artist.cover.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](ac.artist.cover.urls.medium_square_crop)"
alt=""
:class="[{circular: ac.artist.content_category != 'podcast'}]"
v-if="ac.artist.cover && ac.artist.cover.urls.original"
v-lazy="store.getters['instance/absoluteUrl'](ac.artist.cover.urls.small_square_crop)"
:alt="ac.artist.name"
@error="(e) => { e.target && ac.artist.cover ? (e.target as HTMLImageElement).src = store.getters['instance/absoluteUrl'](ac.artist.cover.urls.medium_square_crop) : null }"
>
<i
v-else-if="ac.index === 0"
:class="[ac.artist.content_category != 'podcast' ? 'circular' : 'bordered', 'inverted violet users icon']"
v-else
class="bi bi-person-circle"
style="font-size: 24px;"
/>
</template>
{{ ac.credit }}
</Pill>
</router-link>
<span>{{ ac.joinphrase }}</span>
</template>
</div>
</Layout>
</template>
<style lang="scss" scoped>
a.username {
text-decoration: none;
height: 25px;
}
</style>
......@@ -2,6 +2,9 @@
import type { Artist } from '~/types'
import { computed } from 'vue'
import { useStore } from '~/store'
const store = useStore()
interface Props {
artist: Artist
......@@ -10,7 +13,7 @@ interface Props {
const props = defineProps<Props>()
const route = computed(() => props.artist.channel
? { name: 'channels.detail', params: { id: props.artist.channel.uuid } }
? { name: 'channels.detail', params: { id: props.artist.channel } }
: { name: 'library.artists.detail', params: { id: props.artist.id } }
)
</script>
......@@ -22,9 +25,10 @@ const route = computed(() => props.artist.channel
>
<img
v-if="artist.cover && artist.cover.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](artist.cover.urls.medium_square_crop)"
v-lazy="store.getters['instance/absoluteUrl'](artist.cover.urls.small_square_crop)"
alt=""
:class="[{circular: artist.content_category != 'podcast'}]"
@error="(e) => { e.target && artist.cover ? (e.target as HTMLImageElement).src = store.getters['instance/absoluteUrl'](artist.cover.urls.medium_square_crop) : null }"
>
<i
v-else
......
......@@ -9,7 +9,9 @@ import { computed } from 'vue'
import moment from 'moment'
import PlayButton from '~/components/audio/PlayButton.vue'
import TagsList from '~/components/tags/List.vue'
import Card from '~/components/ui/Card.vue'
import Spacer from '~/components/ui/Spacer.vue'
import ActorLink from '~/components/common/ActorLink.vue'
interface Props {
object: Channel
......@@ -41,64 +43,92 @@ const updatedAgo = computed(() => moment(props.object.artist?.modification_date)
</script>
<template>
<div class="card app-card">
<div
v-lazy:background-image="imageUrl"
:class="['ui', 'head-image', {'circular': object.artist?.content_category != 'podcast'}, {'padded': object.artist?.content_category === 'podcast'}, 'image', {'default-cover': !object.artist?.cover}]"
@click="$router.push({name: 'channels.detail', params: {id: urlId}})"
<Card
:title="object.artist?.name"
:tags="object.artist?.tags ?? []"
class="artist-card"
:to="{name: 'channels.detail', params: {id: urlId}}"
solid
small
>
<play-button
:icon-only="true"
:is-playable="true"
:button-classes="['ui', 'circular', 'large', 'vibrant', 'icon', 'button']"
<template #topright>
<PlayButton
icon-only
:artist="object.artist"
:is-playable="true"
/>
</div>
<div class="content">
<strong>
<router-link
class="discrete link"
:to="{name: 'channels.detail', params: {id: urlId}}"
>
{{ object.artist?.name }}
</router-link>
</strong>
<div class="description">
<span
v-if="object.artist?.content_category === 'podcast'"
class="meta ellipsis"
</template>
<template #image>
<img
v-if="imageUrl"
v-lazy="imageUrl"
:alt="object.artist?.name"
:class="[object.artist?.content_category === 'podcast' ? 'podcast-image' : 'channel-image']"
>
{{ $t('components.audio.ChannelCard.meta.episodes', object.artist.tracks_count) }}
</span>
<span v-else>
{{ $t('components.audio.ChannelCard.meta.tracks', object.artist?.tracks_count ?? 0) }}
</span>
<tags-list
label-classes="tiny"
:truncate-size="20"
:limit="2"
:show-more="false"
:tags="object.artist?.tags ?? []"
<i
v-else
class="bi bi-person-circle"
style="font-size: 167px; margin: 16px;"
/>
</template>
<template #default>
<Spacer :size="8" />
<ActorLink
:actor="object.attributed_to"
discrete
/>
</div>
</div>
<div class="extra content">
</template>
<template #footer>
<time
class="meta ellipsis"
:datetime="object.artist?.modification_date"
:title="updatedTitle"
>
{{ updatedAgo }}
</time>
<play-button
class="right floated basic icon"
<i class="bi bi-dot" />
<span
v-if="object.artist?.content_category === 'podcast'"
>
{{ t('components.audio.ChannelCard.meta.episodes', object.artist.tracks_count) }}
</span>
<span v-else>
{{ t('components.audio.ChannelCard.meta.tracks', object.artist?.tracks_count ?? 0) }}
</span>
<Spacer
h
grow
/>
<PlayButton
:dropdown-only="true"
:is-playable="true"
:dropdown-icon-classes="['ellipsis', 'horizontal', 'large really discrete']"
:artist="object.artist"
:channel="object"
:account="object.attributed_to"
discrete
/>
</div>
</div>
</template>
</Card>
</template>
<style lang="scss" scoped>
.channel-image {
border-radius: 50%;
width: 168px;
height: 168px;
margin: 16px;
}
.podcast-image {
width: 168px;
height: 168px;
margin: 16px;
}
.play-button {
top: 16px;
right: 16px;
}
</style>
......@@ -3,11 +3,14 @@ import type { Cover, Track, BackendResponse, BackendError } from '~/types'
import { clone } from 'lodash-es'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import axios from 'axios'
import PodcastTable from '~/components/audio/podcast/Table.vue'
import TrackTable from '~/components/audio/track/Table.vue'
import Loader from '~/components/ui/Loader.vue'
interface Events {
(e: 'fetched', data: BackendResponse<Track[]>): void
}
......@@ -15,9 +18,10 @@ interface Events {
interface Props {
filters: object
limit?: number
defaultCover: Cover | null
defaultCover?: Cover | null
isPodcast: boolean
}
const { t } = useI18n()
const emit = defineEmits<Events>()
const props = withDefaults(defineProps<Props>(), {
......@@ -61,12 +65,7 @@ watch(page, fetchData, { immediate: true })
<template>
<div>
<slot />
<div class="ui hidden divider" />
<div
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
<Loader v-if="isLoading" />
</div>
<podcast-table
v-if="isPodcast"
......@@ -103,9 +102,8 @@ watch(page, fetchData, { immediate: true })
@refresh="fetchData()"
>
<p>
{{ $t('components.audio.ChannelEntries.help.subscribe') }}
{{ t('components.audio.ChannelEntries.help.subscribe') }}
</p>
</empty-state>
</template>
</div>
</template>
......@@ -5,10 +5,15 @@ import { computed } from 'vue'
import { usePlayer } from '~/composables/audio/player'
import { useQueue } from '~/composables/audio/queue'
import { useStore } from '~/store'
import { useRouter } from 'vue-router'
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import PlayButton from '~/components/audio/PlayButton.vue'
const store = useStore()
const router = useRouter()
interface Props {
entry: Track
defaultCover: Cover
......@@ -28,7 +33,7 @@ const duration = computed(() => props.entry.uploads.find(upload => upload.durati
<div class="controls">
<play-button
class="basic circular icon"
:discrete="true"
discrete
:icon-only="true"
:is-playable="true"
:button-classes="['ui', 'circular', 'inverted vibrant', 'icon', 'button']"
......@@ -37,30 +42,30 @@ const duration = computed(() => props.entry.uploads.find(upload => upload.durati
</div>
<img
v-if="cover && cover.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)"
v-lazy="store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)"
alt=""
class="channel-image image"
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
@click="router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
>
<img
v-else-if="entry.artist_credit?.[0].artist.content_category === 'podcast' && defaultCover != undefined"
v-lazy="$store.getters['instance/absoluteUrl'](defaultCover.urls.medium_square_crop)"
v-else-if="entry.artist_credit[0]?.artist.content_category === 'podcast' && defaultCover != undefined"
v-lazy="store.getters['instance/absoluteUrl'](defaultCover.urls.medium_square_crop)"
class="channel-image image"
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
@click="router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
>
<img
v-else-if="entry.album && entry.album.cover && entry.album.cover.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](entry.album.cover.urls.medium_square_crop)"
v-lazy="store.getters['instance/absoluteUrl'](entry.album.cover.urls.medium_square_crop)"
alt=""
class="channel-image image"
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
@click="router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
>
<img
v-else
alt=""
class="channel-image image"
src="../../assets/audio/default-cover.png"
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
@click="router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
>
<div class="ellipsis content">
<strong>
......@@ -78,7 +83,7 @@ const duration = computed(() => props.entry.uploads.find(upload => upload.durati
/>
</div>
<div class="meta">
<template v-if="$store.state.auth.authenticated && $store.getters['favorites/isFavorite'](entry.id)">
<template v-if="store.state.auth.authenticated && store.getters['favorites/isFavorite'](entry.id)">
<track-favorite-icon
class="tiny"
:track="entry"
......
<script setup lang="ts">
import type { ContentCategory, Channel, BackendError } from '~/types'
import type { paths } from '~/generated/types'
import { slugify } from 'transliteration'
import { reactive, computed, ref, watchEffect, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDataStore } from '~/ui/stores/data'
import axios from 'axios'
import AttachmentInput from '~/components/common/AttachmentInput.vue'
import TagsSelector from '~/components/library/TagsSelector.vue'
interface Events {
(e: 'category', contentCategory: ContentCategory): void
(e: 'submittable', value: boolean): void
(e: 'loading', value: boolean): void
(e: 'errored', errors: string[]): void
(e: 'created', channel: Channel): void
(e: 'updated', channel: Channel): void
}
import Layout from '~/components/ui/Layout.vue'
import Alert from '~/components/ui/Alert.vue'
import Input from '~/components/ui/Input.vue'
import Textarea from '~/components/ui/Textarea.vue'
import Pills from '~/components/ui/Pills.vue'
interface Props {
object?: Channel | null
step: number
step?: number
}
const emit = defineEmits<Events>()
const emit = defineEmits<{
category: [contentCategory: ContentCategory]
submittable: [value: boolean]
loading: [value: boolean]
errored: [errors: string[]]
created: [channel: Channel]
updated: [channel: Channel]
}>()
const props = withDefaults(defineProps<Props>(), {
object: null,
step: 1
})
const { t } = useI18n()
const dataStore = useDataStore()
const newValues = reactive({
name: props.object?.artist?.name ?? '',
......@@ -38,9 +45,11 @@ const newValues = reactive({
description: props.object?.artist?.description?.text ?? '',
cover: props.object?.artist?.cover?.uuid ?? null,
content_category: props.object?.artist?.content_category ?? 'podcast',
metadata: { ...(props.object?.metadata ?? {}) }
metadata: { ...(props.object?.metadata ?? {}) } as Channel['metadata']
})
// If props has an object, then this form edits, else it creates
// TODO: rename to `process : 'creating' | 'editing'`
const creating = computed(() => props.object === null)
const categoryChoices = computed(() => [
{
......@@ -72,6 +81,8 @@ interface MetadataChoices {
const metadataChoices = ref({ itunes_category: null } as MetadataChoices)
const itunesSubcategories = computed(() => {
for (const element of metadataChoices.value.itunes_category ?? []) {
// TODO: Backend: Define schema for `metadata` field
// @ts-expect-error No types defined by backend schema for `metadata` field
if (element.value === newValues.metadata.itunes_category) {
return element.children ?? []
}
......@@ -87,6 +98,7 @@ const labels = computed(() => ({
const submittable = computed(() => !!(
newValues.content_category === 'podcast'
// @ts-expect-error No types defined by backend schema for `metadata` field
? newValues.name && newValues.username && newValues.metadata.itunes_category && newValues.metadata.language
: newValues.name && newValues.username
))
......@@ -97,13 +109,16 @@ watch(() => newValues.name, (name) => {
}
})
// @ts-expect-error No types defined by backend schema for `metadata` field
watch(() => newValues.metadata.itunes_category, () => {
// @ts-expect-error No types defined by backend schema for `metadata` field
newValues.metadata.itunes_subcategory = null
})
const isLoading = ref(false)
const errors = ref([] as string[])
// @ts-expect-error Re-check emits
watchEffect(() => emit('category', newValues.content_category))
watchEffect(() => emit('loading', isLoading.value))
watchEffect(() => emit('submittable', submittable.value))
......@@ -111,8 +126,9 @@ watchEffect(() => emit('submittable', submittable.value))
// TODO (wvffle): Add loader / Use Suspense
const fetchMetadataChoices = async () => {
try {
const response = await axios.get('channels/metadata-choices')
metadataChoices.value = response.data
const response = await axios.get<paths['/api/v2/channels/metadata-choices/']['get']['responses']['200']['content']['application/json']>('channels/metadata-choices/')
// TODO: Fix schema generation so we don't need to typecast here!
metadataChoices.value = response.data as unknown as MetadataChoices
} catch (error) {
errors.value = (error as BackendError).backendErrors
}
......@@ -155,17 +171,17 @@ defineExpose({
</script>
<template>
<form
<Layout
form
class="ui form"
@submit.prevent.stop="submit"
>
<div
<Alert
v-if="errors.length > 0"
role="alert"
class="ui negative message"
red
>
<h4 class="header">
{{ $t('components.audio.ChannelForm.header.error') }}
{{ t('components.audio.ChannelForm.header.error') }}
</h4>
<ul class="list">
<li
......@@ -175,14 +191,14 @@ defineExpose({
{{ error }}
</li>
</ul>
</div>
</Alert>
<template v-if="metadataChoices">
<fieldset
v-if="creating && step === 1"
class="ui grouped channel-type required field"
>
<legend>
{{ $t('components.audio.ChannelForm.legend.purpose') }}
{{ t('components.audio.ChannelForm.legend.purpose') }}
</legend>
<div class="ui hidden divider" />
<div class="field">
......@@ -199,7 +215,7 @@ defineExpose({
:value="choice.value"
>
<label :for="`category-${choice.value}`">
<span :class="['right floated', 'placeholder', 'image', {circular: choice.value === 'music'}]" />
<span :class="['right floated', 'placeholder', 'image', 'shifted', {circular: choice.value === 'music'}]" />
<strong>{{ choice.label }}</strong>
<div class="ui small hidden divider" />
{{ choice.helpText }}
......@@ -207,38 +223,30 @@ defineExpose({
</div>
</div>
</fieldset>
<template v-if="!creating || step === 2">
<div class="ui required field">
<label for="channel-name">
{{ $t('components.audio.ChannelForm.label.name') }}
</label>
<input
<Input
v-model="newValues.name"
type="text"
required
:placeholder="labels.namePlaceholder"
>
:label="t('components.audio.ChannelForm.label.name')"
/>
</div>
<div class="ui required field">
<label for="channel-username">
{{ $t('components.audio.ChannelForm.label.username') }}
</label>
<div class="ui left labeled input">
<div class="ui basic label">
<span class="at symbol" />
</div>
<input
<Input
v-model="newValues.username"
type="text"
:required="creating"
:disabled="!creating"
:placeholder="labels.usernamePlaceholder"
>
</div>
:label="t('components.audio.ChannelForm.label.username')"
/>
<template v-if="creating">
<div class="ui small hidden divider" />
<p>
{{ $t('components.audio.ChannelForm.help.username') }}
{{ t('components.audio.ChannelForm.help.username') }}
</p>
</template>
</div>
......@@ -248,31 +256,23 @@ defineExpose({
:image-class="newValues.content_category === 'podcast' ? '' : 'circular'"
@delete="newValues.cover = null"
>
{{ $t('components.audio.ChannelForm.label.image') }}
{{ t('components.audio.ChannelForm.label.image') }}
</attachment-input>
</div>
<div class="ui small hidden divider" />
<div class="ui stackable grid row">
<div class="ten wide column">
<div class="ui field">
<label for="channel-tags">
{{ $t('components.audio.ChannelForm.label.tags') }}
</label>
<tags-selector
id="channel-tags"
v-model="newValues.tags"
:required="false"
<Pills
:get="model => { newValues.tags = model.currents.map(({ label }) => label) }"
:set="model => ({
currents: newValues.tags.map(tag => ({ type: 'custom' as const, label: tag })),
others: dataStore.tags().value.map(({ name }) => ({ type: 'custom' as const, label: name }))
})"
:label="t('components.audio.ChannelForm.label.tags')"
/>
</div>
</div>
<div
v-if="newValues.content_category === 'podcast'"
class="six wide column"
>
<div class="ui required field">
<div v-if="newValues.content_category === 'podcast'">
<label for="channel-language">
{{ $t('components.audio.ChannelForm.label.language') }}
{{ t('components.audio.ChannelForm.label.language') }}
</label>
<!-- @vue-ignore -->
<select
id="channel-language"
v-model="newValues.metadata.language"
......@@ -289,23 +289,20 @@ defineExpose({
</option>
</select>
</div>
</div>
</div>
<div class="ui small hidden divider" />
<div class="ui field">
<label for="channel-name">
{{ $t('components.audio.ChannelForm.label.description') }}
</label>
<content-form v-model="newValues.description" />
<Textarea
v-model="newValues.description"
:label="t('components.audio.ChannelForm.label.description')"
initial-lines="3"
/>
</div>
<div
v-if="newValues.content_category === 'podcast'"
class="ui two fields"
>
<template v-if="newValues.content_category === 'podcast'">
<div class="ui required field">
<label for="channel-itunes-category">
{{ $t('components.audio.ChannelForm.label.category') }}
{{ t('components.audio.ChannelForm.label.category') }}
</label>
<!-- @vue-ignore -->
<select
id="itunes-category"
v-model="newValues.metadata.itunes_category"
......@@ -324,8 +321,10 @@ defineExpose({
</div>
<div class="ui field">
<label for="channel-itunes-category">
{{ $t('components.audio.ChannelForm.label.subcategory') }}
{{ t('components.audio.ChannelForm.label.subcategory') }}
</label>
<!-- @vue-ignore -->
<select
id="itunes-category"
v-model="newValues.metadata.itunes_subcategory"
......@@ -342,37 +341,35 @@ defineExpose({
</option>
</select>
</div>
</div>
<div
v-if="newValues.content_category === 'podcast'"
class="ui two fields"
>
</template>
<template v-if="newValues.content_category === 'podcast'">
<Alert blue>
<span>
<i class="bi bi-info-circle-fill" />
{{ t('components.audio.ChannelForm.help.podcastFields') }}
</span>
</Alert>
<div class="ui field">
<label for="channel-itunes-email">
{{ $t('components.audio.ChannelForm.label.email') }}
</label>
<input
<!-- @vue-ignore -->
<Input
id="channel-itunes-email"
v-model="newValues.metadata.owner_email"
v-model="newValues.metadata.owner_email as string"
name="channel-itunes-email"
type="email"
>
:label="t('components.audio.ChannelForm.label.email')"
/>
</div>
<div class="ui field">
<label for="channel-itunes-name">
{{ $t('components.audio.ChannelForm.label.owner') }}
</label>
<input
<!-- @vue-ignore -->
<Input
id="channel-itunes-name"
v-model="newValues.metadata.owner_name"
v-model="newValues.metadata.owner_name as string"
name="channel-itunes-name"
maxlength="255"
>
</div>
:label="t('components.audio.ChannelForm.label.owner')"
/>
</div>
<p>
{{ $t('components.audio.ChannelForm.help.podcastFields') }}
</p>
</template>
</template>
</template>
<div
......@@ -380,8 +377,8 @@ defineExpose({
class="ui active inverted dimmer"
>
<div class="ui text loader">
{{ $t('components.audio.ChannelForm.loader.loading') }}
{{ t('components.audio.ChannelForm.loader.loading') }}
</div>
</div>
</form>
</Layout>
</template>