New model update details, author and series inputs with create new, compare & copy utils

This commit is contained in:
advplyr 2022-03-11 19:46:32 -06:00
parent f2be3bc95e
commit 5f4e5cd3d8
19 changed files with 707 additions and 70 deletions

View file

@ -87,7 +87,6 @@ export default {
var categories = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1`)
.then((data) => {
console.log('Personalized data', data)
return data
})
.catch((error) => {

View file

@ -168,9 +168,16 @@ export default {
},
sublistItems() {
return (this[this.sublist] || []).map((item) => {
return {
text: item,
value: this.$encode(item)
if (typeof item === 'string') {
return {
text: item,
value: this.$encode(item)
}
} else {
return {
text: item.name,
value: item.id
}
}
})
},

View file

@ -86,6 +86,7 @@ export default {
},
selectedText() {
var _selected = this.selected
if (!_selected) return ''
if (this.selected.startsWith('book.')) _selected = _selected.replace('book.', 'media.metadata.')
var _sel = this.items.find((i) => i.value === _selected)
if (!_sel) return ''

View file

@ -18,7 +18,7 @@
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div>
</div>
<div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300">
<div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative">
<component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
</div>
</modals-modal>
@ -30,7 +30,7 @@ export default {
return {
processing: false,
libraryItem: null,
fetchOnShow: false,
tabs: [
{
id: 'details',
@ -62,11 +62,6 @@ export default {
title: 'Match',
component: 'modals-edit-tabs-match'
}
// {
// id: 'authors',
// title: 'Authors',
// component: 'modals-edit-tabs-authors'
// }
]
}
},
@ -84,11 +79,6 @@ export default {
this.selectedTab = availableTabIds[0]
}
if (this.libraryItem && this.libraryItem.id === this.selectedLibraryItemId) {
if (this.fetchOnShow) this.fetchFull()
return
}
this.fetchOnShow = false
this.libraryItem = null
this.init()
this.registerListeners()
@ -214,14 +204,10 @@ export default {
this.selectedTab = tab
}
},
audiobookUpdated() {
if (!this.show) this.fetchOnShow = true
else {
this.fetchFull()
}
libraryItemUpdated(expandedLibraryItem) {
this.libraryItem = expandedLibraryItem
},
init() {
this.$store.commit('audiobooks/addListener', { meth: this.audiobookUpdated, id: 'edit-modal', audiobookId: this.selectedLibraryItemId })
this.fetchFull()
},
async fetchFull() {
@ -244,9 +230,11 @@ export default {
},
registerListeners() {
this.$eventBus.$on('modal-hotkey', this.hotkey)
this.$eventBus.$on(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
},
unregisterListeners() {
this.$eventBus.$off('modal-hotkey', this.hotkey)
this.$eventBus.$off(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated)
}
},
mounted() {},

View file

@ -13,9 +13,7 @@
<div class="flex mt-2 -mx-1">
<div class="w-3/4 px-1">
<!-- <ui-text-input-with-label v-model="details.authors" label="Author" /> -->
<!-- <p>Authors placeholder</p> -->
<ui-multi-select-query-input ref="authorsSelect" v-model="authorNames" label="Authors" endpoint="authors/search" />
<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" />
@ -23,12 +21,8 @@
</div>
<div class="flex mt-2 -mx-1">
<div class="w-3/4 px-1">
<p>Series placeholder</p>
<!-- <ui-input-dropdown ref="seriesDropdown" v-model="details.series" label="Series" :items="series" /> -->
</div>
<div class="flex-grow px-1">
<!-- <ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" /> -->
<ui-multi-select-query-input ref="seriesSelect" v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
</div>
</div>
@ -87,6 +81,27 @@
</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>
@ -101,6 +116,8 @@ export default {
},
data() {
return {
selectedSeries: {},
showSeriesForm: false,
details: {
title: null,
subtitle: null,
@ -116,7 +133,6 @@ export default {
genres: []
},
newTags: [],
authorNames: [],
resettingProgress: false,
isScrollable: false,
savingMetadata: false,
@ -180,9 +196,71 @@ 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 = {
@ -249,8 +327,8 @@ export default {
return
}
this.isProcessing = true
if (this.$refs.seriesDropdown && this.$refs.seriesDropdown.isFocused) {
this.$refs.seriesDropdown.blur()
if (this.$refs.authorsSelect && this.$refs.authorsSelect.isFocused) {
this.$refs.authorsSelect.forceBlur()
}
if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {
this.$refs.genresSelect.forceBlur()
@ -262,11 +340,11 @@ export default {
},
async handleForm() {
const updatePayload = {
book: this.details,
metadata: this.details,
tags: this.newTags
}
var updatedAudiobook = await this.$axios.$patch(`/api/books/${this.libraryItemId}`, updatePayload).catch((error) => {
console.log('Sending update', updatePayload)
var updatedAudiobook = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
@ -280,18 +358,24 @@ export default {
this.details.title = this.mediaMetadata.title
this.details.subtitle = this.mediaMetadata.subtitle
this.details.description = this.mediaMetadata.description
this.details.authors = this.mediaMetadata.authors || []
this.$set(
this.details,
'authors',
(this.mediaMetadata.authors || []).map((se) => ({ ...se }))
)
this.details.narrator = this.mediaMetadata.narrator
this.details.genres = this.mediaMetadata.genres || []
this.details.series = this.mediaMetadata.series
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 || []
this.authorNames = this.details.authors.map((au) => au.name)
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`)) {

View file

@ -3,14 +3,15 @@
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
<div ref="wrapper" class="relative">
<form @submit.prevent="submitForm">
<div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded-md px-2 py-1 cursor-text" :class="disabled ? 'bg-black-300' : 'bg-primary'" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative">
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-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 v-if="showEdit" class="material-icons text-white hover:text-warning" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span>
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
</div>
{{ item }}
</div>
<input ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
</div>
</form>
@ -47,7 +48,9 @@ export default {
default: () => []
},
label: String,
disabled: Boolean
disabled: Boolean,
readonly: Boolean,
showEdit: Boolean
},
data() {
return {
@ -76,6 +79,13 @@ export default {
showMenu() {
return this.isFocused
},
wrapperClass() {
var classes = []
if (this.disabled) classes.push('bg-black-300')
else classes.push('bg-primary')
if (!this.readonly) classes.push('cursor-text')
return classes.join(' ')
},
itemsToShow() {
if (!this.currentSearch || !this.textInput) {
return this.items
@ -88,6 +98,9 @@ export default {
}
},
methods: {
editItem(item) {
this.$emit('edit', item)
},
keydownInput() {
clearTimeout(this.typingTimeout)
this.typingTimeout = setTimeout(() => {

View file

@ -3,26 +3,30 @@
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
<div ref="wrapper" class="relative">
<form @submit.prevent="submitForm">
<div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded-md px-2 py-1 cursor-text" :class="disabled ? 'bg-black-300' : 'bg-primary'" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative">
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-0.5" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
<div v-for="item in selected" :key="item.id" class="rounded-full px-2 py-0.5 m-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center justify-center relative min-w-12">
<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)">close</span>
<span v-if="showEdit" class="material-icons text-white hover:text-warning mr-1" style="font-size: 1rem" @click.stop="editItem(item)">edit</span>
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item.id)">close</span>
</div>
{{ item }}
{{ item[textKey] }}
</div>
<input ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
<div v-if="showEdit" class="rounded-full cursor-pointer w-6 h-6 mx-0.5 bg-bg flex items-center justify-center">
<span class="material-icons text-white hover:text-success pt-px pr-px" style="font-size: 1.1rem" @click.stop="addItem">add</span>
</div>
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
</div>
</form>
<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 itemsToShow">
<li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item.name)" @mouseup.stop.prevent @mousedown.prevent>
<li :key="item.id" 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.name }}</span>
</div>
<!-- <span v-if="selected.includes(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span v-if="getIsSelected(item.id)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">checkmark</span>
</span> -->
</span>
</li>
</template>
<li v-if="!itemsToShow.length" class="text-gray-50 select-none relative py-2 pr-9" role="option">
@ -44,7 +48,13 @@ export default {
},
endpoint: String,
label: String,
disabled: Boolean
disabled: Boolean,
readonly: Boolean,
showEdit: Boolean,
textKey: {
type: String,
default: 'name'
}
},
data() {
return {
@ -66,20 +76,36 @@ export default {
computed: {
selected: {
get() {
return this.value
return this.value || []
},
set(val) {
this.$emit('input', val)
}
},
wrapperClass() {
var classes = []
if (this.disabled) classes.push('bg-black-300')
else classes.push('bg-primary')
if (!this.readonly) classes.push('cursor-text')
return classes.join(' ')
},
showMenu() {
return this.isFocused
return this.isFocused && this.currentSearch
},
itemsToShow() {
return this.items
}
},
methods: {
addItem() {
this.$emit('add')
},
editItem(item) {
this.$emit('edit', item)
},
getIsSelected(itemValue) {
return !!this.selected.find((i) => i.id === itemValue)
},
async search() {
if (this.searching) return
this.currentSearch = this.textInput
@ -96,7 +122,7 @@ export default {
clearTimeout(this.typingTimeout)
this.typingTimeout = setTimeout(() => {
this.search()
}, 500)
}, 250)
this.setInputWidth()
},
setInputWidth() {
@ -165,7 +191,7 @@ export default {
if (this.textInput) this.submitForm()
if (this.$refs.input) this.$refs.input.blur()
},
clickedOption(e, itemValue) {
clickedOption(e, item) {
if (e) {
e.stopPropagation()
e.preventDefault()
@ -173,11 +199,11 @@ export default {
if (this.$refs.input) this.$refs.input.focus()
var newSelected = null
if (this.selected.includes(itemValue)) {
newSelected = this.selected.filter((s) => s !== itemValue)
this.$emit('removedItem', itemValue)
if (this.getIsSelected(item.id)) {
newSelected = this.selected.filter((s) => s.id !== item.id)
this.$emit('removedItem', item.id)
} else {
newSelected = this.selected.concat([itemValue])
newSelected = this.selected.concat([item])
}
this.textInput = null
this.currentSearch = null
@ -193,10 +219,10 @@ export default {
}
this.focus()
},
removeItem(item) {
var remaining = this.selected.filter((i) => i !== item)
removeItem(itemId) {
var remaining = this.selected.filter((i) => i.id !== itemId)
this.$emit('input', remaining)
this.$emit('removedItem', item)
this.$emit('removedItem', itemId)
this.$nextTick(() => {
this.recalcMenuPos()
})
@ -221,7 +247,10 @@ export default {
if (matchesItem) {
this.clickedOption(null, matchesItem)
} else {
this.insertNewItem(this.textInput)
this.insertNewItem({
id: `new-${Date.now()}`,
name: this.textInput
})
}
},
scroll() {

View file

@ -0,0 +1,157 @@
<template>
<div class="w-full">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
<div ref="wrapper" class="relative">
<form @submit.prevent="submitForm">
<div ref="inputWrapper" class="input-wrapper flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'">
<input ref="input" v-model="textInput" :disabled="disabled" class="h-full w-full bg-transparent focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
</div>
</form>
<ul ref="menu" v-show="isFocused && 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">
<template v-for="item in items">
<li :key="item.id" 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">
<span class="font-normal ml-3 block truncate">{{ item.name }}</span>
</div>
<span v-if="isItemSelected(item)" 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: String,
disabled: Boolean,
label: String,
endpoint: String
},
data() {
return {
isFocused: false,
currentSearch: null,
typingTimeout: null,
textInput: null,
searching: false,
items: [],
selectedItemObject: null
}
},
watch: {
value: {
immediate: true,
handler(newVal) {
this.textInput = newVal
}
}
},
computed: {
input: {
get() {
return this.value || ''
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
isItemSelected(item) {
return !!this.input.toLowerCase() === item.name
},
async search() {
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) => {
console.error('Failed to get search results', error)
return []
})
console.log('Search results', results)
this.items = results || []
this.searching = false
},
keydownInput() {
clearTimeout(this.typingTimeout)
this.typingTimeout = setTimeout(() => {
this.search()
}, 250)
},
inputFocus() {
this.isFocused = true
},
blur() {
// Handle blur immediately
this.isFocused = false
if (this.inputName.toLowerCase() !== this.textInput.toLowerCase()) {
var val = this.textInput ? this.textInput.trim() : null
if (val) {
this.submitForm()
}
}
if (this.$refs.input) {
this.$refs.input.blur()
}
},
inputBlur() {
if (!this.isFocused) return
setTimeout(() => {
if (document.activeElement === this.$refs.input) {
return
}
this.isFocused = false
if (this.input !== this.textInput) {
var val = this.textInput ? this.textInput.trim() : null
if (val) {
this.setItem(val)
}
}
}, 50)
},
submitForm() {
var val = this.textInput ? this.textInput.trim() : null
if (val) {
this.setItem(val)
}
},
setItem(itemText) {
if (!this.items.find((i) => i.name.toLowerCase() !== val.toLowerCase())) {
var newItem = {
id: `new-${Date.now()}`,
name: val
}
this.$emit('selected', newItem)
this.input = val
} else {
var item = this.items.find((i) => i.name.toLowerCase() !== val.toLowerCase())
this.$emit('selected', item)
this.input = item.name
}
this.currentSearch = null
},
clickedOption(e, item) {
this.textInput = item.name
this.currentSearch = null
this.input = item.name
this.selectedItemObject = item
this.$emit('selected', item)
if (this.$refs.input) this.$refs.input.blur()
}
},
mounted() {}
}
</script>