feat(webui): read lists

closes #106
This commit is contained in:
Gauthier Roebroeck 2020-08-20 10:21:47 +08:00
parent f0c864f4eb
commit 27edf17424
38 changed files with 1539 additions and 52 deletions

View file

@ -19,6 +19,25 @@
@deleted="collectionDeleted"
/>
<read-list-add-to-dialog
v-model="addToReadListDialog"
:books="addToReadListBooks"
@added="readListAdded"
@created="readListAdded"
/>
<read-list-edit-dialog
v-model="editReadListDialog"
:read-list="editReadList"
@updated="readListUpdated"
/>
<read-list-delete-dialog
v-model="deleteReadListDialog"
:read-list="deleteReadList"
@deleted="readListDeleted"
/>
<library-edit-dialog
v-model="editLibraryDialog"
:library="editLibrary"
@ -62,10 +81,17 @@ import {
collectionToEventCollectionDeleted,
LIBRARY_DELETED,
libraryToEventLibraryDeleted,
READLIST_CHANGED,
READLIST_DELETED,
readListToEventReadListChanged,
readListToEventReadListDeleted,
SERIES_CHANGED,
seriesToEventSeriesChanged,
} from '@/types/events'
import Vue from 'vue'
import ReadListAddToDialog from '@/components/dialogs/ReadListAddToDialog.vue'
import ReadListDeleteDialog from '@/components/dialogs/ReadListDeleteDialog.vue'
import ReadListEditDialog from '@/components/dialogs/ReadListEditDialog.vue'
export default Vue.extend({
name: 'Dialogs',
@ -73,12 +99,16 @@ export default Vue.extend({
CollectionAddToDialog,
CollectionEditDialog,
CollectionDeleteDialog,
ReadListAddToDialog,
ReadListEditDialog,
ReadListDeleteDialog,
LibraryEditDialog,
LibraryDeleteDialog,
EditBooksDialog,
EditSeriesDialog,
},
computed: {
// collections
addToCollectionDialog: {
get (): boolean {
return this.$store.state.addToCollectionDialog
@ -112,6 +142,41 @@ export default Vue.extend({
deleteCollection (): CollectionDto {
return this.$store.state.deleteCollection
},
// read lists
addToReadListDialog: {
get (): boolean {
return this.$store.state.addToReadListDialog
},
set (val) {
this.$store.dispatch('dialogAddBooksToReadListDisplay', val)
},
},
addToReadListBooks (): BookDto | BookDto[] {
return this.$store.state.addToReadListBooks
},
editReadListDialog: {
get (): boolean {
return this.$store.state.editReadListDialog
},
set (val) {
this.$store.dispatch('dialogEditReadListDisplay', val)
},
},
editReadList (): ReadListDto {
return this.$store.state.editReadList
},
deleteReadListDialog: {
get (): boolean {
return this.$store.state.deleteReadListDialog
},
set (val) {
this.$store.dispatch('dialogDeleteReadListDisplay', val)
},
},
deleteReadList (): ReadListDto {
return this.$store.state.deleteReadList
},
// libraries
editLibraryDialog: {
get (): boolean {
return this.$store.state.editLibraryDialog
@ -134,6 +199,7 @@ export default Vue.extend({
deleteLibrary (): LibraryDto {
return this.$store.state.deleteLibrary
},
// books
updateBooksDialog: {
get (): boolean {
return this.$store.state.updateBooksDialog
@ -145,6 +211,7 @@ export default Vue.extend({
updateBooks (): BookDto | BookDto[] {
return this.$store.state.updateBooks
},
// series
updateSeriesDialog: {
get (): boolean {
return this.$store.state.updateSeriesDialog
@ -174,6 +241,22 @@ export default Vue.extend({
collectionDeleted () {
this.$eventHub.$emit(COLLECTION_DELETED, collectionToEventCollectionDeleted(this.deleteCollection))
},
readListAdded (readList: ReadListDto) {
if (Array.isArray(this.addToReadListBooks)) {
this.addToReadListBooks.forEach(b => {
this.$eventHub.$emit(BOOK_CHANGED, bookToEventBookChanged(b))
})
} else {
this.$eventHub.$emit(BOOK_CHANGED, bookToEventBookChanged(this.addToReadListBooks))
}
this.$eventHub.$emit(READLIST_CHANGED, readListToEventReadListChanged(readList))
},
readListUpdated () {
this.$eventHub.$emit(READLIST_CHANGED, readListToEventReadListChanged(this.editReadList))
},
readListDeleted () {
this.$eventHub.$emit(READLIST_DELETED, readListToEventReadListDeleted(this.deleteReadList))
},
libraryDeleted () {
this.$eventHub.$emit(LIBRARY_DELETED, libraryToEventLibraryDeleted(this.deleteLibrary))
},

View file

@ -80,6 +80,10 @@
:collection="item"
:menu.sync="actionMenuState"
/>
<read-list-actions-menu v-if="computedItem.type() === ItemTypes.READLIST"
:read-list="item"
:menu.sync="actionMenuState"
/>
</div>
</v-overlay>
</v-fade-transition>
@ -119,13 +123,14 @@ import { ReadStatus } from '@/types/enum-books'
import { createItem, Item, ItemTypes } from '@/types/items'
import Vue from 'vue'
import { RawLocation } from 'vue-router'
import ReadListActionsMenu from '@/components/menus/ReadListActionsMenu.vue'
export default Vue.extend({
name: 'ItemCard',
components: { BookActionsMenu, SeriesActionsMenu, CollectionActionsMenu },
components: { BookActionsMenu, SeriesActionsMenu, CollectionActionsMenu, ReadListActionsMenu },
props: {
item: {
type: Object as () => BookDto | SeriesDto | CollectionDto,
type: Object as () => BookDto | SeriesDto | CollectionDto | ReadListDto,
required: true,
},
// hide the bottom part of the card
@ -184,7 +189,7 @@ export default Vue.extend({
overlay (): boolean {
return this.onEdit !== undefined || this.onSelected !== undefined || this.bookReady || this.canReadPages || this.actionMenu
},
computedItem (): Item<BookDto | SeriesDto | CollectionDto> {
computedItem (): Item<BookDto | SeriesDto | CollectionDto | ReadListDto> {
return createItem(this.item)
},
disableHover (): boolean {

View file

@ -1,31 +1,78 @@
<template>
<v-bottom-navigation grow color="primary"
:fixed="$vuetify.breakpoint.name === 'xs'"
<v-bottom-navigation
v-if="collectionsCount > 0 || readListsCount > 0"
grow color="primary"
:fixed="$vuetify.breakpoint.name === 'xs'"
>
<v-btn :to="{name: 'browse-libraries', params: {libraryId: libraryId}}">
<span>Browse</span>
<v-icon>mdi-bookshelf</v-icon>
</v-btn>
<v-btn :to="{name: 'browse-collections', params: {libraryId: libraryId}}">
<v-btn
v-if="collectionsCount > 0"
:to="{name: 'browse-collections', params: {libraryId: libraryId}}"
>
<span>Collections</span>
<v-icon>mdi-layers-triple</v-icon>
</v-btn>
<v-btn
v-if="readListsCount > 0"
:to="{name: 'browse-readlists', params: {libraryId: libraryId}}"
>
<span>Read Lists</span>
<v-icon>mdi-book-multiple</v-icon>
</v-btn>
</v-bottom-navigation>
</template>
<script lang="ts">
import Vue from 'vue'
import { COLLECTION_CHANGED, READLIST_CHANGED } from '@/types/events'
import { LIBRARIES_ALL } from '@/types/library'
export default Vue.extend({
name: 'LibraryNavigation',
data: () => {
return {
collectionsCount: 0,
readListsCount: 0,
}
},
props: {
libraryId: {
type: String,
required: true,
},
},
watch: {
libraryId: {
handler (val) {
this.loadCounts(val)
},
immediate: true,
},
},
created () {
this.$eventHub.$on(COLLECTION_CHANGED, this.reloadCounts)
this.$eventHub.$on(READLIST_CHANGED, this.reloadCounts)
},
beforeDestroy () {
this.$eventHub.$off(COLLECTION_CHANGED, this.reloadCounts)
this.$eventHub.$off(READLIST_CHANGED, this.reloadCounts)
},
methods: {
reloadCounts () {
this.loadCounts(this.libraryId)
},
async loadCounts (libraryId: string) {
const lib = libraryId !== LIBRARIES_ALL ? [libraryId] : undefined
this.collectionsCount = (await this.$komgaCollections.getCollections(lib, { size: 1 })).totalElements
this.readListsCount = (await this.$komgaReadLists.getReadLists(lib, { size: 1 })).totalElements
},
},
})
</script>

View file

@ -0,0 +1,71 @@
<template>
<v-expansion-panels v-model="readListPanel">
<v-expansion-panel v-for="(r, index) in readLists"
:key="index"
>
<v-expansion-panel-header>{{ r.name }} read list</v-expansion-panel-header>
<v-expansion-panel-content>
<horizontal-scroller>
<template v-slot:prepend>
<router-link class="text-overline"
:to="{name: 'browse-readlist', params: {readListId: r.id}}"
>Manage read list
</router-link>
</template>
<template v-slot:content>
<item-browser :items="readListsContent[index]"
nowrap
:selectable="false"
:action-menu="false"
:fixed-item-width="100"
/>
</template>
</horizontal-scroller>
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
</template>
<script lang="ts">
import HorizontalScroller from '@/components/HorizontalScroller.vue'
import ItemBrowser from '@/components/ItemBrowser.vue'
import Vue from 'vue'
export default Vue.extend({
name: 'ReadListsExpansionPanels',
components: {
HorizontalScroller,
ItemBrowser,
},
props: {
readLists: {
type: Array as () => ReadListDto[],
required: true,
},
},
data: () => {
return {
readListPanel: undefined as number | undefined,
readListsContent: [[]] as any[],
}
},
watch: {
readLists: {
handler (val) {
this.readListPanel = undefined
this.readListsContent = [...Array(val.length)].map(elem => new Array(0))
},
immediate: true,
},
async readListPanel (val) {
if (val !== undefined) {
const rlId = this.readLists[val].id
if (this.$_.isEmpty(this.readListsContent[val])) {
const content = (await this.$komgaReadLists.getBooks(rlId, { unpaged: true } as PageRequest)).content
this.readListsContent.splice(val, 1, content)
}
}
},
},
})
</script>

View file

@ -19,7 +19,9 @@
:min-width="$vuetify.breakpoint.mdAndUp ? $vuetify.breakpoint.width * .4 : $vuetify.breakpoint.width * .8"
>
<v-list>
<v-list-item v-if="series.length === 0 && books.length === 0 && collections.length === 0">No results
<v-list-item
v-if="series.length === 0 && books.length === 0 && collections.length === 0 && readLists.length === 0">
No results
</v-list-item>
<template v-if="series.length !== 0">
@ -76,13 +78,31 @@
</v-list-item>
</template>
<template v-if="readLists.length !== 0">
<v-subheader>READ LISTS</v-subheader>
<v-list-item v-for="item in readLists"
:key="item.id"
link
:to="{name: 'browse-readlist', params: {readListId: item.id}}"
>
<v-img :src="readListThumbnailUrl(item.id)"
height="50"
max-width="35"
class="ma-1 mr-3"
/>
<v-list-item-content>
<v-list-item-title v-text="item.name"/>
</v-list-item-content>
</v-list-item>
</template>
</v-list>
</v-menu>
</div>
</template>
<script lang="ts">
import { bookThumbnailUrl, collectionThumbnailUrl, seriesThumbnailUrl } from '@/functions/urls'
import { bookThumbnailUrl, collectionThumbnailUrl, readListThumbnailUrl, seriesThumbnailUrl } from '@/functions/urls'
import { debounce } from 'lodash'
import Vue from 'vue'
@ -96,6 +116,7 @@ export default Vue.extend({
series: [] as SeriesDto[],
books: [] as BookDto[],
collections: [] as CollectionDto[],
readLists: [] as ReadListDto[],
pageSize: 10,
}
},
@ -114,6 +135,7 @@ export default Vue.extend({
this.series = (await this.$komgaSeries.getSeries(undefined, { size: this.pageSize }, query)).content
this.books = (await this.$komgaBooks.getBooks(undefined, { size: this.pageSize }, query)).content
this.collections = (await this.$komgaCollections.getCollections(undefined, { size: this.pageSize }, query)).content
this.readLists = (await this.$komgaReadLists.getReadLists(undefined, { size: this.pageSize }, query)).content
this.showResults = true
this.loading = false
} else {
@ -126,6 +148,7 @@ export default Vue.extend({
this.series = []
this.books = []
this.collections = []
this.readLists = []
},
searchDetails () {
const s = this.search
@ -142,6 +165,9 @@ export default Vue.extend({
collectionThumbnailUrl (collectionId: string): string {
return collectionThumbnailUrl(collectionId)
},
readListThumbnailUrl (readListId: string): string {
return readListThumbnailUrl(readListId)
},
},
})
</script>

View file

@ -28,6 +28,15 @@
</v-tooltip>
</v-btn>
<v-btn icon @click="addToReadList" v-if="isAdmin">
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-icon v-on="on">mdi-book-plus-multiple</v-icon>
</template>
<span>Add to read list</span>
</v-tooltip>
</v-btn>
<v-btn icon @click="edit" v-if="isAdmin">
<v-tooltip bottom>
<template v-slot:activator="{ on }">
@ -71,6 +80,9 @@ export default Vue.extend({
markUnread () {
this.$emit('mark-unread')
},
addToReadList () {
this.$emit('add-to-readlist')
},
edit () {
this.$emit('edit')
},

View file

@ -90,7 +90,6 @@ export default Vue.extend({
snackText: '',
modal: false,
collections: [] as CollectionDto[],
selectedCollection: null,
newCollection: '',
}
},

View file

@ -92,6 +92,11 @@
label="Collections"
hide-details
/>
<v-checkbox
v-model="form.importComicInfoReadList"
label="Read lists"
hide-details
/>
</v-col>
</v-row>
<v-row>
@ -186,6 +191,7 @@ export default Vue.extend({
importComicInfoBook: true,
importComicInfoSeries: true,
importComicInfoCollection: true,
importComicInfoReadList: true,
importEpubBook: true,
importEpubSeries: true,
importLocalArtwork: true,
@ -254,6 +260,7 @@ export default Vue.extend({
this.form.importComicInfoBook = library ? library.importComicInfoBook : true
this.form.importComicInfoSeries = library ? library.importComicInfoSeries : true
this.form.importComicInfoCollection = library ? library.importComicInfoCollection : true
this.form.importComicInfoReadList = library ? library.importComicInfoReadList : true
this.form.importEpubBook = library ? library.importEpubBook : true
this.form.importEpubSeries = library ? library.importEpubSeries : true
this.form.importLocalArtwork = library ? library.importLocalArtwork : true
@ -271,6 +278,7 @@ export default Vue.extend({
importComicInfoBook: this.form.importComicInfoBook,
importComicInfoSeries: this.form.importComicInfoSeries,
importComicInfoCollection: this.form.importComicInfoCollection,
importComicInfoReadList: this.form.importComicInfoReadList,
importEpubBook: this.form.importEpubBook,
importEpubSeries: this.form.importEpubSeries,
importLocalArtwork: this.form.importLocalArtwork,

View file

@ -0,0 +1,172 @@
<template>
<div>
<v-dialog v-model="modal"
max-width="450"
scrollable
>
<v-card>
<v-card-title>Add to read list</v-card-title>
<v-btn icon absolute top right @click="dialogClose">
<v-icon>mdi-close</v-icon>
</v-btn>
<v-divider/>
<v-card-text style="height: 50%">
<v-container fluid>
<v-row align="center">
<v-col>
<v-text-field
v-model="newReadList"
label="Create new read list"
@keydown.enter="create"
:error-messages="duplicate"
/>
</v-col>
<v-col cols="auto">
<v-btn
color="primary"
@click="create"
:disabled="newReadList.length === 0 || duplicate !== ''"
>Create
</v-btn>
</v-col>
</v-row>
<v-divider/>
<v-row v-if="readLists.length !== 0">
<v-col>
<v-list elevation="5">
<div v-for="(c, index) in readLists"
:key="index"
>
<v-list-item @click="addTo(c)"
two-line
>
<v-list-item-content>
<v-list-item-title>{{ c.name }}</v-list-item-title>
<v-list-item-subtitle>{{ c.bookIds.length }} books</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-divider v-if="index !== readLists.length-1"/>
</div>
</v-list>
</v-col>
</v-row>
</v-container>
</v-card-text>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar"
bottom
color="error"
>
{{ snackText }}
<v-btn
text
@click="snackbar = false"
>
Close
</v-btn>
</v-snackbar>
</div>
</template>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
name: 'ReadListAddToDialog',
data: () => {
return {
confirmDelete: false,
snackbar: false,
snackText: '',
modal: false,
readLists: [] as ReadListDto[],
newReadList: '',
}
},
props: {
value: Boolean,
books: {
type: [Object as () => BookDto, Array as () => BookDto[]],
required: true,
},
},
watch: {
async value (val) {
this.modal = val
if (val) {
this.newReadList = ''
this.readLists = (await this.$komgaReadLists.getReadLists(undefined, { unpaged: true } as PageRequest)).content
}
},
modal (val) {
!val && this.dialogClose()
},
},
async mounted () {
},
computed: {
bookIds (): string[] {
if (Array.isArray(this.books)) return this.books.map(s => s.id)
else return [this.books.id]
},
duplicate (): string {
if (this.newReadList !== '' && this.readLists.some(e => e.name === this.newReadList)) {
return 'A read list with this name already exists'
} else return ''
},
},
methods: {
dialogClose () {
this.$emit('input', false)
},
showSnack (message: string) {
this.snackText = message
this.snackbar = true
},
async addTo (readList: ReadListDto) {
const bookIds = this.$_.uniq(readList.bookIds.concat(this.bookIds))
const toUpdate = {
bookIds: bookIds,
} as ReadListUpdateDto
try {
await this.$komgaReadLists.patchReadList(readList.id, toUpdate)
this.$emit('added', readList)
this.dialogClose()
} catch (e) {
this.showSnack(e.message)
}
},
async create () {
const toCreate = {
name: this.newReadList,
bookIds: this.bookIds,
} as ReadListCreationDto
try {
const created = await this.$komgaReadLists.postReadList(toCreate)
this.$emit('created', created)
this.dialogClose()
} catch (e) {
this.showSnack(e.message)
}
},
},
})
</script>
<style scoped>
</style>

View file

@ -0,0 +1,112 @@
<template>
<div>
<v-dialog v-model="modal"
max-width="450"
>
<v-card>
<v-card-title>Delete Read List</v-card-title>
<v-card-text>
<v-container fluid>
<v-row>
<v-col>The read list <b>{{ readList.name }}</b> will be removed from this server. Your media files will
not be affected. This <b>cannot</b> be undone. Continue ?
</v-col>
</v-row>
<v-row>
<v-col>
<v-checkbox v-model="confirmDelete" color="red">
<template v-slot:label>
Yes, delete the read list "{{ readList.name }}"
</template>
</v-checkbox>
</v-col>
</v-row>
</v-container>
</v-card-text>
<v-card-actions>
<v-spacer/>
<v-btn text @click="dialogCancel">Cancel</v-btn>
<v-btn text class="red--text"
@click="dialogConfirm"
:disabled="!confirmDelete"
>Delete
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar"
bottom
color="error"
>
{{ snackText }}
<v-btn
text
@click="snackbar = false"
>
Close
</v-btn>
</v-snackbar>
</div>
</template>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
name: 'ReadListDeleteDialog',
data: () => {
return {
confirmDelete: false,
snackbar: false,
snackText: '',
modal: false,
}
},
props: {
value: Boolean,
readList: {
type: Object as () => ReadListDto,
required: true,
},
},
watch: {
value (val) {
this.modal = val
},
modal (val) {
!val && this.dialogCancel()
},
},
methods: {
dialogCancel () {
this.$emit('input', false)
this.confirmDelete = false
},
dialogConfirm () {
this.delete()
this.$emit('input', false)
},
showSnack (message: string) {
this.snackText = message
this.snackbar = true
},
async delete () {
try {
await this.$komgaReadLists.deleteReadList(this.readList.id)
this.$emit('deleted', true)
} catch (e) {
this.showSnack(e.message)
}
},
},
})
</script>
<style scoped>
</style>

View file

@ -0,0 +1,139 @@
<template>
<div>
<v-dialog v-model="modal"
max-width="450"
>
<v-card>
<v-card-title>Edit read list</v-card-title>
<v-card-text>
<v-container fluid>
<v-row>
<v-col>
<v-text-field v-model="form.name"
label="Name"
:error-messages="getErrorsName"
/>
</v-col>
</v-row>
</v-container>
</v-card-text>
<v-card-actions>
<v-spacer/>
<v-btn text @click="dialogCancel">Cancel</v-btn>
<v-btn text class="primary--text"
@click="dialogConfirm"
:disabled="getErrorsName !== ''"
>Save changes
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar"
bottom
color="error"
>
{{ snackText }}
<v-btn
text
@click="snackbar = false"
>
Close
</v-btn>
</v-snackbar>
</div>
</template>
<script lang="ts">
import { UserRoles } from '@/types/enum-users'
import Vue from 'vue'
export default Vue.extend({
name: 'ReadListEditDialog',
data: () => {
return {
UserRoles,
snackbar: false,
snackText: '',
modal: false,
readLists: [] as ReadListDto[],
form: {
name: '',
},
}
},
props: {
value: Boolean,
readList: {
type: Object as () => ReadListDto,
required: true,
},
},
watch: {
async value (val) {
this.modal = val
if (val) {
this.readLists = (await this.$komgaReadLists.getReadLists(undefined, { unpaged: true } as PageRequest)).content
this.dialogReset(this.readList)
}
},
modal (val) {
!val && this.dialogCancel()
},
readList: {
handler (val) {
this.dialogReset(val)
},
immediate: true,
},
},
computed: {
libraries (): LibraryDto[] {
return this.$store.state.komgaLibraries.libraries
},
getErrorsName (): string {
if (this.form.name === '') return 'Name is required'
if (this.form.name !== this.readList.name && this.readLists.some(e => e.name === this.form.name)) {
return 'A read list with this name already exists'
}
return ''
},
},
methods: {
async dialogReset (readList: ReadListDto) {
this.form.name = readList.name
},
dialogCancel () {
this.$emit('input', false)
},
dialogConfirm () {
this.edit()
this.$emit('input', false)
},
showSnack (message: string) {
this.snackText = message
this.snackbar = true
},
async edit () {
try {
const update = {
name: this.form.name,
} as ReadListUpdateDto
await this.$komgaReadLists.patchReadList(this.readList.id, update)
this.$emit('updated', true)
} catch (e) {
this.showSnack(e.message)
}
},
},
})
</script>
<style scoped>
</style>

View file

@ -6,13 +6,16 @@
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list>
<v-list dense>
<v-list-item @click="analyze" v-if="isAdmin">
<v-list-item-title>Analyze</v-list-item-title>
</v-list-item>
<v-list-item @click="refreshMetadata" v-if="isAdmin">
<v-list-item-title>Refresh metadata</v-list-item-title>
</v-list-item>
<v-list-item @click="addToReadList" v-if="isAdmin">
<v-list-item-title>Add to read list</v-list-item-title>
</v-list-item>
<v-list-item @click="markRead" v-if="!isRead">
<v-list-item-title>Mark as read</v-list-item-title>
</v-list-item>
@ -69,6 +72,9 @@ export default Vue.extend({
refreshMetadata () {
this.$komgaBooks.refreshMetadata(this.book)
},
addToReadList () {
this.$store.dispatch('dialogAddBooksToReadList', this.book)
},
async markRead () {
const readProgress = { completed: true } as ReadProgressUpdateDto
await this.$komgaBooks.updateReadProgress(this.book.id, readProgress)

View file

@ -6,7 +6,7 @@
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list>
<v-list dense>
<v-list-item @click="promptDeleteCollection"
class="list-warning">
<v-list-item-title>Delete</v-list-item-title>

View file

@ -6,7 +6,7 @@
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list>
<v-list dense>
<v-list-item @click="scan">
<v-list-item-title>Scan library files</v-list-item-title>
</v-list-item>

View file

@ -0,0 +1,57 @@
<template>
<div>
<v-menu offset-y v-if="isAdmin" 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="promptDeleteReadList"
class="list-warning">
<v-list-item-title>Delete</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</template>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
name: 'ReadListActionsMenu',
data: function () {
return {
menuState: false,
}
},
props: {
readList: {
type: Object as () => ReadListDto,
required: true,
},
menu: {
type: Boolean,
default: false,
},
},
watch: {
menuState (val) {
this.$emit('update:menu', val)
},
},
computed: {
isAdmin (): boolean {
return this.$store.getters.meAdmin
},
},
methods: {
promptDeleteReadList () {
this.$store.dispatch('dialogDeleteReadList', this.readList)
},
},
})
</script>
<style scoped>
@import "../../styles/list-warning.css";
</style>

View file

@ -6,7 +6,7 @@
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list>
<v-list dense>
<v-list-item @click="analyze" v-if="isAdmin">
<v-list-item-title>Analyze</v-list-item-title>
</v-list-item>

View file

@ -39,3 +39,7 @@ export function seriesThumbnailUrl (seriesId: string): string {
export function collectionThumbnailUrl (collectionId: string): string {
return `${urls.originNoSlash}/api/v1/collections/${collectionId}/thumbnail`
}
export function readListThumbnailUrl (readListId: string): string {
return `${urls.originNoSlash}/api/v1/readlists/${readListId}/thumbnail`
}

View file

@ -11,6 +11,7 @@ import httpPlugin from './plugins/http.plugin'
import komgaBooks from './plugins/komga-books.plugin'
import komgaClaim from './plugins/komga-claim.plugin'
import komgaCollections from './plugins/komga-collections.plugin'
import komgaReadLists from './plugins/komga-readlists.plugin'
import komgaFileSystem from './plugins/komga-filesystem.plugin'
import komgaLibraries from './plugins/komga-libraries.plugin'
import komgaReferential from './plugins/komga-referential.plugin'
@ -30,6 +31,7 @@ Vue.use(httpPlugin)
Vue.use(komgaFileSystem, { http: Vue.prototype.$http })
Vue.use(komgaSeries, { http: Vue.prototype.$http })
Vue.use(komgaCollections, { http: Vue.prototype.$http })
Vue.use(komgaReadLists, { http: Vue.prototype.$http })
Vue.use(komgaBooks, { http: Vue.prototype.$http })
Vue.use(komgaReferential, { http: Vue.prototype.$http })
Vue.use(komgaClaim, { http: Vue.prototype.$http })

View file

@ -0,0 +1,17 @@
import { AxiosInstance } from 'axios'
import _Vue from 'vue'
import KomgaReadListsService from '@/services/komga-readlists.service'
export default {
install (
Vue: typeof _Vue,
{ http }: { http: AxiosInstance }) {
Vue.prototype.$komgaReadLists = new KomgaReadListsService(http)
},
}
declare module 'vue/types/vue' {
interface Vue {
$komgaReadLists: KomgaReadListsService;
}
}

View file

@ -85,12 +85,25 @@ const router = new Router({
component: () => import(/* webpackChunkName: "browse-collections" */ './views/BrowseCollections.vue'),
props: (route) => ({ libraryId: route.params.libraryId }),
},
{
path: '/libraries/:libraryId/readlists',
name: 'browse-readlists',
beforeEnter: noLibraryGuard,
component: () => import(/* webpackChunkName: "browse-readlists" */ './views/BrowseReadLists.vue'),
props: (route) => ({ libraryId: route.params.libraryId }),
},
{
path: '/collections/:collectionId',
name: 'browse-collection',
component: () => import(/* webpackChunkName: "browse-collection" */ './views/BrowseCollection.vue'),
props: (route) => ({ collectionId: route.params.collectionId }),
},
{
path: '/readlists/:readListId',
name: 'browse-readlist',
component: () => import(/* webpackChunkName: "browse-readlist" */ './views/BrowseReadList.vue'),
props: (route) => ({ readListId: route.params.readListId }),
},
{
path: '/series/:seriesId',
name: 'browse-series',

View file

@ -101,6 +101,18 @@ export default class KomgaBooksService {
}
}
async getReadLists (bookId: string): Promise<ReadListDto[]> {
try {
return (await this.http.get(`${API_BOOKS}/${bookId}/readlists`)).data
} catch (e) {
let msg = 'An error occurred while trying to retrieve read lists'
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async analyzeBook (book: BookDto) {
try {
await this.http.post(`${API_BOOKS}/${book.id}/analyze`)

View file

@ -0,0 +1,95 @@
import { AxiosInstance } from 'axios'
const qs = require('qs')
const API_READLISTS = '/api/v1/readlists'
export default class KomgaReadListsService {
private http: AxiosInstance
constructor (http: AxiosInstance) {
this.http = http
}
async getReadLists (libraryIds?: string[], pageRequest?: PageRequest, search?: string): Promise<Page<ReadListDto>> {
try {
const params = { ...pageRequest } as any
if (libraryIds) params.library_id = libraryIds
if (search) params.search = search
return (await this.http.get(API_READLISTS, {
params: params,
paramsSerializer: params => qs.stringify(params, { indices: false }),
})).data
} catch (e) {
let msg = 'An error occurred while trying to retrieve readLists'
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async getOneReadList (readListId: string): Promise<ReadListDto> {
try {
return (await this.http.get(`${API_READLISTS}/${readListId}`)).data
} catch (e) {
let msg = 'An error occurred while trying to retrieve readList'
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async postReadList (readList: ReadListCreationDto): Promise<ReadListDto> {
try {
return (await this.http.post(API_READLISTS, readList)).data
} catch (e) {
let msg = `An error occurred while trying to add readList '${readList.name}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async patchReadList (readListId: string, readList: ReadListUpdateDto) {
try {
await this.http.patch(`${API_READLISTS}/${readListId}`, readList)
} catch (e) {
let msg = `An error occurred while trying to update readList '${readListId}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async deleteReadList (readListId: string) {
try {
await this.http.delete(`${API_READLISTS}/${readListId}`)
} catch (e) {
let msg = `An error occurred while trying to delete readList '${readListId}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async getBooks (readListId: string, pageRequest?: PageRequest): Promise<Page<BookDto>> {
try {
const params = { ...pageRequest }
return (await this.http.get(`${API_READLISTS}/${readListId}/books`, {
params: params,
})).data
} catch (e) {
let msg = 'An error occurred while trying to retrieve books'
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
}

View file

@ -5,22 +5,34 @@ Vue.use(Vuex)
export default new Vuex.Store({
state: {
// collections
addToCollectionSeries: {} as SeriesDto | SeriesDto[],
addToCollectionDialog: false,
editCollection: {} as CollectionDto,
editCollectionDialog: false,
deleteCollection: {} as CollectionDto,
deleteCollectionDialog: false,
// read lists
addToReadListBooks: {} as BookDto | BookDto[],
addToReadListDialog: false,
editReadList: {} as ReadListDto,
editReadListDialog: false,
deleteReadList: {} as ReadListDto,
deleteReadListDialog: false,
// libraries
editLibrary: {} as LibraryDto | undefined,
editLibraryDialog: false,
deleteLibrary: {} as LibraryDto,
deleteLibraryDialog: false,
// books
updateBooks: {} as BookDto | BookDto[],
updateBooksDialog: false,
// series
updateSeries: {} as SeriesDto | SeriesDto[],
updateSeriesDialog: false,
},
mutations: {
// Collections
setAddToCollectionSeries (state, series) {
state.addToCollectionSeries = series
},
@ -36,27 +48,49 @@ export default new Vuex.Store({
setDeleteCollection (state, collection) {
state.deleteCollection = collection
},
setDeleteCollectionDialog (state, dialog) {
state.deleteCollectionDialog = dialog
},
// Read Lists
setAddToReadListBooks (state, Book) {
state.addToReadListBooks = Book
},
setAddToReadListDialog (state, dialog) {
state.addToReadListDialog = dialog
},
setEditReadList (state, ReadList) {
state.editReadList = ReadList
},
setEditReadListDialog (state, dialog) {
state.editReadListDialog = dialog
},
setDeleteReadList (state, ReadList) {
state.deleteReadList = ReadList
},
setDeleteReadListDialog (state, dialog) {
state.deleteReadListDialog = dialog
},
// Libraries
setEditLibrary (state, library) {
state.editLibrary = library
},
setEditLibraryDialog (state, dialog) {
state.editLibraryDialog = dialog
},
setDeleteCollectionDialog (state, dialog) {
state.deleteCollectionDialog = dialog
},
setDeleteLibrary (state, library) {
state.deleteLibrary = library
},
setDeleteLibraryDialog (state, dialog) {
state.deleteLibraryDialog = dialog
},
// Books
setUpdateBooks (state, books) {
state.updateBooks = books
},
setUpdateBooksDialog (state, dialog) {
state.updateBooksDialog = dialog
},
// Series
setUpdateSeries (state, series) {
state.updateSeries = series
},
@ -65,6 +99,7 @@ export default new Vuex.Store({
},
},
actions: {
// collections
dialogAddSeriesToCollection ({ commit }, series) {
commit('setAddToCollectionSeries', series)
commit('setAddToCollectionDialog', true)
@ -86,6 +121,29 @@ export default new Vuex.Store({
dialogDeleteCollectionDisplay ({ commit }, value) {
commit('setDeleteCollectionDialog', value)
},
// read lists
dialogAddBooksToReadList ({ commit }, books) {
commit('setAddToReadListBooks', books)
commit('setAddToReadListDialog', true)
},
dialogAddBooksToReadListDisplay ({ commit }, value) {
commit('setAddToReadListDialog', value)
},
dialogEditReadList ({ commit }, readList) {
commit('setEditReadList', readList)
commit('setEditReadListDialog', true)
},
dialogEditReadListDisplay ({ commit }, value) {
commit('setEditReadListDialog', value)
},
dialogDeleteReadList ({ commit }, readList) {
commit('setDeleteReadList', readList)
commit('setDeleteReadListDialog', true)
},
dialogDeleteReadListDisplay ({ commit }, value) {
commit('setDeleteReadListDialog', value)
},
// libraries
dialogAddLibrary ({ commit }) {
commit('setEditLibrary', undefined)
commit('setEditLibraryDialog', true)
@ -104,6 +162,7 @@ export default new Vuex.Store({
dialogDeleteLibraryDisplay ({ commit }, value) {
commit('setDeleteLibraryDialog', value)
},
// books
dialogUpdateBooks ({ commit }, books) {
commit('setUpdateBooks', books)
commit('setUpdateBooksDialog', true)
@ -111,6 +170,7 @@ export default new Vuex.Store({
dialogUpdateBooksDisplay ({ commit }, value) {
commit('setUpdateBooksDialog', value)
},
// series
dialogUpdateSeries ({ commit }, series) {
commit('setUpdateSeries', series)
commit('setUpdateSeriesDialog', true)

View file

@ -16,6 +16,14 @@ interface EventCollectionDeleted {
id: string
}
interface EventReadListChanged {
id: string
}
interface EventReadListDeleted {
id: string
}
interface EventLibraryAdded {
id: string
}

View file

@ -2,6 +2,8 @@ export const BOOK_CHANGED = 'book-changed'
export const SERIES_CHANGED = 'series-changed'
export const COLLECTION_DELETED = 'collection-deleted'
export const COLLECTION_CHANGED = 'collection-changed'
export const READLIST_DELETED = 'readlist-deleted'
export const READLIST_CHANGED = 'readlist-changed'
export const LIBRARY_ADDED = 'library-added'
export const LIBRARY_CHANGED = 'library-changed'
export const LIBRARY_DELETED = 'library-deleted'
@ -32,6 +34,18 @@ export function collectionToEventCollectionDeleted (collection: CollectionDto):
} as EventCollectionDeleted
}
export function readListToEventReadListChanged (readList: ReadListDto): EventReadListChanged {
return {
id: readList.id,
} as EventReadListChanged
}
export function readListToEventReadListDeleted (readList: ReadListDto): EventReadListDeleted {
return {
id: readList.id,
} as EventReadListDeleted
}
export function libraryToEventLibraryAdded (library: LibraryDto): EventLibraryAdded {
return {
id: library.id,

View file

@ -1,4 +1,4 @@
import { bookThumbnailUrl, collectionThumbnailUrl, seriesThumbnailUrl } from '@/functions/urls'
import { bookThumbnailUrl, collectionThumbnailUrl, readListThumbnailUrl, seriesThumbnailUrl } from '@/functions/urls'
import { RawLocation } from 'vue-router/types/router'
function plural (count: number, singular: string, plural: string) {
@ -6,11 +6,13 @@ function plural (count: number, singular: string, plural: string) {
}
export enum ItemTypes {
BOOK, SERIES, COLLECTION
BOOK, SERIES, COLLECTION, READLIST
}
export function createItem (item: BookDto | SeriesDto | CollectionDto): Item<BookDto | SeriesDto | CollectionDto> {
if ('seriesIds' in item) {
export function createItem (item: BookDto | SeriesDto | CollectionDto | ReadListDto): Item<BookDto | SeriesDto | CollectionDto | ReadListDto> {
if ('bookIds' in item) {
return new ReadListItem(item)
} else if ('seriesIds' in item) {
return new CollectionItem(item)
} else if ('seriesId' in item) {
return new BookItem(item)
@ -116,3 +118,26 @@ export class CollectionItem extends Item<CollectionDto> {
return { name: 'browse-collection', params: { collectionId: this.item.id.toString() } }
}
}
export class ReadListItem extends Item<ReadListDto> {
thumbnailUrl (): string {
return readListThumbnailUrl(this.item.id)
}
type (): ItemTypes {
return ItemTypes.READLIST
}
title (): string {
return this.item.name
}
body (): string {
const c = this.item.bookIds.length
return `<span>${c} Books</span>`
}
to (): RawLocation {
return { name: 'browse-readlist', params: { readListId: this.item.id.toString() } }
}
}

View file

@ -4,6 +4,7 @@ interface LibraryCreationDto {
importComicInfoBook: boolean,
importComicInfoSeries: boolean,
importComicInfoCollection: boolean,
importComicInfoReadList: boolean,
importEpubBook: boolean,
importEpubSeries: boolean,
importLocalArtwork: boolean,
@ -17,6 +18,7 @@ interface LibraryUpdateDto {
importComicInfoBook: boolean,
importComicInfoSeries: boolean,
importComicInfoCollection: boolean,
importComicInfoReadList: boolean,
importEpubBook: boolean,
importEpubSeries: boolean,
importLocalArtwork: boolean,
@ -31,6 +33,7 @@ interface LibraryDto {
importComicInfoBook: boolean,
importComicInfoSeries: boolean,
importComicInfoCollection: boolean,
importComicInfoReadList: boolean,
importEpubBook: boolean,
importEpubSeries: boolean,
importLocalArtwork: boolean,

View file

@ -0,0 +1,18 @@
interface ReadListDto {
id: string,
name: string,
filtered: boolean,
bookIds: string[],
createdDate: string,
lastModifiedDate: string
}
interface ReadListCreationDto {
name: string,
bookIds: string[]
}
interface ReadListUpdateDto {
name?: string,
bookIds?: string[]
}

View file

@ -0,0 +1 @@
export const LIBRARIES_ALL = 'all'

View file

@ -89,8 +89,7 @@
<badge v-if="book.metadata.ageRating">{{ book.metadata.ageRating }}+</badge>
</v-col>
<v-col cols="auto" v-if="book.metadata.releaseDate">
{{ book.metadata.releaseDate | moment
('MMMM DD, YYYY') }}
{{ book.metadata.releaseDate | moment('MMMM DD, YYYY') }}
</v-col>
</v-row>
@ -121,6 +120,18 @@
</div>
</v-col>
</v-row>
<v-row v-if="$vuetify.breakpoint.name !== 'xs'">
<v-col>
<read-lists-expansion-panels :read-lists="readLists"/>
</v-col>
</v-row>
</v-col>
</v-row>
<v-row v-if="$vuetify.breakpoint.name === 'xs'">
<v-col class="pt-0 py-1">
<read-lists-expansion-panels :read-lists="readLists"/>
</v-col>
</v-row>
@ -199,10 +210,11 @@ import { bookFileUrl, bookThumbnailUrl } from '@/functions/urls'
import { ReadStatus } from '@/types/enum-books'
import { BOOK_CHANGED, LIBRARY_DELETED } from '@/types/events'
import Vue from 'vue'
import ReadListsExpansionPanels from '@/components/ReadListsExpansionPanels.vue'
export default Vue.extend({
name: 'BrowseBook',
components: { ToolbarSticky, Badge, ItemCard, BookActionsMenu },
components: { ToolbarSticky, Badge, ItemCard, BookActionsMenu, ReadListsExpansionPanels },
data: () => {
return {
book: {} as BookDto,
@ -210,6 +222,7 @@ export default Vue.extend({
siblings: [] as BookDto[],
siblingPrevious: {} as BookDto,
siblingNext: {} as BookDto,
readLists: [] as ReadListDto[],
}
},
async created () {
@ -291,6 +304,7 @@ export default Vue.extend({
this.book = await this.$komgaBooks.getBook(bookId)
this.series = await this.$komgaSeries.getOneSeries(this.book.seriesId)
this.siblings = (await this.$komgaSeries.getBooks(this.book.seriesId, { unpaged: true } as PageRequest)).content
this.readLists = await this.$komgaBooks.getReadLists(this.bookId)
if (this.$_.has(this.book, 'metadata.title')) {
document.title = `Komga - ${getBookTitleCompact(this.book.metadata.title, this.series.metadata.title)}`

View file

@ -82,6 +82,7 @@ import ToolbarSticky from '@/components/bars/ToolbarSticky.vue'
import { COLLECTION_CHANGED, COLLECTION_DELETED, SERIES_CHANGED } from '@/types/events'
import Vue from 'vue'
import SeriesMultiSelectBar from '@/components/bars/SeriesMultiSelectBar.vue'
import { LIBRARIES_ALL } from '@/types/library'
export default Vue.extend({
name: 'BrowseCollection',
@ -205,7 +206,7 @@ export default Vue.extend({
this.$store.dispatch('dialogEditCollection', this.collection)
},
afterDelete () {
this.$router.push({ name: 'browse-collections', params: { libraryId: '0' } })
this.$router.push({ name: 'browse-collections', params: { libraryId: LIBRARIES_ALL } })
},
reloadSeries (event: EventSeriesChanged) {
if (this.series.some(s => s.id === event.id)) this.loadCollection(this.collectionId)

View file

@ -43,12 +43,12 @@ import ItemBrowser from '@/components/ItemBrowser.vue'
import LibraryNavigation from '@/components/LibraryNavigation.vue'
import LibraryActionsMenu from '@/components/menus/LibraryActionsMenu.vue'
import PageSizeSelect from '@/components/PageSizeSelect.vue'
import { COLLECTION_CHANGED, LIBRARY_CHANGED } from '@/types/events'
import { COLLECTION_CHANGED, COLLECTION_DELETED, LIBRARY_CHANGED } from '@/types/events'
import Vue from 'vue'
import { Location } from 'vue-router'
import { LIBRARIES_ALL } from '@/types/library'
const cookiePageSize = 'pagesize'
const all = 'all'
export default Vue.extend({
name: 'BrowseCollections',
@ -75,15 +75,17 @@ export default Vue.extend({
props: {
libraryId: {
type: String,
default: all,
default: LIBRARIES_ALL,
},
},
created () {
this.$eventHub.$on(COLLECTION_CHANGED, this.reloadCollections)
this.$eventHub.$on(COLLECTION_DELETED, this.reloadCollections)
this.$eventHub.$on(LIBRARY_CHANGED, this.reloadLibrary)
},
beforeDestroy () {
this.$eventHub.$off(COLLECTION_CHANGED, this.reloadCollections)
this.$eventHub.$off(COLLECTION_DELETED, this.reloadCollections)
this.$eventHub.$off(LIBRARY_CHANGED, this.reloadLibrary)
},
mounted () {
@ -189,7 +191,7 @@ export default Vue.extend({
size: this.pageSize,
} as PageRequest
const lib = libraryId !== all ? [libraryId] : undefined
const lib = libraryId !== LIBRARIES_ALL ? [libraryId] : undefined
const collectionsPage = await this.$komgaCollections.getCollections(lib, pageRequest)
this.totalPages = collectionsPage.totalPages
@ -197,7 +199,7 @@ export default Vue.extend({
this.collections = collectionsPage.content
},
getLibraryLazy (libraryId: string): LibraryDto | undefined {
if (libraryId !== all) {
if (libraryId !== LIBRARIES_ALL) {
return this.$store.getters.getLibraryById(libraryId)
} else {
return undefined

View file

@ -35,9 +35,7 @@
@edit="editMultipleSeries"
/>
<library-navigation v-if="collectionsCount > 0"
:libraryId="libraryId"
/>
<library-navigation :libraryId="libraryId"/>
<v-container fluid>
<empty-state
@ -82,12 +80,12 @@ import SortMenuButton from '@/components/SortMenuButton.vue'
import { parseQueryFilter, parseQuerySort } from '@/functions/query-params'
import { ReadStatus } from '@/types/enum-books'
import { SeriesStatus } from '@/types/enum-series'
import { COLLECTION_CHANGED, LIBRARY_CHANGED, LIBRARY_DELETED, SERIES_CHANGED } from '@/types/events'
import { LIBRARY_CHANGED, LIBRARY_DELETED, SERIES_CHANGED } from '@/types/events'
import Vue from 'vue'
import { Location } from 'vue-router'
import { LIBRARIES_ALL } from '@/types/library'
const cookiePageSize = 'pagesize'
const all = 'all'
export default Vue.extend({
name: 'BrowseLibraries',
@ -133,13 +131,12 @@ export default Vue.extend({
filterUnwatch: null as any,
pageUnwatch: null as any,
pageSizeUnwatch: null as any,
collectionsCount: 0,
}
},
props: {
libraryId: {
type: String,
default: all,
default: LIBRARIES_ALL,
},
},
watch: {
@ -153,13 +150,11 @@ export default Vue.extend({
},
},
created () {
this.$eventHub.$on(COLLECTION_CHANGED, this.reloadCollections)
this.$eventHub.$on(SERIES_CHANGED, this.reloadSeries)
this.$eventHub.$on(LIBRARY_DELETED, this.libraryDeleted)
this.$eventHub.$on(LIBRARY_CHANGED, this.reloadLibrary)
},
beforeDestroy () {
this.$eventHub.$off(COLLECTION_CHANGED, this.reloadCollections)
this.$eventHub.$off(SERIES_CHANGED, this.reloadSeries)
this.$eventHub.$off(LIBRARY_DELETED, this.libraryDeleted)
this.$eventHub.$off(LIBRARY_CHANGED, this.reloadLibrary)
@ -188,7 +183,6 @@ export default Vue.extend({
this.totalPages = 1
this.totalElements = null
this.series = []
this.collectionsCount = 0
this.loadLibrary(to.params.libraryId)
@ -238,7 +232,7 @@ export default Vue.extend({
libraryDeleted (event: EventLibraryDeleted) {
if (event.id === this.libraryId) {
this.$router.push({ name: 'home' })
} else if (this.libraryId === all) {
} else if (this.libraryId === LIBRARIES_ALL) {
this.loadLibrary(this.libraryId)
}
},
@ -278,25 +272,19 @@ export default Vue.extend({
this.setWatches()
},
reloadCollections () {
this.loadLibrary(this.libraryId)
},
reloadSeries (event: EventSeriesChanged) {
if (this.libraryId === all || event.libraryId === this.libraryId) {
if (this.libraryId === LIBRARIES_ALL || event.libraryId === this.libraryId) {
this.loadPage(this.libraryId, this.page, this.sortActive)
}
},
reloadLibrary (event: EventLibraryChanged) {
if (this.libraryId === all || event.id === this.libraryId) {
if (this.libraryId === LIBRARIES_ALL || event.id === this.libraryId) {
this.loadLibrary(this.libraryId)
}
},
async loadLibrary (libraryId: string) {
this.library = this.getLibraryLazy(libraryId)
const lib = libraryId !== all ? [libraryId] : undefined
this.collectionsCount = (await this.$komgaCollections.getCollections(lib, { size: 1 })).totalElements
await this.loadPage(libraryId, this.page, this.sortActive)
},
updateRoute () {
@ -323,7 +311,7 @@ export default Vue.extend({
pageRequest.sort = [`${sort.key},${sort.order}`]
}
const requestLibraryId = libraryId !== all ? libraryId : undefined
const requestLibraryId = libraryId !== LIBRARIES_ALL ? libraryId : undefined
const seriesPage = await this.$komgaSeries.getSeries(requestLibraryId, pageRequest, undefined, this.filters.status, this.filters.readStatus)
this.totalPages = seriesPage.totalPages

View file

@ -0,0 +1,212 @@
<template>
<div v-if="readList">
<toolbar-sticky v-if="!editElements && selectedBooks.length === 0">
<read-list-actions-menu v-if="readList"
:read-list="readList"
/>
<v-toolbar-title v-if="readList">
<span>{{ readList.name }}</span>
<badge class="mx-4">{{ readList.bookIds.length }}</badge>
</v-toolbar-title>
<v-spacer/>
<v-btn icon @click="startEditElements" v-if="isAdmin">
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-icon v-on="on">mdi-playlist-edit</v-icon>
</template>
<span>Edit elements</span>
</v-tooltip>
</v-btn>
<v-btn icon @click="editReadList" v-if="isAdmin">
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-icon v-on="on">mdi-pencil</v-icon>
</template>
<span>Edit read list</span>
</v-tooltip>
</v-btn>
</toolbar-sticky>
<books-multi-select-bar
v-model="selectedBooks"
@unselect-all="selectedBooks = []"
@mark-read="markSelectedRead"
@mark-unread="markSelectedUnread"
@add-to-readlist="addToReadList"
@edit="editMultipleBooks"
/>
<!-- Edit elements sticky bar -->
<v-scroll-y-transition hide-on-leave>
<toolbar-sticky v-if="editElements" :elevation="5" color="base">
<v-btn icon @click="cancelEditElements">
<v-icon>mdi-close</v-icon>
</v-btn>
<v-btn icon color="primary" @click="doEditElements" :disabled="books.length === 0">
<v-icon>mdi-check</v-icon>
</v-btn>
</toolbar-sticky>
</v-scroll-y-transition>
<v-container fluid>
<item-browser
:items.sync="books"
:selected.sync="selectedBooks"
:edit-function="editSingleBook"
:draggable="editElements"
:deletable="editElements"
/>
</v-container>
</div>
</template>
<script lang="ts">
import Badge from '@/components/Badge.vue'
import ItemBrowser from '@/components/ItemBrowser.vue'
import ToolbarSticky from '@/components/bars/ToolbarSticky.vue'
import { BOOK_CHANGED, READLIST_CHANGED, READLIST_DELETED } from '@/types/events'
import Vue from 'vue'
import ReadListActionsMenu from '@/components/menus/ReadListActionsMenu.vue'
import BooksMultiSelectBar from '@/components/bars/BooksMultiSelectBar.vue'
export default Vue.extend({
name: 'BrowseReadList',
components: {
ToolbarSticky,
ItemBrowser,
ReadListActionsMenu,
Badge,
BooksMultiSelectBar,
},
data: () => {
return {
readList: undefined as ReadListDto | undefined,
books: [] as BookDto[],
booksCopy: [] as BookDto[],
selectedBooks: [] as BookDto[],
editElements: false,
}
},
props: {
readListId: {
type: String,
required: true,
},
},
watch: {
selectedBooks (val: BookDto[]) {
val.forEach(s => {
let index = this.books.findIndex(x => x.id === s.id)
if (index !== -1) {
this.books.splice(index, 1, s)
}
index = this.booksCopy.findIndex(x => x.id === s.id)
if (index !== -1) {
this.booksCopy.splice(index, 1, s)
}
})
},
},
created () {
this.$eventHub.$on(READLIST_CHANGED, this.readListChanged)
this.$eventHub.$on(READLIST_DELETED, this.afterDelete)
this.$eventHub.$on(BOOK_CHANGED, this.reloadBook)
},
beforeDestroy () {
this.$eventHub.$off(READLIST_CHANGED, this.readListChanged)
this.$eventHub.$off(READLIST_DELETED, this.afterDelete)
this.$eventHub.$off(BOOK_CHANGED, this.reloadBook)
},
mounted () {
this.loadReadList(this.readListId)
},
beforeRouteUpdate (to, from, next) {
if (to.params.readListId !== from.params.readListId) {
// reset
this.books = []
this.editElements = false
this.loadReadList(to.params.readListId)
}
next()
},
computed: {
isAdmin (): boolean {
return this.$store.getters.meAdmin
},
},
methods: {
readListChanged (event: EventReadListChanged) {
if (event.id === this.readListId) {
this.loadReadList(this.readListId)
}
},
async loadReadList (readListId: string) {
this.readList = await this.$komgaReadLists.getOneReadList(readListId)
this.books = (await this.$komgaReadLists.getBooks(readListId, { unpaged: true } as PageRequest)).content
this.booksCopy = [...this.books]
},
editSingleBook (book: BookDto) {
this.$store.dispatch('dialogUpdateBooks', book)
},
editMultipleBooks () {
this.$store.dispatch('dialogUpdateBooks', this.selectedBooks)
},
async markSelectedRead () {
await Promise.all(this.selectedBooks.map(b =>
this.$komgaBooks.updateReadProgress(b.id, { completed: true } as ReadProgressUpdateDto),
))
this.selectedBooks = await Promise.all(this.selectedBooks.map(b =>
this.$komgaBooks.getBook(b.id),
))
},
async markSelectedUnread () {
await Promise.all(this.selectedBooks.map(b =>
this.$komgaBooks.deleteReadProgress(b.id),
))
this.selectedBooks = await Promise.all(this.selectedBooks.map(b =>
this.$komgaBooks.getBook(b.id),
))
},
addToReadList () {
this.$store.dispatch('dialogAddBooksToReadList', this.selectedBooks)
},
startEditElements () {
this.editElements = true
},
cancelEditElements () {
this.editElements = false
this.books = [...this.booksCopy]
},
doEditElements () {
this.editElements = false
const update = {
bookIds: this.books.map(x => x.id),
} as ReadListUpdateDto
this.$komgaReadLists.patchReadList(this.readListId, update)
this.loadReadList(this.readListId)
},
editReadList () {
this.$store.dispatch('dialogEditReadList', this.readList)
},
afterDelete () {
this.$router.push({ name: 'browse-readlists', params: { libraryId: 'all' } })
},
reloadBook (event: EventBookChanged) {
if (this.books.some(b => b.id === event.id)) this.loadReadList(this.readListId)
},
},
})
</script>

View file

@ -0,0 +1,213 @@
<template>
<div :style="$vuetify.breakpoint.name === 'xs' ? 'margin-bottom: 56px' : undefined">
<toolbar-sticky>
<!-- Action menu -->
<library-actions-menu v-if="library"
:library="library"/>
<v-toolbar-title>
<span>{{ library ? library.name : 'All libraries' }}</span>
<badge class="ml-4">{{ totalElements }}</badge>
</v-toolbar-title>
<v-spacer/>
<page-size-select v-model="pageSize"/>
</toolbar-sticky>
<library-navigation :libraryId="libraryId"/>
<v-container fluid>
<v-pagination
v-if="totalPages > 1"
v-model="page"
:total-visible="paginationVisible"
:length="totalPages"
/>
<item-browser
:items="readLists"
:selectable="false"
:edit-function="editSingle"
/>
</v-container>
</div>
</template>
<script lang="ts">
import Badge from '@/components/Badge.vue'
import ToolbarSticky from '@/components/bars/ToolbarSticky.vue'
import ItemBrowser from '@/components/ItemBrowser.vue'
import LibraryNavigation from '@/components/LibraryNavigation.vue'
import LibraryActionsMenu from '@/components/menus/LibraryActionsMenu.vue'
import PageSizeSelect from '@/components/PageSizeSelect.vue'
import { LIBRARY_CHANGED, READLIST_CHANGED, READLIST_DELETED } from '@/types/events'
import Vue from 'vue'
import { Location } from 'vue-router'
import { LIBRARIES_ALL } from '@/types/library'
const cookiePageSize = 'pagesize'
export default Vue.extend({
name: 'BrowseReadLists',
components: {
LibraryActionsMenu,
ToolbarSticky,
LibraryNavigation,
ItemBrowser,
Badge,
PageSizeSelect,
},
data: () => {
return {
library: undefined as LibraryDto | undefined,
readLists: [] as ReadListDto[],
page: 1,
pageSize: 20,
totalPages: 1,
totalElements: null as number | null,
pageUnwatch: null as any,
pageSizeUnwatch: null as any,
}
},
props: {
libraryId: {
type: String,
default: LIBRARIES_ALL,
},
},
created () {
this.$eventHub.$on(READLIST_CHANGED, this.reloadElements)
this.$eventHub.$on(READLIST_DELETED, this.reloadElements)
this.$eventHub.$on(LIBRARY_CHANGED, this.reloadLibrary)
},
beforeDestroy () {
this.$eventHub.$off(READLIST_CHANGED, this.reloadElements)
this.$eventHub.$off(READLIST_DELETED, this.reloadElements)
this.$eventHub.$off(LIBRARY_CHANGED, this.reloadLibrary)
},
mounted () {
if (this.$cookies.isKey(cookiePageSize)) {
this.pageSize = Number(this.$cookies.get(cookiePageSize))
}
// restore from query param
if (this.$route.query.page) this.page = Number(this.$route.query.page)
if (this.$route.query.pageSize) this.pageSize = Number(this.$route.query.pageSize)
this.loadLibrary(this.libraryId)
this.setWatches()
},
beforeRouteUpdate (to, from, next) {
if (to.params.libraryId !== from.params.libraryId) {
// reset
this.page = 1
this.totalPages = 1
this.totalElements = null
this.readLists = []
this.loadLibrary(to.params.libraryId)
}
next()
},
computed: {
isAdmin (): boolean {
return this.$store.getters.meAdmin
},
paginationVisible (): number {
switch (this.$vuetify.breakpoint.name) {
case 'xs':
return 5
case 'sm':
case 'md':
return 10
case 'lg':
case 'xl':
default:
return 15
}
},
},
methods: {
setWatches () {
this.pageSizeUnwatch = this.$watch('pageSize', (val) => {
this.$cookies.set(cookiePageSize, val, Infinity)
this.updateRouteAndReload()
})
this.pageUnwatch = this.$watch('page', (val) => {
this.updateRoute()
this.loadPage(this.libraryId, val)
})
},
unsetWatches () {
this.pageUnwatch()
this.pageSizeUnwatch()
},
updateRouteAndReload () {
this.unsetWatches()
this.page = 1
this.updateRoute()
this.loadPage(this.libraryId, this.page)
this.setWatches()
},
updateRoute () {
this.$router.replace({
name: this.$route.name,
params: { libraryId: this.$route.params.libraryId },
query: {
page: `${this.page}`,
pageSize: `${this.pageSize}`,
},
} as Location).catch((_: any) => {
})
},
reloadElements () {
this.loadLibrary(this.libraryId)
},
reloadLibrary (event: EventLibraryChanged) {
if (event.id === this.libraryId) {
this.loadLibrary(this.libraryId)
}
},
async loadLibrary (libraryId: string) {
this.library = this.getLibraryLazy(libraryId)
await this.loadPage(libraryId, this.page)
if (this.totalElements === 0) {
await this.$router.push({ name: 'browse-libraries', params: { libraryId: libraryId.toString() } })
}
},
async loadPage (libraryId: string, page: number) {
const pageRequest = {
page: page - 1,
size: this.pageSize,
} as PageRequest
const lib = libraryId !== LIBRARIES_ALL ? [libraryId] : undefined
const elementsPage = await this.$komgaReadLists.getReadLists(lib, pageRequest)
this.totalPages = elementsPage.totalPages
this.totalElements = elementsPage.totalElements
this.readLists = elementsPage.content
},
getLibraryLazy (libraryId: string): LibraryDto | undefined {
if (libraryId !== LIBRARIES_ALL) {
return this.$store.getters.getLibraryById(libraryId)
} else {
return undefined
}
},
editSingle (element: ReadListDto) {
this.$store.dispatch('dialogEditReadList', element)
},
},
})
</script>

View file

@ -44,6 +44,7 @@
@unselect-all="selectedBooks = []"
@mark-read="markSelectedRead"
@mark-unread="markSelectedUnread"
@add-to-readlist="addToReadList"
@edit="editMultipleBooks"
/>
@ -133,7 +134,7 @@ import SortMenuButton from '@/components/SortMenuButton.vue'
import { parseQueryFilter, parseQuerySort } from '@/functions/query-params'
import { seriesThumbnailUrl } from '@/functions/urls'
import { ReadStatus } from '@/types/enum-books'
import { BOOK_CHANGED, LIBRARY_DELETED, SERIES_CHANGED } from '@/types/events'
import { BOOK_CHANGED, LIBRARY_DELETED, READLIST_CHANGED, SERIES_CHANGED } from '@/types/events'
import Vue from 'vue'
import { Location } from 'vue-router'
@ -218,11 +219,13 @@ export default Vue.extend({
},
created () {
this.$eventHub.$on(SERIES_CHANGED, this.reloadSeries)
this.$eventHub.$on(READLIST_CHANGED, this.reloadSeries)
this.$eventHub.$on(BOOK_CHANGED, this.reloadBooks)
this.$eventHub.$on(LIBRARY_DELETED, this.libraryDeleted)
},
beforeDestroy () {
this.$eventHub.$off(SERIES_CHANGED, this.reloadSeries)
this.$eventHub.$off(READLIST_CHANGED, this.reloadSeries)
this.$eventHub.$off(BOOK_CHANGED, this.reloadBooks)
this.$eventHub.$off(LIBRARY_DELETED, this.libraryDeleted)
},
@ -357,6 +360,9 @@ export default Vue.extend({
editMultipleBooks () {
this.$store.dispatch('dialogUpdateBooks', this.selectedBooks)
},
addToReadList () {
this.$store.dispatch('dialogAddBooksToReadList', this.selectedBooks)
},
async markSelectedRead () {
await Promise.all(this.selectedBooks.map(b =>
this.$komgaBooks.updateReadProgress(b.id, { completed: true }),

View file

@ -14,6 +14,7 @@
@unselect-all="selectedBooks = []"
@mark-read="markSelectedBooksRead"
@mark-unread="markSelectedBooksUnread"
@add-to-readlist="addToReadList"
@edit="editMultipleBooks"
/>
@ -259,6 +260,9 @@ export default Vue.extend({
editMultipleBooks () {
this.$store.dispatch('dialogUpdateBooks', this.selectedBooks)
},
addToReadList () {
this.$store.dispatch('dialogAddBooksToReadList', this.selectedBooks)
},
async markSelectedBooksRead () {
await Promise.all(this.selectedBooks.map(b =>
this.$komgaBooks.updateReadProgress(b.id, { completed: true }),

View file

@ -20,6 +20,7 @@
@unselect-all="selectedBooks = []"
@mark-read="markSelectedBooksRead"
@mark-unread="markSelectedBooksUnread"
@add-to-readlist="addToReadList"
@edit="editMultipleBooks"
/>
@ -79,6 +80,20 @@
</template>
</horizontal-scroller>
<horizontal-scroller v-if="readLists.length !== 0" class="mb-4">
<template v-slot:prepend>
<div class="title">Read Lists</div>
</template>
<template v-slot:content>
<item-browser :items="readLists"
nowrap
:edit-function="singleEditReadList"
:selectable="false"
:fixed-item-width="fixedCardWidth"
/>
</template>
</horizontal-scroller>
</template>
</v-container>
@ -92,7 +107,15 @@ import ToolbarSticky from '@/components/bars/ToolbarSticky.vue'
import EmptyState from '@/components/EmptyState.vue'
import HorizontalScroller from '@/components/HorizontalScroller.vue'
import ItemBrowser from '@/components/ItemBrowser.vue'
import { BOOK_CHANGED, COLLECTION_CHANGED, LIBRARY_DELETED, SERIES_CHANGED } from '@/types/events'
import {
BOOK_CHANGED,
COLLECTION_CHANGED,
COLLECTION_DELETED,
LIBRARY_DELETED,
READLIST_CHANGED,
READLIST_DELETED,
SERIES_CHANGED,
} from '@/types/events'
import Vue from 'vue'
export default Vue.extend({
@ -110,6 +133,7 @@ export default Vue.extend({
series: [] as SeriesDto[],
books: [] as BookDto[],
collections: [] as CollectionDto[],
readLists: [] as ReadListDto[],
pageSize: 50,
loading: false,
selectedSeries: [] as SeriesDto[],
@ -121,12 +145,18 @@ export default Vue.extend({
this.$eventHub.$on(SERIES_CHANGED, this.reloadResults)
this.$eventHub.$on(BOOK_CHANGED, this.reloadResults)
this.$eventHub.$on(COLLECTION_CHANGED, this.reloadResults)
this.$eventHub.$on(COLLECTION_DELETED, this.reloadResults)
this.$eventHub.$on(READLIST_CHANGED, this.reloadResults)
this.$eventHub.$on(READLIST_DELETED, this.reloadResults)
},
beforeDestroy () {
this.$eventHub.$off(LIBRARY_DELETED, this.reloadResults)
this.$eventHub.$off(SERIES_CHANGED, this.reloadResults)
this.$eventHub.$off(BOOK_CHANGED, this.reloadResults)
this.$eventHub.$off(COLLECTION_CHANGED, this.reloadResults)
this.$eventHub.$off(COLLECTION_DELETED, this.reloadResults)
this.$eventHub.$off(READLIST_CHANGED, this.reloadResults)
this.$eventHub.$off(READLIST_DELETED, this.reloadResults)
},
watch: {
'$route.query.q': {
@ -163,7 +193,7 @@ export default Vue.extend({
return this.selectedSeries.length === 0 && this.selectedBooks.length === 0
},
emptyResults (): boolean {
return !this.loading && this.series.length === 0 && this.books.length === 0 && this.collections.length === 0
return !this.loading && this.series.length === 0 && this.books.length === 0 && this.collections.length === 0 && this.readLists.length === 0
},
},
methods: {
@ -176,6 +206,9 @@ export default Vue.extend({
singleEditCollection (collection: CollectionDto) {
this.$store.dispatch('dialogEditCollection', collection)
},
singleEditReadList (readList: ReadListDto) {
this.$store.dispatch('dialogEditReadList', readList)
},
async markSelectedSeriesRead () {
await Promise.all(this.selectedSeries.map(s =>
this.$komgaSeries.markAsRead(s.id),
@ -195,6 +228,9 @@ export default Vue.extend({
addToCollection () {
this.$store.dispatch('dialogAddSeriesToCollection', this.selectedSeries)
},
addToReadList () {
this.$store.dispatch('dialogAddBooksToReadList', this.selectedBooks)
},
editMultipleSeries () {
this.$store.dispatch('dialogUpdateSeries', this.selectedSeries)
},
@ -227,12 +263,14 @@ export default Vue.extend({
this.series = (await this.$komgaSeries.getSeries(undefined, { size: this.pageSize }, search)).content
this.books = (await this.$komgaBooks.getBooks(undefined, { size: this.pageSize }, search)).content
this.collections = (await this.$komgaCollections.getCollections(undefined, { size: this.pageSize }, search)).content
this.readLists = (await this.$komgaReadLists.getReadLists(undefined, { size: this.pageSize }, search)).content
this.loading = false
} else {
this.series = []
this.books = []
this.collections = []
this.readLists = []
}
},
},