New data model batch routes and batch editor

This commit is contained in:
advplyr 2022-03-13 17:10:48 -05:00
parent 6597fca576
commit 4bdef893af
19 changed files with 743 additions and 604 deletions

View file

@ -44,8 +44,8 @@
</nuxt-link>
</div>
<div v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
<h1 class="text-2xl px-4">{{ numAudiobooksSelected }} Selected</h1>
<div v-show="numLibraryItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
<h1 class="text-2xl px-4">{{ numLibraryItemsSelected }} Selected</h1>
<div class="flex-grow" />
<ui-tooltip :text="`Mark as ${selectedIsRead ? 'Not Read' : 'Read'}`" direction="bottom">
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsRead" @click="toggleBatchRead" class="mx-1.5" />
@ -53,7 +53,7 @@
<ui-tooltip v-if="userCanUpdate" text="Add to Collection" direction="bottom">
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
</ui-tooltip>
<template v-if="userCanUpdate && numAudiobooksSelected < 50">
<template v-if="userCanUpdate && numLibraryItemsSelected < 50">
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
</template>
<ui-icon-btn v-show="userCanDelete" :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
@ -94,11 +94,11 @@ export default {
username() {
return this.user ? this.user.username : 'err'
},
numAudiobooksSelected() {
return this.selectedAudiobooks.length
numLibraryItemsSelected() {
return this.selectedLibraryItems.length
},
selectedAudiobooks() {
return this.$store.state.selectedAudiobooks
selectedLibraryItems() {
return this.$store.state.selectedLibraryItems
},
userAudiobooks() {
return this.$store.state.user.user.audiobooks || {}
@ -117,8 +117,8 @@ export default {
},
selectedIsRead() {
// Find an audiobook that is not read, if none then all audiobooks read
return !this.selectedAudiobooks.find((ab) => {
var userAb = this.userAudiobooks[ab]
return !this.selectedLibraryItems.find((li) => {
var userAb = this.userAudiobooks[li]
return !userAb || !userAb.isRead
})
},
@ -150,16 +150,16 @@ export default {
},
cancelSelectionMode() {
if (this.processingBatchDelete) return
this.$store.commit('setSelectedAudiobooks', [])
this.$store.commit('setSelectedLibraryItems', [])
this.$eventBus.$emit('bookshelf-clear-selection')
this.isAllSelected = false
},
toggleBatchRead() {
this.$store.commit('setProcessingBatch', true)
var newIsRead = !this.selectedIsRead
var updateProgressPayloads = this.selectedAudiobooks.map((ab) => {
var updateProgressPayloads = this.selectedLibraryItems.map((lid) => {
return {
audiobookId: ab,
audiobookId: lid,
isRead: newIsRead
}
})
@ -168,7 +168,7 @@ export default {
.then(() => {
this.$toast.success('Batch update success!')
this.$store.commit('setProcessingBatch', false)
this.$store.commit('setSelectedAudiobooks', [])
this.$store.commit('setSelectedLibraryItems', [])
this.$eventBus.$emit('bookshelf-clear-selection')
})
.catch((error) => {
@ -178,20 +178,20 @@ export default {
})
},
batchDeleteClick() {
var audiobookText = this.numAudiobooksSelected > 1 ? `these ${this.numAudiobooksSelected} audiobooks` : 'this audiobook'
var audiobookText = this.numLibraryItemsSelected > 1 ? `these ${this.numLibraryItemsSelected} audiobooks` : 'this audiobook'
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the audiobooks from AudioBookshelf`
if (confirm(confirmMsg)) {
this.processingBatchDelete = true
this.$store.commit('setProcessingBatch', true)
this.$axios
.$post(`/api/books/batch/delete`, {
audiobookIds: this.selectedAudiobooks
.$post(`/api/items/batch/delete`, {
libraryItemIds: this.selectedLibraryItems
})
.then(() => {
this.$toast.success('Batch delete success!')
this.processingBatchDelete = false
this.$store.commit('setProcessingBatch', false)
this.$store.commit('setSelectedAudiobooks', [])
this.$store.commit('setSelectedLibraryItems', [])
this.$eventBus.$emit('bookshelf-clear-selection')
})
.catch((error) => {

View file

@ -69,16 +69,16 @@ export default {
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
audiobookId() {
libraryItemId() {
return this.book.id
},
selected: {
get() {
return this.$store.getters['getIsAudiobookSelected'](this.audiobookId)
return this.$store.getters['getIsLibraryItemSelected'](this.libraryItemId)
},
set(val) {
if (this.processingBatch) return
this.$store.commit('setAudiobookSelected', { audiobookId: this.audiobookId, selected: val })
this.$store.commit('setLibraryItemSelected', { libraryItemId: this.libraryItemId, selected: val })
}
},
processingBatch() {
@ -118,7 +118,7 @@ export default {
return this.book.numTracks
},
isStreaming() {
return this.$store.getters['getLibraryItemIdStreaming'] === this.audiobookId
return this.$store.getters['getLibraryItemIdStreaming'] === this.libraryItemId
},
showReadButton() {
return this.showExperimentalFeatures && this.numEbooks
@ -142,7 +142,7 @@ export default {
methods: {
selectBtnClick() {
if (this.processingBatch) return
this.$store.commit('toggleAudiobookSelected', this.audiobookId)
this.$store.commit('toggleLibraryItemSelected', this.libraryItemId)
},
openEbook() {
this.$store.commit('showEReader', this.book)
@ -156,7 +156,7 @@ export default {
}
this.isProcessingReadUpdate = true
this.$axios
.$patch(`/api/me/audiobook/${this.audiobookId}`, updatePayload)
.$patch(`/api/me/audiobook/${this.libraryItemId}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)

View file

@ -4,7 +4,7 @@
<div class="w-full h-full pt-6">
<div v-if="shelf.type === 'books'" class="flex items-center">
<template v-for="(entity, index) in shelf.entities">
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectBook" @edit="editBook" />
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editBook" />
</template>
</div>
<div v-if="shelf.type === 'series'" class="flex items-center">
@ -90,7 +90,7 @@ export default {
return this.$store.state.libraries.currentLibraryId
},
isSelectionMode() {
return this.$store.getters['getNumAudiobooksSelected'] > 0
return this.$store.getters['getNumLibraryItemsSelected'] > 0
}
},
methods: {
@ -100,19 +100,19 @@ export default {
this.$store.commit('showEditModal', audiobook)
},
updateSelectionMode(val) {
var selectedAudiobooks = this.$store.state.selectedAudiobooks
var selectedLibraryItems = this.$store.state.selectedLibraryItems
if (this.shelf.type === 'books') {
this.shelf.entities.forEach((ent) => {
var component = this.$refs[`shelf-book-${ent.id}`]
if (!component || !component.length) return
component = component[0]
component.setSelectionMode(val)
component.selected = selectedAudiobooks.includes(ent.id)
component.selected = selectedLibraryItems.includes(ent.id)
})
}
},
selectBook(audiobook) {
this.$store.commit('toggleAudiobookSelected', audiobook.id)
selectItem(libraryItem) {
this.$store.commit('toggleLibraryItemSelected', libraryItem.id)
},
scrolled() {
clearTimeout(this.scrollTimer)

View file

@ -183,8 +183,8 @@ export default {
// Includes margin
return this.entityWidth + 24
},
selectedAudiobooks() {
return this.$store.state.selectedAudiobooks || []
selectedLibraryItems() {
return this.$store.state.selectedLibraryItems || []
},
sizeMultiplier() {
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
@ -214,9 +214,9 @@ export default {
},
selectEntity(entity) {
if (this.entityName === 'books' || this.entityName === 'series-books') {
this.$store.commit('toggleAudiobookSelected', entity.id)
this.$store.commit('toggleLibraryItemSelected', entity.id)
var newIsSelectionMode = !!this.selectedAudiobooks.length
var newIsSelectionMode = !!this.selectedLibraryItems.length
if (this.isSelectionMode !== newIsSelectionMode) {
this.isSelectionMode = newIsSelectionMode
this.updateBookSelectionMode(newIsSelectionMode)

View file

@ -101,7 +101,7 @@ export default {
return this.$store.state.globals.showBatchUserCollectionModal
},
selectedBookIds() {
return this.$store.state.selectedAudiobooks || []
return this.$store.state.selectedLibraryItems || []
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId

View file

@ -1,106 +1,27 @@
<template>
<div class="w-full h-full relative">
<form class="w-full h-full" @submit.prevent="submitForm">
<div ref="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
<div class="flex -mx-1">
<div class="w-1/2 px-1">
<ui-text-input-with-label v-model="details.title" label="Title" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.subtitle" label="Subtitle" />
</div>
</div>
<widgets-item-details-edit ref="itemDetailsEdit" :library-item="libraryItem" @submit="submitForm" />
<div class="flex mt-2 -mx-1">
<div class="w-3/4 px-1">
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" />
</div>
</div>
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
<div class="flex items-center px-4">
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn>
<div class="flex mt-2 -mx-1">
<div class="flex-grow px-1">
<ui-multi-select-query-input ref="seriesSelect" v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
</div>
</div>
<div class="flex-grow" />
<ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" />
<ui-tooltip v-if="!isMissing" text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="mr-4 hidden sm:block">
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
</ui-tooltip>
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
</div>
<div class="flex-grow px-1">
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
</div>
</div>
<ui-tooltip :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4">
<ui-btn v-if="isRootUser" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
</ui-tooltip>
<div class="flex mt-2 -mx-1">
<div class="w-1/3 px-1">
<ui-text-input-with-label v-model="details.narrator" label="Narrator" />
</div>
<div class="w-1/3 px-1">
<ui-text-input-with-label v-model="details.publisher" label="Publisher" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.language" label="Language" />
</div>
</div>
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
<ui-btn v-if="isRootUser" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
</ui-tooltip>
<div class="flex mt-2 -mx-1">
<div class="w-1/3 px-1">
<ui-text-input-with-label v-model="details.isbn" label="ISBN" />
</div>
<div class="w-1/3 px-1">
<ui-text-input-with-label v-model="details.asin" label="ASIN" />
</div>
</div>
<ui-btn @click="submitForm">Submit</ui-btn>
</div>
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
<div class="flex items-center px-4">
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn>
<div class="flex-grow" />
<ui-tooltip v-if="!isMissing" text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="mr-4 hidden sm:block">
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
</ui-tooltip>
<ui-tooltip :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4">
<ui-btn v-if="isRootUser" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
</ui-tooltip>
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
<ui-btn v-if="isRootUser" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
</ui-tooltip>
<ui-btn type="submit">Submit</ui-btn>
</div>
</div>
</form>
<div v-if="showSeriesForm" class="absolute top-0 left-0 z-20 w-full h-full bg-black bg-opacity-50 rounded-lg flex items-center justify-center" @click="cancelSeriesForm">
<div class="absolute top-0 right-0 p-4">
<span class="material-icons text-gray-200 hover:text-white text-4xl cursor-pointer">close</span>
</div>
<form @submit.prevent="submitSeriesForm">
<div class="bg-bg rounded-lg p-8" @click.stop>
<div class="flex">
<div class="flex-grow p-1 min-w-80">
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" />
</div>
<div class="w-40 p-1">
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
</div>
</div>
<div class="flex justify-end mt-2 p-1">
<ui-btn type="submit">Save</ui-btn>
</div>
</div>
</form>
</div>
</div>
</template>
@ -116,23 +37,6 @@ export default {
},
data() {
return {
selectedSeries: {},
showSeriesForm: false,
details: {
title: null,
subtitle: null,
description: null,
author: null,
narrator: null,
series: null,
publishYear: null,
publisher: null,
language: null,
isbn: null,
asin: null,
genres: []
},
newTags: [],
resettingProgress: false,
isScrollable: false,
savingMetadata: false,
@ -140,14 +44,6 @@ export default {
quickMatching: false
}
},
watch: {
libraryItem: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
isProcessing: {
get() {
@ -175,18 +71,6 @@ export default {
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
genres() {
return this.filterData.genres || []
},
tags() {
return this.filterData.tags || []
},
series() {
return this.filterData.series || []
},
filterData() {
return this.$store.state.libraries.filterData || {}
},
libraryId() {
return this.libraryItem ? this.libraryItem.libraryId : null
},
@ -196,71 +80,9 @@ export default {
libraryScan() {
if (!this.libraryId) return null
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
},
existingSeriesNames() {
// Only show series names not already selected
var alreadySelectedSeriesIds = this.details.series.map((se) => se.id)
return this.series.filter((se) => !alreadySelectedSeriesIds.includes(se.id)).map((se) => se.name)
},
seriesItems: {
get() {
return this.details.series.map((se) => {
return {
displayName: se.sequence ? `${se.name} #${se.sequence}` : se.name,
...se
}
})
},
set(val) {
this.details.series = val
}
}
},
methods: {
cancelSeriesForm() {
this.showSeriesForm = false
},
editSeriesItem(series) {
var _series = this.details.series.find((se) => se.id === series.id)
if (!_series) return
this.selectedSeries = {
..._series
}
this.showSeriesForm = true
},
submitSeriesForm() {
if (!this.selectedSeries.name) {
this.$toast.error('Must enter a series')
return
}
if (this.$refs.newSeriesSelect) {
this.$refs.newSeriesSelect.blur()
}
var existingSeriesIndex = this.details.series.findIndex((se) => se.id === this.selectedSeries.id)
var seriesSameName = this.series.find((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase())
if (existingSeriesIndex < 0 && seriesSameName) {
this.selectedSeries.id = seriesSameName.id
}
if (existingSeriesIndex >= 0) {
this.details.series.splice(existingSeriesIndex, 1, { ...this.selectedSeries })
} else {
this.details.series.push({
...this.selectedSeries
})
}
this.showSeriesForm = false
},
addNewSeries() {
this.selectedSeries = {
id: `new-${Date.now()}`,
name: '',
sequence: ''
}
this.showSeriesForm = true
},
quickMatch() {
this.quickMatching = true
var matchOptions = {
@ -326,25 +148,20 @@ export default {
if (this.isProcessing) {
return
}
this.isProcessing = true
if (this.$refs.authorsSelect && this.$refs.authorsSelect.isFocused) {
this.$refs.authorsSelect.forceBlur()
if (!this.$refs.itemDetailsEdit) {
return
}
if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {
this.$refs.genresSelect.forceBlur()
var updatedDetails = this.$refs.itemDetailsEdit.getDetails()
if (!updatedDetails.hasChanges) {
this.$toast.info('No changes were made')
return
}
if (this.$refs.tagsSelect && this.$refs.tagsSelect.isFocused) {
this.$refs.tagsSelect.forceBlur()
}
this.$nextTick(this.handleForm)
this.updateDetails(updatedDetails)
},
async handleForm() {
const updatePayload = {
metadata: this.details,
tags: this.newTags
}
console.log('Sending update', updatePayload)
var updatedAudiobook = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatePayload).catch((error) => {
async updateDetails(updatedDetails) {
this.isProcessing = true
console.log('Sending update', updatedDetails.updatePayload)
var updatedAudiobook = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatedDetails.updatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
@ -354,29 +171,6 @@ export default {
this.$emit('close')
}
},
init() {
this.details.title = this.mediaMetadata.title
this.details.subtitle = this.mediaMetadata.subtitle
this.details.description = this.mediaMetadata.description
this.$set(
this.details,
'authors',
(this.mediaMetadata.authors || []).map((se) => ({ ...se }))
)
this.details.narrator = this.mediaMetadata.narrator
this.details.genres = [...(this.mediaMetadata.genres || [])]
this.$set(
this.details,
'series',
(this.mediaMetadata.series || []).map((se) => ({ ...se }))
)
this.details.publishYear = this.mediaMetadata.publishYear
this.details.publisher = this.mediaMetadata.publisher || null
this.details.language = this.mediaMetadata.language || null
this.details.isbn = this.mediaMetadata.isbn || null
this.details.asin = this.mediaMetadata.asin || null
this.newTags = [...(this.media.tags || [])]
},
removeItem() {
if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) {
this.isProcessing = true
@ -396,8 +190,9 @@ export default {
},
checkIsScrollable() {
this.$nextTick(() => {
if (this.$refs.formWrapper) {
if (this.$refs.formWrapper.scrollHeight > this.$refs.formWrapper.clientHeight) {
var formWrapper = document.getElementById('formWrapper')
if (formWrapper) {
if (formWrapper.scrollHeight > formWrapper.clientHeight) {
this.isScrollable = true
} else {
this.isScrollable = false
@ -407,12 +202,15 @@ export default {
},
setResizeObserver() {
try {
this.$nextTick(() => {
const resizeObserver = new ResizeObserver(() => {
this.checkIsScrollable()
var formWrapper = document.getElementById('formWrapper')
if (formWrapper) {
this.$nextTick(() => {
const resizeObserver = new ResizeObserver(() => {
this.checkIsScrollable()
})
resizeObserver.observe(formWrapper)
})
resizeObserver.observe(this.$refs.formWrapper)
})
}
} catch (error) {
console.error('Failed to set resize observer')
}

View file

@ -1,10 +1,10 @@
<template>
<label class="flex justify-start items-center cursor-pointer">
<div class="border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center" :class="wrapperClass">
<input v-model="selected" type="checkbox" class="opacity-0 absolute cursor-pointer" />
<label class="flex justify-start items-center" :class="!disabled ? 'cursor-pointer' : ''">
<div class="border-2 rounded flex flex-shrink-0 justify-center items-center" :class="wrapperClass">
<input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" />
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
</div>
<div v-if="label" class="select-none pl-1 text-gray-100" :class="labelClass">{{ label }}</div>
<div v-if="label" class="select-none text-gray-100" :class="labelClassname">{{ label }}</div>
</label>
</template>
@ -18,10 +18,19 @@ export default {
type: String,
default: 'white'
},
borderColor: {
type: String,
default: 'gray-400'
},
checkColor: {
type: String,
default: 'green-500'
}
},
labelClass: {
type: String,
default: ''
},
disabled: Boolean
},
data() {
return {}
@ -36,15 +45,17 @@ export default {
}
},
wrapperClass() {
var classes = [`bg-${this.checkboxBg}`]
var classes = [`bg-${this.checkboxBg} border-${this.borderColor}`]
if (this.small) classes.push('w-4 h-4')
else classes.push('w-6 h-6')
return classes.join(' ')
},
labelClass() {
if (this.small) return 'text-xs md:text-sm'
return ''
labelClassname() {
if (this.labelClass) return this.labelClass
var classes = ['pl-1']
if (this.small) classes.push('text-xs md:text-sm')
return classes.join(' ')
},
svgClass() {
var classes = [`text-${this.checkColor}`]

View file

@ -8,7 +8,7 @@
</div>
</form>
<ul ref="menu" v-show="isFocused && items.length && (itemsToShow.length || currentSearch)" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded 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">
<ul ref="menu" v-show="isFocused && itemsToShow.length" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded 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 itemsToShow">
<li :key="item" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">
@ -47,7 +47,7 @@ export default {
data() {
return {
isFocused: false,
currentSearch: null,
// currentSearch: null,
typingTimeout: null,
textInput: null
}
@ -70,12 +70,13 @@ export default {
}
},
itemsToShow() {
if (!this.currentSearch || !this.textInput || this.textInput === this.input) {
return this.items
if (!this.editable) return this.items
if (!this.textInput || this.textInput === this.input) {
return []
}
return this.items.filter((i) => {
var iValue = String(i).toLowerCase()
return iValue.includes(this.currentSearch.toLowerCase())
return iValue.includes(this.textInput.toLowerCase())
})
}
},
@ -83,7 +84,7 @@ export default {
keydownInput() {
clearTimeout(this.typingTimeout)
this.typingTimeout = setTimeout(() => {
this.currentSearch = this.textInput
// this.currentSearch = this.textInput
}, 100)
},
inputFocus() {
@ -127,11 +128,11 @@ export default {
if (val && !this.items.includes(val)) {
this.$emit('newItem', val)
}
this.currentSearch = null
// this.currentSearch = null
},
clickedOption(e, item) {
this.textInput = null
this.currentSearch = null
// this.currentSearch = null
this.input = item
if (this.$refs.input) this.$refs.input.blur()
}

View file

@ -70,7 +70,7 @@ export default {
computed: {
selected: {
get() {
return this.value
return this.value || []
},
set(val) {
this.$emit('input', val)

View file

@ -82,6 +82,9 @@ export default {
this.$emit('input', val)
}
},
userToken() {
return this.$store.getters['user/getToken']
},
wrapperClass() {
var classes = []
if (this.disabled) classes.push('bg-black-300')
@ -110,7 +113,7 @@ export default {
if (this.searching) return
this.currentSearch = this.textInput
this.searching = true
var results = await this.$axios.$get(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15`).catch((error) => {
var results = await this.$axios.$get(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15&token=${this.userToken}`).catch((error) => {
console.error('Failed to get search results', error)
return []
})

View file

@ -0,0 +1,334 @@
<template>
<div class="w-full h-full relative">
<form class="w-full h-full" @submit.prevent="submitForm">
<div id="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
<div class="flex -mx-1">
<div class="w-1/2 px-1">
<ui-text-input-with-label v-model="details.title" label="Title" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.subtitle" label="Subtitle" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-3/4 px-1">
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="flex-grow px-1">
<ui-multi-select-query-input ref="seriesSelect" v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
</div>
</div>
<ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" />
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
</div>
<div class="flex-grow px-1">
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" />
</div>
<div class="w-1/4 px-1">
<ui-text-input-with-label v-model="details.isbn" label="ISBN" />
</div>
<div class="w-1/4 px-1">
<ui-text-input-with-label v-model="details.asin" label="ASIN" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-text-input-with-label v-model="details.publisher" label="Publisher" />
</div>
<div class="w-1/4 px-1">
<ui-text-input-with-label v-model="details.language" label="Language" />
</div>
<div class="flex-grow px-1 pt-6">
<div class="flex justify-center">
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div>
</div>
</div>
</div>
</form>
<div v-if="showSeriesForm" class="absolute top-0 left-0 z-20 w-full h-full bg-black bg-opacity-50 rounded-lg flex items-center justify-center" @click="cancelSeriesForm">
<div class="absolute top-0 right-0 p-4">
<span class="material-icons text-gray-200 hover:text-white text-4xl cursor-pointer">close</span>
</div>
<form @submit.prevent="submitSeriesForm">
<div class="bg-bg rounded-lg p-8" @click.stop>
<div class="flex">
<div class="flex-grow p-1 min-w-80">
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" />
</div>
<div class="w-40 p-1">
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
</div>
</div>
<div class="flex justify-end mt-2 p-1">
<ui-btn type="submit">Save</ui-btn>
</div>
</div>
</form>
</div>
</div>
</template>
<script>
export default {
props: {
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
selectedSeries: {},
showSeriesForm: false,
details: {
title: null,
subtitle: null,
description: null,
authors: [],
narrators: [],
series: [],
publishYear: null,
publisher: null,
language: null,
isbn: null,
asin: null,
genres: [],
explicit: false
},
newTags: []
}
},
watch: {
libraryItem: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaMetadata() {
return this.media.metadata || {}
},
genres() {
return this.filterData.genres || []
},
tags() {
return this.filterData.tags || []
},
series() {
return this.filterData.series || []
},
narrators() {
return this.filterData.narrators || []
},
filterData() {
return this.$store.state.libraries.filterData || {}
},
existingSeriesNames() {
// Only show series names not already selected
var alreadySelectedSeriesIds = this.details.series.map((se) => se.id)
return this.series.filter((se) => !alreadySelectedSeriesIds.includes(se.id)).map((se) => se.name)
},
seriesItems: {
get() {
return this.details.series.map((se) => {
return {
displayName: se.sequence ? `${se.name} #${se.sequence}` : se.name,
...se
}
})
},
set(val) {
this.details.series = val
}
}
},
methods: {
getDetails() {
this.forceBlur()
return this.checkForChanges()
},
mapBatchDetails(batchDetails) {
for (const key in batchDetails) {
if (key === 'tags') {
this.newTags = [...batchDetails.tags]
} else if (key === 'genres' || key === 'narrators') {
this.details[key] = [...batchDetails[key]]
} else if (key === 'authors' || key === 'series') {
this.details[key] = batchDetails[key].map((i) => ({ ...i }))
} else {
this.details[key] = batchDetails[key]
}
}
},
forceBlur() {
if (this.$refs.authorsSelect && this.$refs.authorsSelect.isFocused) {
this.$refs.authorsSelect.forceBlur()
}
if (this.$refs.narratorsSelect && this.$refs.narratorsSelect.isFocused) {
this.$refs.narratorsSelect.forceBlur()
}
if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {
this.$refs.genresSelect.forceBlur()
}
if (this.$refs.tagsSelect && this.$refs.tagsSelect.isFocused) {
this.$refs.tagsSelect.forceBlur()
}
},
cancelSeriesForm() {
this.showSeriesForm = false
},
editSeriesItem(series) {
var _series = this.details.series.find((se) => se.id === series.id)
if (!_series) return
this.selectedSeries = {
..._series
}
this.showSeriesForm = true
},
addNewSeries() {
this.selectedSeries = {
id: `new-${Date.now()}`,
name: '',
sequence: ''
}
this.showSeriesForm = true
},
submitSeriesForm() {
if (!this.selectedSeries.name) {
this.$toast.error('Must enter a series')
return
}
if (this.$refs.newSeriesSelect) {
this.$refs.newSeriesSelect.blur()
}
var existingSeriesIndex = this.details.series.findIndex((se) => se.id === this.selectedSeries.id)
var seriesSameName = this.series.find((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase())
if (existingSeriesIndex < 0 && seriesSameName) {
this.selectedSeries.id = seriesSameName.id
}
if (existingSeriesIndex >= 0) {
this.details.series.splice(existingSeriesIndex, 1, { ...this.selectedSeries })
} else {
this.details.series.push({
...this.selectedSeries
})
}
this.showSeriesForm = false
},
stringArrayEqual(array1, array2) {
// return false if different
if (array1.length !== array2.length) return false
for (var item of array1) {
if (!array2.includes(item)) return false
}
return true
},
objectArrayEqual(array1, array2) {
const isIterable = (value) => {
return Symbol.iterator in Object(value)
}
if (!isIterable(array1) || !isIterable(array2)) {
console.error(array1, array2)
throw new Error('Invalid arrays passed in')
}
// array of objects with id key
if (array1.length !== array2.length) return false
for (var item of array1) {
var matchingItem = array2.find((a) => a.id === item.id)
if (!matchingItem) return false
for (var key in item) {
if (item[key] !== matchingItem[key]) {
console.log('Object array item keys changed', key, item[key], matchingItem[key])
return false
}
}
}
return true
},
checkForChanges() {
var metadata = {}
for (const key in this.details) {
var newValue = this.details[key]
var oldValue = this.mediaMetadata[key]
// Key cleared out or key first populated
if ((!newValue && oldValue) || (newValue && !oldValue)) {
metadata[key] = newValue
} else if (key === 'narrators' || key === 'genres') {
// Check array of strings
if (!this.stringArrayEqual(newValue, oldValue)) {
metadata[key] = [...newValue]
}
} else if (key === 'authors' || key === 'series') {
if (!this.objectArrayEqual(newValue, oldValue)) {
metadata[key] = newValue.map((v) => ({ ...v }))
}
} else if (newValue && newValue != oldValue) {
// Intentional !=
metadata[key] = newValue
}
}
var updatePayload = {}
if (!!Object.keys(metadata).length) updatePayload.metadata = metadata
if (!this.stringArrayEqual(this.newTags, this.media.tags || [])) {
updatePayload.tags = [...this.newTags]
}
return {
updatePayload,
hasChanges: !!Object.keys(updatePayload).length
}
},
init() {
this.details.title = this.mediaMetadata.title
this.details.subtitle = this.mediaMetadata.subtitle
this.details.description = this.mediaMetadata.description
this.details.authors = (this.mediaMetadata.authors || []).map((se) => ({ ...se }))
this.details.narrators = [...(this.mediaMetadata.narrators || [])]
this.details.genres = [...(this.mediaMetadata.genres || [])]
this.details.series = (this.mediaMetadata.series || []).map((se) => ({ ...se }))
this.details.publishYear = this.mediaMetadata.publishYear
this.details.publisher = this.mediaMetadata.publisher || null
this.details.language = this.mediaMetadata.language || null
this.details.isbn = this.mediaMetadata.isbn || null
this.details.asin = this.mediaMetadata.asin || null
this.details.explicit = !!this.mediaMetadata.explicit
this.newTags = [...(this.media.tags || [])]
},
submitForm() {
this.$emit('submit')
}
},
mounted() {}
}
</script>