feat(webui): oneshots handling

This commit is contained in:
Gauthier Roebroeck 2023-07-28 16:03:06 +08:00
parent 39e7ae9e64
commit 2b238cccaf
30 changed files with 2414 additions and 112 deletions

View file

@ -31,7 +31,7 @@
<template v-else>
<div style="height: 2em" class="missing"></div>
</template>
<series-picker-dialog v-model="modalSeriesPicker" :series.sync="selectedSeries"></series-picker-dialog>
<series-picker-dialog v-model="modalSeriesPicker" :series.sync="selectedSeries" :include-oneshots="false"/>
</td>
<!-- Book number chooser -->

View file

@ -20,7 +20,7 @@
<div class="unread" v-if="isUnread"/>
<!-- unread count for series -->
<span v-if="unreadCount"
<span v-else-if="unreadCount"
class="white--text pa-1 px-2 text-subtitle-2"
:style="{background: 'orange', position: 'absolute', right: 0}"
>
@ -72,11 +72,19 @@
<div v-if="!selected && !preselect && actionMenu"
:style="'position: absolute; bottom: 5px; ' + ($vuetify.rtl ? 'left' : 'right') +': 5px'"
>
<book-actions-menu v-if="computedItem.type() === ItemTypes.BOOK"
<one-shot-actions-menu v-if="computedItem.type() === ItemTypes.BOOK && item.oneshot"
:book="item"
:menu.sync="actionMenuState"
/>
<book-actions-menu v-if="computedItem.type() === ItemTypes.BOOK && !item.oneshot"
:book="item"
:menu.sync="actionMenuState"
/>
<series-actions-menu v-if="computedItem.type() === ItemTypes.SERIES"
<one-shot-actions-menu v-if="computedItem.type() === ItemTypes.SERIES && item.oneshot"
:series="item"
:menu.sync="actionMenuState"
/>
<series-actions-menu v-if="computedItem.type() === ItemTypes.SERIES && !item.oneshot"
:series="item"
:menu.sync="actionMenuState"
/>
@ -128,7 +136,7 @@
</router-link>
</v-card-subtitle>
</template>
<v-card-text class="px-2 font-weight-light" v-html="body">
<v-card-text class="px-2 pt-0 font-weight-light" v-html="body">
</v-card-text>
</template>
</v-card>
@ -166,10 +174,11 @@ import {
} from '@/types/komga-sse'
import {coverBase64} from '@/types/image'
import {ReadListDto} from '@/types/komga-readlists'
import OneShotActionsMenu from '@/components/menus/OneshotActionsMenu.vue'
export default Vue.extend({
name: 'ItemCard',
components: {BookActionsMenu, SeriesActionsMenu, CollectionActionsMenu, ReadListActionsMenu},
components: {OneShotActionsMenu, BookActionsMenu, SeriesActionsMenu, CollectionActionsMenu, ReadListActionsMenu},
props: {
item: {
type: Object as () => BookDto | SeriesDto | CollectionDto | ReadListDto,
@ -291,6 +300,7 @@ export default Vue.extend({
},
isUnread(): boolean {
if (this.computedItem.type() === ItemTypes.BOOK) return getReadProgress(this.item as BookDto) === ReadStatus.UNREAD
if (this.computedItem.type() === ItemTypes.SERIES && (this.item as SeriesDto).oneshot) return (this.item as SeriesDto).booksUnreadCount + (this.item as SeriesDto).booksInProgressCount > 0
return false
},
unreadCount(): number | undefined {

View file

@ -65,6 +65,11 @@
:books="updateBulkBooks"
/>
<edit-oneshot-dialog
v-model="updateOneshotsDialog"
:oneshots="updateOneshots"
/>
<edit-series-dialog
v-model="updateSeriesDialog"
:series="updateSeries"
@ -103,16 +108,18 @@ import Vue from 'vue'
import ReadListAddToDialog from '@/components/dialogs/ReadListAddToDialog.vue'
import ReadListEditDialog from '@/components/dialogs/ReadListEditDialog.vue'
import {BookDto} from '@/types/komga-books'
import {SeriesDto} from '@/types/komga-series'
import {Oneshot, SeriesDto} from '@/types/komga-series'
import {ERROR} from '@/types/events'
import ConfirmationDialog from '@/components/dialogs/ConfirmationDialog.vue'
import {LibraryDto} from '@/types/komga-libraries'
import BulkEditBooksDialog from '@/components/dialogs/BulkEditBooksDialog.vue'
import {ReadListDto} from '@/types/komga-readlists'
import EditOneshotDialog from '@/components/dialogs/EditOneshotDialog.vue'
export default Vue.extend({
name: 'ReusableDialogs',
components: {
EditOneshotDialog,
BulkEditBooksDialog,
ConfirmationDialog,
CollectionAddToDialog,
@ -259,6 +266,18 @@ export default Vue.extend({
booksToDeleteSingle(): boolean {
return !Array.isArray(this.booksToDelete)
},
// oneshots
updateOneshotsDialog: {
get(): boolean {
return this.$store.state.updateOneshotsDialog
},
set(val) {
this.$store.dispatch('dialogUpdateOneshotsDisplay', val)
},
},
updateOneshots(): Oneshot | Oneshot[] {
return this.$store.state.updateOneshots
},
// series
updateSeriesDialog: {
get(): boolean {

View file

@ -76,7 +76,10 @@
<v-list-item-content>
<v-list-item-title>{{ data.item.metadata.title }}</v-list-item-title>
<v-list-item-subtitle>{{ data.item.seriesTitle }} - {{ data.item.metadata.number }}</v-list-item-subtitle>
<v-list-item-subtitle v-if="!data.item.oneshot">{{ data.item.seriesTitle }} - {{
data.item.metadata.number
}}
</v-list-item-subtitle>
<v-list-item-subtitle>{{
$t('searchbox.in_library', {library: getLibraryName(data.item)})
}}
@ -144,6 +147,10 @@ export default Vue.extend({
})
if (val.type === 'series') this.$router.push({name: 'browse-series', params: {seriesId: val.id}})
else if (val.type === 'book' && val.oneshot) this.$router.push({
name: 'browse-oneshot',
params: {seriesId: val.seriesId},
})
else if (val.type === 'book') this.$router.push({name: 'browse-book', params: {bookId: val.id}})
else if (val.type === 'collection') this.$router.push({
name: 'browse-collection',

View file

@ -43,7 +43,7 @@
</v-tooltip>
</v-btn>
<v-btn icon @click="addToCollection" v-if="isAdmin && kind === 'series'">
<v-btn icon @click="addToCollection" v-if="isAdmin && (kind === 'series' || (kind === 'books' && oneshots))">
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-icon v-on="on">mdi-playlist-plus</v-icon>
@ -52,7 +52,7 @@
</v-tooltip>
</v-btn>
<v-btn icon @click="addToReadList" v-if="isAdmin && kind === 'books'">
<v-btn icon @click="addToReadList" v-if="isAdmin && (kind === 'books' || (kind === 'series' && oneshots))">
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-icon v-on="on">mdi-book-plus-multiple</v-icon>
@ -114,6 +114,10 @@ export default Vue.extend({
type: String,
required: true,
},
oneshots: {
type: Boolean,
default: false,
},
showSelectAll: {
type: Boolean,
default: false,

File diff suppressed because it is too large Load diff

View file

@ -108,6 +108,22 @@
</v-tooltip>
</template>
</v-checkbox>
<v-text-field v-model="form.oneshotsDirectory"
clearable
:label="$t('dialog.edit_library.field_oneshotsdirectory')"
:error-messages="getErrors('oneshotsDirectory')"
class="mx-4 mt-4"
>
<template v-slot:append-outer>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-icon v-on="on" color="info">mdi-help-circle-outline</v-icon>
</template>
{{ $t('dialog.edit_library.tooltip_oneshotsdirectory') }}
</v-tooltip>
</template>
</v-text-field>
</v-col>
</v-row>
<v-row>
@ -392,6 +408,7 @@ export default Vue.extend({
hashFiles: true,
hashPages: false,
analyzeDimensions: true,
oneshotsDirectory: '',
},
validationFieldNames: new Map([]),
}
@ -524,6 +541,7 @@ export default Vue.extend({
this.form.hashFiles = library ? library.hashFiles : true
this.form.hashPages = library ? library.hashPages : false
this.form.analyzeDimensions = library ? library.analyzeDimensions : true
this.form.oneshotsDirectory = library ? library.oneshotsDirectory : ''
this.$v.$reset()
},
validateLibrary() {
@ -551,6 +569,7 @@ export default Vue.extend({
hashFiles: this.form.hashFiles,
hashPages: this.form.hashPages,
analyzeDimensions: this.form.analyzeDimensions,
oneshotsDirectory: this.form.oneshotsDirectory,
}
}
return null

View file

@ -100,6 +100,10 @@ export default Vue.extend({
type: Object as PropType<SeriesDto>,
required: false,
},
includeOneshots: {
type: Boolean,
default: true,
},
},
watch: {
value(val) {
@ -119,7 +123,7 @@ export default Vue.extend({
searchItems: debounce(async function (this: any, query: string) {
if (query) {
this.showResults = false
this.results = (await this.$komgaSeries.getSeries(undefined, {unpaged: true}, query)).content
this.results = (await this.$komgaSeries.getSeries(undefined, {unpaged: true}, query, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, this.includeOneshots)).content
this.showResults = true
} else {
this.clear()

View file

@ -0,0 +1,111 @@
<template>
<div>
<v-menu offset-y v-model="menuState">
<template v-slot:activator="{ on }">
<v-btn icon v-on="on" @click.prevent="">
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list dense>
<v-list-item @click="analyze" v-if="isAdmin">
<v-list-item-title>{{ $t('menu.analyze') }}</v-list-item-title>
</v-list-item>
<v-list-item @click="refreshMetadata" v-if="isAdmin">
<v-list-item-title>{{ $t('menu.refresh_metadata') }}</v-list-item-title>
</v-list-item>
<v-list-item @click="addToCollection" v-if="isAdmin">
<v-list-item-title>{{ $t('menu.add_to_collection') }}</v-list-item-title>
</v-list-item>
<v-list-item @click="addToReadList" v-if="isAdmin">
<v-list-item-title>{{ $t('menu.add_to_readlist') }}</v-list-item-title>
</v-list-item>
<v-list-item @click="markRead" v-if="!isRead">
<v-list-item-title>{{ $t('menu.mark_read') }}</v-list-item-title>
</v-list-item>
<v-list-item @click="markUnread" v-if="!isUnread">
<v-list-item-title>{{ $t('menu.mark_unread') }}</v-list-item-title>
</v-list-item>
<v-list-item @click="promptDelete" class="list-danger" v-if="isAdmin">
<v-list-item-title>{{ $t('menu.delete') }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</template>
<script lang="ts">
import {getReadProgress} from '@/functions/book-progress'
import {ReadStatus} from '@/types/enum-books'
import Vue from 'vue'
import {BookDto} from '@/types/komga-books'
import {SeriesDto} from '@/types/komga-series'
export default Vue.extend({
name: 'OneShotActionsMenu',
data: () => {
return {
menuState: false,
localBookId: undefined as unknown as string,
}
},
props: {
book: {
type: Object as () => BookDto,
required: false,
},
series: {
type: Object as () => SeriesDto,
required: false,
},
menu: {
type: Boolean,
default: false,
},
},
watch: {
menuState(val) {
this.$emit('update:menu', val)
},
},
computed: {
isAdmin(): boolean {
return this.$store.getters.meAdmin
},
isRead(): boolean {
return this.series ? this.series.booksReadCount === this.series.booksCount : getReadProgress(this.book) === ReadStatus.READ
},
isUnread(): boolean {
return this.series ? this.series.booksUnreadCount === this.series.booksCount : getReadProgress(this.book) === ReadStatus.UNREAD
},
seriesId(): string {
return this.series ? this.series.id : this.book?.seriesId
},
},
methods: {
analyze() {
if (this.book) this.$komgaBooks.analyzeBook(this.book)
else this.$komgaSeries.analyzeSeries(this.series)
},
refreshMetadata() {
if (this.book) this.$komgaBooks.refreshMetadata(this.book)
this.$komgaSeries.refreshMetadata(this.series)
},
addToCollection() {
this.$store.dispatch('dialogAddSeriesToCollection', [this.seriesId])
},
async addToReadList() {
if (!this.book && !this.localBookId) this.localBookId = (await this.$komgaSeries.getBooks(this.seriesId)).content[0].id
this.$store.dispatch('dialogAddBooksToReadList', [this.book?.id || this.localBookId])
},
async markRead() {
await this.$komgaSeries.markAsRead(this.seriesId)
},
async markUnread() {
await this.$komgaSeries.markAsUnread(this.seriesId)
},
promptDelete() {
if (this.book) this.$store.dispatch('dialogDeleteBook', this.book)
else this.$store.dispatch('dialogDeleteSeries', this.series)
},
},
})
</script>

View file

@ -222,6 +222,7 @@
"lock_all": "Lock all",
"n_selected": "{count} selected",
"nothing_to_show": "Nothing to show",
"oneshot": "One-shot",
"outdated": "Outdated",
"page": "Page",
"page_number": "Page number",
@ -428,6 +429,7 @@
"field_import_local_artwork": "Local artwork",
"field_import_mylar_series": "Series metadata",
"field_name": "Name",
"field_oneshotsdirectory": "One-Shots directory",
"field_repair_extensions": "Automatically repair incorrect file extensions",
"field_root_folder": "Root folder",
"field_scanner_empty_trash_after_scan": "Empty trash automatically after every scan",
@ -447,6 +449,7 @@
"tab_general": "General",
"tab_metadata": "Metadata",
"tab_options": "Options",
"tooltip_oneshotsdirectory": "Leave empty to disable",
"tooltip_scanner_force_modified_time": "Enable if the library is on a Google Drive",
"tooltip_use_resources": "Can consume lots of resources on large libraries or slow hardware"
},
@ -698,6 +701,7 @@
"in_progress": "In Progress",
"language": "language",
"library": "library",
"oneshot": "One-shot",
"publisher": "publisher",
"read": "Read",
"release_date": "release date",

View file

@ -213,6 +213,12 @@ const router = new Router({
component: () => import(/* webpackChunkName: "browse-book" */ './views/BrowseBook.vue'),
props: (route) => ({bookId: route.params.bookId}),
},
{
path: '/oneshot/:seriesId',
name: 'browse-oneshot',
component: () => import(/* webpackChunkName: "browse-oneshot" */ './views/BrowseOneshot.vue'),
props: (route) => ({seriesId: route.params.seriesId}),
},
{
path: '/search',
name: 'search',

View file

@ -48,7 +48,7 @@ export default class KomgaLibrariesService {
async updateLibrary(libraryId: string, library: LibraryUpdateDto) {
try {
await this.http.put(`${API_LIBRARIES}/${libraryId}`, library)
await this.http.patch(`${API_LIBRARIES}/${libraryId}`, library)
} catch (e) {
let msg = `An error occurred while trying to update library '${libraryId}'`
if (e.response.data.message) {

View file

@ -16,7 +16,7 @@ export default class KomgaSeriesService {
async getSeries(libraryId?: string, pageRequest?: PageRequest, search?: string, status?: string[],
readStatus?: string[], genre?: string[], tag?: string[], language?: string[],
publisher?: string[], ageRating?: string[], releaseDate?: string[], authors?: AuthorDto[],
searchRegex?: string, complete?: boolean, sharingLabel?: string[]): Promise<Page<SeriesDto>> {
searchRegex?: string, complete?: boolean, sharingLabel?: string[], oneshot?: boolean): Promise<Page<SeriesDto>> {
try {
const params = {...pageRequest} as any
if (libraryId) params.library_id = libraryId
@ -33,6 +33,7 @@ export default class KomgaSeriesService {
if (authors) params.author = authors.map(a => `${a.name},${a.role}`)
if (complete !== undefined) params.complete = complete
if (sharingLabel) params.sharing_label = sharingLabel
if (oneshot !== undefined) params.oneshot = oneshot
return (await this.http.get(API_SERIES, {
params: params,
@ -50,7 +51,7 @@ export default class KomgaSeriesService {
async getAlphabeticalGroups(libraryId?: string, search?: string, status?: string[],
readStatus?: string[], genre?: string[], tag?: string[], language?: string[],
publisher?: string[], ageRating?: string[], releaseDate?: string[], authors?: AuthorDto[],
complete?: boolean, sharingLabel?: string[]): Promise<GroupCountDto[]> {
complete?: boolean, sharingLabel?: string[], oneshot?: boolean): Promise<GroupCountDto[]> {
try {
const params = {} as any
if (libraryId) params.library_id = libraryId
@ -66,6 +67,7 @@ export default class KomgaSeriesService {
if (authors) params.author = authors.map(a => `${a.name},${a.role}`)
if (complete !== undefined) params.complete = complete
if (sharingLabel) params.sharing_label = sharingLabel
if (oneshot !== undefined) params.oneshot = oneshot
return (await this.http.get(`${API_SERIES}/alphabetical-groups`, {
params: params,
@ -80,12 +82,11 @@ export default class KomgaSeriesService {
}
}
async getNewSeries(libraryId?: string, pageRequest?: PageRequest): Promise<Page<SeriesDto>> {
async getNewSeries(libraryId?: string, oneshot?: boolean, pageRequest?: PageRequest): Promise<Page<SeriesDto>> {
try {
const params = {...pageRequest} as any
if (libraryId) {
params.library_id = libraryId
}
if (libraryId) params.library_id = libraryId
if (oneshot !== undefined) params.oneshot = oneshot
return (await this.http.get(`${API_SERIES}/new`, {
params: params,
})).data
@ -98,12 +99,11 @@ export default class KomgaSeriesService {
}
}
async getUpdatedSeries(libraryId?: string, pageRequest?: PageRequest): Promise<Page<SeriesDto>> {
async getUpdatedSeries(libraryId?: string, oneshot?: boolean, pageRequest?: PageRequest): Promise<Page<SeriesDto>> {
try {
const params = {...pageRequest} as any
if (libraryId) {
params.library_id = libraryId
}
if (libraryId) params.library_id = libraryId
if (oneshot !== undefined) params.oneshot = oneshot
return (await this.http.get(`${API_SERIES}/updated`, {
params: params,
})).data

View file

@ -1,7 +1,7 @@
import Vue from 'vue'
import Vuex from 'vuex'
import {BookDto} from '@/types/komga-books'
import {SeriesDto} from '@/types/komga-series'
import {Oneshot, SeriesDto} from '@/types/komga-series'
import createPersistedState from 'vuex-persistedstate'
import {persistedModule} from './plugins/persisted-state'
import {LibraryDto} from '@/types/komga-libraries'
@ -44,6 +44,10 @@ export default new Vuex.Store({
updateBulkBooks: [] as BookDto[],
updateBulkBooksDialog: false,
// oneshots
updateOneshots: {} as Oneshot | Oneshot[],
updateOneshotsDialog: false,
// series
updateSeries: {} as SeriesDto | SeriesDto[],
updateSeriesDialog: false,
@ -133,6 +137,13 @@ export default new Vuex.Store({
setUpdateBulkBooksDialog(state, dialog) {
state.updateBulkBooksDialog = dialog
},
// One-shots
setUpdateOneshots(state, oneshots) {
state.updateOneshots = oneshots
},
setUpdateOneshotsDialog(state, dialog) {
state.updateOneshotsDialog = dialog
},
// Series
setUpdateSeries(state, series) {
state.updateSeries = series
@ -240,6 +251,15 @@ export default new Vuex.Store({
dialogUpdateBulkBooksDisplay({commit}, value) {
commit('setUpdateBulkBooksDialog', value)
},
// oneshots
dialogUpdateOneshots({commit}, oneshots) {
commit('setUpdateOneshots', oneshots)
commit('setUpdateOneshotsDialog', true)
},
dialogUpdateOneshotsDisplay({commit}, value) {
commit('setUpdateOneshotsDialog', value)
},
// series
dialogUpdateSeries({commit}, series) {
commit('setUpdateSeries', series)

View file

@ -77,6 +77,11 @@ export class BookItem extends Item<BookDto> {
title(context: ItemContext[]): ItemTitle | ItemTitle[] {
if (this.item.oneshot)
return {
title: this.item.metadata.title,
to: this.to(),
}
if (context.includes(ItemContext.SHOW_SERIES))
return [
{
@ -107,16 +112,23 @@ export class BookItem extends Item<BookDto> {
let text
let title
if (context.includes(ItemContext.RELEASE_DATE))
text = this.item.metadata.releaseDate ? new Intl.DateTimeFormat(i18n.locale, {dateStyle: 'medium', timeZone: 'UTC'} as Intl.DateTimeFormatOptions).format(new Date(this.item.metadata.releaseDate)) : i18n.t('book_card.no_release_date')
else if (context.includes(ItemContext.DATE_ADDED))
{
text = new Intl.DateTimeFormat(i18n.locale, {dateStyle: 'medium'} as Intl.DateTimeFormatOptions).format(this.item.created)
title = new Intl.DateTimeFormat(i18n.locale, {dateStyle: 'long', timeStyle: 'medium'} as Intl.DateTimeFormatOptions).format(this.item.created)
}
else if (context.includes(ItemContext.READ_DATE)) {
text = this.item.metadata.releaseDate ? new Intl.DateTimeFormat(i18n.locale, {
dateStyle: 'medium',
timeZone: 'UTC',
} as Intl.DateTimeFormatOptions).format(new Date(this.item.metadata.releaseDate)) : i18n.t('book_card.no_release_date')
else if (context.includes(ItemContext.DATE_ADDED)) {
text = new Intl.DateTimeFormat(i18n.locale, {dateStyle: 'medium'} as Intl.DateTimeFormatOptions).format(this.item.created)
title = new Intl.DateTimeFormat(i18n.locale, {
dateStyle: 'long',
timeStyle: 'medium',
} as Intl.DateTimeFormatOptions).format(this.item.created)
} else if (context.includes(ItemContext.READ_DATE)) {
if (this.item.readProgress?.readDate) {
text = new Intl.DateTimeFormat(i18n.locale, {dateStyle: 'medium'} as Intl.DateTimeFormatOptions).format(this.item.readProgress?.readDate)
title = new Intl.DateTimeFormat(i18n.locale, {dateStyle: 'long', timeStyle: 'medium'} as Intl.DateTimeFormatOptions).format(this.item.readProgress?.readDate)
title = new Intl.DateTimeFormat(i18n.locale, {
dateStyle: 'long',
timeStyle: 'medium',
} as Intl.DateTimeFormatOptions).format(this.item.readProgress?.readDate)
} else {
text = i18n.t('book_card.unread')
}
@ -124,23 +136,35 @@ export class BookItem extends Item<BookDto> {
text = getFileSize(this.item.sizeBytes)
else
text = i18n.tc('common.pages_n', this.item.media.pagesCount)
return `<div class="text-truncate" title="${title}">${text}</div>`
return `<div class="text-truncate" title="${title ? title : ''}">${text}</div>`
}
}
to(): RawLocation {
return {
name: 'browse-book',
params: {bookId: this.item.id},
query: {context: this.item?.context?.origin, contextId: this.item?.context?.id},
}
return this.item.oneshot ?
{
name: 'browse-oneshot',
params: {seriesId: this.item.seriesId},
query: {context: this.item?.context?.origin, contextId: this.item?.context?.id},
} :
{
name: 'browse-book',
params: {bookId: this.item.id},
query: {context: this.item?.context?.origin, contextId: this.item?.context?.id},
}
}
seriesTo(): RawLocation {
return {
name: 'browse-series',
params: {seriesId: this.item.seriesId},
}
return this.item.oneshot ?
{
name: 'browse-oneshot',
params: {seriesId: this.item.seriesId},
query: {context: this.item?.context?.origin, contextId: this.item?.context?.id},
} :
{
name: 'browse-series',
params: {seriesId: this.item.seriesId},
}
}
fabTo(): RawLocation {
@ -174,25 +198,32 @@ export class SeriesItem extends Item<SeriesDto> {
let text
let title
if (context.includes(ItemContext.RELEASE_DATE))
text = this.item.booksMetadata.releaseDate ? new Intl.DateTimeFormat(i18n.locale, {dateStyle: 'medium', timeZone: 'UTC'} as Intl.DateTimeFormatOptions).format(new Date(this.item.booksMetadata.releaseDate)) : i18n.t('book_card.no_release_date')
else if (context.includes(ItemContext.DATE_ADDED))
{
text = new Intl.DateTimeFormat(i18n.locale, {dateStyle: 'medium'} as Intl.DateTimeFormatOptions).format(this.item.created)
title = new Intl.DateTimeFormat(i18n.locale, {dateStyle: 'long', timeStyle: 'medium'} as Intl.DateTimeFormatOptions).format(this.item.created)
}
else if (context.includes(ItemContext.DATE_UPDATED))
{
text = new Intl.DateTimeFormat(i18n.locale, {dateStyle: 'medium'} as Intl.DateTimeFormatOptions).format(this.item.lastModified)
title = new Intl.DateTimeFormat(i18n.locale, {dateStyle: 'long', timeStyle: 'medium'} as Intl.DateTimeFormatOptions).format(this.item.lastModified)
}
else
text = this.item.booksMetadata.releaseDate ? new Intl.DateTimeFormat(i18n.locale, {
dateStyle: 'medium',
timeZone: 'UTC',
} as Intl.DateTimeFormatOptions).format(new Date(this.item.booksMetadata.releaseDate)) : i18n.t('book_card.no_release_date')
else if (context.includes(ItemContext.DATE_ADDED)) {
text = new Intl.DateTimeFormat(i18n.locale, {dateStyle: 'medium'} as Intl.DateTimeFormatOptions).format(this.item.created)
title = new Intl.DateTimeFormat(i18n.locale, {
dateStyle: 'long',
timeStyle: 'medium',
} as Intl.DateTimeFormatOptions).format(this.item.created)
} else if (context.includes(ItemContext.DATE_UPDATED)) {
text = new Intl.DateTimeFormat(i18n.locale, {dateStyle: 'medium'} as Intl.DateTimeFormatOptions).format(this.item.lastModified)
title = new Intl.DateTimeFormat(i18n.locale, {
dateStyle: 'long',
timeStyle: 'medium',
} as Intl.DateTimeFormatOptions).format(this.item.lastModified)
} else if (this.item.oneshot) {
text = i18n.t('common.oneshot')
} else
text = i18n.tc('common.books_n', this.item.booksCount)
return `<div class="text-truncate" title="${title}">${text}</div>`
return `<div class="text-truncate" title="${title ? title : ''}">${text}</div>`
}
to(): RawLocation {
return {
name: 'browse-series', params: {seriesId: this.item.id.toString()},
name: this.item.oneshot ? 'browse-oneshot' : 'browse-series', params: {seriesId: this.item.id.toString()},
query: {context: this.item?.context?.origin, contextId: this.item?.context?.id},
}
}

View file

@ -17,6 +17,7 @@ export interface BookDto {
metadata: BookMetadataDto,
readProgress?: ReadProgressDto,
deleted: boolean,
oneshot: boolean,
// custom fields
context: Context

View file

@ -21,6 +21,7 @@ export interface LibraryCreationDto {
hashFiles: boolean,
hashPages: boolean,
analyzeDimensions: boolean,
oneshotsDirectory: string,
}
export interface LibraryUpdateDto {
@ -44,6 +45,7 @@ export interface LibraryUpdateDto {
hashFiles: boolean,
hashPages: boolean,
analyzeDimensions: boolean,
oneshotsDirectory: string,
}
export interface LibraryDto {
@ -68,5 +70,6 @@ export interface LibraryDto {
hashFiles: boolean,
hashPages: boolean,
analyzeDimensions: boolean,
oneshotsDirectory: string,
unavailable: boolean,
}

View file

@ -1,4 +1,4 @@
import {AuthorDto, WebLinkDto} from '@/types/komga-books'
import {AuthorDto, BookDto, WebLinkDto} from '@/types/komga-books'
import {Context} from '@/types/context'
export interface SeriesDto {
@ -15,6 +15,7 @@ export interface SeriesDto {
metadata: SeriesMetadataDto,
booksMetadata: SeriesBooksMetadataDto,
deleted: boolean,
oneshot: boolean,
// custom fields
context: Context
@ -110,3 +111,8 @@ export interface AlternateTitleDto {
label: string,
title: string
}
export interface Oneshot {
series: SeriesDto,
book: BookDto,
}

View file

@ -69,7 +69,7 @@
<v-list-item @click="setCurrentPageAsPoster(ItemTypes.SERIES)">
<v-list-item-title>{{ $t('bookreader.set_current_page_as_series_poster') }}</v-list-item-title>
</v-list-item>
<v-list-item v-if="contextReadList" @click="setCurrentPageAsPoster(ItemTypes.READLIST)">
<v-list-item v-if="contextReadList" @click="setCurrentPageAsPoster(ItemTypes.READLIST)">
<v-list-item-title>{{ $t('bookreader.set_current_page_as_readlist_poster') }}</v-list-item-title>
</v-list-item>
</v-list>
@ -780,11 +780,12 @@ export default Vue.extend({
} as Location)
},
closeBook() {
this.$router.push({
name: 'browse-book',
params: {bookId: this.bookId.toString()},
query: {context: this.context.origin, contextId: this.context.id},
})
this.$router.push(
{
name: this.book.oneshot ? 'browse-oneshot' : 'browse-book',
params: {bookId: this.bookId.toString(), seriesId: this.book.seriesId},
query: {context: this.context.origin, contextId: this.context.id},
})
},
changeReadingDir(dir: ReadingDirection) {
this.readingDirection = dir

View file

@ -40,7 +40,7 @@
<v-btn
icon
:disabled="$_.isEmpty(siblingPrevious)"
:to="{ name: 'browse-book', params: { bookId: previousId }, query: { context: context.origin, contextId: context.id} }"
:to="{ name: siblingPrevious.oneshot ? 'browse-oneshot' : 'browse-book', params: { seriesId: siblingPrevious.seriesId, bookId: previousId }, query: { context: context.origin, contextId: context.id} }"
>
<rtl-icon icon="mdi-chevron-left" rtl="mdi-chevron-right"/>
</v-btn>
@ -64,12 +64,13 @@
<v-list-item
v-for="(book, i) in siblings"
:key="i"
:to="{ name: 'browse-book', params: { bookId: book.id }, query: { context: context.origin, contextId: context.id} }"
:to="{ name: book.oneshot ? 'browse-oneshot' : 'browse-book', params: { seriesId: book.seriesId, bookId: book.id }, query: { context: context.origin, contextId: context.id} }"
>
<v-list-item-title class="text-wrap text-body-2">
<template v-if="contextReadList">{{ book.seriesTitle }} {{ book.metadata.number }}:
<template v-if="contextReadList && !book.oneshot">{{ book.seriesTitle }} {{ book.metadata.number }}:
{{ book.metadata.title }}
</template>
<template v-else-if="contextReadList && book.oneshot">{{ book.metadata.title }}</template>
<template v-else>{{ book.metadata.number }} - {{ book.metadata.title }}</template>
</v-list-item-title>
</v-list-item>
@ -81,7 +82,7 @@
<v-btn
icon
:disabled="$_.isEmpty(siblingNext)"
:to="{ name: 'browse-book', params: { bookId: nextId }, query: { context: context.origin, contextId: context.id} }"
:to="{ name: siblingNext.oneshot ? 'browse-oneshot' : 'browse-book', params: { seriesId: siblingNext.seriesId, bookId: nextId }, query: { context: context.origin, contextId: context.id} }"
>
<rtl-icon icon="mdi-chevron-right" rtl="mdi-chevron-left"/>
</v-btn>
@ -159,7 +160,10 @@
<v-col cols="auto" v-if="book.metadata.releaseDate">
{{
new Intl.DateTimeFormat($i18n.locale, {dateStyle: 'long', timeZone: 'UTC'}).format(new Date(book.metadata.releaseDate))
new Intl.DateTimeFormat($i18n.locale, {
dateStyle: 'long',
timeZone: 'UTC'
}).format(new Date(book.metadata.releaseDate))
}}
</v-col>
@ -581,6 +585,11 @@ export default Vue.extend({
},
async loadBook(bookId: string) {
this.book = await this.$komgaBooks.getBook(bookId)
// for the cases where we can't change the origin target route because we don't have the full BookDto
if (this.book.oneshot) await this.$router.replace({
name: 'browse-oneshot',
params: {seriesId: this.book.seriesId},
})
// parse query params to get context and contextId
if (this.$route.query.contextId && this.$route.query.context

View file

@ -47,12 +47,14 @@
<multi-select-bar
v-model="selectedSeries"
kind="series"
:oneshots="selectedOneshots"
show-select-all
@unselect-all="selectedSeries = []"
@select-all="selectedSeries = series"
@mark-read="markSelectedRead"
@mark-unread="markSelectedUnread"
@add-to-collection="addToCollection"
@add-to-readlist="addToReadList"
@edit="editMultipleSeries"
@delete="deleteSeries"
/>
@ -154,9 +156,9 @@ import FilterPanels from '@/components/FilterPanels.vue'
import FilterList from '@/components/FilterList.vue'
import {Location} from 'vue-router'
import EmptyState from '@/components/EmptyState.vue'
import {SeriesDto} from '@/types/komga-series'
import {Oneshot, SeriesDto} from '@/types/komga-series'
import {authorRoles} from '@/types/author-roles'
import {AuthorDto, BookDto} from '@/types/komga-books'
import {AuthorDto} from '@/types/komga-books'
import {CollectionSseDto, ReadProgressSeriesSseDto, SeriesSseDto} from '@/types/komga-sse'
import {throttle} from 'lodash'
import {LibraryDto} from '@/types/komga-libraries'
@ -322,6 +324,9 @@ export default Vue.extend({
filterActive(): boolean {
return Object.keys(this.filters).some(x => this.filters[x].length !== 0)
},
selectedOneshots(): boolean {
return this.selectedSeries.every(s => s.oneshot)
},
},
methods: {
resetFilters() {
@ -503,11 +508,20 @@ export default Vue.extend({
this.$router.replace(loc).catch((_: any) => {
})
},
editSingleSeries(series: SeriesDto) {
this.$store.dispatch('dialogUpdateSeries', series)
async editSingleSeries(series: SeriesDto) {
if (series.oneshot) {
const book = (await this.$komgaSeries.getBooks(series.id)).content[0]
this.$store.dispatch('dialogUpdateOneshots', {series: series, book: book})
} else
this.$store.dispatch('dialogUpdateSeries', series)
},
editMultipleSeries() {
this.$store.dispatch('dialogUpdateSeries', this.selectedSeries)
async editMultipleSeries() {
if (this.selectedSeries.every(s => s.oneshot)) {
const books = await Promise.all(this.selectedSeries.map(s => this.$komgaSeries.getBooks(s.id)))
const oneshots = this.selectedSeries.map((s, index) => ({series: s, book: books[index].content[0]} as Oneshot))
this.$store.dispatch('dialogUpdateOneshots', oneshots)
} else
this.$store.dispatch('dialogUpdateSeries', this.selectedSeries)
},
deleteSeries() {
this.$store.dispatch('dialogDeleteSeries', this.selectedSeries)
@ -527,6 +541,10 @@ export default Vue.extend({
addToCollection() {
this.$store.dispatch('dialogAddSeriesToCollection', this.selectedSeries.map(s => s.id))
},
async addToReadList() {
const books = await Promise.all(this.selectedSeries.map(s => this.$komgaSeries.getBooks(s.id)))
this.$store.dispatch('dialogAddBooksToReadList', books.map(b => b.content[0].id))
},
async startEditElements() {
this.filters = {}
this.unpaged = true

View file

@ -28,12 +28,14 @@
<multi-select-bar
v-model="selectedSeries"
kind="series"
:oneshots="selectedOneshots"
show-select-all
@unselect-all="selectedSeries = []"
@select-all="selectedSeries = series"
@mark-read="markSelectedRead"
@mark-unread="markSelectedUnread"
@add-to-collection="addToCollection"
@add-to-readlist="addToReadList"
@edit="editMultipleSeries"
@delete="deleteSeries"
/>
@ -149,7 +151,7 @@ import SortList from '@/components/SortList.vue'
import FilterPanels from '@/components/FilterPanels.vue'
import FilterList from '@/components/FilterList.vue'
import {mergeFilterParams, sortOrFilterActive, toNameValue} from '@/functions/filter'
import {GroupCountDto, SeriesDto} from '@/types/komga-series'
import {GroupCountDto, Oneshot, SeriesDto} from '@/types/komga-series'
import {AuthorDto} from '@/types/komga-books'
import {authorRoles} from '@/types/author-roles'
import {LibrarySseDto, ReadProgressSeriesSseDto, SeriesSseDto} from '@/types/komga-sse'
@ -265,9 +267,9 @@ export default Vue.extend({
},
computed: {
searchRegex(): string | undefined {
if (this.selectedSymbol === 'ALL') return undefined
if (this.selectedSymbol === '#') return '^[^a-z],title_sort'
return `^${this.selectedSymbol},title_sort`
if (this.selectedSymbol === 'ALL') return undefined
if (this.selectedSymbol === '#') return '^[^a-z],title_sort'
return `^${this.selectedSymbol},title_sort`
},
itemContext(): ItemContext[] {
if (this.sortActive.key === 'booksMetadata.releaseDate') return [ItemContext.RELEASE_DATE]
@ -297,6 +299,9 @@ export default Vue.extend({
complete: {
values: [{name: this.$t('filter.complete').toString(), value: 'true', nValue: 'false'}],
},
oneshot: {
values: [{name: this.$t('filter.oneshot').toString(), value: 'true', nValue: 'false'}],
},
} as FiltersOptions
},
filterOptionsPanel(): FiltersOptions {
@ -348,6 +353,9 @@ export default Vue.extend({
sortOrFilterActive(): boolean {
return sortOrFilterActive(this.sortActive, this.sortDefault, this.filters)
},
selectedOneshots(): boolean {
return this.selectedSeries.every(s => s.oneshot)
},
},
methods: {
filterByStarting(symbol: string) {
@ -393,7 +401,7 @@ export default Vue.extend({
// get filter from query params or local storage and validate with available filter values
let activeFilters: any
if (route.query.status || route.query.readStatus || route.query.genre || route.query.tag || route.query.language || route.query.ageRating || route.query.publisher || authorRoles.some(role => role in route.query) || route.query.complete || route.query.sharingLabel) {
if (route.query.status || route.query.readStatus || route.query.genre || route.query.tag || route.query.language || route.query.ageRating || route.query.publisher || authorRoles.some(role => role in route.query) || route.query.complete || route.query.oneshot || route.query.sharingLabel) {
activeFilters = {
status: route.query.status || [],
readStatus: route.query.readStatus || [],
@ -404,6 +412,7 @@ export default Vue.extend({
ageRating: route.query.ageRating || [],
releaseDate: route.query.releaseDate || [],
complete: route.query.complete || [],
oneshot: route.query.oneshot || [],
sharingLabel: route.query.sharingLabel || [],
}
authorRoles.forEach((role: string) => {
@ -425,6 +434,7 @@ export default Vue.extend({
ageRating: filters.ageRating?.filter(x => this.filterOptions.ageRating.map(n => n.value).includes(x)) || [],
releaseDate: filters.releaseDate?.filter(x => this.filterOptions.releaseDate.map(n => n.value).includes(x)) || [],
complete: filters.complete?.filter(x => x === 'true' || x === 'false') || [],
oneshot: filters.oneshot?.filter(x => x === 'true' || x === 'false') || [],
sharingLabel: filters.sharingLabel?.filter(x => this.filterOptions.sharingLabel.map(n => n.value).includes(x)) || [],
} as any
authorRoles.forEach((role: string) => {
@ -532,13 +542,14 @@ export default Vue.extend({
const requestLibraryId = libraryId !== LIBRARIES_ALL ? libraryId : undefined
const complete = parseBooleanFilter(this.filters.complete)
const seriesPage = await this.$komgaSeries.getSeries(requestLibraryId, pageRequest, undefined, this.filters.status, replaceCompositeReadStatus(this.filters.readStatus), this.filters.genre, this.filters.tag, this.filters.language, this.filters.publisher, this.filters.ageRating, this.filters.releaseDate, authorsFilter, searchRegex, complete, this.filters.sharingLabel)
const oneshot = parseBooleanFilter(this.filters.oneshot)
const seriesPage = await this.$komgaSeries.getSeries(requestLibraryId, pageRequest, undefined, this.filters.status, replaceCompositeReadStatus(this.filters.readStatus), this.filters.genre, this.filters.tag, this.filters.language, this.filters.publisher, this.filters.ageRating, this.filters.releaseDate, authorsFilter, searchRegex, complete, this.filters.sharingLabel, oneshot)
this.totalPages = seriesPage.totalPages
this.totalElements = seriesPage.totalElements
this.series = seriesPage.content
const seriesGroups = await this.$komgaSeries.getAlphabeticalGroups(requestLibraryId, undefined, this.filters.status, replaceCompositeReadStatus(this.filters.readStatus), this.filters.genre, this.filters.tag, this.filters.language, this.filters.publisher, this.filters.ageRating, this.filters.releaseDate, authorsFilter, complete, this.filters.sharingLabel)
const seriesGroups = await this.$komgaSeries.getAlphabeticalGroups(requestLibraryId, undefined, this.filters.status, replaceCompositeReadStatus(this.filters.readStatus), this.filters.genre, this.filters.tag, this.filters.language, this.filters.publisher, this.filters.ageRating, this.filters.releaseDate, authorsFilter, complete, this.filters.sharingLabel, oneshot)
const nonAlpha = seriesGroups
.filter((g) => !(/[a-zA-Z]/).test(g.group))
.reduce((a, b) => a + b.count, 0)
@ -571,11 +582,24 @@ export default Vue.extend({
addToCollection() {
this.$store.dispatch('dialogAddSeriesToCollection', this.selectedSeries.map(s => s.id))
},
editSingleSeries(series: SeriesDto) {
this.$store.dispatch('dialogUpdateSeries', series)
async addToReadList() {
const books = await Promise.all(this.selectedSeries.map(s => this.$komgaSeries.getBooks(s.id)))
this.$store.dispatch('dialogAddBooksToReadList', books.map(b => b.content[0].id))
},
editMultipleSeries() {
this.$store.dispatch('dialogUpdateSeries', this.selectedSeries)
async editSingleSeries(series: SeriesDto) {
if (series.oneshot) {
const book = (await this.$komgaSeries.getBooks(series.id)).content[0]
this.$store.dispatch('dialogUpdateOneshots', {series: series, book: book})
} else
this.$store.dispatch('dialogUpdateSeries', series)
},
async editMultipleSeries() {
if (this.selectedOneshots) {
const books = await Promise.all(this.selectedSeries.map(s => this.$komgaSeries.getBooks(s.id)))
const oneshots = this.selectedSeries.map((s, index) => ({series: s, book: books[index].content[0]} as Oneshot))
this.$store.dispatch('dialogUpdateOneshots', oneshots)
} else
this.$store.dispatch('dialogUpdateSeries', this.selectedSeries)
},
deleteSeries() {
this.$store.dispatch('dialogDeleteSeries', this.selectedSeries)

View file

@ -0,0 +1,796 @@
<template>
<div v-if="!$_.isEmpty(book) && !$_.isEmpty(series)">
<toolbar-sticky>
<v-tooltip bottom :disabled="!isAdmin">
<template v-slot:activator="{ on }">
<v-btn icon
v-on="on"
:to="parentLocation"
>
<rtl-icon icon="mdi-arrow-left" rtl="mdi-arrow-right"/>
</v-btn>
</template>
<span v-if="context.origin === ContextOrigin.READLIST">{{ $t('common.go_to_readlist') }}</span>
<span v-else-if="context.origin === ContextOrigin.COLLECTION">{{ $t('common.go_to_collection') }}</span>
<span v-else>{{ $t('common.go_to_library') }}</span>
</v-tooltip>
<!-- Action menu -->
<oneshot-actions-menu v-if="book"
:book="book"
:series="series"
/>
<v-btn icon @click="editBook" v-if="isAdmin">
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-spacer/>
<!-- Context notification for navigation -->
<v-alert
v-if="context.origin === ContextOrigin.READLIST && $vuetify.breakpoint.mdAndUp"
type="info"
text
dense
border="right"
class="mb-0"
>{{ $t('browse_book.navigation_within_readlist', {name: contextName}) }}
</v-alert>
<!-- Navigate to previous book -->
<v-btn
v-if="context.origin === ContextOrigin.READLIST"
icon
:disabled="$_.isEmpty(siblingPrevious)"
:to="{ name: siblingPrevious.oneshot ? 'browse-oneshot' : 'browse-book', params: { seriesId: siblingPrevious.seriesId, bookId: previousId }, query: { context: context.origin, contextId: context.id} }"
>
<rtl-icon icon="mdi-chevron-left" rtl="mdi-chevron-right"/>
</v-btn>
<!-- List of all books in context (series/readlist) for navigation -->
<v-menu
v-if="context.origin === ContextOrigin.READLIST"
bottom
offset-y
:max-height="$vuetify.breakpoint.height * .7"
:max-width="250"
>
<template v-slot:activator="{ on }">
<v-btn icon v-on="on">
<v-icon>mdi-menu</v-icon>
</v-btn>
</template>
<v-list
flat
>
<v-list-item-group color="primary">
<v-list-item
v-for="(book, i) in siblings"
:key="i"
:to="{ name: book.oneshot ? 'browse-oneshot' : 'browse-book', params: { seriesId: book.seriesId, bookId: book.id }, query: { context: context.origin, contextId: context.id} }"
>
<v-list-item-title class="text-wrap text-body-2">
<template v-if="context.origin === ContextOrigin.READLIST && !book.oneshot">{{ book.seriesTitle }}
{{ book.metadata.number }}:
{{ book.metadata.title }}
</template>
<template v-else-if="context.origin === ContextOrigin.READLIST && book.oneshot">{{
book.metadata.title
}}
</template>
<template v-else>{{ book.metadata.number }} - {{ book.metadata.title }}</template>
</v-list-item-title>
</v-list-item>
</v-list-item-group>
</v-list>
</v-menu>
<!-- Navigate to next book -->
<v-btn
v-if="context.origin === ContextOrigin.READLIST"
icon
:disabled="$_.isEmpty(siblingNext)"
:to="{ name: siblingNext.oneshot ? 'browse-oneshot' : 'browse-book', params: { seriesId: siblingNext.seriesId, bookId: nextId }, query: { context: context.origin, contextId: context.id} }"
>
<rtl-icon icon="mdi-chevron-right" rtl="mdi-chevron-left"/>
</v-btn>
</toolbar-sticky>
<v-container fluid class="pa-6">
<!-- Context notification for navigation -->
<v-row>
<v-alert
v-if="context.origin === ContextOrigin.READLIST && $vuetify.breakpoint.smAndDown"
type="info"
text
dense
border="right"
class="mb-0"
>{{ $t('browse_book.navigation_within_readlist', {name: contextName}) }}
</v-alert>
</v-row>
<v-row>
<v-col cols="4" sm="4" md="auto" lg="auto" xl="auto">
<item-card
v-if="book.hasOwnProperty('id')"
width="212"
:item="book"
thumbnail-only
no-link
:action-menu="false"
></item-card>
<div v-if="isInProgress"
class="text-caption text-center mt-1"
:title="$t('common.read_on', {date: readProgressDate})"
>
{{ $tc('common.pages_left', pagesLeft) }}
</div>
<div v-else-if="isRead"
class="text-caption text-center mt-1"
>
{{ $t('common.read_on', {date: readProgressDate}) }}
</div>
</v-col>
<v-col cols="8">
<v-container>
<v-row>
<v-col class="py-1">
<span class="text-h6">{{ book.metadata.title }}</span>
<router-link
class="caption link-underline"
:class="$vuetify.breakpoint.smAndUp ? 'mx-1' : ''"
:style="$vuetify.breakpoint.xsOnly ? 'display: block' : ''"
:to="{name:'browse-libraries', params: {libraryId: book.libraryId }}"
>{{ $t('searchbox.in_library', {library: getLibraryName(book)}) }}
</router-link>
</v-col>
</v-row>
<v-row class="text-body-2">
<v-col class="py-1 pe-0" cols="auto" v-if="series.metadata && series.metadata.ageRating">
<v-chip label small link
:to="{name:'browse-libraries', params: {libraryId: series.libraryId }, query: {ageRating: [series.metadata.ageRating]}}"
>
{{ series.metadata.ageRating }}+
</v-chip>
</v-col>
<v-col class="py-1 pe-0" cols="auto" v-if="series.metadata.language">
<v-chip label small link
:to="{name:'browse-libraries', params: {libraryId: series.libraryId }, query: {language: [series.metadata.language]}}"
>
{{ languageDisplay }}
</v-chip>
</v-col>
<v-col class="py-1 pe-0" cols="auto"
v-if="series.metadata.readingDirection">
<v-chip label small>
{{ $t(`enums.reading_direction.${series.metadata.readingDirection}`) }}
</v-chip>
</v-col>
<v-col class="py-1 pe-0" cols="auto" v-if="unavailable">
<v-chip label small color="error">
{{ $t('common.unavailable') }}
</v-chip>
</v-col>
</v-row>
<v-row class="text-caption" align="center">
<v-col cols="auto" v-if="book.media.status === MediaStatus.UNKNOWN">
{{ $t('book_card.unknown') }}
</v-col>
<v-col cols="auto" v-else>
{{ $tc('common.pages_n', book.media.pagesCount) }}
</v-col>
<v-col cols="auto" v-if="book.metadata.releaseDate">
{{
new Intl.DateTimeFormat($i18n.locale, {
dateStyle: 'long',
timeZone: 'UTC'
}).format(new Date(book.metadata.releaseDate))
}}
</v-col>
<v-col class="py-1 pe-0"
cols="auto"
v-if="book.media.status === MediaStatus.OUTDATED">
<v-tooltip bottom :disabled="!isAdmin">
<template v-slot:activator="{ on }">
<v-chip label small color="warning" v-on="on">
{{ $t('common.outdated') }}
</v-chip>
</template>
{{ $t('browse_book.outdated_tooltip') }}
</v-tooltip>
</v-col>
<v-col class="py-1 pe-0" cols="auto" v-if="unavailable">
<v-chip label small color="error">
{{ $t('common.unavailable') }}
</v-chip>
</v-col>
</v-row>
<template v-if="$vuetify.breakpoint.smAndUp">
<v-row class="align-center">
<v-col cols="auto">
<v-btn color="accent"
small
:title="$t('browse_book.read_book')"
:to="{name: 'read-book', params: { bookId: book.id}, query: { context: context.origin, contextId: context.id}}"
:disabled="!canRead"
>
<v-icon left small>mdi-book-open-page-variant</v-icon>
{{ $t('common.read') }}
</v-btn>
</v-col>
<v-col cols="auto">
<v-btn small
:title="$t('browse_book.read_incognito')"
:to="{name: 'read-book', params: { bookId: book.id}, query: { context: context.origin, contextId: context.id, incognito: true}}"
:disabled="!canRead"
>
<v-icon left small>mdi-incognito</v-icon>
{{ $t('common.read') }}
</v-btn>
</v-col>
<v-col cols="auto">
<v-btn :title="$t('browse_book.download_file')"
small
:disabled="!canDownload"
:href="fileUrl">
<v-icon left small>mdi-file-download</v-icon>
{{ $t('common.download') }}
</v-btn>
</v-col>
</v-row>
<v-row v-if="book.metadata.summary">
<v-col>
<read-more>{{ book.metadata.summary }}</read-more>
</v-col>
</v-row>
</template>
</v-container>
</v-col>
</v-row>
<template v-if="$vuetify.breakpoint.xsOnly">
<v-row class="align-center">
<v-col cols="auto">
<v-btn color="accent"
small
:title="$t('browse_book.read_book')"
:to="{name: 'read-book', params: { bookId: book.id}, query: { context: context.origin, contextId: context.id}}"
:disabled="!canRead"
>
<v-icon left small>mdi-book-open-page-variant</v-icon>
{{ $t('common.read') }}
</v-btn>
</v-col>
<v-col cols="auto">
<v-btn small
:title="$t('browse_book.read_incognito')"
:to="{name: 'read-book', params: { bookId: book.id}, query: { context: context.origin, contextId: context.id, incognito: true}}"
:disabled="!canRead"
>
<v-icon left small>mdi-incognito</v-icon>
{{ $t('common.read') }}
</v-btn>
</v-col>
<v-col cols="auto">
<v-btn :title="$t('browse_book.download_file')"
small
:disabled="!canDownload"
:href="fileUrl">
<v-icon left small>mdi-file-download</v-icon>
{{ $t('common.download') }}
</v-btn>
</v-col>
</v-row>
<v-row v-if="book.metadata.summary">
<v-col>
<read-more>{{ book.metadata.summary }}</read-more>
</v-col>
</v-row>
</template>
<!-- Publisher -->
<v-row v-if="series.metadata.publisher" class="align-center text-caption">
<v-col cols="4" sm="3" md="2" xl="1" class="py-1 text-uppercase">{{ $t('common.publisher') }}</v-col>
<v-col cols="8" sm="9" md="10" xl="11" class="py-1">
<v-chip
class="me-2"
:title="series.metadata.publisher"
:to="{name:'browse-libraries', params: {libraryId: series.libraryId }, query: {publisher: [series.metadata.publisher]}}"
label
small
outlined
link
>{{ series.metadata.publisher }}
</v-chip>
</v-col>
</v-row>
<!-- Genres -->
<v-row v-if="series.metadata.genres.length > 0" class="align-center text-caption">
<v-col cols="4" sm="3" md="2" xl="1" class="py-1 text-uppercase">{{ $t('common.genre') }}</v-col>
<v-col cols="8" sm="9" md="10" xl="11" class="py-1 text-capitalize">
<vue-horizontal>
<template v-slot:btn-prev>
<v-btn icon small>
<v-icon>mdi-chevron-left</v-icon>
</v-btn>
</template>
<template v-slot:btn-next>
<v-btn icon small>
<v-icon>mdi-chevron-right</v-icon>
</v-btn>
</template>
<v-chip v-for="(t, i) in $_.sortBy(series.metadata.genres)"
:key="i"
class="me-2"
:title="t"
:to="{name:'browse-libraries', params: {libraryId: series.libraryId }, query: {genre: [t]}}"
label
small
outlined
link
>{{ t }}
</v-chip>
</vue-horizontal>
</v-col>
</v-row>
<v-row v-for="role in displayedRoles"
:key="role"
class="align-center text-caption"
>
<v-col cols="4" sm="3" md="2" xl="1" class="py-1 text-uppercase">
{{ $te(`author_roles.${role}`) ? $t(`author_roles.${role}`) : role }}
</v-col>
<v-col cols="8" sm="9" md="10" xl="11" class="py-1">
<vue-horizontal>
<template v-slot:btn-prev>
<v-btn icon small>
<v-icon>mdi-chevron-left</v-icon>
</v-btn>
</template>
<template v-slot:btn-next>
<v-btn icon small>
<v-icon>mdi-chevron-right</v-icon>
</v-btn>
</template>
<v-chip v-for="(name, i) in authorsByRole[role]"
:key="i"
class="me-2"
:title="name"
:to="{name:'browse-libraries', params: {libraryId: book.libraryId }, query: {[role]: [name]}}"
label
small
outlined
link
>{{ name }}
</v-chip>
</vue-horizontal>
</v-col>
</v-row>
<v-row v-if="book.metadata.tags.length > 0" class="align-center text-caption">
<v-col cols="4" sm="3" md="2" xl="1" class="py-1 text-uppercase">{{ $i18n.t('common.tags') }}</v-col>
<v-col cols="8" sm="9" md="10" xl="11" class="py-1 text-capitalize">
<vue-horizontal>
<template v-slot:btn-prev>
<v-btn icon small>
<v-icon>mdi-chevron-left</v-icon>
</v-btn>
</template>
<template v-slot:btn-next>
<v-btn icon small>
<v-icon>mdi-chevron-right</v-icon>
</v-btn>
</template>
<v-chip v-for="(t, i) in book.metadata.tags"
:key="i"
class="me-2"
:title="t"
:to="{name:'browse-libraries', params: {libraryId: book.libraryId}, query: {tag: [t]}}"
label
small
outlined
link
>{{ t }}
</v-chip>
</vue-horizontal>
</v-col>
</v-row>
<v-row>
<v-col cols="12" class="pb-1">
<collections-expansion-panels :collections="collections"/>
</v-col>
<v-col cols="12" class="pt-1">
<read-lists-expansion-panels :read-lists="readLists"/>
</v-col>
</v-row>
<v-row v-if="book.metadata.links.length > 0" class="align-center text-caption">
<v-col class="py-1 text-uppercase" cols="4" sm="3" md="2" xl="1">{{ $t('browse_book.links') }}</v-col>
<v-col class="py-1" cols="8" sm="9" md="10" xl="11">
<v-chip
v-for="(link, i) in book.metadata.links"
:href="link.url"
target="_blank"
class="me-2"
label
small
outlined
link
:key="i"
>
{{ link.label }}
<v-icon
x-small
color="grey"
class="ps-1"
>
mdi-open-in-new
</v-icon>
</v-chip>
</v-col>
</v-row>
<v-row class="align-center text-caption">
<v-col class="py-1 text-uppercase" cols="4" sm="3" md="2" xl="1">{{ $t('browse_book.size') }}</v-col>
<v-col class="py-1" cols="8" sm="9" md="10" xl="11">{{ book.size }}</v-col>
</v-row>
<v-row v-if="book.media.comment" class="align-center text-caption">
<v-col class="py-1 text-uppercase" cols="4" sm="3" md="2" xl="1">{{ $t('browse_book.comment') }}</v-col>
<v-col class="py-1 error--text font-weight-bold" cols="8" sm="9" md="10" xl="11">{{ mediaComment }}</v-col>
</v-row>
<v-row class="align-center text-caption">
<v-col class="py-1 text-uppercase" cols="4" sm="3" md="2" xl="1">{{ $t('browse_book.format') }}</v-col>
<v-col class="py-1" cols="8" sm="9" md="10" xl="11">{{ format.type }}</v-col>
</v-row>
<v-row v-if="book.metadata.isbn" class="align-center text-caption">
<v-col class="py-1 text-uppercase" cols="4" sm="3" md="2" xl="1">{{ $t('browse_book.isbn') }}</v-col>
<v-col class="py-1" cols="8" sm="9" md="10" xl="11">{{ book.metadata.isbn }}</v-col>
</v-row>
<v-row class="align-center text-caption">
<v-col class="py-1 text-uppercase" cols="4" sm="3" md="2" xl="1">{{ $t('browse_book.file') }}</v-col>
<v-col class="py-1" cols="8" sm="9" md="10" xl="11">{{ book.url }}</v-col>
</v-row>
</v-container>
</div>
</template>
<script lang="ts">
import BookActionsMenu from '@/components/menus/BookActionsMenu.vue'
import ItemCard from '@/components/ItemCard.vue'
import ToolbarSticky from '@/components/bars/ToolbarSticky.vue'
import {groupAuthorsByRole} from '@/functions/authors'
import {getBookFormatFromMediaType} from '@/functions/book-format'
import {getPagesLeft, getReadProgress, getReadProgressPercentage} from '@/functions/book-progress'
import {getBookTitleCompact} from '@/functions/book-title'
import {bookFileUrl, seriesThumbnailUrl} from '@/functions/urls'
import {MediaStatus, ReadStatus} from '@/types/enum-books'
import {
BOOK_CHANGED,
BOOK_DELETED,
COLLECTION_ADDED,
COLLECTION_CHANGED,
COLLECTION_DELETED,
LIBRARY_DELETED,
READLIST_ADDED,
READLIST_CHANGED,
READLIST_DELETED,
READPROGRESS_CHANGED,
READPROGRESS_DELETED,
SERIES_CHANGED,
SERIES_DELETED,
} from '@/types/events'
import Vue from 'vue'
import ReadListsExpansionPanels from '@/components/ReadListsExpansionPanels.vue'
import {BookDto, BookFormat} from '@/types/komga-books'
import {Context, ContextOrigin} from '@/types/context'
import ReadMore from '@/components/ReadMore.vue'
import VueHorizontal from 'vue-horizontal'
import {authorRoles} from '@/types/author-roles'
import {convertErrorCodes} from '@/functions/error-codes'
import RtlIcon from '@/components/RtlIcon.vue'
import {
BookSseDto,
CollectionSseDto,
LibrarySseDto,
ReadListSseDto,
ReadProgressSseDto,
SeriesSseDto,
} from '@/types/komga-sse'
import {RawLocation} from 'vue-router/types/router'
import {ReadListDto} from '@/types/komga-readlists'
import {Oneshot, SeriesDto} from '@/types/komga-series'
import CollectionsExpansionPanels from '@/components/CollectionsExpansionPanels.vue'
import OneshotActionsMenu from '@/components/menus/OneshotActionsMenu.vue'
const tags = require('language-tags')
export default Vue.extend({
name: 'BrowseOneshot',
components: {
OneshotActionsMenu,
CollectionsExpansionPanels,
ReadMore, ToolbarSticky, ItemCard, ReadListsExpansionPanels, VueHorizontal, RtlIcon,
},
data: () => {
return {
MediaStatus,
ContextOrigin,
book: {} as BookDto,
series: {} as SeriesDto,
context: {} as Context,
siblings: [] as BookDto[],
siblingPrevious: {} as BookDto,
siblingNext: {} as BookDto,
contextName: '',
collections: [] as CollectionDto[],
readLists: [] as ReadListDto[],
}
},
async created() {
this.loadSeries(this.seriesId)
this.$eventHub.$on(SERIES_CHANGED, this.seriesChanged)
this.$eventHub.$on(SERIES_DELETED, this.seriesDeleted)
this.$eventHub.$on(BOOK_CHANGED, this.bookChanged)
this.$eventHub.$on(BOOK_DELETED, this.bookDeleted)
this.$eventHub.$on(READPROGRESS_CHANGED, this.readProgressChanged)
this.$eventHub.$on(READPROGRESS_DELETED, this.readProgressChanged)
this.$eventHub.$on(LIBRARY_DELETED, this.libraryDeleted)
this.$eventHub.$on(READLIST_ADDED, this.readListChanged)
this.$eventHub.$on(READLIST_CHANGED, this.readListChanged)
this.$eventHub.$on(READLIST_DELETED, this.readListChanged)
this.$eventHub.$on(COLLECTION_ADDED, this.collectionChanged)
this.$eventHub.$on(COLLECTION_CHANGED, this.collectionChanged)
this.$eventHub.$on(COLLECTION_DELETED, this.collectionChanged)
},
beforeDestroy() {
this.$eventHub.$off(SERIES_CHANGED, this.seriesChanged)
this.$eventHub.$off(SERIES_DELETED, this.seriesDeleted)
this.$eventHub.$off(BOOK_CHANGED, this.bookChanged)
this.$eventHub.$off(BOOK_DELETED, this.bookDeleted)
this.$eventHub.$off(READPROGRESS_CHANGED, this.readProgressChanged)
this.$eventHub.$off(READPROGRESS_DELETED, this.readProgressChanged)
this.$eventHub.$off(LIBRARY_DELETED, this.libraryDeleted)
this.$eventHub.$off(READLIST_ADDED, this.readListChanged)
this.$eventHub.$off(READLIST_CHANGED, this.readListChanged)
this.$eventHub.$off(READLIST_DELETED, this.readListChanged)
this.$eventHub.$off(COLLECTION_ADDED, this.collectionChanged)
this.$eventHub.$off(COLLECTION_CHANGED, this.collectionChanged)
this.$eventHub.$off(COLLECTION_DELETED, this.collectionChanged)
},
props: {
seriesId: {
type: String,
required: true,
},
},
async beforeRouteUpdate(to, from, next) {
if (to.params.seriesId !== from.params.seriesId) {
this.loadSeries(to.params.seriesId)
}
next()
},
computed: {
isAdmin(): boolean {
return this.$store.getters.meAdmin
},
unavailable(): boolean {
return this.book.deleted || this.$store.getters.getLibraryById(this.book.libraryId).unavailable
},
canRead(): boolean {
return this.book.media.status === 'READY' && this.$store.getters.mePageStreaming && !this.unavailable
},
canDownload(): boolean {
return this.$store.getters.meFileDownload && !this.unavailable
},
thumbnailUrl(): string {
return seriesThumbnailUrl(this.seriesId)
},
fileUrl(): string {
return bookFileUrl(this.book.id)
},
format(): BookFormat {
return getBookFormatFromMediaType(this.book.media.mediaType)
},
authorsByRole(): any {
return groupAuthorsByRole(this.book.metadata.authors)
},
isRead(): boolean {
return getReadProgress(this.book) === ReadStatus.READ
},
isUnread(): boolean {
return getReadProgress(this.book) === ReadStatus.UNREAD
},
isInProgress(): boolean {
return getReadProgress(this.book) === ReadStatus.IN_PROGRESS
},
pagesLeft(): number {
return getPagesLeft(this.book)
},
readProgressPercentage(): number {
return getReadProgressPercentage(this.book)
},
readProgressDate(): string | undefined {
if (this.book.readProgress)
return new Intl.DateTimeFormat(this.$i18n.locale, {
dateStyle: 'medium',
timeStyle: 'short',
} as Intl.DateTimeFormatOptions).format(this.book.readProgress.readDate)
return undefined
},
previousId(): string {
return this.siblingPrevious?.id?.toString() || '0'
},
nextId(): string {
return this.siblingNext?.id?.toString() || '0'
},
mediaComment(): string {
return convertErrorCodes(this.book.media.comment)
},
parentLocation(): RawLocation {
if (this.context.origin === ContextOrigin.READLIST)
return {name: 'browse-readlist', params: {readListId: this.context.id}}
else if (this.context.origin === ContextOrigin.COLLECTION)
return {name: 'browse-collection', params: {collectionId: this.context.id}}
else
return {name: 'browse-libraries', params: {libraryId: this.book.libraryId}}
},
displayedRoles(): string[] {
const allRoles = this.$_.uniq([...authorRoles, ...(this.book.metadata.authors.map(x => x.role))])
return allRoles.filter(x => this.authorsByRole[x])
},
languageDisplay(): string {
return tags(this.series.metadata.language).language().descriptions()[0]
},
},
methods: {
getLibraryName(item: BookDto): string {
return this.$store.getters.getLibraryById(item.libraryId).name
},
libraryDeleted(event: LibrarySseDto) {
if (event.libraryId === this.book.libraryId) {
this.$router.push({name: 'home'})
}
},
readListChanged(event: ReadListSseDto) {
if (event.bookIds.includes(this.book.id) || this.readLists.map(x => x.id).includes(event.readListId)) {
this.$komgaBooks.getReadLists(this.book.id)
.then(v => this.readLists = v)
}
},
collectionChanged(event: CollectionSseDto) {
if (event.seriesIds.includes(this.seriesId) || this.collections.map(x => x.id).includes(event.collectionId)) {
this.$komgaSeries.getCollections(this.seriesId)
.then(v => this.collections = v)
}
},
seriesChanged(event: SeriesSseDto) {
if (event.seriesId === this.seriesId)
this.$komgaSeries.getOneSeries(this.seriesId)
.then(v => this.series = v)
},
seriesDeleted(event: SeriesSseDto) {
if (event.seriesId === this.seriesId) {
this.$router.push({name: 'browse-libraries', params: {libraryId: this.series.libraryId}})
}
},
bookChanged(event: BookSseDto) {
if (event.bookId === this.book.id) this.loadBook(this.seriesId)
},
bookDeleted(event: BookSseDto) {
if (event.bookId === this.book.id) {
this.$router.push({name: 'browse-libraries', params: {libraryId: this.book.libraryId}})
}
},
readProgressChanged(event: ReadProgressSseDto) {
if (event.bookId === this.book.id) this.loadBook(this.seriesId)
},
async loadSeries(seriesId: string) {
this.$komgaSeries.getOneSeries(seriesId)
.then(v => this.series = v)
this.$komgaSeries.getCollections(seriesId)
.then(v => this.collections = v)
// parse query params to get context and contextId
if (this.$route.query.contextId && this.$route.query.context
&& Object.values(ContextOrigin).includes(this.$route.query.context as ContextOrigin)) {
this.context = {
origin: this.$route.query.context as ContextOrigin,
id: this.$route.query.contextId as string,
}
this.series.context = this.context
}
await this.loadBook(seriesId)
},
async loadBook(seriesId: string) {
this.book = (await this.$komgaSeries.getBooks(seriesId)).content[0]
// parse query params to get context and contextId
if (this.$route.query.contextId && this.$route.query.context
&& Object.values(ContextOrigin).includes(this.$route.query.context as ContextOrigin)) {
this.context = {
origin: this.$route.query.context as ContextOrigin,
id: this.$route.query.contextId as string,
}
this.book.context = this.context
if (this.context.origin === ContextOrigin.READLIST)
this.$komgaReadLists.getOneReadList(this.context.id)
.then(v => this.contextName = v.name)
}
// Get siblings depending on origin
if (this?.context.origin === ContextOrigin.READLIST) {
this.$komgaReadLists.getBooks(this.context.id, {unpaged: true} as PageRequest)
.then(v => this.siblings = v.content)
} else {
this.siblings = {} as BookDto[]
}
this.$komgaBooks.getReadLists(this.book.id)
.then(v => this.readLists = v)
if (this.$_.has(this.book, 'metadata.title')) {
document.title = `Komga - ${getBookTitleCompact(this.book.metadata.title, this.book.seriesTitle)}`
}
if (this?.context.origin === ContextOrigin.READLIST) {
this.$komgaReadLists.getBookSiblingNext(this.context.id, this.book.id)
.then(v => this.siblingNext = v)
.catch(e => this.siblingNext = {} as BookDto)
} else {
this.siblingNext = {} as BookDto
}
if (this?.context.origin === ContextOrigin.READLIST) {
this.$komgaReadLists.getBookSiblingPrevious(this.context.id, this.book.id)
.then(v => this.siblingPrevious = v)
.catch(e => this.siblingPrevious = {} as BookDto)
} else {
this.siblingPrevious = {} as BookDto
}
},
analyze() {
this.$komgaBooks.analyzeBook(this.book)
},
refreshMetadata() {
this.$komgaBooks.refreshMetadata(this.book)
},
editBook() {
this.$store.dispatch('dialogUpdateOneshots', {series: this.series, book: this.book} as Oneshot)
},
},
})
</script>
<style scoped>
</style>

View file

@ -47,11 +47,13 @@
<multi-select-bar
v-model="selectedBooks"
kind="books"
:oneshots="selectedOneshots"
show-select-all
@unselect-all="selectedBooks = []"
@select-all="selectedBooks = books"
@mark-read="markSelectedRead"
@mark-unread="markSelectedUnread"
@add-to-collection="addToCollection"
@add-to-readlist="addToReadList"
@edit="editMultipleBooks"
@bulk-edit="bulkEditMultipleBooks"
@ -186,6 +188,7 @@ import {ItemContext} from '@/types/items'
import PageSizeSelect from '@/components/PageSizeSelect.vue'
import EmptyState from '@/components/EmptyState.vue'
import {ReadListDto, ReadListUpdateDto} from '@/types/komga-readlists'
import {Oneshot} from '@/types/komga-series'
export default Vue.extend({
name: 'BrowseReadList',
@ -336,6 +339,9 @@ export default Vue.extend({
filterActive(): boolean {
return Object.keys(this.filters).some(x => this.filters[x].length !== 0)
},
selectedOneshots(): boolean {
return this.selectedBooks.every(b => b.oneshot)
},
},
methods: {
resetFilters() {
@ -472,11 +478,20 @@ export default Vue.extend({
reloadPage: throttle(function (this: any) {
this.loadPage(this.readListId, this.page)
}, 1000),
editSingleBook(book: BookDto) {
this.$store.dispatch('dialogUpdateBooks', book)
async editSingleBook(book: BookDto) {
if (book.oneshot) {
const series = (await this.$komgaSeries.getOneSeries(book.seriesId))
this.$store.dispatch('dialogUpdateOneshots', {series: series, book: book})
} else
this.$store.dispatch('dialogUpdateBooks', book)
},
editMultipleBooks() {
this.$store.dispatch('dialogUpdateBooks', this.selectedBooks)
async editMultipleBooks() {
if (this.selectedBooks.every(b => b.oneshot)) {
const series = await Promise.all(this.selectedBooks.map(b => this.$komgaSeries.getOneSeries(b.seriesId)))
const oneshots = this.selectedBooks.map((b, index) => ({series: series[index], book: b} as Oneshot))
this.$store.dispatch('dialogUpdateOneshots', oneshots)
} else
this.$store.dispatch('dialogUpdateBooks', this.selectedBooks)
},
bulkEditMultipleBooks() {
this.$store.dispatch('dialogUpdateBulkBooks', this.selectedBooks)
@ -496,6 +511,9 @@ export default Vue.extend({
))
this.selectedBooks = []
},
addToCollection() {
this.$store.dispatch('dialogAddSeriesToCollection', this.selectedBooks.map(b => b.seriesId))
},
addToReadList() {
this.$store.dispatch('dialogAddBooksToReadList', this.selectedBooks.map(b => b.id))
},

View file

@ -821,7 +821,11 @@ export default Vue.extend({
}, 1000),
async loadSeries(seriesId: string) {
this.$komgaSeries.getOneSeries(seriesId)
.then(v => this.series = v)
.then(v => {
this.series = v
// for the cases where we can't change the origin target route because we don't have the full BookDto
if (this.series.oneshot) this.$router.replace({name: 'browse-oneshot', params: {seriesId: this.seriesId}})
})
this.$komgaSeries.getCollections(seriesId)
.then(v => this.collections = v)

View file

@ -34,10 +34,12 @@
<multi-select-bar
v-model="selectedBooks"
kind="books"
:oneshots="selectedOneshots"
@unselect-all="selectedBooks = []"
@mark-read="markSelectedBooksRead"
@mark-unread="markSelectedBooksUnread"
@add-to-readlist="addToReadList"
@add-to-collection="addOneshotsToCollection"
@edit="editMultipleBooks"
@bulk-edit="bulkEditMultipleBooks"
@delete="deleteBooks"
@ -222,7 +224,7 @@ import {
SERIES_DELETED,
} from '@/types/events'
import Vue from 'vue'
import {SeriesDto} from '@/types/komga-series'
import {Oneshot, SeriesDto} from '@/types/komga-series'
import {LIBRARIES_ALL, LIBRARY_ROUTE} from '@/types/library'
import {throttle} from 'lodash'
import {subMonths} from 'date-fns'
@ -327,6 +329,9 @@ export default Vue.extend({
individualLibrary(): boolean {
return this.libraryId !== LIBRARIES_ALL
},
selectedOneshots(): boolean {
return this.selectedBooks.every(b => b.oneshot)
},
},
methods: {
async scrollChanged(loader: PageLoader<any>, percent: number) {
@ -383,11 +388,11 @@ export default Vue.extend({
this.loaderNewSeries = new PageLoader<SeriesDto>(
{},
(pageable: PageRequest) => this.$komgaSeries.getNewSeries(this.getRequestLibraryId(libraryId), pageable),
(pageable: PageRequest) => this.$komgaSeries.getNewSeries(this.getRequestLibraryId(libraryId), false, pageable),
)
this.loaderUpdatedSeries = new PageLoader<SeriesDto>(
{},
(pageable: PageRequest) => this.$komgaSeries.getUpdatedSeries(this.getRequestLibraryId(libraryId), pageable),
(pageable: PageRequest) => this.$komgaSeries.getUpdatedSeries(this.getRequestLibraryId(libraryId), false, pageable),
)
},
loadAll(libraryId: string, reload: boolean = false) {
@ -422,11 +427,19 @@ export default Vue.extend({
})
}
},
singleEditSeries(series: SeriesDto) {
this.$store.dispatch('dialogUpdateSeries', series)
async singleEditSeries(series: SeriesDto) {
if (series.oneshot) {
let book = (await this.$komgaSeries.getBooks(series.id)).content[0]
this.$store.dispatch('dialogUpdateOneshots', {series: series, book: book})
} else
this.$store.dispatch('dialogUpdateSeries', series)
},
singleEditBook(book: BookDto) {
this.$store.dispatch('dialogUpdateBooks', book)
async singleEditBook(book: BookDto) {
if (book.oneshot) {
const series = (await this.$komgaSeries.getOneSeries(book.seriesId))
this.$store.dispatch('dialogUpdateOneshots', {series: series, book: book})
} else
this.$store.dispatch('dialogUpdateBooks', book)
},
async markSelectedSeriesRead() {
await Promise.all(this.selectedSeries.map(s =>
@ -444,11 +457,25 @@ export default Vue.extend({
this.$store.dispatch('dialogAddSeriesToCollection', this.selectedSeries.map(s => s.id))
this.selectedSeries = []
},
editMultipleSeries() {
this.$store.dispatch('dialogUpdateSeries', this.selectedSeries)
addOneshotsToCollection() {
this.$store.dispatch('dialogAddSeriesToCollection', this.selectedBooks.map(b => b.seriesId))
this.selectedBooks = []
},
editMultipleBooks() {
this.$store.dispatch('dialogUpdateBooks', this.selectedBooks)
async editMultipleSeries() {
if (this.selectedSeries.every(s => s.oneshot)) {
const books = await Promise.all(this.selectedSeries.map(s => this.$komgaSeries.getBooks(s.id)))
const oneshots = this.selectedSeries.map((s, index) => ({series: s, book: books[index].content[0]} as Oneshot))
this.$store.dispatch('dialogUpdateOneshots', oneshots)
} else
this.$store.dispatch('dialogUpdateSeries', this.selectedSeries)
},
async editMultipleBooks() {
if (this.selectedBooks.every(b => b.oneshot)) {
const series = await Promise.all(this.selectedBooks.map(b => this.$komgaSeries.getOneSeries(b.seriesId)))
const oneshots = this.selectedBooks.map((b, index) => ({series: series[index], book: b} as Oneshot))
this.$store.dispatch('dialogUpdateOneshots', oneshots)
} else
this.$store.dispatch('dialogUpdateBooks', this.selectedBooks)
},
deleteSeries() {
this.$store.dispatch('dialogDeleteSeries', this.selectedSeries)

View file

@ -16,7 +16,10 @@
}"
>
<template v-slot:item.url="{ item }">
<router-link :to="{name:'browse-book', params: {bookId: item.id}}">{{ item.url }}</router-link>
<router-link
:to="{name: item.oneshot ? 'browse-oneshot' : 'browse-book', params: {bookId: item.id, seriesId: item.seriesId}}">
{{ item.url }}
</router-link>
</template>
<template v-slot:item.deleted="{ item }">

View file

@ -82,7 +82,7 @@
<v-btn @click="modalSeriesPicker = true" :disabled="globalSelect === 0">
{{ $t('book_import.button_select_series') }}
</v-btn>
<series-picker-dialog v-model="modalSeriesPicker" :series.sync="selectedSeries"></series-picker-dialog>
<series-picker-dialog v-model="modalSeriesPicker" :series.sync="selectedSeries" :include-oneshots="false"/>
</v-col>
<v-spacer/>

View file

@ -14,7 +14,10 @@
}"
>
<template v-slot:item.name="{ item }">
<router-link :to="{name:'browse-book', params: {bookId: item.id}}">{{ item.name }}</router-link>
<router-link
:to="{name: item.oneshot ? 'browse-oneshot' : 'browse-book', params: {bookId: item.id, seriesId: item.seriesId}}">
{{ item.name }}
</router-link>
</template>
<template v-slot:item.deleted="{ item }">

View file

@ -20,10 +20,12 @@
<multi-select-bar
v-model="selectedBooks"
kind="books"
:oneshots="selectedOneshots"
@unselect-all="selectedBooks = []"
@mark-read="markSelectedBooksRead"
@mark-unread="markSelectedBooksUnread"
@add-to-readlist="addToReadList"
@add-to-collection="addOneshotsToCollection"
@edit="editMultipleBooks"
@bulk-edit="bulkEditMultipleBooks"
@delete="deleteBooks"
@ -165,7 +167,7 @@ import {
SERIES_DELETED,
} from '@/types/events'
import Vue from 'vue'
import {SeriesDto} from '@/types/komga-series'
import {Oneshot, SeriesDto} from '@/types/komga-series'
import {
BookSseDto,
CollectionSseDto,
@ -264,6 +266,9 @@ export default Vue.extend({
this.loaderCollections?.items.length === 0 &&
this.loaderReadLists?.items.length === 0
},
selectedOneshots(): boolean {
return this.selectedBooks.every(b => b.oneshot)
},
},
methods: {
async scrollChanged(loader: PageLoader<any>, percent: number) {
@ -299,11 +304,19 @@ export default Vue.extend({
this.reloadResults()
}
},
singleEditSeries(series: SeriesDto) {
this.$store.dispatch('dialogUpdateSeries', series)
async singleEditSeries(series: SeriesDto) {
if (series.oneshot) {
let book = (await this.$komgaSeries.getBooks(series.id)).content[0]
this.$store.dispatch('dialogUpdateOneshots', {series: series, book: book})
} else
this.$store.dispatch('dialogUpdateSeries', series)
},
singleEditBook(book: BookDto) {
this.$store.dispatch('dialogUpdateBooks', book)
async singleEditBook(book: BookDto) {
if (book.oneshot) {
const series = (await this.$komgaSeries.getOneSeries(book.seriesId))
this.$store.dispatch('dialogUpdateOneshots', {series: series, book: book})
} else
this.$store.dispatch('dialogUpdateBooks', book)
},
singleEditCollection(collection: CollectionDto) {
this.$store.dispatch('dialogEditCollection', collection)
@ -333,11 +346,24 @@ export default Vue.extend({
addToReadList() {
this.$store.dispatch('dialogAddBooksToReadList', this.selectedBooks.map(b => b.id))
},
editMultipleSeries() {
this.$store.dispatch('dialogUpdateSeries', this.selectedSeries)
addOneshotsToCollection() {
this.$store.dispatch('dialogAddSeriesToCollection', this.selectedBooks.map(b => b.seriesId))
},
editMultipleBooks() {
this.$store.dispatch('dialogUpdateBooks', this.selectedBooks)
async editMultipleSeries() {
if (this.selectedSeries.every(s => s.oneshot)) {
const books = await Promise.all(this.selectedSeries.map(s => this.$komgaSeries.getBooks(s.id)))
const oneshots = this.selectedSeries.map((s, index) => ({series: s, book: books[index].content[0]} as Oneshot))
this.$store.dispatch('dialogUpdateOneshots', oneshots)
} else
this.$store.dispatch('dialogUpdateSeries', this.selectedSeries)
},
async editMultipleBooks() {
if (this.selectedBooks.every(b => b.oneshot)) {
const series = await Promise.all(this.selectedBooks.map(b => this.$komgaSeries.getOneSeries(b.seriesId)))
const oneshots = this.selectedBooks.map((b, index) => ({series: series[index], book: b} as Oneshot))
this.$store.dispatch('dialogUpdateOneshots', oneshots)
} else
this.$store.dispatch('dialogUpdateBooks', this.selectedBooks)
},
bulkEditMultipleBooks() {
this.$store.dispatch('dialogUpdateBulkBooks', this.selectedBooks)