feat(webui): edit series thumbnails

Co-authored-by: Gauthier Roebroeck <gauthier.roebroeck@gmail.com>
This commit is contained in:
Andreas 2021-09-17 10:27:35 +02:00 committed by GitHub
parent 22126e1c7d
commit 6757acfd24
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 425 additions and 5 deletions

View file

@ -0,0 +1,55 @@
<template>
<label class="drop-zone" v-cloak @drop.prevent="dropHandler" @dragover.prevent>
<span class="file-input">Choose an image</span> - drag and drop
<input hidden aria-hidden="true" type="file" accept="image/*" multiple @change="dropHandler" >
</label>
</template>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
name: 'DropZone',
methods: {
dropHandler(event: Event) {
if (event instanceof DragEvent && event.dataTransfer) {
const droppedFiles = event.dataTransfer.files
if (!droppedFiles) return
this.$emit('on-input-change', Array.from(droppedFiles))
}
if (event.target instanceof HTMLInputElement && event.target.files) {
const selectedFiles = event.target.files
if (!selectedFiles) return
this.$emit('on-input-change', Array.from(selectedFiles))
}
},
},
})
</script>
<style scoped>
.drop-zone {
background:repeating-linear-gradient(
135deg,
var(--v-base-lighten1),
var(--v-base-lighten1) 20px,
var(--v-base-darken1) 20px,
var(--v-base-darken1) 40px
);
color: var(--v-contrast-light-2-base);
display: block;
font-weight: 600;
text-align: center;
width: 100%;
}
.file-input {
color: var(--v-info-base);
}
.file-input:hover {
color: var(--v-info-lighten1);
}
</style>

View file

@ -0,0 +1,148 @@
<template>
<v-card>
<v-img
:src="getImage(item)"
aspect-ratio="0.7071"
contain/>
<v-card-actions align="center">
<v-tooltip top>
<template v-slot:activator="{ on, attrs }">
<v-icon
class="v-btn--icon v-size--default px-2"
:color="isFileToBig(item) ? 'error' : ''"
v-bind="attrs"
v-on="on">
{{ getStatusIcon(item) }}
</v-icon>
</template>
<span>{{ getStatusTooltip(item) }}</span>
</v-tooltip>
<v-tooltip v-if="!isFileToBig(item)" top>
<template v-slot:activator="{ on, attrs }">
<v-btn
icon
:color="selected ? 'success' : ''"
@click="onClickSelect"
v-bind="attrs"
v-on="on">
<v-icon>mdi-check</v-icon>
</v-btn>
</template>
<span>{{
selected ? $t('thumbnail_card.tooltip_selected') : $t('thumbnail_card.tooltip_mark_as_selected')
}}</span>
</v-tooltip>
<v-tooltip v-if="isDeletable(item)" top>
<template v-slot:activator="{ on, attrs }">
<v-btn
icon
:color="toBeDeleted ? 'error' : ''"
@click="onClickDelete"
v-bind="attrs"
v-on="on">
<v-icon>mdi-trash-can-outline</v-icon>
</v-btn>
</template>
<span>{{
toBeDeleted ? $t('thumbnail_card.tooltip_to_be_deleted') : $t('thumbnail_card.tooltip_delete')
}}</span>
</v-tooltip>
</v-card-actions>
</v-card>
</template>
<script lang="ts">
import Vue from 'vue'
import {SeriesThumbnailDto} from '@/types/komga-series'
import {seriesThumbnailUrlByThumbnailId} from '@/functions/urls'
export default Vue.extend({
name: 'ThumbnailCard',
props: {
item: {
required: true,
validator: (value: any) => {
if (value instanceof File) {
return true
}
return 'id' in value && 'seriesId' in value && 'type' in value && 'selected' in value
},
},
selected: {
type: Boolean,
required: true,
},
toBeDeleted: {
type: Boolean,
required: true,
},
},
methods: {
getStatusIcon(item: File | SeriesThumbnailDto): string {
if (item instanceof File) {
if (this.isFileToBig(item)) {
return 'mdi-alert-circle'
} else {
return 'mdi-cloud-upload-outline'
}
} else {
if (item.type === 'SIDECAR') {
return 'mdi-folder-outline'
} else {
return 'mdi-cloud-check-outline'
}
}
},
getStatusTooltip(item: File | SeriesThumbnailDto): string {
if (item instanceof File) {
if (this.isFileToBig(item)) {
return this.$t('thumbnail_card.tooltip_too_big').toString()
} else {
return this.$t('thumbnail_card.tooltip_to_be_uploaded').toString()
}
} else {
if (item.type === 'SIDECAR') {
return this.$t('thumbnail_card.tooltip_sidecar').toString()
} else {
return this.$t('thumbnail_card.tooltip_user_uploaded').toString()
}
}
},
isFileToBig(item: File | SeriesThumbnailDto): boolean {
if (item instanceof File) {
return item.size > 1_000_000
} else {
return false
}
},
getImage(item: File | SeriesThumbnailDto): string {
if (item instanceof File) {
return URL.createObjectURL(item)
} else {
return seriesThumbnailUrlByThumbnailId(item.seriesId, item.id)
}
},
onClickSelect() {
if (!this.selected) {
this.$emit('on-select-thumbnail', this.item)
}
},
isDeletable(item: File | SeriesThumbnailDto) {
if (item instanceof File) {
return true
} else {
return item.type !== 'SIDECAR'
}
},
onClickDelete() {
this.$emit('on-delete-thumbnail', this.item)
},
},
})
</script>
<style scoped>
</style>

View file

@ -31,6 +31,10 @@
<v-icon left class="hidden-xs-only">mdi-tag-multiple</v-icon>
{{ $t('dialog.edit_series.tab_tags') }}
</v-tab>
<v-tab class="justify-start" v-if="single">
<v-icon left class="hidden-xs-only">mdi-image</v-icon>
{{ $t('dialog.edit_series.tab_poster') }}
</v-tab>
<!-- Tab: General -->
<v-tab-item>
@ -311,6 +315,38 @@
</v-container>
</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 @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.seriesThumbnails]"
: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">
@ -328,8 +364,10 @@ import Vue from 'vue'
import {SeriesStatus} from '@/types/enum-series'
import {helpers, minValue, requiredIf} from 'vuelidate/lib/validators'
import {ReadingDirection} from '@/types/enum-books'
import {SeriesDto} from '@/types/komga-series'
import {ERROR} from '@/types/events'
import {SeriesDto, SeriesThumbnailDto} from '@/types/komga-series'
import {ERROR, ErrorEvent} from '@/types/events'
import DropZone from '@/components/DropZone.vue'
import ThumbnailCard from '@/components/ThumbnailCard.vue'
const tags = require('language-tags')
@ -337,6 +375,7 @@ const validLanguage = (value: string) => !helpers.req(value) || tags.check(value
export default Vue.extend({
name: 'EditSeriesDialog',
components: {ThumbnailCard, DropZone},
data: () => {
return {
modal: false,
@ -372,6 +411,12 @@ export default Vue.extend({
ageRating: false,
language: false,
},
poster: {
selectedThumbnail: '',
uploadQueue: [] as File[],
deleteQueue: [] as SeriesThumbnailDto[],
seriesThumbnails: [] as SeriesThumbnailDto[],
},
genresAvailable: [] as string[],
tagsAvailable: [] as string[],
}
@ -389,9 +434,11 @@ export default Vue.extend({
},
modal(val) {
!val && this.dialogCancel()
val && this.getThumbnails(this.series)
},
series(val) {
this.dialogReset(val)
this.getThumbnails(val)
},
},
validations: {
@ -529,6 +576,10 @@ export default Vue.extend({
this.form.genres = []
this.form.tags = []
this.$_.merge(this.form, (series as SeriesDto).metadata)
this.poster.selectedThumbnail = ''
this.poster.deleteQueue = []
this.poster.uploadQueue = []
this.poster.seriesThumbnails = []
}
},
dialogCancel() {
@ -611,6 +662,36 @@ export default Vue.extend({
return null
},
async editSeries(): Promise<boolean> {
if (this.single && this.poster.uploadQueue.length > 0) {
const series = this.series as SeriesDto
let hadErrors = false
for (const file of this.poster.uploadQueue) {
try {
await this.$komgaSeries.uploadThumbnail(series.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(series)
return false
}
}
if (this.single && this.poster.selectedThumbnail !== '') {
const id = this.poster.selectedThumbnail
const series = this.series as SeriesDto
if (this.poster.seriesThumbnails.find(value => value.id === id)) {
this.$komgaSeries.markThumbnailAsSelected(series.id, id)
}
}
if (this.single && this.poster.deleteQueue.length > 0) {
this.poster.deleteQueue.forEach(toDelete => this.$komgaSeries.deleteThumbnail(toDelete.seriesId, toDelete.id))
}
const metadata = this.validateForm()
if (metadata) {
const toUpdate = (this.single ? [this.series] : this.series) as SeriesDto[]
@ -624,6 +705,67 @@ 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
}
}
}
},
async getThumbnails(series: SeriesDto | SeriesDto[]) {
if (Array.isArray(series)) return
const thumbnails = await this.$komgaSeries.getThumbnails(series.id)
this.selectThumbnail(thumbnails.find(x => x.selected))
this.poster.seriesThumbnails = thumbnails
},
isThumbnailSelected(item: File | SeriesThumbnailDto): boolean {
return item instanceof File ? item.name === this.poster.selectedThumbnail : item.id === this.poster.selectedThumbnail
},
selectThumbnail(item: File | SeriesThumbnailDto | undefined) {
if (!item) {
return
} else if (item instanceof File) {
this.poster.selectedThumbnail = item.name
} else {
this.poster.selectedThumbnail = item.id
}
},
isThumbnailToBeDeleted(item: File | SeriesThumbnailDto) {
if (item instanceof File) {
return false
} else {
return this.poster.deleteQueue.includes(item)
}
},
deleteThumbnail(item: File | SeriesThumbnailDto) {
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.selectThumbnail(this.poster.seriesThumbnails.find(x => x.selected))
}
} 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)
}
}
},
},
})
</script>

View file

@ -40,6 +40,10 @@ export function seriesThumbnailUrl (seriesId: string): string {
return `${urls.originNoSlash}/api/v1/series/${seriesId}/thumbnail`
}
export function seriesThumbnailUrlByThumbnailId(seriesId: string, thumbnailId: string) {
return `${urls.originNoSlash}/api/v1/series/${seriesId}/thumbnails/${thumbnailId}`
}
export function collectionThumbnailUrl (collectionId: string): string {
return `${urls.originNoSlash}/api/v1/collections/${collectionId}/thumbnail`
}

View file

@ -396,6 +396,7 @@
"field_total_book_count_error": "Total book count must be 1 or more",
"mixed": "MIXED",
"tab_general": "General",
"tab_poster": "Poster",
"tab_tags": "Tags",
"tags_notice_multiple_edit": "You are editing tags for multiple series. This will override existing tags of each series."
},
@ -669,6 +670,16 @@
"light": "Light",
"system": "System"
},
"thumbnail_card": {
"tooltip_delete": "Delete",
"tooltip_mark_as_selected": "Mark as selected",
"tooltip_selected": "Selected",
"tooltip_sidecar": "Local artwork",
"tooltip_to_be_deleted": "To be deleted",
"tooltip_to_be_uploaded": "To be uploaded",
"tooltip_too_big": "File too big!",
"tooltip_user_uploaded": "User uploaded"
},
"user_roles": {
"ADMIN": "Administrator",
"FILE_DOWNLOAD": "File download",

View file

@ -1,6 +1,6 @@
import {AxiosInstance} from 'axios'
import {AuthorDto, BookDto} from '@/types/komga-books'
import {GroupCountDto, SeriesDto, SeriesMetadataUpdateDto} from '@/types/komga-series'
import {GroupCountDto, SeriesDto, SeriesMetadataUpdateDto, SeriesThumbnailDto} from '@/types/komga-series'
const qs = require('qs')
@ -214,4 +214,55 @@ export default class KomgaSeriesService {
throw new Error(msg)
}
}
async getThumbnails(seriesId: string): Promise<SeriesThumbnailDto[]> {
try {
return (await this.http.get(`${API_SERIES}/${seriesId}/thumbnails`)).data
} catch (e) {
let msg = `An error occurred while trying to retrieve thumbnails for series '${seriesId}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async uploadThumbnail(seriesId: string, file: File, selected: boolean) {
try {
const body = new FormData()
body.append('file', file)
body.append('selected', `${selected}`)
await this.http.post(`${API_SERIES}/${seriesId}/thumbnails`, body)
} catch (e) {
let msg = `An error occurred while trying to upload thumbnail for series '${seriesId}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async deleteThumbnail(seriesId: string, thumbnailId: string) {
try {
await this.http.delete(`${API_SERIES}/${seriesId}/thumbnails/${thumbnailId}`)
} catch (e) {
let msg = `An error occurred while trying to delete thumbnail for series '${seriesId}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async markThumbnailAsSelected(seriesId: string, thumbnailId: string) {
try {
await this.http.put(`${API_SERIES}/${seriesId}/thumbnails/${thumbnailId}/selected`)
} catch (e) {
let msg = `An error occurred while trying to mark thumbnail as selected for series '${seriesId}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
}

View file

@ -81,3 +81,10 @@ export interface GroupCountDto {
group: string,
count: number,
}
export interface SeriesThumbnailDto {
id: string,
seriesId: string,
type: string,
selected: boolean
}

View file

@ -375,18 +375,20 @@ class SeriesController(
@ResponseStatus(HttpStatus.ACCEPTED)
fun postUserUploadedSeriesThumbnail(
@PathVariable(name = "seriesId") seriesId: String,
@RequestParam("file") file: MultipartFile
@RequestParam("file") file: MultipartFile,
@RequestParam("selected") selected: Boolean = true,
) {
val series = seriesRepository.findByIdOrNull(seriesId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
if (!contentDetector.isImage(file.inputStream.buffered().use { contentDetector.detectMediaType(it) }))
throw ResponseStatusException(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
seriesLifecycle.addThumbnailForSeries(
ThumbnailSeries(
seriesId = series.id,
thumbnail = file.bytes,
type = ThumbnailSeries.Type.USER_UPLOADED
),
MarkSelectedPreference.YES
if (selected) MarkSelectedPreference.YES else MarkSelectedPreference.NO
)
}