Change: config page to multiple pages, Add: user permissions for accessible libraries #120, Add: map genre metadata tag #114, Add: experimental audio player keyboard controls #121, Add: view user audiobook progress list

This commit is contained in:
advplyr 2021-10-22 20:08:02 -05:00
parent 7d9ed75a28
commit ff1eeda468
42 changed files with 957 additions and 464 deletions

View file

@ -15,7 +15,7 @@
</div>
<div class="absolute right-32 top-0 bottom-0">
<controls-volume-control v-model="volume" @input="updateVolume" />
<controls-volume-control ref="volumeControl" v-model="volume" @input="updateVolume" />
</div>
<div class="flex my-2">
<div class="flex-grow" />
@ -496,14 +496,39 @@ export default {
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
this.updatePlaybackRate(settings.playbackRate)
}
},
volumeUp() {
if (this.volume >= 1) return
this.volume = Math.min(1, this.volume + 0.1)
this.updateVolume(this.volume)
},
volumeDown() {
if (this.volume <= 0) return
this.volume = Math.max(0, this.volume - 0.1)
this.updateVolume(this.volume)
},
toggleMute() {
if (this.$refs.volumeControl && this.$refs.volumeControl.toggleMute) {
this.$refs.volumeControl.toggleMute()
}
},
hotkey(keyCode) {
if (keyCode === this.$hotkeys.PLAY_PAUSE) this.playPauseClick()
else if (keyCode === this.$hotkeys.JUMP_FORWARD) this.forward10()
else if (keyCode === this.$hotkeys.JUMP_BACKWARD) this.backward10()
else if (keyCode === this.$hotkeys.VOLUME_UP) this.volumeUp()
else if (keyCode === this.$hotkeys.VOLUME_DOWN) this.volumeDown()
else if (keyCode === this.$hotkeys.MUTE) this.toggleMute()
}
},
mounted() {
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
this.init()
this.$eventBus.$on('player-hotkey', this.hotkey)
},
beforeDestroy() {
this.$store.commit('user/removeSettingsListener', 'audioplayer')
this.$eventBus.$off('player-hotkey', this.hotkey)
}
}
</script>

View file

@ -8,13 +8,6 @@
</a>
<h1 class="text-2xl font-book mr-6">AudioBookshelf</h1>
<!-- <div class="bg-black bg-opacity-20 rounded-md py-1.5 px-3 flex items-center text-white text-opacity-70 cursor-pointer hover:bg-opacity-10 hover:text-opacity-90" @click="clickLibrary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
<p class="text-sm leading-3 font-sans pl-2">{{ libraryName }}</p>
</div> -->
<ui-libraries-dropdown />
<controls-global-search />
@ -133,9 +126,6 @@ export default {
}
},
methods: {
clickLibrary() {
this.$store.commit('libraries/setShowModal', true)
},
async back() {
var popped = await this.$store.dispatch('popRoute')
var backTo = popped || '/'

View file

@ -0,0 +1,56 @@
<template>
<div class="w-44 fixed left-0 top-16 z-40 h-full bg-bg bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-4">
<nuxt-link to="/config" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>Settings</p>
<div v-show="routeName === 'config'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link to="/config/libraries" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-libraries' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>Libraries</p>
<div v-show="routeName === 'config-libraries'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link to="/config/users" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-users' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>Users</p>
<div v-show="routeName === 'config-users'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link to="/config/backups" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-backups' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>Backups</p>
<div v-show="routeName === 'config-backups'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link to="/config/log" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-log' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
<p>Log</p>
<div v-show="routeName === 'config-log'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<div class="w-full h-12 px-4 border-t border-black border-opacity-20 absolute bottom-20 left-0 flex flex-col justify-center">
<p class="font-mono text-sm">v{{ $config.version }}</p>
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-sm">Update available: {{ latestVersion }}</a>
</div>
</div>
</template>
<script>
export default {
data() {
return {}
},
computed: {
routeName() {
return this.$route.name
},
versionData() {
return this.$store.state.versionData || {}
},
hasUpdate() {
return !!this.versionData.hasUpdate
},
latestVersion() {
return this.versionData.latestVersion
},
githubTagUrl() {
return this.versionData.githubTagUrl
}
},
methods: {},
mounted() {}
}
</script>

View file

@ -164,11 +164,14 @@ export default {
showError() {
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isIncomplete
},
isStreaming() {
return this.$store.getters['getAudiobookIdStreaming'] === this.audiobookId
},
showReadButton() {
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isIncomplete && this.hasTracks
return !this.isSelectionMode && !this.isMissing && !this.isIncomplete && this.hasTracks && !this.isStreaming
},
showSmallEBookIcon() {
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook

View file

@ -114,6 +114,9 @@ export default {
this.volume = this.lastValue || 0.5
}
},
toggleMute() {
this.clickVolumeIcon()
},
clickVolumeTrack(e) {
var vol = e.offsetX / this.trackWidth
vol = Math.min(Math.max(vol, 0), 1)

View file

@ -1,5 +1,5 @@
<template>
<modals-modal v-model="show" :width="800" :height="'unset'" :processing="processing">
<modals-modal v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
@ -64,6 +64,19 @@
<ui-toggle-switch v-model="newUser.permissions.upload" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>Can Access All Libraries</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.accessAllLibraries" @input="accessAllLibrariesToggled" />
</div>
</div>
<div v-if="!newUser.permissions.accessAllLibraries" class="my-4">
<ui-multi-select-dropdown v-model="newUser.librariesAccessible" :items="libraryItems" label="Libraries Accessible to User" />
</div>
</div>
<div class="flex pt-4">
@ -116,14 +129,31 @@ export default {
},
isEditingRoot() {
return this.account && this.account.type === 'root'
},
libraries() {
return this.$store.state.libraries.libraries
},
libraryItems() {
return this.libraries.map((lib) => ({ text: lib.name, value: lib.id }))
}
},
methods: {
accessAllLibrariesToggled(val) {
if (!val && !this.newUser.librariesAccessible.length) {
this.newUser.librariesAccessible = this.libraries.map((l) => l.id)
} else if (val && this.newUser.librariesAccessible.length) {
this.newUser.librariesAccessible = []
}
},
submitForm() {
if (!this.newUser.username) {
this.$toast.error('Enter a username')
return
}
if (!this.newUser.permissions.accessAllLibraries && !this.newUser.librariesAccessible.length) {
this.$toast.error('Must select at least one library')
return
}
if (this.isNew) {
this.submitCreateAccount()
@ -139,6 +169,7 @@ export default {
if (account.type === 'root' && !account.isActive) return
this.processing = true
console.log('Calling update', account)
this.$axios
.$patch(`/api/user/${this.account.id}`, account)
.then((data) => {
@ -146,6 +177,7 @@ export default {
if (data.error) {
this.$toast.error(`Failed to update account: ${data.error}`)
} else {
console.log('Account updated', data.user)
this.$toast.success('Account updated')
this.show = false
}
@ -197,12 +229,14 @@ export default {
init() {
this.isNew = !this.account
if (this.account) {
var librariesAccessible = this.account.librariesAccessible || []
this.newUser = {
username: this.account.username,
password: this.account.password,
type: this.account.type,
isActive: this.account.isActive,
permissions: { ...this.account.permissions }
permissions: { ...this.account.permissions },
librariesAccessible: [...librariesAccessible]
}
} else {
this.newUser = {
@ -214,8 +248,10 @@ export default {
download: true,
update: false,
delete: false,
upload: false
}
upload: false,
accessAllLibraries: true
},
librariesAccessible: []
}
}
}

View file

@ -1,5 +1,5 @@
<template>
<modals-modal v-model="show" :width="500" :height="'unset'">
<modals-modal v-model="show" name="chapters" :width="500" :height="'unset'">
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<template v-for="chap in chapters">
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-bg bg-opacity-80' : 'bg-opacity-20'" @click="clickChapter(chap)">

View file

@ -1,5 +1,5 @@
<template>
<modals-modal v-model="show" :width="700" :height="'unset'" :processing="processing">
<modals-modal v-model="show" name="edit-library" :width="700" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>

View file

@ -1,5 +1,5 @@
<template>
<modals-modal v-model="show" :width="800" :height="height" :processing="processing" :content-margin-top="75">
<modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="75">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>

View file

@ -1,99 +0,0 @@
<template>
<modals-modal v-model="show" :width="700" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div v-if="show" class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 200px; max-height: 80vh">
<div v-if="!showAddLibrary" class="w-full h-full flex flex-col justify-center px-4">
<div class="flex items-center mb-4">
<p>{{ libraries.length }} Libraries</p>
<!-- <div class="flex-grow" />
<ui-btn @click="addLibraryClick">Add Library</ui-btn> -->
</div>
<template v-for="library in libraries">
<modals-libraries-library-item :key="library.id" :library="library" :selected="currentLibraryId === library.id" :show-edit="false" @edit="editLibrary" @delete="deleteLibrary" @click="clickLibrary" />
</template>
</div>
<modals-libraries-edit-library v-else :library="selectedLibrary" :show="showAddLibrary" :processing.sync="processing" @back="showAddLibrary = false" @close="showAddLibrary = false" />
</div>
</modals-modal>
</template>
<script>
export default {
data() {
return {
selectedLibrary: null,
processing: false,
showAddLibrary: false
}
},
computed: {
show: {
get() {
return this.$store.state.libraries.showModal
},
set(val) {
this.$store.commit('libraries/setShowModal', val)
}
},
title() {
return 'Libraries'
},
currentLibrary() {
return this.$store.getters['libraries/getCurrentLibrary']
},
currentLibraryId() {
return this.currentLibrary ? this.currentLibrary.id : null
},
libraries() {
return this.$store.state.libraries.libraries
}
},
watch: {
show(newVal) {
if (newVal) this.showAddLibrary = false
}
},
methods: {
async clickLibrary(library) {
await this.$store.dispatch('libraries/fetch', library.id)
this.$router.push(`/library/${library.id}`)
this.show = false
},
editLibrary(library) {
this.selectedLibrary = library
this.showAddLibrary = true
},
addLibraryClick() {
this.selectedLibrary = null
this.showAddLibrary = true
},
deleteLibrary(library) {
if (confirm(`Are you sure you want to delete library "${library.name}"?\n(no files will be deleted but book data will be lost)`)) {
console.log('Delete library', library)
this.processing = true
this.$axios
.$delete(`/api/library/${library.id}`)
.then(() => {
console.log('Library delete success')
this.$toast.success(`Library "${library.name}" deleted`)
this.processing = false
})
.catch((error) => {
console.error('Failed to delete library', error)
var errMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.$toast.error(errMsg)
this.processing = false
})
}
}
},
mounted() {},
beforeDestroy() {}
}
</script>

View file

@ -2,7 +2,7 @@
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-40 opacity-0">
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
<span class="material-icons text-4xl">close</span>
</div>
<slot name="outer" />
@ -18,6 +18,7 @@
<script>
export default {
props: {
name: String,
value: Boolean,
processing: Boolean,
persistent: {
@ -73,6 +74,9 @@ export default {
}
},
methods: {
clickClose() {
this.show = false
},
clickBg(vm, ev) {
if (this.processing && this.persistent) return
if (vm.srcElement.classList.contains('modal-bg')) {
@ -85,11 +89,15 @@ export default {
this.content.style.transform = 'scale(1)'
}, 10)
document.documentElement.classList.add('modal-open')
this.$store.commit('setOpenModal', this.name)
},
setHide() {
this.content.style.transform = 'scale(0)'
this.el.remove()
document.documentElement.classList.remove('modal-open')
this.$store.commit('setOpenModal', null)
}
},
mounted() {

View file

@ -17,17 +17,11 @@
<th class="w-32">Created</th>
<th class="w-32"></th>
</tr>
<tr v-for="user in users" :key="user.id" :class="user.isActive ? '' : 'bg-error bg-opacity-20'">
<tr v-for="user in users" :key="user.id" class="cursor-pointer" :class="user.isActive ? '' : 'bg-error bg-opacity-20'" @click="$router.push(`/config/users/${user.id}`)">
<td>
<div class="flex items-center">
<span v-if="usersOnline[user.id]" class="w-3 h-3 text-sm mr-2 text-success animate-pulse"
><svg viewBox="0 0 24 24">
<path fill="currentColor" d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" /></svg
></span>
<svg v-else class="w-3 h-3 mr-2 text-white text-opacity-20" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
</svg>
{{ user.username }} <span v-show="$isDev" class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span>
<widgets-online-indicator :value="!!usersOnline[user.id]" />
<span class="pl-2">{{ user.username }}</span> <span v-show="$isDev" class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span>
</div>
</td>
<td class="text-sm">{{ user.type }}</td>
@ -49,10 +43,15 @@
{{ $formatDate(user.createdAt, 'MMM d, yyyy') }}
</ui-tooltip>
</td>
<td>
<td class="py-0">
<div class="w-full flex justify-center">
<span class="material-icons hover:text-gray-400 cursor-pointer text-base pr-2" @click="editUser(user)">edit</span>
<span v-show="user.type !== 'root'" class="material-icons text-base hover:text-error cursor-pointer" @click="deleteUserClick(user)">delete</span>
<!-- <span class="material-icons hover:text-gray-400 cursor-pointer text-base pr-2" @click.stop="editUser(user)">edit</span> -->
<div class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-opacity-100 cursor-pointer" @click.stop="editUser(user)">
<span class="material-icons text-base">edit</span>
</div>
<div v-show="user.type !== 'root'" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="deleteUserClick(user)">
<span class="material-icons text-base">delete</span>
</div>
</div>
</td>
</tr>
@ -81,21 +80,8 @@ export default {
return this.$store.state.streamAudiobook
},
usersOnline() {
var _users = this.$store.state.users.users
var currUserStream = null
if (this.userStream) {
currUserStream = {
audiobook: this.userStream
}
}
var usermap = {
[this.currentUserId]: {
online: true,
stream: currUserStream
}
}
_users.forEach((u) => (usermap[u.id] = { online: true, stream: u.stream }))
var usermap = {}
this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, stream: u.stream }))
return usermap
}
},
@ -104,7 +90,9 @@ export default {
var abs = Object.values(audiobooks)
if (abs.length) {
abs = abs.sort((a, b) => a.lastUpdate - b.lastUpdate)
return abs[0] && abs[0].audiobookTitle ? abs[0].audiobookTitle : null
// Book object is attached on request
if (abs[0].book) return abs[0].book.title
return abs[0].audiobookTitle ? abs[0].audiobookTitle : null
}
return null
},
@ -141,7 +129,9 @@ export default {
this.$axios
.$get('/api/users')
.then((users) => {
this.users = users
this.users = users.sort((a, b) => {
return a.createdAt - b.createdAt
})
})
.catch((error) => {
console.error('Failed', error)
@ -192,16 +182,21 @@ export default {
#accounts {
table-layout: fixed;
border-collapse: collapse;
border: 1px solid #474747;
width: 100%;
}
#accounts td,
#accounts th {
border: 1px solid #2e2e2e;
/* border: 1px solid #2e2e2e; */
padding: 8px 8px;
text-align: left;
}
#accounts td.py-0 {
padding: 0px 8px;
}
#accounts tr:nth-child(even) {
background-color: #3a3a3a;
}

View file

@ -1,6 +1,6 @@
<template>
<div v-if="currentLibrary" class="relative w-36 h-8" v-click-outside="clickOutside">
<button type="button" :disabled="disabled" class="relative h-full w-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm pl-3 pr-10 text-left focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<button type="button" :disabled="disabled" class="relative h-full w-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="flex items-center">
<widgets-library-icon :icon="currentLibraryIcon" class="mr-2" />
<span class="block truncate text-sm">{{ currentLibrary.name }}</span>
@ -9,7 +9,7 @@
<transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
<template v-for="library in libraries">
<template v-for="library in librariesFiltered">
<li :key="library.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="selectLibrary(library)">
<div class="flex items-center px-3">
<widgets-library-icon :icon="currentLibraryIcon" class="mr-2" />
@ -43,8 +43,17 @@ export default {
libraries() {
return this.$store.getters['libraries/getSortedLibraries']()
},
libraryItems() {
return this.libraries.map((lib) => ({ value: lib.id, text: lib.name }))
canUserAccessAllLibraries() {
return this.$store.getters['user/getUserCanAccessAllLibraries']
},
userLibrariesAccessible() {
return this.$store.getters['user/getLibrariesAccessible']
},
librariesFiltered() {
if (this.canUserAccessAllLibraries) return this.libraries
return this.libraries.filter((lib) => {
return this.userLibrariesAccessible.includes(lib.id)
})
}
},
methods: {

View file

@ -0,0 +1,120 @@
<template>
<div class="w-full" v-click-outside="closeMenu">
<p class="px-1 text-sm font-semibold">{{ label }}</p>
<div ref="wrapper" class="relative">
<div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded-md px-2 py-1 cursor-pointer" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
<div v-for="item in selectedItems" :key="item.value" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative">
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item.value)">close</span>
</div>
{{ item.text }}
</div>
</div>
<ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
</div>
<span v-if="selected.includes(item.value)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">checkmark</span>
</span>
</li>
</template>
<li v-if="!items.length" class="text-gray-50 select-none relative py-2 pr-9" role="option">
<div class="flex items-center justify-center">
<span class="font-normal">No items</span>
</div>
</li>
</ul>
</div>
</div>
</template>
<script>
export default {
props: {
value: {
type: Array,
default: () => []
},
items: {
type: Array,
default: () => []
},
label: String
},
data() {
return {
showMenu: false,
menu: null
}
},
computed: {
selected: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
selectedItems() {
return (this.value || []).map((v) => {
return this.items.find((i) => i.value === v) || {}
})
}
},
methods: {
recalcMenuPos() {
if (!this.menu) return
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'
this.menu.style.left = boundingBox.x + 'px'
this.menu.style.width = boundingBox.width + 'px'
},
unmountMountMenu() {
if (!this.$refs.menu) return
this.menu = this.$refs.menu
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
this.menu.remove()
document.body.appendChild(this.menu)
this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'
this.menu.style.left = boundingBox.x + 'px'
this.menu.style.width = boundingBox.width + 'px'
},
clickedOption(e, item) {
if (e) {
e.stopPropagation()
e.preventDefault()
}
var newSelected = null
if (this.selected.includes(item.value)) {
newSelected = this.selected.filter((s) => s !== item.value)
} else {
newSelected = this.selected.concat([item.value])
}
this.$emit('input', newSelected)
this.$nextTick(() => {
this.recalcMenuPos()
})
},
closeMenu() {
this.showMenu = false
},
clickWrapper() {
this.showMenu = !this.showMenu
},
removeItem(itemValue) {
var remaining = this.selected.filter((i) => i !== itemValue)
this.$emit('input', remaining)
this.$nextTick(() => {
this.recalcMenuPos()
})
}
},
mounted() {}
}
</script>

View file

@ -0,0 +1,26 @@
<template>
<div class="w-3 h-3">
<div v-if="value" class="w-full h-full text-sm mr-2 text-success animate-pulse">
<svg viewBox="0 0 24 24">
<path fill="currentColor" d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
</svg>
</div>
<svg v-else class="w-full h-full mr-2 text-white text-opacity-20" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
</svg>
</div>
</template>
<script>
export default {
props: {
value: Boolean
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>

View file

@ -1,64 +0,0 @@
<template>
<div v-show="isScanning" class="fixed bottom-4 left-0 right-0 mx-auto z-20 max-w-lg">
<div class="w-full my-1 rounded-lg drop-shadow-lg px-4 py-2 flex items-center justify-center text-center transition-all border border-white border-opacity-40 shadow-md bg-warning">
<p class="text-lg font-sans" v-html="text" />
</div>
<div v-show="!hasCanceled" class="absolute right-0 top-3 bottom-0 px-2">
<ui-btn color="red-600" small :padding-x="1" @click="cancelScan">Cancel</ui-btn>
</div>
</div>
</template>
<script>
export default {
data() {
return {
hasCanceled: false
}
},
watch: {
isScanning(newVal) {
if (newVal) {
this.hasCanceled = false
}
}
},
computed: {
text() {
var scanText = this.isScanningFiles ? 'Scanning...' : 'Scanning Covers...'
return `${scanText} <span class="font-mono">${this.scanNum}</span> of <span class="font-mono">${this.scanTotal}</span> <strong class='font-mono px-2'>${this.scanPercent}</strong>`
},
isScanning() {
return this.isScanningFiles || this.isScanningCovers
},
isScanningFiles() {
return this.$store.state.isScanning
},
isScanningCovers() {
return this.$store.state.isScanningCovers
},
scanProgressKey() {
return this.isScanningFiles ? 'scanProgress' : 'coverScanProgress'
},
scanProgress() {
return this.$store.state[this.scanProgressKey]
},
scanPercent() {
return this.scanProgress ? this.scanProgress.progress + '%' : '0%'
},
scanNum() {
return this.scanProgress ? this.scanProgress.done : 0
},
scanTotal() {
return this.scanProgress ? this.scanProgress.total : 0
}
},
methods: {
cancelScan() {
this.hasCanceled = true
this.$root.socket.emit('cancel_scan')
}
},
mounted() {}
}
</script>