feat(webui): custom cover upload

Closes #473 

Co-authored-by: Gauthier Roebroeck <gauthier.roebroeck@gmail.com>
This commit is contained in:
Snd-R 2022-01-24 14:46:39 +03:00 committed by GitHub
parent 9871487194
commit 2a56fffa9a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 914 additions and 141 deletions

View file

@ -129,8 +129,18 @@ import {RawLocation} from 'vue-router'
import ReadListActionsMenu from '@/components/menus/ReadListActionsMenu.vue'
import {BookDto} from '@/types/komga-books'
import {SeriesDto} from '@/types/komga-series'
import {THUMBNAILBOOK_ADDED, THUMBNAILSERIES_ADDED} from '@/types/events'
import {ThumbnailBookSseDto, ThumbnailSeriesSseDto} from '@/types/komga-sse'
import {
THUMBNAILBOOK_ADDED, THUMBNAILBOOK_DELETED,
THUMBNAILCOLLECTION_ADDED, THUMBNAILCOLLECTION_DELETED,
THUMBNAILREADLIST_ADDED, THUMBNAILREADLIST_DELETED,
THUMBNAILSERIES_ADDED, THUMBNAILSERIES_DELETED,
} from '@/types/events'
import {
ThumbnailBookSseDto,
ThumbnailCollectionSseDto,
ThumbnailReadListSseDto,
ThumbnailSeriesSseDto,
} from '@/types/komga-sse'
import {coverBase64} from '@/types/image'
export default Vue.extend({
@ -194,12 +204,30 @@ export default Vue.extend({
}
},
created() {
this.$eventHub.$on(THUMBNAILBOOK_ADDED, this.thumbnailBookAdded)
this.$eventHub.$on(THUMBNAILSERIES_ADDED, this.thumbnailSeriesAdded)
this.$eventHub.$on(THUMBNAILBOOK_ADDED, this.thumbnailBookChanged)
this.$eventHub.$on(THUMBNAILBOOK_DELETED, this.thumbnailBookChanged)
this.$eventHub.$on(THUMBNAILSERIES_ADDED, this.thumbnailSeriesChanged)
this.$eventHub.$on(THUMBNAILSERIES_DELETED, this.thumbnailSeriesChanged)
this.$eventHub.$on(THUMBNAILREADLIST_ADDED, this.thumbnailReadListChanged)
this.$eventHub.$on(THUMBNAILREADLIST_DELETED, this.thumbnailReadListChanged)
this.$eventHub.$on(THUMBNAILCOLLECTION_ADDED, this.thumbnailCollectionChanged)
this.$eventHub.$on(THUMBNAILCOLLECTION_DELETED, this.thumbnailCollectionChanged)
},
beforeDestroy() {
this.$eventHub.$off(THUMBNAILBOOK_ADDED, this.thumbnailBookAdded)
this.$eventHub.$off(THUMBNAILSERIES_ADDED, this.thumbnailSeriesAdded)
this.$eventHub.$off(THUMBNAILBOOK_ADDED, this.thumbnailBookChanged)
this.$eventHub.$off(THUMBNAILBOOK_DELETED, this.thumbnailBookChanged)
this.$eventHub.$off(THUMBNAILSERIES_ADDED, this.thumbnailSeriesChanged)
this.$eventHub.$off(THUMBNAILSERIES_DELETED, this.thumbnailSeriesChanged)
this.$eventHub.$off(THUMBNAILREADLIST_ADDED, this.thumbnailReadListChanged)
this.$eventHub.$off(THUMBNAILREADLIST_DELETED, this.thumbnailReadListChanged)
this.$eventHub.$off(THUMBNAILCOLLECTION_ADDED, this.thumbnailCollectionChanged)
this.$eventHub.$off(THUMBNAILCOLLECTION_DELETED, this.thumbnailCollectionChanged)
},
computed: {
canReadPages(): boolean {
@ -259,15 +287,25 @@ export default Vue.extend({
},
},
methods: {
thumbnailBookAdded(event: ThumbnailBookSseDto) {
if (this.thumbnailError &&
((this.computedItem.type() === ItemTypes.BOOK && event.bookId === this.item.id) || (this.computedItem.type() === ItemTypes.SERIES && event.seriesId === this.item.id))
thumbnailBookChanged(event: ThumbnailBookSseDto) {
if (event.selected && (this.computedItem.type() === ItemTypes.BOOK && event.bookId === this.item.id)
|| (this.computedItem.type() === ItemTypes.SERIES && event.seriesId === this.item.id)
) {
this.thumbnailCacheBust = '?' + this.$_.random(1000)
}
},
thumbnailSeriesAdded(event: ThumbnailSeriesSseDto) {
if (this.computedItem.type() === ItemTypes.SERIES && event.seriesId === this.item.id) {
thumbnailSeriesChanged(event: ThumbnailSeriesSseDto) {
if (event.selected && this.computedItem.type() === ItemTypes.SERIES && event.seriesId === this.item.id) {
this.thumbnailCacheBust = '?' + this.$_.random(1000)
}
},
thumbnailReadListChanged(event: ThumbnailReadListSseDto) {
if (event.selected && this.computedItem.type() === ItemTypes.READLIST && event.readListId === this.item.id) {
this.thumbnailCacheBust = '?' + this.$_.random(1000)
}
},
thumbnailCollectionChanged(event: ThumbnailCollectionSseDto) {
if (event.selected && this.computedItem.type() === ItemTypes.COLLECTION && event.collectionId === this.item.id) {
this.thumbnailCacheBust = '?' + this.$_.random(1000)
}
},

View file

@ -56,7 +56,13 @@
<script lang="ts">
import Vue from 'vue'
import {SeriesThumbnailDto} from '@/types/komga-series'
import {seriesThumbnailUrlByThumbnailId} from '@/functions/urls'
import {
bookThumbnailUrlByThumbnailId,
collectionThumbnailUrlByThumbnailId,
readListThumbnailUrlByThumbnailId,
seriesThumbnailUrlByThumbnailId,
} from '@/functions/urls'
import {BookThumbnailDto} from '@/types/komga-books'
export default Vue.extend({
name: 'ThumbnailCard',
@ -67,7 +73,7 @@ export default Vue.extend({
if (value instanceof File) {
return true
}
return 'id' in value && 'seriesId' in value && 'type' in value && 'selected' in value
return 'id' in value && 'type' in value && 'selected' in value && ('seriesId' in value || 'bookId' in value || 'readListId' in value || 'collectionId' in value)
},
},
selected: {
@ -80,7 +86,7 @@ export default Vue.extend({
},
},
methods: {
getStatusIcon(item: File | SeriesThumbnailDto): string {
getStatusIcon(item: File | SeriesThumbnailDto | BookThumbnailDto | ReadListThumbnailDto | CollectionThumbnailDto): string {
if (item instanceof File) {
if (this.isFileToBig(item)) {
return 'mdi-alert-circle'
@ -90,12 +96,14 @@ export default Vue.extend({
} else {
if (item.type === 'SIDECAR') {
return 'mdi-folder-outline'
} else if (item.type === 'GENERATED') {
return 'mdi-file-outline'
} else {
return 'mdi-cloud-check-outline'
}
}
},
getStatusTooltip(item: File | SeriesThumbnailDto): string {
getStatusTooltip(item: File | SeriesThumbnailDto | BookThumbnailDto | ReadListThumbnailDto | CollectionThumbnailDto): string {
if (item instanceof File) {
if (this.isFileToBig(item)) {
return this.$t('thumbnail_card.tooltip_too_big').toString()
@ -105,23 +113,34 @@ export default Vue.extend({
} else {
if (item.type === 'SIDECAR') {
return this.$t('thumbnail_card.tooltip_sidecar').toString()
}
if (item.type === 'GENERATED') {
return this.$t('thumbnail_card.tooltip_generated').toString()
} else {
return this.$t('thumbnail_card.tooltip_user_uploaded').toString()
}
}
},
isFileToBig(item: File | SeriesThumbnailDto): boolean {
isFileToBig(item: File | SeriesThumbnailDto | BookThumbnailDto | ReadListThumbnailDto | CollectionThumbnailDto): boolean {
if (item instanceof File) {
return item.size > 1_000_000
} else {
return false
}
},
getImage(item: File | SeriesThumbnailDto): string {
getImage(item: File | SeriesThumbnailDto | BookThumbnailDto | ReadListThumbnailDto | CollectionThumbnailDto): string {
if (item instanceof File) {
return URL.createObjectURL(item)
} else {
} else if ('seriesId' in item) {
return seriesThumbnailUrlByThumbnailId(item.seriesId, item.id)
} else if ('bookId' in item) {
return bookThumbnailUrlByThumbnailId(item.bookId, item.id)
} else if ('readListId' in item) {
return readListThumbnailUrlByThumbnailId(item.readListId, item.id)
} else if ('collectionId' in item) {
return collectionThumbnailUrlByThumbnailId(item.collectionId, item.id)
} else {
throw new Error('The given item type is not known!')
}
},
onClickSelect() {
@ -129,11 +148,11 @@ export default Vue.extend({
this.$emit('on-select-thumbnail', this.item)
}
},
isDeletable(item: File | SeriesThumbnailDto) {
isDeletable(item: File | SeriesThumbnailDto | BookThumbnailDto | ReadListThumbnailDto | CollectionThumbnailDto) {
if (item instanceof File) {
return true
} else {
return item.type !== 'SIDECAR'
return item.type !== 'SIDECAR' && item.type !== 'GENERATED'
}
},
onClickDelete() {

View file

@ -1,65 +1,128 @@
<template>
<v-dialog v-model="modal"
max-width="450"
:fullscreen="$vuetify.breakpoint.xsOnly"
max-width="800"
@keydown.esc="dialogCancel"
>
<v-card>
<v-card-title>{{ $t('dialog.edit_collection.dialog_title') }}</v-card-title>
<form novalidate>
<v-card>
<v-card-title class="hidden-xs-only">
<v-icon class="mx-4">mdi-pencil</v-icon>
{{ $t('dialog.edit_collection.dialog_title') }}
</v-card-title>
<v-card-text>
<v-container fluid>
<v-row>
<v-col>
<v-text-field v-model="form.name"
label="Name"
:error-messages="getErrorsName"
/>
</v-col>
</v-row>
<v-tabs :vertical="$vuetify.breakpoint.smAndUp" v-model="tab">
<v-tab class="justify-start">
<v-icon left class="hidden-xs-only">mdi-format-align-center</v-icon>
{{ $t('dialog.edit_collection.tab_general') }}
</v-tab>
<v-tab class="justify-start">
<v-icon left class="hidden-xs-only">mdi-image</v-icon>
{{ $t('dialog.edit_collection.tab_poster') }}
</v-tab>
<v-row>
<v-col>
<div class="text-body-2">{{ $t('dialog.edit_collection.label_ordering') }}</div>
<v-checkbox
v-model="form.ordered"
:label="$t('dialog.edit_collection.field_manual_ordering')"
hide-details
/>
</v-col>
</v-row>
<!-- Tab: General -->
<v-tab-item>
<v-card flat>
<v-container fluid>
<v-row>
<v-col>
<v-text-field v-model="form.name"
label="Name"
:error-messages="getErrorsName"
/>
</v-col>
</v-row>
</v-container>
</v-card-text>
<v-row>
<v-col>
<div class="text-body-2">{{ $t('dialog.edit_collection.label_ordering') }}</div>
<v-checkbox
v-model="form.ordered"
:label="$t('dialog.edit_collection.field_manual_ordering')"
hide-details
/>
</v-col>
</v-row>
<v-card-actions>
<v-spacer/>
<v-btn text @click="dialogCancel">{{ $t('dialog.edit_collection.button_cancel') }}</v-btn>
<v-btn color="primary"
@click="dialogConfirm"
:disabled="getErrorsName !== ''"
>{{ $t('dialog.edit_collection.button_confirm') }}
</v-btn>
</v-card-actions>
</v-card>
</v-container>
</v-card>
</v-tab-item>
<!-- Tab: Thumbnails -->
<v-tab-item>
<v-card flat>
<v-container fluid>
<!-- Upload -->
<v-row>
<v-col class="pa-1">
<drop-zone ref="thumbnailsUpload" @on-input-change="addThumbnail" class="pa-8"/>
</v-col>
</v-row>
<!-- Gallery -->
<v-row>
<v-col
cols="6" sm="4" lg="3" class="pa-1"
v-for="(item, index) in [...poster.uploadQueue, ...poster.collectionThumbnails]"
:key="index"
>
<thumbnail-card
:item="item"
:selected="isThumbnailSelected(item)"
:toBeDeleted="isThumbnailToBeDeleted(item)"
@on-select-thumbnail="selectThumbnail"
@on-delete-thumbnail="deleteThumbnail"
/>
</v-col>
</v-row>
</v-container>
</v-card>
</v-tab-item>
</v-tabs>
<v-card-actions>
<v-spacer/>
<v-btn text @click="dialogCancel">{{ $t('dialog.edit_collection.button_cancel') }}</v-btn>
<v-btn color="primary"
@click="dialogConfirm"
:disabled="getErrorsName !== ''"
>{{ $t('dialog.edit_collection.button_confirm') }}
</v-btn>
</v-card-actions>
</v-card>
</form>
</v-dialog>
</template>
<script lang="ts">
import {UserRoles} from '@/types/enum-users'
import Vue from 'vue'
import {ERROR} from '@/types/events'
import {ERROR, ErrorEvent} from '@/types/events'
import {LibraryDto} from '@/types/komga-libraries'
import ThumbnailCard from '@/components/ThumbnailCard.vue'
import DropZone from '@/components/DropZone.vue'
export default Vue.extend({
name: 'CollectionEditDialog',
components: {ThumbnailCard, DropZone},
data: () => {
return {
UserRoles,
modal: false,
tab: 0,
collections: [] as CollectionDto[],
form: {
name: '',
ordered: false,
},
poster: {
selectedThumbnail: '',
uploadQueue: [] as File[],
deleteQueue: [] as CollectionThumbnailDto[],
collectionThumbnails: [] as CollectionThumbnailDto[],
},
}
},
props: {
@ -79,6 +142,7 @@ export default Vue.extend({
},
modal(val) {
!val && this.dialogCancel()
val && this.getThumbnails(this.collection)
},
collection: {
handler(val) {
@ -101,8 +165,14 @@ export default Vue.extend({
},
methods: {
async dialogReset(collection: CollectionDto) {
this.tab = 0
this.form.name = collection.name
this.form.ordered = collection.ordered
this.poster.selectedThumbnail = ''
this.poster.deleteQueue = []
this.poster.uploadQueue = []
this.poster.collectionThumbnails = []
},
dialogCancel() {
this.$emit('input', false)
@ -113,6 +183,34 @@ export default Vue.extend({
},
async editCollection() {
try {
if (this.poster.uploadQueue.length > 0) {
let hadErrors = false
for (const file of this.poster.uploadQueue.slice()) {
try {
await this.$komgaCollections.uploadThumbnail(this.collection.id, file, file.name === this.poster.selectedThumbnail)
this.deleteThumbnail(file)
} catch (e) {
this.$eventHub.$emit(ERROR, {message: e.message} as ErrorEvent)
hadErrors = true
}
}
if (hadErrors) {
await this.getThumbnails(this.collection)
return false
}
}
if (this.poster.selectedThumbnail !== '') {
const id = this.poster.selectedThumbnail
if (this.poster.collectionThumbnails.find(value => value.id === id)) {
await this.$komgaCollections.markThumbnailAsSelected(this.collection.id, id)
}
}
if (this.poster.deleteQueue.length > 0) {
this.poster.deleteQueue.forEach(toDelete => this.$komgaCollections.deleteThumbnail(toDelete.collectionId, toDelete.id))
}
const update = {
name: this.form.name,
ordered: this.form.ordered,
@ -123,6 +221,71 @@ export default Vue.extend({
this.$eventHub.$emit(ERROR, {message: e.message} as ErrorEvent)
}
},
addThumbnail(files: File[]) {
let hasSelected = false
for (const file of files) {
if (!this.poster.uploadQueue.find(value => value.name === file.name)) {
this.poster.uploadQueue.push(file)
if (!hasSelected) {
this.selectThumbnail(file)
hasSelected = true
}
}
}
(this.$refs.thumbnailsUpload as any).reset()
},
async getThumbnails(readList: CollectionDto) {
const thumbnails = await this.$komgaCollections.getThumbnails(readList.id)
this.selectThumbnail(thumbnails.find(x => x.selected))
this.poster.collectionThumbnails = thumbnails
},
isThumbnailSelected(item: File | CollectionThumbnailDto): boolean {
return item instanceof File ? item.name === this.poster.selectedThumbnail : item.id === this.poster.selectedThumbnail
},
selectThumbnail(item: File | CollectionThumbnailDto | undefined) {
if (!item) {
return
} else if (item instanceof File) {
this.poster.selectedThumbnail = item.name
} else {
const index = this.poster.deleteQueue.indexOf(item, 0)
if (index > -1) this.poster.deleteQueue.splice(index, 1)
this.poster.selectedThumbnail = item.id
}
},
isThumbnailToBeDeleted(item: File | CollectionThumbnailDto) {
if (item instanceof File) {
return false
} else {
return this.poster.deleteQueue.includes(item)
}
},
deleteThumbnail(item: File | CollectionThumbnailDto) {
if (item instanceof File) {
const index = this.poster.uploadQueue.indexOf(item, 0)
if (index > -1) {
this.poster.uploadQueue.splice(index, 1)
}
if (item.name === this.poster.selectedThumbnail) {
this.poster.selectedThumbnail = ''
}
} else {
// if thumbnail was marked for deletion, unmark it
if (this.isThumbnailToBeDeleted(item)) {
const index = this.poster.deleteQueue.indexOf(item, 0)
if (index > -1) {
this.poster.deleteQueue.splice(index, 1)
}
} else {
this.poster.deleteQueue.push(item)
if (item.id === this.poster.selectedThumbnail) this.poster.selectedThumbnail = ''
}
}
},
},
})
</script>

View file

@ -39,6 +39,10 @@
<v-icon left class="hidden-xs-only">mdi-link</v-icon>
{{ $t('dialog.edit_books.tab_links') }}
</v-tab>
<v-tab class="justify-start" v-if="single">
<v-icon left class="hidden-xs-only">mdi-image</v-icon>
{{ $t('dialog.edit_books.tab_poster') }}
</v-tab>
<!-- Tab: General -->
<v-tab-item v-if="single">
@ -357,6 +361,37 @@
</v-card>
</v-tab-item>
<!-- Tab: Thumbnails -->
<v-tab-item v-if="single">
<v-card flat>
<v-container fluid>
<!-- Upload -->
<v-row>
<v-col class="pa-1">
<drop-zone ref="thumbnailsUpload" @on-input-change="addThumbnail" class="pa-8"/>
</v-col>
</v-row>
<!-- Gallery -->
<v-row>
<v-col
cols="6" sm="4" lg="3" class="pa-1"
v-for="(item, index) in [...poster.uploadQueue, ...poster.bookThumbnails]"
:key="index"
>
<thumbnail-card
:item="item"
:selected="isThumbnailSelected(item)"
:toBeDeleted="isThumbnailToBeDeleted(item)"
@on-select-thumbnail="selectThumbnail"
@on-delete-thumbnail="deleteThumbnail"
/>
</v-col>
</v-row>
</v-container>
</v-card>
</v-tab-item>
</v-tabs>
<v-card-actions class="hidden-xs-only">
@ -374,16 +409,19 @@ import {groupAuthorsByRole} from '@/functions/authors'
import {authorRoles} from '@/types/author-roles'
import Vue from 'vue'
import {helpers, requiredIf} from 'vuelidate/lib/validators'
import {BookDto} from '@/types/komga-books'
import {BookDto, BookThumbnailDto} from '@/types/komga-books'
import IsbnVerify from '@saekitominaga/isbn-verify'
import {isMatch} from 'date-fns'
import {ERROR} from '@/types/events'
import {ERROR, ErrorEvent} from '@/types/events'
import DropZone from '@/components/DropZone.vue'
import ThumbnailCard from '@/components/ThumbnailCard.vue'
const validDate = (value: string) => !helpers.req(value) || isMatch(value, 'yyyy-MM-dd')
const validIsbn = (value: string) => !helpers.req(value) || new IsbnVerify(value).isIsbn13({check_digit: true})
export default Vue.extend({
name: 'EditBooksDialog',
components: {ThumbnailCard, DropZone},
data: () => {
return {
modal: false,
@ -412,6 +450,12 @@ export default Vue.extend({
links: [],
linksLock: false,
},
poster: {
selectedThumbnail: '',
uploadQueue: [] as File[],
deleteQueue: [] as BookThumbnailDto[],
bookThumbnails: [] as BookThumbnailDto[],
},
authorSearch: [],
authorSearchResults: [] as string[],
tagsAvailable: [] as string[],
@ -430,6 +474,7 @@ export default Vue.extend({
},
modal(val) {
!val && this.dialogCancel()
val && this.getThumbnails(this.books)
},
books: {
immediate: true,
@ -571,6 +616,10 @@ export default Vue.extend({
const book = books as BookDto
this.$_.merge(this.form, book.metadata)
this.form.authors = groupAuthorsByRole(book.metadata.authors)
this.poster.selectedThumbnail = ''
this.poster.deleteQueue = []
this.poster.uploadQueue = []
this.poster.bookThumbnails = []
}
},
dialogCancel() {
@ -646,6 +695,36 @@ export default Vue.extend({
return null
},
async editBooks(): Promise<boolean> {
if (this.single && this.poster.uploadQueue.length > 0) {
const book = this.books as BookDto
let hadErrors = false
for (const file of this.poster.uploadQueue.slice()) {
try {
await this.$komgaBooks.uploadThumbnail(book.id, file, file.name === this.poster.selectedThumbnail)
this.deleteThumbnail(file)
} catch (e) {
this.$eventHub.$emit(ERROR, {message: e.message} as ErrorEvent)
hadErrors = true
}
}
if (hadErrors) {
await this.getThumbnails(book)
return false
}
}
if (this.single && this.poster.selectedThumbnail !== '') {
const id = this.poster.selectedThumbnail
const book = this.books as BookDto
if (this.poster.bookThumbnails.find(value => value.id === id)) {
await this.$komgaBooks.markThumbnailAsSelected(book.id, id)
}
}
if (this.single && this.poster.deleteQueue.length > 0) {
this.poster.deleteQueue.forEach(toDelete => this.$komgaBooks.deleteThumbnail(toDelete.bookId, toDelete.id))
}
const metadata = this.validateForm()
if (metadata) {
const toUpdate = (this.single ? [this.books] : this.books) as BookDto[]
@ -659,6 +738,73 @@ export default Vue.extend({
return true
} else return false
},
addThumbnail(files: File[]) {
let hasSelected = false
for (const file of files) {
if (!this.poster.uploadQueue.find(value => value.name === file.name)) {
this.poster.uploadQueue.push(file)
if (!hasSelected) {
this.selectThumbnail(file)
hasSelected = true
}
}
}
(this.$refs.thumbnailsUpload as any).reset()
},
async getThumbnails(book: BookDto | BookDto[]) {
if (Array.isArray(book)) return
const thumbnails = await this.$komgaBooks.getThumbnails(book.id)
this.selectThumbnail(thumbnails.find(x => x.selected))
this.poster.bookThumbnails = thumbnails
},
isThumbnailSelected(item: File | BookThumbnailDto): boolean {
return item instanceof File ? item.name === this.poster.selectedThumbnail : item.id === this.poster.selectedThumbnail
},
selectThumbnail(item: File | BookThumbnailDto | undefined) {
if (!item) {
return
} else if (item instanceof File) {
this.poster.selectedThumbnail = item.name
} else {
const index = this.poster.deleteQueue.indexOf(item, 0)
if (index > -1) this.poster.deleteQueue.splice(index, 1)
this.poster.selectedThumbnail = item.id
}
},
isThumbnailToBeDeleted(item: File | BookThumbnailDto) {
if (item instanceof File) {
return false
} else {
return this.poster.deleteQueue.includes(item)
}
},
deleteThumbnail(item: File | BookThumbnailDto) {
if (item instanceof File) {
const index = this.poster.uploadQueue.indexOf(item, 0)
if (index > -1) {
this.poster.uploadQueue.splice(index, 1)
}
if (item.name === this.poster.selectedThumbnail) {
this.poster.selectedThumbnail = ''
}
} else {
// if thumbnail was marked for deletion, unmark it
if (this.isThumbnailToBeDeleted(item)) {
const index = this.poster.deleteQueue.indexOf(item, 0)
if (index > -1) {
this.poster.deleteQueue.splice(index, 1)
}
} else {
this.poster.deleteQueue.push(item)
if (item.id === this.poster.selectedThumbnail) this.poster.selectedThumbnail = ''
}
}
},
},
})
</script>

View file

@ -737,6 +737,9 @@ export default Vue.extend({
} else if (item instanceof File) {
this.poster.selectedThumbnail = item.name
} else {
const index = this.poster.deleteQueue.indexOf(item, 0)
if (index > -1) this.poster.deleteQueue.splice(index, 1)
this.poster.selectedThumbnail = item.id
}
},
@ -765,6 +768,7 @@ export default Vue.extend({
}
} else {
this.poster.deleteQueue.push(item)
if (item.id === this.poster.selectedThumbnail) this.poster.selectedThumbnail = ''
}
}
},

View file

@ -1,66 +1,130 @@
<template>
<v-dialog v-model="modal"
max-width="450"
:fullscreen="$vuetify.breakpoint.xsOnly"
max-width="800"
@keydown.esc="dialogCancel"
>
<v-card>
<v-card-title>{{ $t('dialog.edit_readlist.dialog_title') }}</v-card-title>
<form novalidate>
<v-card>
<v-card-title class="hidden-xs-only">
<v-icon class="mx-4">mdi-pencil</v-icon>
{{ $t('dialog.edit_readlist.dialog_title') }}
</v-card-title>
<v-card-text>
<v-container fluid>
<!-- Name -->
<v-row>
<v-col>
<v-text-field v-model="form.name"
:label="$t('dialog.edit_readlist.field_name')"
:error-messages="getErrorsName"
filled
/>
</v-col>
</v-row>
<v-tabs :vertical="$vuetify.breakpoint.smAndUp" v-model="tab">
<v-tab class="justify-start">
<v-icon left class="hidden-xs-only">mdi-format-align-center</v-icon>
{{ $t('dialog.edit_readlist.tab_general') }}
</v-tab>
<v-tab class="justify-start">
<v-icon left class="hidden-xs-only">mdi-image</v-icon>
{{ $t('dialog.edit_readlist.tab_poster') }}
</v-tab>
<!-- Summary -->
<v-row>
<v-col>
<v-textarea v-model="form.summary"
:label="$t('dialog.edit_readlist.field_summary')"
filled
/>
</v-col>
</v-row>
<!-- Tab: General -->
<v-tab-item>
<v-card flat>
<v-container fluid>
<!-- Name -->
<v-row>
<v-col>
<v-text-field v-model="form.name"
:label="$t('dialog.edit_readlist.field_name')"
:error-messages="getErrorsName"
filled
/>
</v-col>
</v-row>
</v-container>
</v-card-text>
<!-- Summary -->
<v-row>
<v-col>
<v-textarea v-model="form.summary"
:label="$t('dialog.edit_readlist.field_summary')"
filled
/>
</v-col>
</v-row>
<v-card-actions>
<v-spacer/>
<v-btn text @click="dialogCancel">{{ $t('dialog.edit_readlist.button_cancel') }}</v-btn>
<v-btn color="primary"
@click="dialogConfirm"
:disabled="getErrorsName !== ''"
>{{ $t('dialog.edit_readlist.button_confirm') }}
</v-btn>
</v-card-actions>
</v-card>
</v-container>
</v-card>
</v-tab-item>
<!-- Tab: Thumbnails -->
<v-tab-item>
<v-card flat>
<v-container fluid>
<!-- Upload -->
<v-row>
<v-col class="pa-1">
<drop-zone ref="thumbnailsUpload" @on-input-change="addThumbnail" class="pa-8"/>
</v-col>
</v-row>
<!-- Gallery -->
<v-row>
<v-col
cols="6" sm="4" lg="3" class="pa-1"
v-for="(item, index) in [...poster.uploadQueue, ...poster.readListThumbnails]"
:key="index"
>
<thumbnail-card
:item="item"
:selected="isThumbnailSelected(item)"
:toBeDeleted="isThumbnailToBeDeleted(item)"
@on-select-thumbnail="selectThumbnail"
@on-delete-thumbnail="deleteThumbnail"
/>
</v-col>
</v-row>
</v-container>
</v-card>
</v-tab-item>
</v-tabs>
<v-card-actions>
<v-spacer/>
<v-btn text @click="dialogCancel">{{ $t('dialog.edit_readlist.button_cancel') }}</v-btn>
<v-btn color="primary"
@click="dialogConfirm"
:disabled="getErrorsName !== ''"
>{{ $t('dialog.edit_readlist.button_confirm') }}
</v-btn>
</v-card-actions>
</v-card>
</form>
</v-dialog>
</template>
<script lang="ts">
import {UserRoles} from '@/types/enum-users'
import Vue from 'vue'
import {ERROR} from '@/types/events'
import {ERROR, ErrorEvent} from '@/types/events'
import {LibraryDto} from '@/types/komga-libraries'
import DropZone from '@/components/DropZone.vue'
import ThumbnailCard from '@/components/ThumbnailCard.vue'
export default Vue.extend({
name: 'ReadListEditDialog',
components: {ThumbnailCard, DropZone},
data: () => {
return {
UserRoles,
modal: false,
tab: 0,
readLists: [] as ReadListDto[],
form: {
name: '',
summary: '',
},
poster: {
selectedThumbnail: '',
uploadQueue: [] as File[],
deleteQueue: [] as ReadListThumbnailDto[],
readListThumbnails: [] as ReadListThumbnailDto[],
},
}
},
props: {
@ -80,6 +144,7 @@ export default Vue.extend({
},
modal(val) {
!val && this.dialogCancel()
val && this.getThumbnails(this.readList)
},
readList: {
handler(val) {
@ -102,8 +167,14 @@ export default Vue.extend({
},
methods: {
async dialogReset(readList: ReadListDto) {
this.tab = 0
this.form.name = readList.name
this.form.summary = readList.summary
this.poster.selectedThumbnail = ''
this.poster.deleteQueue = []
this.poster.uploadQueue = []
this.poster.readListThumbnails = []
},
dialogCancel() {
this.$emit('input', false)
@ -114,6 +185,34 @@ export default Vue.extend({
},
async edit() {
try {
if (this.poster.uploadQueue.length > 0) {
let hadErrors = false
for (const file of this.poster.uploadQueue.slice()) {
try {
await this.$komgaReadLists.uploadThumbnail(this.readList.id, file, file.name === this.poster.selectedThumbnail)
this.deleteThumbnail(file)
} catch (e) {
this.$eventHub.$emit(ERROR, {message: e.message} as ErrorEvent)
hadErrors = true
}
}
if (hadErrors) {
await this.getThumbnails(this.readList)
return false
}
}
if (this.poster.selectedThumbnail !== '') {
const id = this.poster.selectedThumbnail
if (this.poster.readListThumbnails.find(value => value.id === id)) {
await this.$komgaReadLists.markThumbnailAsSelected(this.readList.id, id)
}
}
if (this.poster.deleteQueue.length > 0) {
this.poster.deleteQueue.forEach(toDelete => this.$komgaReadLists.deleteThumbnail(toDelete.readListId, toDelete.id))
}
const update = {
name: this.form.name,
summary: this.form.summary,
@ -124,6 +223,71 @@ export default Vue.extend({
this.$eventHub.$emit(ERROR, {message: e.message} as ErrorEvent)
}
},
addThumbnail(files: File[]) {
let hasSelected = false
for (const file of files) {
if (!this.poster.uploadQueue.find(value => value.name === file.name)) {
this.poster.uploadQueue.push(file)
if (!hasSelected) {
this.selectThumbnail(file)
hasSelected = true
}
}
}
(this.$refs.thumbnailsUpload as any).reset()
},
async getThumbnails(readList: ReadListDto) {
const thumbnails = await this.$komgaReadLists.getThumbnails(readList.id)
this.selectThumbnail(thumbnails.find(x => x.selected))
this.poster.readListThumbnails = thumbnails
},
isThumbnailSelected(item: File | ReadListThumbnailDto): boolean {
return item instanceof File ? item.name === this.poster.selectedThumbnail : item.id === this.poster.selectedThumbnail
},
selectThumbnail(item: File | ReadListThumbnailDto | undefined) {
if (!item) {
return
} else if (item instanceof File) {
this.poster.selectedThumbnail = item.name
} else {
const index = this.poster.deleteQueue.indexOf(item, 0)
if (index > -1) this.poster.deleteQueue.splice(index, 1)
this.poster.selectedThumbnail = item.id
}
},
isThumbnailToBeDeleted(item: File | ReadListThumbnailDto) {
if (item instanceof File) {
return false
} else {
return this.poster.deleteQueue.includes(item)
}
},
deleteThumbnail(item: File | ReadListThumbnailDto) {
if (item instanceof File) {
const index = this.poster.uploadQueue.indexOf(item, 0)
if (index > -1) {
this.poster.uploadQueue.splice(index, 1)
}
if (item.name === this.poster.selectedThumbnail) {
this.poster.selectedThumbnail = ''
}
} else {
// if thumbnail was marked for deletion, unmark it
if (this.isThumbnailToBeDeleted(item)) {
const index = this.poster.deleteQueue.indexOf(item, 0)
if (index > -1) {
this.poster.deleteQueue.splice(index, 1)
}
} else {
this.poster.deleteQueue.push(item)
if (item.id === this.poster.selectedThumbnail) this.poster.selectedThumbnail = ''
}
}
},
},
})
</script>

View file

@ -12,15 +12,19 @@ const urls = {
export default urls
export function bookThumbnailUrl (bookId: string): string {
export function bookThumbnailUrl(bookId: string): string {
return `${urls.originNoSlash}/api/v1/books/${bookId}/thumbnail`
}
export function bookFileUrl (bookId: string): string {
export function bookThumbnailUrlByThumbnailId(bookId: string, thumbnailId: string) {
return `${urls.originNoSlash}/api/v1/books/${bookId}/thumbnails/${thumbnailId}`
}
export function bookFileUrl(bookId: string): string {
return `${urls.originNoSlash}/api/v1/books/${bookId}/file`
}
export function bookPageUrl (bookId: string, page: number, convertTo?: string): string {
export function bookPageUrl(bookId: string, page: number, convertTo?: string): string {
let url = `${urls.originNoSlash}/api/v1/books/${bookId}/pages/${page}`
if (convertTo) {
url += `?convert=${convertTo}`
@ -28,15 +32,15 @@ export function bookPageUrl (bookId: string, page: number, convertTo?: string):
return url
}
export function bookPageThumbnailUrl (bookId: string, page: number): string {
export function bookPageThumbnailUrl(bookId: string, page: number): string {
return `${urls.originNoSlash}/api/v1/books/${bookId}/pages/${page}/thumbnail`
}
export function seriesFileUrl (seriesId: string): string {
export function seriesFileUrl(seriesId: string): string {
return `${urls.originNoSlash}/api/v1/series/${seriesId}/file`
}
export function seriesThumbnailUrl (seriesId: string): string {
export function seriesThumbnailUrl(seriesId: string): string {
return `${urls.originNoSlash}/api/v1/series/${seriesId}/thumbnail`
}
@ -44,18 +48,26 @@ export function seriesThumbnailUrlByThumbnailId(seriesId: string, thumbnailId: s
return `${urls.originNoSlash}/api/v1/series/${seriesId}/thumbnails/${thumbnailId}`
}
export function collectionThumbnailUrl (collectionId: string): string {
export function collectionThumbnailUrl(collectionId: string): string {
return `${urls.originNoSlash}/api/v1/collections/${collectionId}/thumbnail`
}
export function readListThumbnailUrl (readListId: string): string {
export function collectionThumbnailUrlByThumbnailId(collectionId: string, thumbnailId: string) {
return `${urls.originNoSlash}/api/v1/collections/${collectionId}/thumbnails/${thumbnailId}`
}
export function readListThumbnailUrl(readListId: string): string {
return `${urls.originNoSlash}/api/v1/readlists/${readListId}/thumbnail`
}
export function readListFileUrl (readListId: string): string {
export function readListFileUrl(readListId: string): string {
return `${urls.originNoSlash}/api/v1/readlists/${readListId}/file`
}
export function transientBookPageUrl (transientBookId: string, page: number): string {
export function readListThumbnailUrlByThumbnailId(readListId: string, thumbnailId: string) {
return `${urls.originNoSlash}/api/v1/readlists/${readListId}/thumbnails/${thumbnailId}`
}
export function transientBookPageUrl(transientBookId: string, page: number): string {
return `${urls.originNoSlash}/api/v1/transient-books/${transientBookId}/pages/${page}`
}

View file

@ -345,6 +345,7 @@
"tab_authors": "Authors",
"tab_general": "General",
"tab_links": "Links",
"tab_poster": "Poster",
"tab_tags": "Tags",
"tags_notice_multiple_edit": "You are editing tags for multiple books. This will override existing tags of each book."
},
@ -353,7 +354,9 @@
"button_confirm": "Save changes",
"dialog_title": "Edit collection",
"field_manual_ordering": "Manual ordering",
"label_ordering": "By default, series in a collection will be ordered by name. You can enable manual ordering to define your own order."
"label_ordering": "By default, series in a collection will be ordered by name. You can enable manual ordering to define your own order.",
"tab_general": "General",
"tab_poster": "Poster"
},
"edit_library": {
"button_browse": "Browse",
@ -405,7 +408,9 @@
"button_confirm": "Save changes",
"dialog_title": "Edit read list",
"field_name": "Name",
"field_summary": "Summary"
"field_summary": "Summary",
"tab_general": "General",
"tab_poster": "Poster"
},
"edit_series": {
"button_cancel": "Cancel",
@ -716,6 +721,7 @@
},
"thumbnail_card": {
"tooltip_delete": "Delete",
"tooltip_generated": "Generated artwork",
"tooltip_mark_as_selected": "Mark as selected",
"tooltip_selected": "Selected",
"tooltip_sidecar": "Local artwork",

View file

@ -4,6 +4,7 @@ import {
BookImportBatchDto,
BookMetadataUpdateBatchDto,
BookMetadataUpdateDto,
BookThumbnailDto,
PageDto,
ReadProgressUpdateDto,
} from '@/types/komga-books'
@ -239,4 +240,55 @@ export default class KomgaBooksService {
throw new Error(msg)
}
}
async getThumbnails(bookId: string): Promise<BookThumbnailDto[]> {
try {
return (await this.http.get(`${API_BOOKS}/${bookId}/thumbnails`)).data
} catch (e) {
let msg = `An error occurred while trying to retrieve thumbnails for book '${bookId}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async uploadThumbnail(bookId: string, file: File, selected: boolean) {
try {
const body = new FormData()
body.append('file', file)
body.append('selected', `${selected}`)
await this.http.post(`${API_BOOKS}/${bookId}/thumbnails`, body)
} catch (e) {
let msg = `An error occurred while trying to upload thumbnail for book '${bookId}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async deleteThumbnail(bookId: string, thumbnailId: string) {
try {
await this.http.delete(`${API_BOOKS}/${bookId}/thumbnails/${thumbnailId}`)
} catch (e) {
let msg = `An error occurred while trying to delete thumbnail for book '${bookId}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async markThumbnailAsSelected(bookId: string, thumbnailId: string) {
try {
await this.http.put(`${API_BOOKS}/${bookId}/thumbnails/${thumbnailId}/selected`)
} catch (e) {
let msg = `An error occurred while trying to mark thumbnail as selected for book '${bookId}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
}

View file

@ -104,7 +104,58 @@ export default class KomgaCollectionsService {
paramsSerializer: params => qs.stringify(params, {indices: false}),
})).data
} catch (e) {
let msg = 'An error occurred while trying to retrieve series'
let msg = `An error occurred while trying to retrieve series for collection '${collectionId}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async getThumbnails(collectionId: string): Promise<CollectionThumbnailDto[]> {
try {
return (await this.http.get(`${API_COLLECTIONS}/${collectionId}/thumbnails`)).data
} catch (e) {
let msg = `An error occurred while trying to retrieve thumbnails for collection '${collectionId}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async uploadThumbnail(collecitonId: string, file: File, selected: boolean) {
try {
const body = new FormData()
body.append('file', file)
body.append('selected', `${selected}`)
await this.http.post(`${API_COLLECTIONS}/${collecitonId}/thumbnails`, body)
} catch (e) {
let msg = `An error occurred while trying to upload thumbnail for collection '${collecitonId}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async deleteThumbnail(collectionId: string, thumbnailId: string) {
try {
await this.http.delete(`${API_COLLECTIONS}/${collectionId}/thumbnails/${thumbnailId}`)
} catch (e) {
let msg = `An error occurred while trying to delete thumbnail for collection '${collectionId}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async markThumbnailAsSelected(collectionId: string, thumbnailId: string) {
try {
await this.http.put(`${API_COLLECTIONS}/${collectionId}/thumbnails/${thumbnailId}/selected`)
} catch (e) {
let msg = `An error occurred while trying to mark thumbnail as selected for collection '${collectionId}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}

View file

@ -109,7 +109,7 @@ export default class KomgaReadListsService {
return (await this.http.get(`${API_READLISTS}/${readListId}/books`, {
params: params,
paramsSerializer: params => qs.stringify(params, { indices: false }),
paramsSerializer: params => qs.stringify(params, {indices: false}),
})).data
} catch (e) {
let msg = 'An error occurred while trying to retrieve books'
@ -143,4 +143,55 @@ export default class KomgaReadListsService {
throw new Error(msg)
}
}
async getThumbnails(readListId: string): Promise<ReadListThumbnailDto[]> {
try {
return (await this.http.get(`${API_READLISTS}/${readListId}/thumbnails`)).data
} catch (e) {
let msg = `An error occurred while trying to retrieve thumbnails for readlist '${readListId}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async uploadThumbnail(readListId: string, file: File, selected: boolean) {
try {
const body = new FormData()
body.append('file', file)
body.append('selected', `${selected}`)
await this.http.post(`${API_READLISTS}/${readListId}/thumbnails`, body)
} catch (e) {
let msg = `An error occurred while trying to upload thumbnail for readlist '${readListId}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async deleteThumbnail(readListId: string, thumbnailId: string) {
try {
await this.http.delete(`${API_READLISTS}/${readListId}/thumbnails/${thumbnailId}`)
} catch (e) {
let msg = `An error occurred while trying to delete thumbnail for readlist '${readListId}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async markThumbnailAsSelected(readListId: string, thumbnailId: string) {
try {
await this.http.put(`${API_READLISTS}/${readListId}/thumbnails/${thumbnailId}/selected`)
} catch (e) {
let msg = `An error occurred while trying to mark thumbnail as selected for readlist '${readListId}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
}

View file

@ -21,7 +21,13 @@ import {
SERIES_CHANGED,
SERIES_DELETED,
THUMBNAILBOOK_ADDED,
THUMBNAILBOOK_DELETED,
THUMBNAILCOLLECTION_ADDED,
THUMBNAILCOLLECTION_DELETED,
THUMBNAILREADLIST_ADDED,
THUMBNAILREADLIST_DELETED,
THUMBNAILSERIES_ADDED,
THUMBNAILSERIES_DELETED,
} from '@/types/events'
import Vue from 'vue'
import {TaskQueueSseDto} from '@/types/komga-sse'
@ -83,7 +89,16 @@ export default class KomgaSseService {
// Thumbnails
this.eventSource.addEventListener('ThumbnailBookAdded', (event: any) => this.emit(THUMBNAILBOOK_ADDED, event))
this.eventSource.addEventListener('ThumbnailBookDeleted', (event: any) => this.emit(THUMBNAILBOOK_DELETED, event))
this.eventSource.addEventListener('ThumbnailSeriesAdded', (event: any) => this.emit(THUMBNAILSERIES_ADDED, event))
this.eventSource.addEventListener('ThumbnailSeriesDeleted', (event: any) => this.emit(THUMBNAILSERIES_DELETED, event))
this.eventSource.addEventListener('ThumbnailReadListAdded', (event: any) => this.emit(THUMBNAILREADLIST_ADDED, event))
this.eventSource.addEventListener('ThumbnailReadListDeleted', (event: any) => this.emit(THUMBNAILREADLIST_DELETED, event))
this.eventSource.addEventListener('ThumbnailSeriesCollectionAdded', (event: any) => this.emit(THUMBNAILCOLLECTION_ADDED, event))
this.eventSource.addEventListener('ThumbnailSeriesCollectionDeleted', (event: any) => this.emit(THUMBNAILCOLLECTION_DELETED, event))
this.eventSource.addEventListener('TaskQueueStatus', (event: any) => this.updateTaskCount(event))
}

View file

@ -25,7 +25,16 @@ export const READPROGRESS_SERIES_CHANGED = 'readprogress-series-changed'
export const READPROGRESS_SERIES_DELETED = 'readprogress-series-deleted'
export const THUMBNAILBOOK_ADDED = 'thumbnailbook-added'
export const THUMBNAILBOOK_DELETED = 'thumbnailbook-deleted'
export const THUMBNAILSERIES_ADDED = 'thumbnailseries-added'
export const THUMBNAILSERIES_DELETED = 'thumbnailseries-deleted'
export const THUMBNAILREADLIST_ADDED = 'thumbnailreadlist-added'
export const THUMBNAILREADLIST_DELETED = 'thumbnailreadlist-deleted'
export const THUMBNAILCOLLECTION_ADDED = 'thumbnailcollection-added'
export const THUMBNAILCOLLECTION_DELETED = 'thumbnailcollection-deleted'
export const ERROR = 'error'
export const NOTIFICATION = 'notification'

View file

@ -134,3 +134,10 @@ export interface BookImportDto {
upgradeBookId?: string,
destinationName?: string,
}
export interface BookThumbnailDto {
id: string,
bookId: string,
type: string,
selected: boolean
}

View file

@ -19,3 +19,10 @@ interface CollectionUpdateDto {
ordered?: boolean,
seriesIds?: string[]
}
interface CollectionThumbnailDto {
id: string,
collectionId: string,
type: string,
selected: boolean
}

View file

@ -36,3 +36,10 @@ interface ReadListRequestBookDto {
series: string,
number: string,
}
interface ReadListThumbnailDto {
id: string,
readListId: string,
type: string,
selected: boolean
}

View file

@ -36,10 +36,22 @@ export interface ReadProgressSeriesSseDto {
export interface ThumbnailBookSseDto {
bookId: string,
seriesId: string,
selected: boolean,
}
export interface ThumbnailSeriesSseDto {
seriesId: string,
selected: boolean,
}
export interface ThumbnailReadListSseDto {
readListId: string,
selected: boolean,
}
export interface ThumbnailCollectionSseDto {
collectionId: string,
selected: boolean,
}
export interface TaskQueueSseDto {

View file

@ -128,23 +128,19 @@ class BookLifecycle(
}
}
when (markSelected) {
MarkSelectedPreference.YES -> {
thumbnailBookRepository.markSelected(thumbnail)
}
val selected = when (markSelected) {
MarkSelectedPreference.YES -> true
MarkSelectedPreference.IF_NONE_OR_GENERATED -> {
val selectedThumbnail = thumbnailBookRepository.findSelectedByBookIdOrNull(thumbnail.bookId)
if (selectedThumbnail == null || selectedThumbnail.type == ThumbnailBook.Type.GENERATED)
thumbnailBookRepository.markSelected(thumbnail)
else thumbnailsHouseKeeping(thumbnail.bookId)
}
MarkSelectedPreference.NO -> {
thumbnailsHouseKeeping(thumbnail.bookId)
selectedThumbnail == null || selectedThumbnail.type == ThumbnailBook.Type.GENERATED
}
MarkSelectedPreference.NO -> false
}
eventPublisher.publishEvent(DomainEvent.ThumbnailBookAdded(thumbnail))
if (selected) thumbnailBookRepository.markSelected(thumbnail)
else thumbnailsHouseKeeping(thumbnail.bookId)
eventPublisher.publishEvent(DomainEvent.ThumbnailBookAdded(thumbnail.copy(selected = selected)))
}
fun deleteThumbnailForBook(thumbnail: ThumbnailBook) {

View file

@ -92,7 +92,7 @@ class ReadListLifecycle(
fun markSelectedThumbnail(thumbnail: ThumbnailReadList) {
thumbnailReadListRepository.markSelected(thumbnail)
eventPublisher.publishEvent(DomainEvent.ThumbnailReadListAdded(thumbnail))
eventPublisher.publishEvent(DomainEvent.ThumbnailReadListAdded(thumbnail.copy(selected = true)))
}
fun deleteThumbnail(thumbnail: ThumbnailReadList) {

View file

@ -88,7 +88,7 @@ class SeriesCollectionLifecycle(
fun markSelectedThumbnail(thumbnail: ThumbnailSeriesCollection) {
thumbnailSeriesCollectionRepository.markSelected(thumbnail)
eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesCollectionAdded(thumbnail))
eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesCollectionAdded(thumbnail.copy(selected = true)))
}
fun deleteThumbnail(thumbnail: ThumbnailSeriesCollection) {

View file

@ -274,15 +274,17 @@ class SeriesLifecycle(
}
thumbnailsSeriesRepository.insert(thumbnail.copy(selected = false))
if (markSelected == MarkSelectedPreference.YES ||
(
markSelected == MarkSelectedPreference.IF_NONE_OR_GENERATED &&
thumbnailsSeriesRepository.findSelectedBySeriesIdOrNull(thumbnail.seriesId) == null
)
) {
thumbnailsSeriesRepository.markSelected(thumbnail)
eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesAdded(thumbnail))
val selected = when (markSelected) {
MarkSelectedPreference.YES -> true
MarkSelectedPreference.IF_NONE_OR_GENERATED -> {
thumbnailsSeriesRepository.findSelectedBySeriesIdOrNull(thumbnail.seriesId) == null
}
MarkSelectedPreference.NO -> false
}
if (selected) thumbnailsSeriesRepository.markSelected(thumbnail)
eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesAdded(thumbnail.copy(selected = selected)))
}
fun deleteThumbnailForSeries(thumbnail: ThumbnailSeries) {

View file

@ -346,7 +346,7 @@ class BookController(
thumbnailBookRepository.findByIdOrNull(thumbnailId)?.let {
thumbnailBookRepository.markSelected(it)
eventPublisher.publishEvent(DomainEvent.ThumbnailBookAdded(it))
eventPublisher.publishEvent(DomainEvent.ThumbnailBookAdded(it.copy(selected = true)))
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}

View file

@ -8,8 +8,10 @@ import mu.KotlinLogging
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream
import org.apache.commons.io.IOUtils
import org.gotson.komga.application.events.EventPublisher
import org.gotson.komga.domain.model.Author
import org.gotson.komga.domain.model.BookSearchWithReadProgress
import org.gotson.komga.domain.model.DomainEvent
import org.gotson.komga.domain.model.DuplicateNameException
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.ROLE_ADMIN
@ -86,6 +88,7 @@ class ReadListController(
private val thumbnailReadListRepository: ThumbnailReadListRepository,
private val contentDetector: ContentDetector,
private val bookLifecycle: BookLifecycle,
private val eventPublisher: EventPublisher,
) {
@PageableWithoutSortAsQueryParam
@ -217,6 +220,7 @@ class ReadListController(
readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let {
thumbnailReadListRepository.findByIdOrNull(thumbnailId)?.let {
readListLifecycle.markSelectedThumbnail(it)
eventPublisher.publishEvent(DomainEvent.ThumbnailReadListAdded(it.copy(selected = true)))
}
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}

View file

@ -5,7 +5,9 @@ import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import mu.KotlinLogging
import org.gotson.komga.application.events.EventPublisher
import org.gotson.komga.domain.model.Author
import org.gotson.komga.domain.model.DomainEvent
import org.gotson.komga.domain.model.DuplicateNameException
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ReadStatus
@ -66,6 +68,7 @@ class SeriesCollectionController(
private val seriesDtoRepository: SeriesDtoRepository,
private val contentDetector: ContentDetector,
private val thumbnailSeriesCollectionRepository: ThumbnailSeriesCollectionRepository,
private val eventPublisher: EventPublisher,
) {
@PageableWithoutSortAsQueryParam
@ -179,6 +182,7 @@ class SeriesCollectionController(
collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let {
thumbnailSeriesCollectionRepository.findByIdOrNull(thumbnailId)?.let {
collectionLifecycle.markSelectedThumbnail(it)
eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesCollectionAdded(it.copy(selected = true)))
}
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}

View file

@ -407,7 +407,7 @@ class SeriesController(
seriesRepository.findByIdOrNull(seriesId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
thumbnailsSeriesRepository.findByIdOrNull(thumbnailId)?.let {
thumbnailsSeriesRepository.markSelected(it)
eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesAdded(it))
eventPublisher.publishEvent(DomainEvent.ThumbnailSeriesAdded(it.copy(selected = true)))
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}

View file

@ -101,14 +101,14 @@ class SseController(
is DomainEvent.ReadProgressSeriesChanged -> emitSse("ReadProgressSeriesChanged", ReadProgressSeriesSseDto(event.seriesId, event.userId), userIdOnly = event.userId)
is DomainEvent.ReadProgressSeriesDeleted -> emitSse("ReadProgressSeriesDeleted", ReadProgressSeriesSseDto(event.seriesId, event.userId), userIdOnly = event.userId)
is DomainEvent.ThumbnailBookAdded -> emitSse("ThumbnailBookAdded", ThumbnailBookSseDto(event.thumbnail.bookId, bookRepository.getSeriesIdOrNull(event.thumbnail.bookId).orEmpty()))
is DomainEvent.ThumbnailBookDeleted -> emitSse("ThumbnailBookDeleted", ThumbnailBookSseDto(event.thumbnail.bookId, bookRepository.getSeriesIdOrNull(event.thumbnail.bookId).orEmpty()))
is DomainEvent.ThumbnailSeriesAdded -> emitSse("ThumbnailSeriesAdded", ThumbnailSeriesSseDto(event.thumbnail.seriesId))
is DomainEvent.ThumbnailSeriesDeleted -> emitSse("ThumbnailSeriesDeleted", ThumbnailSeriesSseDto(event.thumbnail.seriesId))
is DomainEvent.ThumbnailSeriesCollectionAdded -> emitSse("ThumbnailSeriesCollectionAdded", ThumbnailSeriesCollectionSseDto(event.thumbnail.collectionId))
is DomainEvent.ThumbnailSeriesCollectionDeleted -> emitSse("ThumbnailSeriesCollectionDeleted", ThumbnailSeriesCollectionSseDto(event.thumbnail.collectionId))
is DomainEvent.ThumbnailReadListAdded -> emitSse("ThumbnailReadListAdded", ThumbnailReadListSseDto(event.thumbnail.readListId))
is DomainEvent.ThumbnailReadListDeleted -> emitSse("ThumbnailReadListDeleted", ThumbnailReadListSseDto(event.thumbnail.readListId))
is DomainEvent.ThumbnailBookAdded -> emitSse("ThumbnailBookAdded", ThumbnailBookSseDto(event.thumbnail.bookId, bookRepository.getSeriesIdOrNull(event.thumbnail.bookId).orEmpty(), event.thumbnail.selected))
is DomainEvent.ThumbnailBookDeleted -> emitSse("ThumbnailBookDeleted", ThumbnailBookSseDto(event.thumbnail.bookId, bookRepository.getSeriesIdOrNull(event.thumbnail.bookId).orEmpty(), event.thumbnail.selected))
is DomainEvent.ThumbnailSeriesAdded -> emitSse("ThumbnailSeriesAdded", ThumbnailSeriesSseDto(event.thumbnail.seriesId, event.thumbnail.selected))
is DomainEvent.ThumbnailSeriesDeleted -> emitSse("ThumbnailSeriesDeleted", ThumbnailSeriesSseDto(event.thumbnail.seriesId, event.thumbnail.selected))
is DomainEvent.ThumbnailSeriesCollectionAdded -> emitSse("ThumbnailSeriesCollectionAdded", ThumbnailSeriesCollectionSseDto(event.thumbnail.collectionId, event.thumbnail.selected))
is DomainEvent.ThumbnailSeriesCollectionDeleted -> emitSse("ThumbnailSeriesCollectionDeleted", ThumbnailSeriesCollectionSseDto(event.thumbnail.collectionId, event.thumbnail.selected))
is DomainEvent.ThumbnailReadListAdded -> emitSse("ThumbnailReadListAdded", ThumbnailReadListSseDto(event.thumbnail.readListId, event.thumbnail.selected))
is DomainEvent.ThumbnailReadListDeleted -> emitSse("ThumbnailReadListDeleted", ThumbnailReadListSseDto(event.thumbnail.readListId, event.thumbnail.selected))
}
}

View file

@ -3,4 +3,5 @@ package org.gotson.komga.interfaces.sse.dto
data class ThumbnailBookSseDto(
val bookId: String,
val seriesId: String,
val selected: Boolean,
)

View file

@ -2,4 +2,5 @@ package org.gotson.komga.interfaces.sse.dto
data class ThumbnailReadListSseDto(
val readListId: String,
val selected: Boolean,
)

View file

@ -2,4 +2,5 @@ package org.gotson.komga.interfaces.sse.dto
data class ThumbnailSeriesCollectionSseDto(
val collectionId: String,
val selected: Boolean,
)

View file

@ -2,4 +2,5 @@ package org.gotson.komga.interfaces.sse.dto
data class ThumbnailSeriesSseDto(
val seriesId: String,
val selected: Boolean,
)