mirror of
https://github.com/gotson/komga.git
synced 2025-12-20 15:34:17 +01:00
feat(webui): edit series thumbnails
Co-authored-by: Gauthier Roebroeck <gauthier.roebroeck@gmail.com>
This commit is contained in:
parent
22126e1c7d
commit
6757acfd24
8 changed files with 425 additions and 5 deletions
55
komga-webui/src/components/DropZone.vue
Normal file
55
komga-webui/src/components/DropZone.vue
Normal 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>
|
||||
148
komga-webui/src/components/ThumbnailCard.vue
Normal file
148
komga-webui/src/components/ThumbnailCard.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,3 +81,10 @@ export interface GroupCountDto {
|
|||
group: string,
|
||||
count: number,
|
||||
}
|
||||
|
||||
export interface SeriesThumbnailDto {
|
||||
id: string,
|
||||
seriesId: string,
|
||||
type: string,
|
||||
selected: boolean
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue