[web] Switch to camel case

This commit is contained in:
Alain Nussbaumer
2025-03-13 23:29:06 +01:00
parent 6c09457e5d
commit 7e8672917e
64 changed files with 663 additions and 747 deletions

View File

@@ -518,9 +518,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.5.0.tgz",
"integrity": "sha512-RoV8Xs9eNwiDvhv7M+xcL4PWyRyIXRY/FLp3buU4h1EYfdF7unWUy3dOjPqb3C7rMUewIcqwW850PgS8h1o1yg==",
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.5.1.tgz",
"integrity": "sha512-soEIOALTfTK6EjmKMMoLugwaP0rzkad90iIWd1hMO9ARkSAyjfMfkRRhLvD5qH7vvM0Cg72pieUfR6yh6XxC4w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1960,12 +1960,9 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz",
"integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==",
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz",
"integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==",
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz",
"integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",

View File

@@ -5,8 +5,8 @@
<component :is="Component" />
</router-view>
<modal-dialog-remote-pairing
:show="pairing_active"
@close="pairing_active = false"
:show="pairingActive"
@close="pairingActive = false"
/>
<modal-dialog-update
:show="showUpdateDialog"
@@ -67,9 +67,9 @@ export default {
},
data() {
return {
pairing_active: false,
pairingActive: false,
reconnect_attempts: 0,
token_timer_id: 0
timerId: 0
}
},
computed: {
@@ -100,10 +100,10 @@ export default {
},
watch: {
showBurgerMenu() {
this.update_is_clipped()
this.updateClipping()
},
showPlayerMenu() {
this.update_is_clipped()
this.updateClipping()
}
},
created() {
@@ -145,7 +145,7 @@ export default {
})
},
openWebsocket() {
const socket = this.create_websocket()
const socket = this.createWebsocket()
const vm = this
socket.onopen = () => {
vm.reconnect_attempts = 0
@@ -165,14 +165,14 @@ export default {
]
})
)
vm.update_outputs()
vm.update_player_status()
vm.update_library_stats()
vm.update_settings()
vm.update_queue()
vm.update_spotify()
vm.update_lastfm()
vm.update_pairing()
vm.updateOutputs()
vm.updatePlayerStatus()
vm.updateLibraryStats()
vm.updateSettings()
vm.updateQueue()
vm.updateSpotify()
vm.updateLastfm()
vm.updatePairing()
}
/*
@@ -181,23 +181,23 @@ export default {
* There are two relevant events - focus and visibilitychange, so we
* throttle the updates to avoid multiple redundant updates.
*/
let update_throttled = false
let updateThrottled = false
const update_info = () => {
if (update_throttled) {
const updateInfo = () => {
if (updateThrottled) {
return
}
vm.update_outputs()
vm.update_player_status()
vm.update_library_stats()
vm.update_settings()
vm.update_queue()
vm.update_spotify()
vm.update_lastfm()
vm.update_pairing()
update_throttled = true
vm.updateOutputs()
vm.updatePlayerStatus()
vm.updateLibraryStats()
vm.updateSettings()
vm.updateQueue()
vm.updateSpotify()
vm.updateLastfm()
vm.updatePairing()
updateThrottled = true
setTimeout(() => {
update_throttled = false
updateThrottled = false
}, 500)
}
@@ -205,10 +205,10 @@ export default {
* These events are fired when the window becomes active in different
* ways. When this happens, we should update 'now playing' info, etc.
*/
window.addEventListener('focus', update_info)
window.addEventListener('focus', updateInfo)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
update_info()
updateInfo()
}
})
@@ -218,33 +218,33 @@ export default {
data.notify.includes('update') ||
data.notify.includes('database')
) {
vm.update_library_stats()
vm.updateLibraryStats()
}
if (
data.notify.includes('player') ||
data.notify.includes('options') ||
data.notify.includes('volume')
) {
vm.update_player_status()
vm.updatePlayerStatus()
}
if (data.notify.includes('outputs') || data.notify.includes('volume')) {
vm.update_outputs()
vm.updateOutputs()
}
if (data.notify.includes('queue')) {
vm.update_queue()
vm.updateQueue()
}
if (data.notify.includes('spotify')) {
vm.update_spotify()
vm.updateSpotify()
}
if (data.notify.includes('lastfm')) {
vm.update_lastfm()
vm.updateLastfm()
}
if (data.notify.includes('pairing')) {
vm.update_pairing()
vm.updatePairing()
}
}
},
create_websocket() {
createWebsocket() {
const protocol = window.location.protocol.replace('http', 'ws')
const hostname =
(import.meta.env.DEV &&
@@ -258,19 +258,19 @@ export default {
reconnectInterval: 1000
})
},
update_is_clipped() {
updateClipping() {
if (this.showBurgerMenu || this.showPlayerMenu) {
document.querySelector('html').classList.add('is-clipped')
} else {
document.querySelector('html').classList.remove('is-clipped')
}
},
update_lastfm() {
updateLastfm() {
webapi.lastfm().then(({ data }) => {
this.servicesStore.lastfm = data
})
},
update_library_stats() {
updateLibraryStats() {
webapi.library_stats().then(({ data }) => {
this.libraryStore.$state = data
})
@@ -278,7 +278,7 @@ export default {
this.libraryStore.rss = data
})
},
update_lyrics() {
updateLyrics() {
const track = this.queueStore.current
if (track?.track_id) {
webapi.library_track(track.track_id).then(({ data }) => {
@@ -288,44 +288,44 @@ export default {
this.lyricsStore.$reset()
}
},
update_outputs() {
updateOutputs() {
webapi.outputs().then(({ data }) => {
this.outputsStore.outputs = data.outputs
})
},
update_pairing() {
updatePairing() {
webapi.pairing().then(({ data }) => {
this.remotesStore.$state = data
this.pairing_active = data.active
this.pairingActive = data.active
})
},
update_player_status() {
updatePlayerStatus() {
webapi.player_status().then(({ data }) => {
this.playerStore.$state = data
this.update_lyrics()
this.updateLyrics()
})
},
update_queue() {
updateQueue() {
webapi.queue().then(({ data }) => {
this.queueStore.$state = data
this.update_lyrics()
this.updateLyrics()
})
},
update_settings() {
updateSettings() {
webapi.settings().then(({ data }) => {
this.settingsStore.$state = data
})
},
update_spotify() {
updateSpotify() {
webapi.spotify().then(({ data }) => {
this.servicesStore.spotify = data
if (this.token_timer_id > 0) {
window.clearTimeout(this.token_timer_id)
this.token_timer_id = 0
if (this.timerId > 0) {
window.clearTimeout(this.timerId)
this.timerId = 0
}
if (data.webapi_token_expires_in > 0 && data.webapi_token) {
this.token_timer_id = window.setTimeout(
this.update_spotify,
this.timerId = window.setTimeout(
this.updateSpotify,
1000 * data.webapi_token_expires_in
)
}

View File

@@ -1,75 +1,48 @@
<template>
<template v-for="item in items" :key="item.itemId">
<div v-if="!item.isItem" class="py-5">
<span
:id="`index_${item.index}`"
class="tag is-small has-text-weight-bold"
v-text="item.index"
/>
</div>
<div
v-else
class="media is-align-items-center is-clickable mb-0"
@click="open(item.item)"
>
<control-image
v-if="settingsStore.show_cover_artwork_in_album_lists"
:url="item.item.artwork_url"
:artist="item.item.artist"
:album="item.item.name"
class="media-left is-small"
/>
<div class="media-content">
<div class="is-size-6 has-text-weight-bold" v-text="item.item.name" />
<div
class="is-size-7 has-text-grey has-text-weight-bold"
v-text="item.item.artist"
/>
<div
v-if="item.item.date_released && item.item.media_kind === 'music'"
class="is-size-7 has-text-grey"
v-text="$filters.toDate(item.item.date_released)"
/>
</div>
<div class="media-right">
<a @click.prevent.stop="openDialog(item.item)">
<mdicon class="icon has-text-grey" name="dots-vertical" size="16" />
</a>
</div>
</div>
</template>
<teleport to="#app">
<modal-dialog-album
:item="selectedItem"
:media_kind="media_kind"
:show="showDetailsModal"
@close="showDetailsModal = false"
@remove-podcast="openRemovePodcastDialog()"
@play-count-changed="onPlayCountChange()"
/>
<modal-dialog
:actions="actions"
:show="showRemovePodcastModal"
:title="$t('page.podcast.remove-podcast')"
@cancel="showRemovePodcastModal = false"
@remove="removePodcast"
>
<template #content>
<i18n-t keypath="list.albums.info" tag="p" scope="global">
<template #separator>
<br />
</template>
<template #name>
<b v-text="rss_playlist_to_remove.name" />
</template>
</i18n-t>
</template>
</modal-dialog>
</teleport>
<list-item
v-for="item in items"
:key="item.itemId"
:is-item="item.isItem"
:image="url(item)"
:index="item.index"
:lines="[
item.item.name,
item.item.artist,
$filters.toDate(item.item.date_released)
]"
@open="open(item.item)"
@open-details="openDetails(item.item)"
/>
<modal-dialog-album
:item="selectedItem"
:media_kind="media_kind"
:show="showDetailsModal"
@close="showDetailsModal = false"
@remove-podcast="openRemovePodcastDialog()"
@play-count-changed="onPlayCountChange()"
/>
<modal-dialog
:actions="actions"
:show="showRemovePodcastModal"
:title="$t('page.podcast.remove-podcast')"
@cancel="showRemovePodcastModal = false"
@remove="removePodcast"
>
<template #content>
<i18n-t keypath="list.albums.info" tag="p" scope="global">
<template #separator>
<br />
</template>
<template #name>
<b v-text="playlistToRemove.name" />
</template>
</i18n-t>
</template>
</modal-dialog>
</template>
<script>
import ControlImage from '@/components/ControlImage.vue'
import ListItem from '@/components/ListItem.vue'
import ModalDialog from '@/components/ModalDialog.vue'
import ModalDialogAlbum from '@/components/ModalDialogAlbum.vue'
import { useSettingsStore } from '@/stores/settings'
@@ -77,7 +50,7 @@ import webapi from '@/webapi'
export default {
name: 'ListAlbums',
components: { ControlImage, ModalDialog, ModalDialogAlbum },
components: { ListItem, ModalDialog, ModalDialogAlbum },
props: {
items: { required: true, type: Object },
media_kind: { default: '', type: String }
@@ -88,7 +61,7 @@ export default {
},
data() {
return {
rss_playlist_to_remove: {},
playlistToRemove: {},
selectedItem: {},
showDetailsModal: false,
showRemovePodcastModal: false
@@ -119,7 +92,7 @@ export default {
this.$router.push({ name: 'music-album', params: { id: item.id } })
}
},
openDialog(item) {
openDetails(item) {
this.selectedItem = item
this.showDetailsModal = true
},
@@ -128,7 +101,7 @@ export default {
.library_album_tracks(this.selectedItem.id, { limit: 1 })
.then(({ data: album }) => {
webapi.library_track_playlists(album.items[0].id).then(({ data }) => {
;[this.rss_playlist_to_remove] = data.items.filter(
;[this.playlistToRemove] = data.items.filter(
(playlist) => playlist.type === 'rss'
)
this.showRemovePodcastModal = true
@@ -141,11 +114,15 @@ export default {
},
removePodcast() {
this.showRemovePodcastModal = false
webapi
.library_playlist_delete(this.rss_playlist_to_remove.id)
.then(() => {
this.$emit('podcast-deleted')
})
webapi.library_playlist_delete(this.playlistToRemove.id).then(() => {
this.$emit('podcast-deleted')
})
},
url(item) {
if (this.settingsStore.show_cover_artwork_in_album_lists) {
return item.item.artwork_url
}
return null
}
}
}

View File

@@ -1,51 +1,33 @@
<template>
<template v-for="item in items" :key="item.id">
<div
class="media is-align-items-center is-clickable mb-0"
@click="open(item)"
>
<control-image
v-if="settingsStore.show_cover_artwork_in_album_lists"
:url="item.images?.[0]?.url ?? ''"
:artist="item.artist"
:album="item.name"
class="media-left is-small"
/>
<div class="media-content">
<div class="is-size-6 has-text-weight-bold" v-text="item.name" />
<div
class="is-size-7 has-text-weight-bold has-text-grey"
v-text="item.artists[0]?.name"
/>
<div
class="is-size-7 has-text-grey"
v-text="$filters.toDate(item.release_date)"
/>
</div>
<div class="media-right">
<a @click.prevent.stop="openDialog(item)">
<mdicon class="icon has-text-grey" name="dots-vertical" size="16" />
</a>
</div>
</div>
</template>
<teleport to="#app">
<modal-dialog-album-spotify
:item="selectedItem"
:show="showDetailsModal"
@close="showDetailsModal = false"
/>
</teleport>
<list-item
v-for="item in items"
:key="item.id"
:is-item="item.isItem"
:image="url(item)"
:index="item.index"
:lines="[
item.name,
item.artists[0]?.name,
$filters.toDate(item.release_date)
]"
@open="open(item)"
@open-details="openDetails(item)"
/>
<modal-dialog-album-spotify
:item="selectedItem"
:show="showDetailsModal"
@close="showDetailsModal = false"
/>
</template>
<script>
import ControlImage from '@/components/ControlImage.vue'
import ListItem from '@/components/ListItem.vue'
import ModalDialogAlbumSpotify from '@/components/ModalDialogAlbumSpotify.vue'
import { useSettingsStore } from '@/stores/settings'
export default {
name: 'ListAlbumsSpotify',
components: { ControlImage, ModalDialogAlbumSpotify },
components: { ListItem, ModalDialogAlbumSpotify },
props: { items: { required: true, type: Object } },
setup() {
return { settingsStore: useSettingsStore() }
@@ -60,9 +42,15 @@ export default {
params: { id: item.id }
})
},
openDialog(item) {
openDetails(item) {
this.selectedItem = item
this.showDetailsModal = true
},
url(item) {
if (this.settingsStore.show_cover_artwork_in_album_lists) {
return item.images?.[0]?.url ?? ''
}
return null
}
}
}

View File

@@ -1,48 +1,31 @@
<template>
<template v-for="item in items" :key="item.itemId">
<div v-if="!item.isItem" class="py-5">
<span
:id="`index_${item.index}`"
class="tag is-small has-text-weight-bold"
v-text="item.index"
/>
</div>
<div
v-else
class="media is-align-items-center is-clickable mb-0"
@click="open(item.item)"
>
<div class="media-content">
<p class="is-size-6 has-text-weight-bold" v-text="item.item.name" />
</div>
<div class="media-right">
<a @click.prevent.stop="openDialog(item.item)">
<mdicon class="icon has-text-grey" name="dots-vertical" size="16" />
</a>
</div>
</div>
</template>
<teleport to="#app">
<modal-dialog-artist
:item="selectedItem"
:show="showDetailsModal"
@close="showDetailsModal = false"
/>
</teleport>
<list-item
v-for="item in items"
:key="item.itemId"
:is-item="item.isItem"
:index="item.index"
:lines="[item.item.name]"
@open="open(item.item)"
@open-details="openDetails(item.item)"
/>
<modal-dialog-artist
:item="selectedItem"
:show="showDetailsModal"
@close="showDetailsModal = false"
/>
</template>
<script>
import ListItem from '@/components/ListItem.vue'
import ModalDialogArtist from '@/components/ModalDialogArtist.vue'
export default {
name: 'ListArtists',
components: { ModalDialogArtist },
components: { ListItem, ModalDialogArtist },
props: { items: { required: true, type: Object } },
data() {
return { selectedItem: {}, showDetailsModal: false }
},
methods: {
open(item) {
this.selectedItem = item
@@ -50,7 +33,7 @@ export default {
item.media_kind === 'audiobook' ? 'audiobooks-artist' : 'music-artist'
this.$router.push({ name: route, params: { id: item.id } })
},
openDialog(item) {
openDetails(item) {
this.selectedItem = item
this.showDetailsModal = true
}

View File

@@ -1,31 +1,27 @@
<template>
<template v-for="item in items" :key="item.id">
<div class="media is-align-items-center is-clickable mb-0">
<div class="media-content" @click="open(item)">
<p class="is-size-6 has-text-weight-bold" v-text="item.name" />
</div>
<div class="media-right">
<a @click.prevent.stop="openDialog(item)">
<mdicon class="icon has-text-grey" name="dots-vertical" size="16" />
</a>
</div>
</div>
</template>
<teleport to="#app">
<modal-dialog-artist-spotify
:item="selectedItem"
:show="showDetailsModal"
@close="showDetailsModal = false"
/>
</teleport>
<list-item
v-for="item in items"
:key="item.id"
:is-item="true"
:index="item.index"
:lines="[item.name]"
@open="open(item)"
@open-details="openDetails(item)"
/>
<modal-dialog-artist-spotify
:item="selectedItem"
:show="showDetailsModal"
@close="showDetailsModal = false"
/>
</template>
<script>
import ListItem from '@/components/ListItem.vue'
import ModalDialogArtistSpotify from '@/components/ModalDialogArtistSpotify.vue'
export default {
name: 'ListArtistsSpotify',
components: { ModalDialogArtistSpotify },
components: { ListItem, ModalDialogArtistSpotify },
props: { items: { required: true, type: Object } },
data() {
@@ -38,7 +34,7 @@ export default {
params: { id: item.id }
})
},
openDialog(item) {
openDetails(item) {
this.selectedItem = item
this.showDetailsModal = true
}

View File

@@ -1,50 +1,31 @@
<template>
<template v-for="item in items" :key="item.itemId">
<div v-if="!item.isItem" class="py-5">
<div class="media-content">
<span
:id="`index_${item.index}`"
class="tag is-small has-text-weight-bold"
v-text="item.index"
/>
</div>
</div>
<div
v-else
class="media is-align-items-center is-clickable mb-0"
@click="open(item.item)"
>
<div class="media-content">
<p class="is-size-6 has-text-weight-bold" v-text="item.item.name" />
</div>
<div class="media-right">
<a @click.prevent.stop="openDialog(item.item)">
<mdicon class="icon has-text-grey" name="dots-vertical" size="16" />
</a>
</div>
</div>
</template>
<teleport to="#app">
<modal-dialog-composer
:item="selectedItem"
:show="showDetailsModal"
@close="showDetailsModal = false"
/>
</teleport>
<list-item
v-for="item in items"
:key="item.itemId"
:is-item="item.isItem"
:index="item.index"
:lines="[item.item.name]"
@open="open(item.item)"
@open-details="openDetails(item.item)"
/>
<modal-dialog-composer
:item="selectedItem"
:show="showDetailsModal"
@close="showDetailsModal = false"
/>
</template>
<script>
import ListItem from '@/components/ListItem.vue'
import ModalDialogComposer from '@/components/ModalDialogComposer.vue'
export default {
name: 'ListComposers',
components: { ModalDialogComposer },
components: { ListItem, ModalDialogComposer },
props: { items: { required: true, type: Object } },
data() {
return { selectedItem: {}, showDetailsModal: false }
},
methods: {
open(item) {
this.selectedItem = item
@@ -53,8 +34,7 @@ export default {
params: { name: item.name }
})
},
openDialog(item) {
openDetails(item) {
this.selectedItem = item
this.showDetailsModal = true
}

View File

@@ -27,22 +27,20 @@
>
<mdicon class="media-left icon" name="folder" />
<div class="media-content">
<p class="is-size-6 has-text-weight-bold" v-text="item.name" />
<div class="is-size-6 has-text-weight-bold" v-text="item.name" />
</div>
<div class="media-right">
<a @click.prevent.stop="openDialog(item)">
<a @click.prevent.stop="openDetails(item)">
<mdicon class="icon has-text-grey" name="dots-vertical" size="16" />
</a>
</div>
</div>
</template>
<teleport to="#app">
<modal-dialog-directory
:item="selectedItem"
:show="showDetailsModal"
@close="showDetailsModal = false"
/>
</teleport>
<modal-dialog-directory
:item="selectedItem"
:show="showDetailsModal"
@close="showDetailsModal = false"
/>
</template>
<script>
@@ -80,7 +78,7 @@ export default {
}
this.$router.push(route)
},
openDialog(item) {
openDetails(item) {
this.selectedItem = item.path
this.showDetailsModal = true
},

View File

@@ -1,45 +1,28 @@
<template>
<template v-for="item in items" :key="item.itemId">
<div v-if="!item.isItem" class="py-5">
<div class="media-content">
<span
:id="`index_${item.index}`"
class="tag is-small has-text-weight-bold"
v-text="item.index"
/>
</div>
</div>
<div
v-else
class="media is-align-items-center is-clickable mb-0"
@click="open(item.item)"
>
<div class="media-content">
<p class="is-size-6 has-text-weight-bold" v-text="item.item.name" />
</div>
<div class="media-right">
<a @click.prevent.stop="openDialog(item.item)">
<mdicon class="icon has-text-grey" name="dots-vertical" size="16" />
</a>
</div>
</div>
</template>
<teleport to="#app">
<modal-dialog-genre
:item="selectedItem"
:media_kind="media_kind"
:show="showDetailsModal"
@close="showDetailsModal = false"
/>
</teleport>
<list-item
v-for="item in items"
:key="item.itemId"
:is-item="item.isItem"
:index="item.index"
:lines="[item.item.name]"
@open="open(item.item)"
@open-details="openDetails(item.item)"
/>
<modal-dialog-genre
:item="selectedItem"
:media_kind="media_kind"
:show="showDetailsModal"
@close="showDetailsModal = false"
/>
</template>
<script>
import ListItem from '@/components/ListItem.vue'
import ModalDialogGenre from '@/components/ModalDialogGenre.vue'
export default {
name: 'ListGenres',
components: { ModalDialogGenre },
components: { ListItem, ModalDialogGenre },
props: {
items: { required: true, type: Object },
media_kind: { required: true, type: String }
@@ -55,7 +38,7 @@ export default {
query: { media_kind: this.media_kind }
})
},
openDialog(item) {
openDetails(item) {
this.selectedItem = item
this.showDetailsModal = true
}

View File

@@ -0,0 +1,80 @@
<template>
<div v-if="!isItem" class="py-5">
<div class="media-content">
<span
:id="`index_${index}`"
class="tag is-small has-text-weight-bold"
v-text="index"
/>
</div>
</div>
<div
v-else
class="media is-align-items-center is-clickable mb-0"
:class="{ 'with-progress': progress }"
@click="open"
>
<mdicon v-if="icon" class="media-left icon" :name="icon" />
<control-image v-if="image" :url="image" class="media-left is-small" />
<div class="media-content">
<div
v-for="(line, position) in lines"
:key="position"
:class="{
'is-size-6': position === 0,
'is-size-7': position !== 0,
'has-text-weight-bold': position !== 2,
'has-text-grey': position !== 0
}"
v-text="line"
/>
<progress
v-if="progress"
class="progress is-dark"
max="1"
:value="progress"
/>
</div>
<div class="media-right">
<a @click.prevent.stop="openDetails">
<mdicon class="icon has-text-grey" name="dots-vertical" size="16" />
</a>
</div>
</div>
</template>
<script>
import ControlImage from '@/components/ControlImage.vue'
export default {
name: 'ListItem',
components: { ControlImage },
props: {
icon: { default: null, type: String },
image: { default: null, type: String },
index: { default: null, type: [String, Number] },
isItem: { default: true, type: Boolean },
lines: { default: null, type: Array },
progress: { default: null, type: Number }
},
emits: ['open', 'openDetails'],
methods: {
open() {
this.$emit('open')
},
openDetails() {
this.$emit('openDetails')
}
}
}
</script>
<style scoped>
.progress {
height: 0.25rem;
}
.media.with-progress {
margin-top: 0.375rem;
}
</style>

View File

@@ -52,7 +52,7 @@ import webapi from '@/webapi'
export default {
name: 'ListItemQueueItem',
props: {
current_position: { required: true, type: Number },
currentPosition: { required: true, type: Number },
editing: Boolean,
item: { required: true, type: Object },
position: { required: true, type: Number },
@@ -66,7 +66,7 @@ export default {
return this.item.id === this.playerStore.item_id
},
isNext() {
return this.current_position < 0 || this.position >= this.current_position
return this.currentPosition < 0 || this.position >= this.currentPosition
}
},
methods: {

View File

@@ -1,41 +1,32 @@
<template>
<template v-for="item in items" :key="item.itemId">
<div
class="media is-align-items-center is-clickable mb-0"
@click="open(item.item)"
>
<mdicon class="media-left icon" :name="icon(item.item)" />
<div class="media-content">
<p class="is-size-6 has-text-weight-bold" v-text="item.item.name" />
</div>
<div class="media-right">
<a @click.prevent.stop="openDialog(item.item)">
<mdicon class="icon has-text-grey" name="dots-vertical" size="16" />
</a>
</div>
</div>
</template>
<teleport to="#app">
<modal-dialog-playlist
:item="selectedItem"
:show="showDetailsModal"
@close="showDetailsModal = false"
/>
</teleport>
<list-item
v-for="item in items"
:key="item.itemId"
:icon="icon(item.item)"
:is-item="item.isItem"
:index="item.index"
:lines="[item.item.name]"
@open="open(item.item)"
@open-details="openDetails(item.item)"
/>
<modal-dialog-playlist
:item="selectedItem"
:show="showDetailsModal"
@close="showDetailsModal = false"
/>
</template>
<script>
import ListItem from '@/components/ListItem.vue'
import ModalDialogPlaylist from '@/components/ModalDialogPlaylist.vue'
export default {
name: 'ListPlaylists',
components: { ModalDialogPlaylist },
components: { ListItem, ModalDialogPlaylist },
props: { items: { required: true, type: Object } },
data() {
return { selectedItem: {}, showDetailsModal: false }
},
methods: {
icon(item) {
if (item.type === 'folder') {
@@ -52,7 +43,7 @@ export default {
this.$router.push({ name: 'playlist', params: { id: item.id } })
}
},
openDialog(item) {
openDetails(item) {
this.selectedItem = item
this.showDetailsModal = true
}

View File

@@ -1,40 +1,27 @@
<template>
<template v-for="item in items" :key="item.id">
<div
class="media is-align-items-center is-clickable mb-0"
@click="open(item)"
>
<div class="media-content">
<div class="is-size-6 has-text-weight-bold" v-text="item.name" />
<div
class="is-size-7 has-text-weight-bold has-text-grey"
v-text="item.owner.display_name"
/>
</div>
<div class="media-right">
<a @click.prevent.stop="openDialog(item)">
<mdicon class="icon has-text-grey" name="dots-vertical" size="16" />
</a>
</div>
</div>
</template>
<teleport to="#app">
<modal-dialog-playlist-spotify
:item="selectedItem"
:show="showDetailsModal"
@close="showDetailsModal = false"
/>
</teleport>
<list-item
v-for="item in items"
:key="item.id"
:is-item="item.isItem"
:index="item.index"
:lines="[item.name, item.owner.display_name]"
@open="open(item)"
@open-details="openDetails(item)"
/>
<modal-dialog-playlist-spotify
:item="selectedItem"
:show="showDetailsModal"
@close="showDetailsModal = false"
/>
</template>
<script>
import ListItem from '@/components/ListItem.vue'
import ModalDialogPlaylistSpotify from '@/components/ModalDialogPlaylistSpotify.vue'
export default {
name: 'ListPlaylistsSpotify',
components: {
ModalDialogPlaylistSpotify
},
components: { ListItem, ModalDialogPlaylistSpotify },
props: { items: { required: true, type: Object } },
data() {
@@ -45,7 +32,7 @@ export default {
open(item) {
this.$router.push({ name: 'playlist-spotify', params: { id: item.id } })
},
openDialog(item) {
openDetails(item) {
this.selectedItem = item
this.showDetailsModal = true
}

View File

@@ -1,73 +1,36 @@
<template>
<template v-for="item in items" :key="item.itemId">
<div v-if="!item.isItem" class="py-5">
<span
:id="`index_${item.index}`"
class="tag is-small has-text-weight-bold"
v-text="item.index"
/>
</div>
<div
v-else
class="media is-align-items-center is-clickable mb-0"
:class="{ 'with-progress': showProgress }"
@click="play(item.item)"
>
<mdicon
v-if="showIcon"
class="media-left icon"
name="file-music-outline"
/>
<div class="media-content">
<div
class="is-size-6 has-text-weight-bold"
:class="{
'has-text-grey':
item.item.media_kind === 'podcast' && item.item.play_count > 0
}"
v-text="item.item.title"
/>
<div
class="is-size-7 has-text-weight-bold has-text-grey"
v-text="item.item.artist"
/>
<div class="is-size-7 has-text-grey" v-text="item.item.album" />
<progress
v-if="showProgress && item.item.seek_ms > 0"
class="progress is-dark"
:max="item.item.length_ms"
:value="item.item.seek_ms"
/>
</div>
<div class="media-right">
<a @click.prevent.stop="openDialog(item.item)">
<mdicon class="icon has-text-grey" name="dots-vertical" size="16" />
</a>
</div>
</div>
</template>
<teleport to="#app">
<modal-dialog-track
:item="selectedItem"
:show="showDetailsModal"
@close="showDetailsModal = false"
@play-count-changed="$emit('play-count-changed')"
/>
</teleport>
<list-item
v-for="item in items"
:key="item.itemId"
:icon="icon"
:is-item="item.isItem"
:index="item.index"
:lines="[item.item.title, item.item.artist, item.item.album]"
:progress="progress(item.item)"
@open="open(item.item)"
@open-details="openDetails(item.item)"
/>
<modal-dialog-track
:item="selectedItem"
:show="showDetailsModal"
@close="showDetailsModal = false"
@play-count-changed="$emit('play-count-changed')"
/>
</template>
<script>
import ListItem from '@/components/ListItem.vue'
import ModalDialogTrack from '@/components/ModalDialogTrack.vue'
import webapi from '@/webapi'
export default {
name: 'ListTracks',
components: { ModalDialogTrack },
components: { ListItem, ModalDialogTrack },
props: {
expression: { default: '', type: String },
items: { required: true, type: Object },
showIcon: Boolean,
showProgress: Boolean,
items: { default: null, type: Object },
icon: { default: null, type: String },
showProgress: { default: false, type: Boolean },
uris: { default: '', type: String }
},
emits: ['play-count-changed'],
@@ -75,11 +38,7 @@ export default {
return { selectedItem: {}, showDetailsModal: false }
},
methods: {
openDialog(item) {
this.selectedItem = item
this.showDetailsModal = true
},
play(item) {
open(item) {
if (this.uris) {
webapi.player_play_uri(this.uris, false, this.items.items.indexOf(item))
} else if (this.expression) {
@@ -91,17 +50,20 @@ export default {
} else {
webapi.player_play_uri(item.uri, false)
}
},
openDetails(item) {
this.selectedItem = item
this.showDetailsModal = true
},
// 'has-text-grey': item.item.media_kind === 'podcast' && item.item.play_count > 0
progress(item) {
if (item.item) {
if (this.showProgress && item.item.seek_ms > 0) {
return item.item.seek_ms / item.item.length_ms
}
}
return null
}
}
}
</script>
<style scoped>
.progress {
height: 0.25rem;
}
.media.with-progress {
margin-top: 0.375rem;
}
</style>

View File

@@ -36,19 +36,17 @@
</div>
</div>
<div class="media-right">
<a @click.prevent.stop="openDialog(item)">
<a @click.prevent.stop="openDetails(item)">
<mdicon class="icon has-text-grey" name="dots-vertical" size="16" />
</a>
</div>
</div>
</template>
<teleport to="#app">
<modal-dialog-track-spotify
:item="selectedItem"
:show="showDetailsModal"
@close="showDetailsModal = false"
/>
</teleport>
<modal-dialog-track-spotify
:item="selectedItem"
:show="showDetailsModal"
@close="showDetailsModal = false"
/>
</template>
<script>
@@ -66,7 +64,7 @@ export default {
return { selectedItem: {}, showDetailsModal: false }
},
methods: {
openDialog(item) {
openDetails(item) {
this.selectedItem = item
this.showDetailsModal = true
},

View File

@@ -4,12 +4,12 @@
class="lyrics"
@touchstart="autoScrolling = false"
@touchend="autoScrolling = true"
@scroll.passive="start_scrolling"
@wheel.passive="start_scrolling"
@scroll.passive="startScrolling"
@wheel.passive="startScrolling"
>
<template v-for="(verse, index) in lyrics" :key="index">
<div
v-if="index === verse_index"
v-if="index === verseIndex"
:class="{ 'is-highlighted': playerStore.isPlaying }"
>
<span
@@ -85,7 +85,7 @@ export default {
}
return parsed
},
verse_index() {
verseIndex() {
if (this.lyrics.length && this.lyrics[0].time) {
const currentTime = this.playerStore.item_progress_ms / 1000,
la = this.lyrics,
@@ -96,7 +96,7 @@ export default {
la[this.lastIndex].time > currentTime
// Reset the cache when the track has changed or has been seeked
if (trackChanged || trackSeeked) {
this.reset_scrolling()
this.resetScrolling()
}
// Check the next two items and the last one before searching
if (
@@ -134,20 +134,20 @@ export default {
}
return index
}
this.reset_scrolling()
this.resetScrolling()
return -1
}
},
watch: {
verse_index() {
verseIndex() {
if (this.autoScrolling) {
this.scroll_to_verse()
this.scrollToVerse()
}
this.lastIndex = this.verse_index
this.lastIndex = this.verseIndex
}
},
methods: {
reset_scrolling() {
resetScrolling() {
// Scroll to the start of the lyrics in all cases
if (this.playerStore.item_id !== this.lastItemId && this.$refs.lyrics) {
this.$refs.lyrics.scrollTo(0, 0)
@@ -155,13 +155,13 @@ export default {
this.lastItemId = this.playerStore.item_id
this.lastIndex = -1
},
scroll_to_verse() {
scrollToVerse() {
const pane = this.$refs.lyrics
if (this.verse_index === -1) {
if (this.verseIndex === -1) {
pane.scrollTo(0, 0)
return
}
const currentVerse = pane.children[this.verse_index]
const currentVerse = pane.children[this.verseIndex]
pane.scrollBy({
behavior: 'smooth',
left: 0,
@@ -172,7 +172,7 @@ export default {
pane.scrollTop
})
},
start_scrolling(event) {
startScrolling(event) {
// Consider only user events
if (event.screenX ?? event.screenY) {
this.autoScrolling = false

View File

@@ -11,14 +11,14 @@
<p class="control has-icons-left">
<input
ref="playlist_name_field"
v-model="playlist_name"
v-model="playlistName"
class="input"
type="text"
pattern=".+"
required
:placeholder="$t('dialog.playlist.save.playlist-name')"
:disabled="loading"
@input="check_name"
@input="check"
/>
<mdicon class="icon is-left" name="playlist-music" size="16" />
</p>
@@ -41,7 +41,7 @@ export default {
return {
disabled: true,
loading: false,
playlist_name: ''
playlistName: ''
}
},
computed: {
@@ -75,17 +75,17 @@ export default {
cancel() {
this.$emit('close')
},
check_name(event) {
check(event) {
const { validity } = event.target
this.disabled = validity.patternMismatch || validity.valueMissing
},
save() {
this.loading = true
webapi
.queue_save_playlist(this.playlist_name)
.queue_save_playlist(this.playlistName)
.then(() => {
this.$emit('close')
this.playlist_name = ''
this.playlistName = ''
})
.catch(() => {
this.loading = false

View File

@@ -23,7 +23,7 @@ export default {
},
data() {
return {
spotify_track: {}
spotifyTrack: {}
}
},
computed: {
@@ -89,10 +89,10 @@ export default {
spotifyApi
.getTrack(this.item.path.slice(this.item.path.lastIndexOf(':') + 1))
.then((response) => {
this.spotify_track = response
this.spotifyTrack = response
})
} else {
this.spotify_track = {}
this.spotifyTrack = {}
}
}
},
@@ -102,7 +102,7 @@ export default {
if (this.item.data_kind === 'spotify') {
this.$router.push({
name: 'music-spotify-album',
params: { id: this.spotify_track.album.id }
params: { id: this.spotifyTrack.album.id }
})
} else if (this.item.media_kind === 'podcast') {
this.$router.push({
@@ -126,7 +126,7 @@ export default {
if (this.item.data_kind === 'spotify') {
this.$router.push({
name: 'music-spotify-artist',
params: { id: this.spotify_track.artists[0].id }
params: { id: this.spotifyTrack.artists[0].id }
})
} else if (
this.item.media_kind === 'music' ||

View File

@@ -31,7 +31,7 @@
</div>
</div>
</div>
<control-switch v-model="rescan_metadata">
<control-switch v-model="rescanMetadata">
<template #label>
<span v-text="$t('dialog.update.rescan-metadata')" />
</template>
@@ -64,7 +64,7 @@ export default {
},
data() {
return {
rescan_metadata: false
rescanMetadata: false
}
},
computed: {
@@ -87,7 +87,7 @@ export default {
},
methods: {
analyse() {
if (this.rescan_metadata) {
if (this.rescanMetadata) {
webapi.library_rescan(this.libraryStore.update_dialog_scan_kind)
} else {
webapi.library_update(this.libraryStore.update_dialog_scan_kind)

View File

@@ -107,6 +107,12 @@ export default {
show: true,
sub: true
},
{
key: 'navigation.composers',
name: 'music-composers',
show: true,
sub: true
},
{
key: 'navigation.spotify',
name: 'music-spotify',

View File

@@ -1,14 +1,17 @@
<template>
<section v-if="notifications.length > 0" class="notifications">
<section v-if="!notificationsStore.isEmpty" class="notifications">
<div class="columns is-centered">
<div class="column is-half">
<div
v-for="notification in notifications"
v-for="notification in notificationsStore.list"
:key="notification.id"
class="notification"
:class="notification.type ? `is-${notification.type}` : ''"
>
<button class="delete" @click="remove(notification)" />
<button
class="delete"
@click="notificationsStore.remove(notification)"
/>
<div class="text" v-text="notification.text" />
</div>
</div>
@@ -21,21 +24,8 @@ import { useNotificationsStore } from '@/stores/notifications'
export default {
name: 'NotificationList',
setup() {
return { notificationsStore: useNotificationsStore() }
},
computed: {
notifications() {
return this.notificationsStore.list
}
},
methods: {
remove(notification) {
this.notificationsStore.remove(notification)
}
}
}
</script>

View File

@@ -20,7 +20,8 @@
"save": "Speichern",
"send": "Senden",
"show-more": "Zeige mehr",
"shuffle": "Zufallswiedergabe"
"shuffle": "Zufallswiedergabe",
"update": "Neu einlesen"
},
"count": {
"albums": "{count} Album|{count} Album|{count} Alben",
@@ -116,6 +117,7 @@
"albums": "Alben",
"artists": "Künstler",
"audiobooks": "Hörbücher",
"composers": "Komponisten",
"now-playing": " - {album}",
"stream-error": "HTTP-stream-Fehler: Stream kann nicht geladen werden oder wurde wg. Netzwerkfehler gestopt",
"stream": "HTTP-stream",
@@ -216,8 +218,7 @@
},
"podcasts": {
"new-episodes": "Neue Episoden",
"title": "Podcasts",
"update": "Neu einlesen"
"title": "Podcasts"
},
"queue": {
"title": "Warteschlange"

View File

@@ -20,7 +20,8 @@
"save": "Save",
"send": "Send",
"show-more": "Show more",
"shuffle": "Shuffle"
"shuffle": "Shuffle",
"update": "Update"
},
"count": {
"albums": "{count} album|{count} album|{count} albums",
@@ -116,6 +117,7 @@
"albums": "Albums",
"artists": "Artists",
"audiobooks": "Audiobooks",
"composers": "Composers",
"now-playing": " - {album}",
"stream-error": "HTTP stream error: failed to load stream or stopped loading due to network problem",
"stream": "HTTP stream",
@@ -216,8 +218,7 @@
},
"podcasts": {
"new-episodes": "New Episodes",
"title": "Podcasts",
"update": "Update"
"title": "Podcasts"
},
"queue": {
"title": "Queue"

View File

@@ -20,7 +20,8 @@
"save": "Enregistrer",
"send": "Envoyer",
"show-more": "Afficher plus",
"shuffle": "Lecture aléatoire"
"shuffle": "Lecture aléatoire",
"update": "Actualiser"
},
"count": {
"albums": "{count} album|{count} album|{count} albums",
@@ -116,6 +117,7 @@
"albums": "Albums",
"artists": "Artistes",
"audiobooks": "Livres audio",
"composers": "Compositeurs",
"now-playing": " - {album}",
"stream-error": "Erreur du flux HTTP : échec du chargement du flux ou arrêt du chargement en raison dun problème réseau",
"stream": "Flux HTTP",
@@ -216,8 +218,7 @@
},
"podcasts": {
"new-episodes": "Nouveaux épisodes",
"title": "Podcasts",
"update": "Actualiser"
"title": "Podcasts"
},
"queue": {
"title": "File dattente"

View File

@@ -20,7 +20,8 @@
"save": "保存",
"send": "发送",
"show-more": "显示更多",
"shuffle": "随机播放"
"shuffle": "随机播放",
"update": "更新"
},
"count": {
"albums": "{count} 张专辑|{count} 张专辑",
@@ -116,6 +117,7 @@
"albums": "专辑",
"artists": "艺人",
"audiobooks": "有声读物",
"composers": "作曲家",
"now-playing": " - {album}",
"stream-error": "HTTP流错误流载入失败或者由于网络原因无法载入",
"stream": "HTTP流",
@@ -216,8 +218,7 @@
},
"podcasts": {
"new-episodes": "最新单集",
"title": "播客",
"update": "更新"
"title": "播客"
},
"queue": {
"title": "清单"

View File

@@ -20,7 +20,8 @@
"save": "儲存",
"send": "發送",
"show-more": "顯示更多",
"shuffle": "隨機播放"
"shuffle": "隨機播放",
"update": "更新"
},
"count": {
"albums": "{count} 張專輯|{count} 張專輯",
@@ -116,6 +117,7 @@
"albums": "專輯",
"artists": "藝人",
"audiobooks": "有聲書",
"composers": "作曲家",
"now-playing": " - {album}",
"stream-error": "HTTP串流錯誤串流載入失敗或者由於網絡原因無法載入",
"stream": "HTTP串流",
@@ -216,8 +218,7 @@
},
"podcasts": {
"new-episodes": "最新單集",
"title": "Podcast",
"update": "更新"
"title": "Podcast"
},
"queue": {
"title": "清單"

View File

@@ -34,8 +34,6 @@
/>
</div>
</div>
</template>
<template #footer>
<div
class="is-size-7 mt-6"
v-text="

View File

@@ -15,7 +15,7 @@
:button="{ handler: play, icon: 'shuffle', key: 'actions.shuffle' }"
/>
<control-button
:button="{ handler: showDetails, icon: 'dots-horizontal' }"
:button="{ handler: openDetails, icon: 'dots-horizontal' }"
/>
</div>
</template>
@@ -25,7 +25,7 @@
:artist="album.artist"
:album="album.name"
class="is-clickable is-medium"
@click="showDetails"
@click="openDetails"
/>
</template>
<template #content>
@@ -98,11 +98,11 @@ export default {
params: { id: this.album.artist_id }
})
},
openDetails() {
this.showDetailsModal = true
},
play() {
webapi.player_play_uri(this.album.uri, true)
},
showDetails() {
this.showDetailsModal = true
}
}
}

View File

@@ -15,7 +15,7 @@
:button="{ handler: play, icon: 'shuffle', key: 'actions.shuffle' }"
/>
<control-button
:button="{ handler: showDetails, icon: 'dots-horizontal' }"
:button="{ handler: openDetails, icon: 'dots-horizontal' }"
/>
</div>
</template>
@@ -25,7 +25,7 @@
:artist="album.artists[0].name"
:album="album.name"
class="is-clickable is-medium"
@click="showDetails"
@click="openDetails"
/>
</template>
<template #content>
@@ -102,12 +102,12 @@ export default {
params: { id: this.album.artists[0].id }
})
},
openDetails() {
this.showDetailsModal = true
},
play() {
this.showDetailsModal = false
webapi.player_play_uri(this.album.uri, true)
},
showDetails() {
this.showDetailsModal = true
}
}
}

View File

@@ -36,7 +36,7 @@
v-text="$t('options.sort.title')"
/>
<control-dropdown
v-model:value="uiStore.albums_sort"
v-model:value="uiStore.albumsSort"
:options="groupings"
/>
</div>
@@ -101,7 +101,7 @@ export default {
computed: {
albums() {
const { options } = this.groupings.find(
(grouping) => grouping.id === this.uiStore.albums_sort
(grouping) => grouping.id === this.uiStore.albumsSort
)
options.filters = [
(album) => !this.uiStore.hideSingles || album.track_count > 2,

View File

@@ -26,7 +26,7 @@
v-text="$t('page.artist.sort.title')"
/>
<control-dropdown
v-model:value="uiStore.artist_albums_sort"
v-model:value="uiStore.artistAlbumsSort"
:options="groupings"
/>
</div>
@@ -37,7 +37,7 @@
</template>
<template #heading-right>
<control-button
:button="{ handler: showDetails, icon: 'dots-horizontal' }"
:button="{ handler: openDetails, icon: 'dots-horizontal' }"
/>
<control-button
:button="{ handler: play, icon: 'shuffle', key: 'actions.shuffle' }"
@@ -110,7 +110,7 @@ export default {
computed: {
albums() {
const { options } = this.groupings.find(
(grouping) => grouping.id === this.uiStore.artist_albums_sort
(grouping) => grouping.id === this.uiStore.artistAlbumsSort
)
options.filters = [
(album) => !this.uiStore.hideSpotify || album.data_kind !== 'spotify'
@@ -153,6 +153,9 @@ export default {
}
},
methods: {
openDetails() {
this.showDetailsModal = true
},
openTracks() {
this.$router.push({
name: 'music-artist-tracks',
@@ -164,9 +167,6 @@ export default {
this.albums.items.map((item) => item.uri).join(),
true
)
},
showDetails() {
this.showDetailsModal = true
}
}
}

View File

@@ -6,7 +6,7 @@
</template>
<template #heading-right>
<control-button
:button="{ handler: showDetails, icon: 'dots-horizontal' }"
:button="{ handler: openDetails, icon: 'dots-horizontal' }"
/>
<control-button
:button="{ handler: play, icon: 'shuffle', key: 'actions.shuffle' }"
@@ -106,7 +106,7 @@ export default {
heading() {
return {
subtitle: [{ count: this.total, key: 'count.albums' }],
title: this.$t('artist.name')
title: this.artist.name
}
}
},
@@ -130,12 +130,12 @@ export default {
loaded(data.items.length, PAGE_SIZE)
})
},
openDetails() {
this.showDetailsModal = true
},
play() {
this.showDetailsModal = false
webapi.player_play_uri(this.artist.uri, true)
},
showDetails() {
this.showDetailsModal = true
}
}
}

View File

@@ -27,7 +27,7 @@
v-text="$t('options.sort.title')"
/>
<control-dropdown
v-model:value="uiStore.artist_tracks_sort"
v-model:value="uiStore.artistTracksSort"
:options="groupings"
/>
</div>
@@ -38,14 +38,14 @@
</template>
<template #heading-right>
<control-button
:button="{ handler: showDetails, icon: 'dots-horizontal' }"
:button="{ handler: openDetails, icon: 'dots-horizontal' }"
/>
<control-button
:button="{ handler: play, icon: 'shuffle', key: 'actions.shuffle' }"
/>
</template>
<template #content>
<list-tracks :items="tracks" :uris="track_uris" />
<list-tracks :items="tracks" :uris="trackUris" />
<modal-dialog-artist
:item="artist"
:show="showDetailsModal"
@@ -111,7 +111,7 @@ export default {
}
},
computed: {
album_count() {
albumCount() {
return new Set(
[...this.tracks]
.filter((track) => track.isItem)
@@ -139,7 +139,7 @@ export default {
return {
subtitle: [
{
count: this.album_count,
count: this.albumCount,
handler: this.openArtist,
key: 'count.albums'
},
@@ -148,12 +148,12 @@ export default {
title: this.artist.name
}
},
track_uris() {
trackUris() {
return this.trackList.items.map((item) => item.uri).join()
},
tracks() {
const { options } = this.groupings.find(
(grouping) => grouping.id === this.uiStore.artist_tracks_sort
(grouping) => grouping.id === this.uiStore.artistTracksSort
)
options.filters = [
(track) => !this.uiStore.hideSpotify || track.data_kind !== 'spotify'
@@ -169,14 +169,14 @@ export default {
params: { id: this.artist.id }
})
},
openDetails() {
this.showDetailsModal = true
},
play() {
webapi.player_play_uri(
this.trackList.items.map((item) => item.uri).join(),
true
)
},
showDetails() {
this.showDetailsModal = true
}
}
}

View File

@@ -35,7 +35,7 @@
v-text="$t('options.sort.title')"
/>
<control-dropdown
v-model:value="uiStore.artists_sort"
v-model:value="uiStore.artistsSort"
:options="groupings"
/>
</div>
@@ -100,7 +100,7 @@ export default {
computed: {
artists() {
const { options } = this.groupings.find(
(grouping) => grouping.id === this.uiStore.artists_sort
(grouping) => grouping.id === this.uiStore.artistsSort
)
options.filters = [
(artist) =>

View File

@@ -19,7 +19,7 @@
}"
/>
<control-button
:button="{ handler: showDetails, icon: 'dots-horizontal' }"
:button="{ handler: openDetails, icon: 'dots-horizontal' }"
/>
</div>
</template>
@@ -29,7 +29,7 @@
:artist="album.artist"
:album="album.name"
class="is-clickable is-medium"
@click="showDetails"
@click="openDetails"
/>
</template>
<template #content>
@@ -96,11 +96,11 @@ export default {
params: { id: this.album.artist_id }
})
},
openDetails() {
this.showDetailsModal = true
},
play() {
webapi.player_play_uri(this.album.uri, false)
},
showDetails() {
this.showDetailsModal = true
}
}
}

View File

@@ -6,14 +6,10 @@
</template>
<template #heading-right>
<control-button
:button="{ handler: showDetails, icon: 'dots-horizontal' }"
:button="{ handler: openDetails, icon: 'dots-horizontal' }"
/>
<control-button
:button="{
handler: play,
icon: 'play',
key: 'page.audiobooks.artist.play'
}"
:button="{ handler: play, icon: 'play', key: 'actions.play' }"
/>
</template>
<template #content>
@@ -85,14 +81,14 @@ export default {
}
},
methods: {
openDetails() {
this.showDetailsModal = true
},
play() {
webapi.player_play_uri(
this.albums.items.map((item) => item.uri).join(),
false
)
},
showDetails() {
this.showDetailsModal = true
}
}
}

View File

@@ -6,7 +6,7 @@
</template>
<template #heading-right>
<control-button
:button="{ handler: showDetails, icon: 'dots-horizontal' }"
:button="{ handler: openDetails, icon: 'dots-horizontal' }"
/>
<control-button
:button="{ handler: play, icon: 'shuffle', key: 'actions.shuffle' }"
@@ -89,6 +89,9 @@ export default {
}
},
methods: {
openDetails() {
this.showDetailsModal = true
},
openTracks() {
this.$router.push({
name: 'music-composer-tracks',
@@ -97,9 +100,6 @@ export default {
},
play() {
webapi.player_play_expression(this.expression, true)
},
showDetails() {
this.showDetailsModal = true
}
}
}

View File

@@ -10,7 +10,7 @@
v-text="$t('options.sort.title')"
/>
<control-dropdown
v-model:value="uiStore.composer_tracks_sort"
v-model:value="uiStore.composerTracksSort"
:options="groupings"
/>
</div>
@@ -21,7 +21,7 @@
</template>
<template #heading-right>
<control-button
:button="{ handler: showDetails, icon: 'dots-horizontal' }"
:button="{ handler: openDetails, icon: 'dots-horizontal' }"
/>
<control-button
:button="{ handler: play, icon: 'shuffle', key: 'actions.shuffle' }"
@@ -129,9 +129,9 @@ export default {
},
tracks() {
const { options } = this.groupings.find(
(grouping) => grouping.id === this.uiStore.composer_tracks_sort
(grouping) => grouping.id === this.uiStore.composerTracksSort
)
return this.tracks_list.group(options)
return this.trackList.group(options)
}
},
methods: {
@@ -141,11 +141,11 @@ export default {
params: { name: this.composer.name }
})
},
openDetails() {
this.showDetailsModal = true
},
play() {
webapi.player_play_expression(this.expression, true)
},
showDetails() {
this.showDetailsModal = true
}
}
}

View File

@@ -25,7 +25,7 @@ import TabsMusic from '@/components/TabsMusic.vue'
import webapi from '@/webapi'
const dataObject = {
load(to) {
load() {
return webapi.library_composers('music')
},
set(vm, response) {
@@ -45,7 +45,7 @@ export default {
TabsMusic
},
beforeRouteEnter(to, from, next) {
dataObject.load(to).then((response) => {
dataObject.load().then((response) => {
next((vm) => dataObject.set(vm, response))
})
},

View File

@@ -6,7 +6,7 @@
</template>
<template #heading-right>
<control-button
:button="{ handler: showDetails, icon: 'dots-horizontal' }"
:button="{ handler: openDetails, icon: 'dots-horizontal' }"
/>
<control-button
:button="{ handler: play, icon: 'play', key: 'actions.play' }"
@@ -18,7 +18,7 @@
<list-tracks
:expression="expression"
:items="tracks"
:show-icon="true"
icon="file-music-outline"
/>
<modal-dialog-directory
:item="current"
@@ -120,12 +120,12 @@ export default {
}
},
methods: {
openDetails() {
this.showDetailsModal = true
},
play() {
webapi.player_play_expression(this.expression, false)
},
showDetails() {
this.showDetailsModal = true
},
transform(path) {
return { name: path.slice(path.lastIndexOf('/') + 1), path }
}

View File

@@ -9,7 +9,7 @@
</template>
<template #heading-right>
<control-button
:button="{ handler: showDetails, icon: 'dots-horizontal' }"
:button="{ handler: openDetails, icon: 'dots-horizontal' }"
/>
<control-button
:button="{ handler: play, icon: 'shuffle', key: 'actions.shuffle' }"
@@ -95,6 +95,9 @@ export default {
}
},
methods: {
openDetails() {
this.showDetailsModal = true
},
openTracks() {
this.showDetailsModal = false
this.$router.push({
@@ -108,9 +111,6 @@ export default {
`genre is "${this.genre.name}" and media_kind is ${this.media_kind}`,
true
)
},
showDetails() {
this.showDetailsModal = true
}
}
}

View File

@@ -10,7 +10,7 @@
v-text="$t('options.sort.title')"
/>
<control-dropdown
v-model:value="uiStore.genre_tracks_sort"
v-model:value="uiStore.genreTracksSort"
:options="groupings"
/>
</div>
@@ -21,7 +21,7 @@
</template>
<template #heading-right>
<control-button
:button="{ handler: showDetails, icon: 'dots-horizontal' }"
:button="{ handler: openDetails, icon: 'dots-horizontal' }"
/>
<control-button
:button="{ handler: play, icon: 'shuffle', key: 'actions.shuffle' }"
@@ -131,7 +131,7 @@ export default {
},
tracks() {
const { options } = this.groupings.find(
(grouping) => grouping.id === this.uiStore.genre_tracks_sort
(grouping) => grouping.id === this.uiStore.genreTracksSort
)
return this.trackList.group(options)
}
@@ -148,7 +148,7 @@ export default {
play() {
webapi.player_play_expression(this.expression, true)
},
showDetails() {
openDetails() {
this.showDetailsModal = true
}
}

View File

@@ -25,7 +25,7 @@ import TabsMusic from '@/components/TabsMusic.vue'
import webapi from '@/webapi'
const dataObject = {
load(to) {
load() {
return webapi.library_genres('music')
},
set(vm, response) {
@@ -45,7 +45,7 @@ export default {
TabsMusic
},
beforeRouteEnter(to, from, next) {
dataObject.load(to).then((response) => {
dataObject.load().then((response) => {
next((vm) => dataObject.set(vm, response))
})
},

View File

@@ -50,7 +50,7 @@ import TabsMusic from '@/components/TabsMusic.vue'
import webapi from '@/webapi'
const dataObject = {
load(to) {
load() {
return Promise.all([
webapi.search({
expression:
@@ -82,14 +82,14 @@ export default {
TabsMusic
},
beforeRouteEnter(to, from, next) {
dataObject.load(to).then((response) => {
dataObject.load().then((response) => {
next((vm) => dataObject.set(vm, response))
})
},
data() {
return {
albums: [],
tracks: { items: [] }
tracks: null
}
}
}

View File

@@ -24,7 +24,7 @@ import { useSettingsStore } from '@/stores/settings'
import webapi from '@/webapi'
const dataObject = {
load(to) {
load() {
const limit = useSettingsStore().recently_added_limit
return webapi.search({
expression:
@@ -45,7 +45,7 @@ export default {
name: 'PageMusicRecentlyAdded',
components: { ContentWithHeading, HeadingTitle, ListAlbums, TabsMusic },
beforeRouteEnter(to, from, next) {
dataObject.load(to).then((response) => {
dataObject.load().then((response) => {
next((vm) => dataObject.set(vm, response))
})
},

View File

@@ -23,7 +23,7 @@ import TabsMusic from '@/components/TabsMusic.vue'
import webapi from '@/webapi'
const dataObject = {
load(to) {
load() {
return webapi.search({
expression:
'time_played after 8 weeks ago and media_kind is music order by time_played desc',
@@ -40,7 +40,7 @@ export default {
name: 'PageMusicRecentlyPlayed',
components: { ContentWithHeading, HeadingTitle, ListTracks, TabsMusic },
beforeRouteEnter(to, from, next) {
dataObject.load(to).then((response) => {
dataObject.load().then((response) => {
next((vm) => dataObject.set(vm, response))
})
},

View File

@@ -50,7 +50,7 @@ import TabsMusic from '@/components/TabsMusic.vue'
import webapi from '@/webapi'
const dataObject = {
load(to) {
load() {
return webapi.spotify().then(({ data }) => {
const spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(data.webapi_token)
@@ -82,7 +82,7 @@ export default {
TabsMusic
},
beforeRouteEnter(to, from, next) {
dataObject.load(to).then((response) => {
dataObject.load().then((response) => {
next((vm) => dataObject.set(vm, response))
})
},

View File

@@ -21,7 +21,7 @@ import TabsMusic from '@/components/TabsMusic.vue'
import webapi from '@/webapi'
const dataObject = {
load(to) {
load() {
return webapi.spotify().then(({ data }) => {
const spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(data.webapi_token)
@@ -45,7 +45,7 @@ export default {
TabsMusic
},
beforeRouteEnter(to, from, next) {
dataObject.load(to).then((response) => {
dataObject.load().then((response) => {
next((vm) => dataObject.set(vm, response))
})
},

View File

@@ -9,21 +9,21 @@
:album="track.album"
class="is-clickable is-big"
:class="{ 'is-masked': lyricsStore.active }"
@click="openDialog(track)"
@click="openDetails(track)"
/>
<lyrics-pane v-if="lyricsStore.active" />
<control-slider
v-model:value="track_progress"
v-model:value="trackProgress"
class="mt-5"
:disabled="is_live"
:max="track_progress_max"
:disabled="isLive"
:max="trackProgressMax"
@change="seek"
@mousedown="start_dragging"
@mouseup="end_dragging"
@mousedown="startDragging"
@mouseup="endDragging"
/>
<div class="is-flex is-justify-content-space-between">
<p class="subtitle is-7" v-text="track_elapsed_time" />
<p class="subtitle is-7" v-text="track_total_time" />
<p class="subtitle is-7" v-text="trackElapsedTime" />
<p class="subtitle is-7" v-text="trackTotalTime" />
</div>
<p class="title is-5" v-text="track.title" />
<p class="title is-6" v-text="track.artist" />
@@ -83,8 +83,8 @@ export default {
data() {
return {
INTERVAL,
interval_id: 0,
is_dragged: false,
intervalId: 0,
isDragged: false,
selectedItem: {},
showDetailsModal: false
}
@@ -109,16 +109,16 @@ export default {
}
return null
},
is_live() {
isLive() {
return this.track.length_ms === 0
},
track() {
return this.queueStore.current
},
track_elapsed_time() {
return this.$filters.toTimecode(this.track_progress * INTERVAL)
trackElapsedTime() {
return this.$filters.toTimecode(this.trackProgress * INTERVAL)
},
track_progress: {
trackProgress: {
get() {
return Math.floor(this.playerStore.item_progress_ms / INTERVAL)
},
@@ -126,23 +126,23 @@ export default {
this.playerStore.item_progress_ms = value * INTERVAL
}
},
track_progress_max() {
return this.is_live ? 1 : Math.floor(this.track.length_ms / INTERVAL)
trackProgressMax() {
return this.isLive ? 1 : Math.floor(this.track.length_ms / INTERVAL)
},
track_total_time() {
return this.is_live
trackTotalTime() {
return this.isLive
? this.$t('page.now-playing.live')
: this.$filters.toTimecode(this.track.length_ms)
}
},
watch: {
'playerStore.state'(newState) {
if (this.interval_id > 0) {
window.clearTimeout(this.interval_id)
this.interval_id = 0
if (this.intervalId > 0) {
window.clearTimeout(this.intervalId)
this.intervalId = 0
}
if (newState === 'play') {
this.interval_id = window.setInterval(this.tick, INTERVAL)
this.intervalId = window.setInterval(this.tick, INTERVAL)
}
}
},
@@ -150,35 +150,35 @@ export default {
webapi.player_status().then(({ data }) => {
this.playerStore.$state = data
if (this.playerStore.state === 'play') {
this.interval_id = window.setInterval(this.tick, INTERVAL)
this.intervalId = window.setInterval(this.tick, INTERVAL)
}
})
},
unmounted() {
if (this.interval_id > 0) {
window.clearTimeout(this.interval_id)
this.interval_id = 0
if (this.intervalId > 0) {
window.clearTimeout(this.intervalId)
this.intervalId = 0
}
},
methods: {
end_dragging() {
this.is_dragged = false
endDragging() {
this.isDragged = false
},
openDialog(item) {
openDetails(item) {
this.selectedItem = item
this.showDetailsModal = true
},
seek() {
if (!this.is_live) {
webapi.player_seek_to_pos(this.track_progress * INTERVAL)
if (!this.isLive) {
webapi.player_seek_to_pos(this.trackProgress * INTERVAL)
}
},
start_dragging() {
this.is_dragged = true
startDragging() {
this.isDragged = true
},
tick() {
if (!this.is_dragged) {
this.track_progress += 1
if (!this.isDragged) {
this.trackProgress += 1
}
}
}

View File

@@ -28,7 +28,7 @@ const dataObject = {
},
set(vm, response) {
vm.playlist = response[0].data
vm.playlists_list = new GroupedList(response[1].data)
vm.playlistList = new GroupedList(response[1].data)
}
}
@@ -54,7 +54,7 @@ export default {
data() {
return {
playlist: {},
playlists_list: new GroupedList()
playlistList: new GroupedList()
}
},
computed: {
@@ -68,7 +68,7 @@ export default {
}
},
playlists() {
return this.playlists_list.group({
return this.playlistList.group({
filters: [
(playlist) =>
playlist.folder ||

View File

@@ -6,7 +6,7 @@
</template>
<template #heading-right>
<control-button
:button="{ handler: showDetails, icon: 'dots-horizontal' }"
:button="{ handler: openDetails, icon: 'dots-horizontal' }"
/>
<control-button
:button="{
@@ -91,7 +91,7 @@ export default {
play() {
webapi.player_play_uri(this.uris, true)
},
showDetails() {
openDetails() {
this.showDetailsModal = true
}
}

View File

@@ -6,7 +6,7 @@
</template>
<template #heading-right>
<control-button
:button="{ handler: showDetails, icon: 'dots-horizontal' }"
:button="{ handler: openDetails, icon: 'dots-horizontal' }"
/>
<control-button
:button="{
@@ -153,7 +153,7 @@ export default {
this.showDetailsModal = false
webapi.player_play_uri(this.playlist.uri, true)
},
showDetails() {
openDetails() {
this.showDetailsModal = true
}
}

View File

@@ -15,7 +15,7 @@
:button="{ handler: play, icon: 'play', key: 'actions.play' }"
/>
<control-button
:button="{ handler: showDetails, icon: 'dots-horizontal' }"
:button="{ handler: openDetails, icon: 'dots-horizontal' }"
/>
</div>
</template>
@@ -25,7 +25,7 @@
:artist="album.artist"
:album="album.name"
class="is-clickable is-medium"
@click="showDetails"
@click="openDetails"
/>
</template>
<template #content>
@@ -55,7 +55,7 @@
<br />
</template>
<template #name>
<b v-text="rss_playlist_to_remove.name" />
<b v-text="playlistToRemove.name" />
</template>
</i18n-t>
</template>
@@ -106,7 +106,7 @@ export default {
data() {
return {
album: {},
rss_playlist_to_remove: {},
playlistToRemove: {},
showDetailsModal: false,
showRemovePodcastModal: false,
tracks: new GroupedList()
@@ -133,7 +133,7 @@ export default {
webapi
.library_track_playlists(this.tracks.items[0].id)
.then(({ data }) => {
;[this.rss_playlist_to_remove] = data.items.filter(
;[this.playlistToRemove] = data.items.filter(
(pl) => pl.type === 'rss'
)
this.showRemovePodcastModal = true
@@ -150,13 +150,11 @@ export default {
},
removePodcast() {
this.showRemovePodcastModal = false
webapi
.library_playlist_delete(this.rss_playlist_to_remove.id)
.then(() => {
this.$router.replace({ name: 'podcasts' })
})
webapi.library_playlist_delete(this.playlistToRemove.id).then(() => {
this.$router.replace({ name: 'podcasts' })
})
},
showDetails() {
openDetails() {
this.showDetailsModal = true
}
}

View File

@@ -53,12 +53,12 @@
<list-item-queue-item
:item="element"
:position="index"
:current_position="current_position"
:current-position="currentPosition"
:hide-read-items="uiStore.hideReadItems"
:editing="editing"
>
<template #actions>
<a v-if="!editing" @click.prevent.stop="openDialog(element)">
<a v-if="!editing" @click.prevent.stop="openDetails(element)">
<mdicon
class="icon has-text-grey"
name="dots-vertical"
@@ -139,7 +139,7 @@ export default {
}
},
computed: {
current_position() {
currentPosition() {
return this.queueStore.current?.position ?? -1
},
heading() {
@@ -166,7 +166,7 @@ export default {
},
moveItem(event) {
const oldPosition =
event.oldIndex + (this.uiStore.hideReadItems && this.current_position)
event.oldIndex + (this.uiStore.hideReadItems && this.currentPosition)
const item = this.items[oldPosition]
const newPosition = item.position + (event.newIndex - event.oldIndex)
if (newPosition !== oldPosition) {
@@ -176,7 +176,7 @@ export default {
openAddStreamDialog() {
this.showAddStreamDialog = true
},
openDialog(item) {
openDetails(item) {
this.selectedItem = item
this.showDetailsModal = true
},

View File

@@ -33,7 +33,7 @@
/>
<div class="content">
<control-setting-switch
v-if="spotify.spotify_logged_in"
v-if="servicesStore.spotify_logged_in"
category="artwork"
name="use_artwork_source_spotify"
>
@@ -95,11 +95,6 @@ export default {
},
setup() {
return { servicesStore: useServicesStore() }
},
computed: {
spotify() {
return this.servicesStore.spotify
}
}
}
</script>

View File

@@ -47,8 +47,8 @@
<div class="control">
<a
class="button is-danger"
@click="logout_spotify"
v-text="$t('page.settings.services.logout')"
@click="logoutSpotify"
v-text="$t('actions.logout')"
/>
</div>
</div>
@@ -75,11 +75,11 @@
<a
class="button is-danger"
@click="logoutLastfm"
v-text="$t('page.settings.services.logout')"
v-text="$t('actions.logout')"
/>
</div>
<div v-if="!lastfm.scrobbling_enabled">
<form @submit.prevent="login_lastfm">
<form @submit.prevent="loginLastfm">
<div class="field is-grouped">
<div class="control">
<input
@@ -106,7 +106,7 @@
<button
class="button"
type="submit"
v-text="$t('page.settings.services.login')"
v-text="$t('actions.login')"
/>
</div>
</div>
@@ -172,7 +172,7 @@ export default {
}
},
methods: {
login_lastfm() {
loginLastfm() {
webapi.lastfm_login(this.lastfm_login).then((response) => {
this.lastfm_login.user = ''
this.lastfm_login.password = ''
@@ -190,7 +190,7 @@ export default {
logoutLastfm() {
webapi.lastfm_logout()
},
logout_spotify() {
logoutSpotify() {
webapi.spotify_logout()
}
}

View File

@@ -9,7 +9,7 @@
</template>
<template #content>
<div v-if="pairing.active">
<form @submit.prevent="kickoff_pairing">
<form @submit.prevent="kickoffPairing">
<label class="label has-text-weight-normal content">
<span v-text="$t('page.settings.devices.pairing-request')" />
<b v-text="pairing.remote" />
@@ -17,7 +17,7 @@
<div class="field is-grouped">
<div class="control">
<input
v-model="pairing_req.pin"
v-model="pairingRequest.pin"
class="input"
inputmode="numeric"
pattern="[\d]{4}"
@@ -62,12 +62,12 @@
<form
v-if="output.needs_auth_key"
class="mb-5"
@submit.prevent="kickoff_verification(output.id)"
@submit.prevent="kickoffVerification(output.id)"
>
<div class="field is-grouped">
<div class="control">
<input
v-model="verification_req.pin"
v-model="verificationRequest.pin"
class="input"
inputmode="numeric"
pattern="[\d]{4}"
@@ -106,8 +106,8 @@ export default {
},
data() {
return {
pairing_req: { pin: '' },
verification_req: { pin: '' }
pairingRequest: { pin: '' },
verificationRequest: { pin: '' }
}
},
computed: {
@@ -119,11 +119,11 @@ export default {
}
},
methods: {
kickoff_pairing() {
webapi.pairing_kickoff(this.pairing_req)
kickoffPairing() {
webapi.pairing_kickoff(this.pairingRequest)
},
kickoff_verification(identifier) {
webapi.output_update(identifier, this.verification_req)
kickoffVerification(identifier) {
webapi.output_update(identifier, this.verificationRequest)
},
toggleOutput(identifier) {
webapi.output_toggle(identifier)

View File

@@ -8,7 +8,10 @@
/>
</template>
<template #content>
<control-dropdown v-model:value="locale" :options="locales" />
<control-dropdown
v-model:value="locale"
:options="settingsStore.locales"
/>
</template>
</content-with-heading>
<content-with-heading>
@@ -198,14 +201,6 @@ export default {
set(locale) {
this.$i18n.locale = locale
}
},
locales: {
get() {
return this.$i18n.availableLocales.map((item) => ({
id: item,
name: this.$t(`language.${item}`)
}))
}
}
}
}

View File

@@ -33,5 +33,8 @@ export const useNotificationsStore = defineStore('NotificationsStore', {
}
}
},
getters: {
isEmpty: (state) => state.list.length <= 0
},
state: () => ({ list: [], nextId: 1 })
})

View File

@@ -1,4 +1,7 @@
import { defineStore } from 'pinia'
import i18n from '@/i18n'
const { t, availableLocales } = i18n.global
export const useSettingsStore = defineStore('SettingsStore', {
actions: {
@@ -25,6 +28,12 @@ export const useSettingsStore = defineStore('SettingsStore', {
}
},
getters: {
locales(state) {
return availableLocales.map((item) => ({
id: item,
name: t(`language.${item}`)
}))
},
recently_added_limit: (state) =>
state.setting('webinterface', 'recently_added_limit')?.value ?? 100,
show_composer_for_genre: (state) =>

View File

@@ -19,12 +19,12 @@ export const useUIStore = defineStore('UIStore', {
}
},
state: () => ({
albums_sort: 1,
artist_albums_sort: 1,
artist_tracks_sort: 1,
artists_sort: 1,
composer_tracks_sort: 1,
genre_tracks_sort: 1,
albumsSort: 1,
artistAlbumsSort: 1,
artistTracksSort: 1,
artistsSort: 1,
composerTracksSort: 1,
genreTracksSort: 1,
hideReadItems: false,
hideSingles: false,
hideSpotify: false,

View File

@@ -34,11 +34,12 @@
</div>
</nav>
<slot name="content" />
<nav v-if="$slots.footer" class="level mt-4">
<div class="level-item">
<slot name="footer" />
</div>
</nav>
<div
v-if="$slots.footer"
class="is-flex is-justify-content-center mt-4"
>
<slot name="footer" />
</div>
</div>
</div>
</div>