feat(webui): collections management

closes #30
This commit is contained in:
Gauthier Roebroeck 2020-06-19 17:29:09 +08:00
parent c2f940336a
commit 2f8255a05f
24 changed files with 1440 additions and 82 deletions

View file

@ -2898,6 +2898,15 @@
"integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==",
"dev": true
},
"@types/vuedraggable": {
"version": "2.23.1",
"resolved": "https://registry.npmjs.org/@types/vuedraggable/-/vuedraggable-2.23.1.tgz",
"integrity": "sha512-icDSUwIc1xqtMBxYlaO76ywpzxBumcGV3gC7WVtRl37A+TNuNFg3bMwPpi3M5KZWrIYChBmM9uwMNB7C8hvWWQ==",
"dev": true,
"requires": {
"vue": ">=2.0.0"
}
},
"@types/vuelidate": {
"version": "0.7.10",
"resolved": "https://registry.npmjs.org/@types/vuelidate/-/vuelidate-0.7.10.tgz",
@ -15505,6 +15514,11 @@
"is-plain-obj": "^1.0.0"
}
},
"sortablejs": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.10.2.tgz",
"integrity": "sha512-YkPGufevysvfwn5rfdlGyrGjt7/CRHwvRPogD/lC+TnvcN29jDpCifKP+rBqf+LRldfXSTh+0CGLcSg0VIxq3A=="
},
"source-list-map": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz",
@ -16945,6 +16959,14 @@
"resolved": "https://registry.npmjs.org/vue-typed-mixins/-/vue-typed-mixins-0.2.0.tgz",
"integrity": "sha512-0OxuinandPWv3nm5k/reYkuKtX3jjPZ40Sy9roJz0ih8PUzmI7zSRiXFEJ62LsyRegw9Tqy+qMkajk7ipKP8Vg=="
},
"vuedraggable": {
"version": "2.23.2",
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-2.23.2.tgz",
"integrity": "sha512-PgHCjUpxEAEZJq36ys49HfQmXglattf/7ofOzUrW2/rRdG7tu6fK84ir14t1jYv4kdXewTEa2ieKEAhhEMdwkQ==",
"requires": {
"sortablejs": "^1.10.1"
}
},
"vuelidate": {
"version": "0.7.5",
"resolved": "https://registry.npmjs.org/vuelidate/-/vuelidate-0.7.5.tgz",

View file

@ -21,6 +21,7 @@
"vue-moment": "^4.1.0",
"vue-router": "^3.1.5",
"vue-typed-mixins": "^0.2.0",
"vuedraggable": "^2.23.2",
"vuelidate": "^0.7.5",
"vuetify": "^2.2.14",
"vuex": "^3.1.2",
@ -31,6 +32,7 @@
"@mdi/font": "^4.9.95",
"@types/jest": "^25.1.3",
"@types/lodash": "^4.14.149",
"@types/vuedraggable": "^2.23.1",
"@types/vuelidate": "^0.7.10",
"@vue/cli-plugin-babel": "^4.2.2",
"@vue/cli-plugin-eslint": "^4.2.2",

View file

@ -0,0 +1,64 @@
<template>
<div>
<v-menu offset-y v-if="isAdmin">
<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>
<v-list-item @click="promptDeleteCollection"
class="list-warning">
<v-list-item-title>Delete</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<collection-delete-dialog v-model="modalDeleteCollection"
:collection="collection"
@deleted="afterDelete"
/>
</div>
</template>
<script lang="ts">
import CollectionDeleteDialog from '@/components/CollectionDeleteDialog.vue'
import Vue from 'vue'
export default Vue.extend({
name: 'CollectionActionsMenu',
components: { CollectionDeleteDialog },
data: function () {
return {
modalDeleteCollection: false,
}
},
props: {
collection: {
type: Object,
required: true,
},
},
computed: {
isAdmin (): boolean {
return this.$store.getters.meAdmin
},
},
methods: {
promptDeleteCollection () {
this.modalDeleteCollection = true
},
afterDelete () {
this.$emit('deleted', true)
},
},
})
</script>
<style scoped>
.list-warning:hover {
background: #F44336;
}
.list-warning:hover .v-list-item__title {
color: white;
}
</style>

View file

@ -0,0 +1,174 @@
<template>
<div>
<v-dialog v-model="modal"
max-width="450"
scrollable
>
<v-card>
<v-card-title>Add to collection</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="newCollection"
label="Create new collection"
@keydown.enter="create"
:error-messages="duplicate"
/>
</v-col>
<v-col cols="auto">
<v-btn
color="primary"
@click="create"
:disabled="newCollection.length === 0 || duplicate !== ''"
>Create
</v-btn>
</v-col>
</v-row>
<v-divider/>
<v-row v-if="collections.length !== 0">
<v-col>
<v-list elevation="5">
<div v-for="(c, index) in collections"
: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.seriesIds.length }} series</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-divider v-if="index !== collections.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: 'CollectionAddToDialog',
data: () => {
return {
confirmDelete: false,
snackbar: false,
snackText: '',
modal: false,
collections: [] as CollectionDto[],
selectedCollection: null,
newCollection: '',
}
},
props: {
value: Boolean,
series: {
type: [Object as () => SeriesDto, Array as () => SeriesDto[]],
required: true,
},
},
watch: {
async value (val) {
this.modal = val
if (val) {
this.newCollection = ''
this.collections = await this.$komgaCollections.getCollections()
}
},
modal (val) {
!val && this.dialogClose()
},
},
async mounted () {
},
computed: {
seriesIds (): number[] {
if (Array.isArray(this.series)) return this.series.map(s => s.id)
else return [this.series.id]
},
duplicate (): string {
if (this.newCollection !== '' && this.collections.some(e => e.name === this.newCollection)) {
return 'A collection with this name already exists'
} else return ''
},
},
methods: {
dialogClose () {
this.$emit('input', false)
},
showSnack (message: string) {
this.snackText = message
this.snackbar = true
},
async addTo (collection: CollectionDto) {
const seriesIds = this.$_.uniq(collection.seriesIds.concat(this.seriesIds))
const toUpdate = {
seriesIds: seriesIds,
} as CollectionUpdateDto
try {
await this.$komgaCollections.patchCollection(collection.id, toUpdate)
this.$emit('added', true)
this.dialogClose()
} catch (e) {
this.showSnack(e.message)
}
},
async create () {
const toCreate = {
name: this.newCollection,
ordered: false,
seriesIds: this.seriesIds,
} as CollectionCreationDto
try {
await this.$komgaCollections.postCollection(toCreate)
this.$emit('added', true)
this.dialogClose()
} catch (e) {
this.showSnack(e.message)
}
},
},
})
</script>
<style scoped>
</style>

View file

@ -0,0 +1,114 @@
<template>
<div>
<v-dialog v-model="modal"
max-width="450"
>
<v-card>
<v-card-title>Delete Collection</v-card-title>
<v-card-text>
<v-container fluid>
<v-row>
<v-col>The collection <b>{{ collection.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 collection "{{ collection.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: 'CollectionDeleteDialog',
data: () => {
return {
confirmDelete: false,
snackbar: false,
snackText: '',
modal: false,
}
},
props: {
value: Boolean,
collection: {
type: Object as () => CollectionDto,
required: true,
},
},
watch: {
value (val) {
this.modal = val
},
modal (val) {
!val && this.dialogCancel()
},
},
methods: {
dialogCancel () {
this.$emit('input', false)
this.confirmDelete = false
},
dialogConfirm () {
this.deleteCollection()
this.$emit('input', false)
},
showSnack (message: string) {
this.snackText = message
this.snackbar = true
},
async deleteCollection () {
try {
await this.$komgaCollections.deleteCollection(this.collection.id)
this.$emit('deleted', true)
} catch (e) {
this.showSnack(e.message)
}
},
},
})
</script>
<style scoped>
</style>

View file

@ -0,0 +1,154 @@
<template>
<div>
<v-dialog v-model="modal"
max-width="450"
>
<v-card>
<v-card-title>Edit collection</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-row>
<v-col>
<div class="body-2">By default, series in a collection will be ordered by name. You can enable manual
ordering to define your own order.
</div>
<v-checkbox
v-model="form.ordered"
label="Manual ordering"
hide-details
/>
</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: 'CollectionEditDialog',
data: () => {
return {
UserRoles,
snackbar: false,
snackText: '',
modal: false,
collections: [] as CollectionDto[],
form: {
name: '',
ordered: false,
},
}
},
props: {
value: Boolean,
collection: {
type: Object as () => CollectionDto,
required: true,
},
},
watch: {
value (val) {
this.modal = val
},
modal (val) {
!val && this.dialogCancel()
},
collection: {
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.collection.name && this.collections.some(e => e.name === this.form.name)) {
return 'A collection with this name already exists'
}
return ''
},
},
methods: {
async dialogReset (collection: CollectionDto) {
this.form.name = collection.name
this.form.ordered = collection.ordered
this.collections = await this.$komgaCollections.getCollections()
},
dialogCancel () {
this.$emit('input', false)
this.dialogReset(this.collection)
},
dialogConfirm () {
this.editCollection()
this.$emit('input', false)
this.dialogReset(this.collection)
},
showSnack (message: string) {
this.snackText = message
this.snackbar = true
},
async editCollection () {
try {
const update = {
name: this.form.name,
ordered: this.form.ordered,
} as CollectionUpdateDto
await this.$komgaCollections.patchCollection(this.collection.id, update)
this.$emit('updated', true)
} catch (e) {
this.showSnack(e.message)
}
},
},
})
</script>
<style scoped>
</style>

View file

@ -1,6 +1,8 @@
<template>
<div style="position: relative">
<slot name="prepend"/>
<div style="min-height: 36px">
<slot name="prepend"/>
</div>
<div style="position: absolute; top: 0; right: 0">
<v-btn icon
:disabled="!canScrollLeft"

View file

@ -1,25 +1,65 @@
<template>
<v-item-group multiple v-model="selectedItems">
<v-row justify="start" ref="content" v-resize="onResize" v-if="hasItems">
<v-item
v-for="(item, index) in items"
:key="index"
class="my-3 mx-2"
v-slot:default="{ toggle, active }" :value="$_.get(item, 'id', 0)"
<div v-if="hasItems"
ref="content"
v-resize="onResize"
>
<draggable v-model="localItems"
class="d-flex flex-wrap"
v-bind="dragOptions"
>
<slot name="item"
v-bind:data="{ toggle, active, item, index, itemWidth, preselect: shouldPreselect(), editItem }">
<item-card
:item="item"
:width="itemWidth"
:selected="active"
:preselect="shouldPreselect()"
:onEdit="editItem"
:onSelected="toggle"
></item-card>
</slot>
</v-item>
</v-row>
<transition-group type="transition" :name="!draggable ? 'flip-list' : null"
class="d-flex flex-wrap"
>
<v-item
v-for="item in localItems"
:key="item.id"
class="my-3 mx-2"
v-slot:default="{ toggle, active }" :value="$_.get(item, 'id', 0)"
>
<slot name="item">
<div style="position: relative"
:class="draggable ? 'draggable-item' : undefined"
>
<item-card
:item="item"
:width="itemWidth"
:selected="active"
:no-link="draggable || deletable"
:preselect="shouldPreselect"
:onEdit="(draggable || deletable) ? undefined : editFunction"
:onSelected="(draggable || deletable) ? undefined : selectable ? toggle: undefined"
></item-card>
<v-slide-y-reverse-transition>
<v-icon v-if="draggable"
style="position: absolute; bottom: 0; left: 50%; margin-left: -12px;"
>
mdi-drag-horizontal
</v-icon>
</v-slide-y-reverse-transition>
<!-- FAB delete (center) -->
<v-fab-transition>
<v-btn
v-if="deletable"
fab
small
color="accent"
class="fab-delete"
@click="deleteItem(item)"
style="position: absolute; bottom: 10px; right: 10px;"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</v-fab-transition>
</div>
</slot>
</v-item>
</transition-group>
</draggable>
</div>
<v-row v-else justify="center">
<slot name="empty"></slot>
</v-row>
@ -30,18 +70,23 @@
import ItemCard from '@/components/ItemCard.vue'
import { computeCardWidth } from '@/functions/grid-utilities'
import Vue from 'vue'
import draggable from 'vuedraggable'
export default Vue.extend({
name: 'ItemBrowser',
components: { ItemCard },
components: { ItemCard, draggable },
props: {
items: {
type: Array,
required: true,
},
selectable: {
type: Boolean,
default: true,
},
selected: {
type: Array,
required: true,
default: () => [],
},
editFunction: {
type: Function,
@ -49,10 +94,19 @@ export default Vue.extend({
resizeFunction: {
type: Function,
},
draggable: {
type: Boolean,
default: false,
},
deletable: {
type: Boolean,
default: false,
},
},
data: () => {
return {
selectedItems: [],
localItems: [],
width: 150,
}
},
@ -69,6 +123,18 @@ export default Vue.extend({
},
immediate: true,
},
items: {
handler () {
this.localItems = this.items as []
},
immediate: true,
},
localItems: {
handler () {
this.$emit('update:items', this.localItems)
},
immediate: true,
},
},
computed: {
hasItems (): boolean {
@ -77,25 +143,46 @@ export default Vue.extend({
itemWidth (): number {
return this.width
},
itemHeight (): number {
return this.width / 0.7071 + 116
},
},
methods: {
shouldPreselect (): boolean {
return this.selectedItems.length > 0
},
editItem (item: any) {
this.editFunction(item)
dragOptions (): any {
return {
animation: 200,
group: 'item-cards',
disabled: !this.draggable,
ghostClass: 'ghost',
}
},
},
methods: {
onResize () {
const content = this.$refs.content as HTMLElement
this.width = computeCardWidth(content.clientWidth, this.$vuetify.breakpoint.name)
},
deleteItem (item: any) {
const index = this.localItems.findIndex((e: any) => e.id === item.id)
this.localItems.splice(index, 1)
},
},
})
</script>
<style scoped>
.ghost * {
opacity: 0.5;
background: #c8ebfb;
}
.draggable-item * {
cursor: move;
}
.flip-list-move {
transition: transform 0.4s;
}
.fab-delete * {
cursor: pointer;
}
</style>

View file

@ -92,14 +92,14 @@
<script lang="ts">
import { getReadProgress, getReadProgressPercentage } from '@/functions/book-progress'
import { ReadStatus } from '@/types/enum-books'
import { createItem, Item } from '@/types/items'
import { createItem, Item, ItemTypes } from '@/types/items'
import Vue from 'vue'
export default Vue.extend({
name: 'ItemCard',
props: {
item: {
type: Object as () => BookDto | SeriesDto,
type: Object as () => BookDto | SeriesDto | CollectionDto,
required: true,
},
// hide the bottom part of the card
@ -145,12 +145,12 @@ export default Vue.extend({
},
computed: {
canReadPages (): boolean {
return this.$store.getters.mePageStreaming
return this.$store.getters.mePageStreaming && this.computedItem.type() === ItemTypes.BOOK
},
overlay (): boolean {
return this.onEdit !== undefined || this.onSelected !== undefined || this.bookReady || this.canReadPages
},
computedItem (): Item<BookDto | SeriesDto> {
computedItem (): Item<BookDto | SeriesDto | CollectionDto> {
return createItem(this.item)
},
disableHover (): boolean {
@ -169,24 +169,24 @@ export default Vue.extend({
return this.computedItem.body()
},
isInProgress (): boolean {
if ('seriesId' in this.item) return getReadProgress(this.item) === ReadStatus.IN_PROGRESS
if (this.computedItem.type() === ItemTypes.BOOK) return getReadProgress(this.item as BookDto) === ReadStatus.IN_PROGRESS
return false
},
isUnread (): boolean {
if ('seriesId' in this.item) return getReadProgress(this.item) === ReadStatus.UNREAD
if (this.computedItem.type() === ItemTypes.BOOK) return getReadProgress(this.item as BookDto) === ReadStatus.UNREAD
return false
},
unreadCount (): number | undefined {
if (!('seriesId' in this.item)) return this.item.booksUnreadCount
if (this.computedItem.type() === ItemTypes.SERIES) return (this.item as SeriesDto).booksUnreadCount
return undefined
},
readProgressPercentage (): number {
if ('seriesId' in this.item) return getReadProgressPercentage(this.item)
if (this.computedItem.type() === ItemTypes.BOOK) return getReadProgressPercentage(this.item as BookDto)
return 0
},
bookReady (): boolean {
if ('seriesId' in this.item) {
return this.item.media.status === 'READY'
if (this.computedItem.type() === ItemTypes.BOOK) {
return (this.item as BookDto).media.status === 'READY'
}
return false
},

View file

@ -34,7 +34,7 @@ import LibraryDeleteDialog from '@/components/LibraryDeleteDialog.vue'
import Vue from 'vue'
export default Vue.extend({
name: 'library-actions-menu',
name: 'LibraryActionsMenu',
components: { LibraryDeleteDialog },
data: function () {
return {

View file

@ -0,0 +1,34 @@
<template>
<v-bottom-navigation grow color="primary"
:fixed="$vuetify.breakpoint.name === 'xs'"
>
<v-btn :to="{name: 'browse-libraries', params: {libraryId: libraryId}}" exact>
<span>Browse</span>
<v-icon>mdi-bookshelf</v-icon>
</v-btn>
<v-btn :to="{name: 'browse-collections', params: {libraryId: libraryId}}">
<span>Collections</span>
<v-icon>mdi-layers-triple</v-icon>
</v-btn>
</v-bottom-navigation>
</template>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
name: 'LibraryNavigation',
props: {
libraryId: {
type: Number,
required: true,
},
},
})
</script>
<style scoped>
</style>

View file

@ -0,0 +1,71 @@
<template>
<div>
<v-menu offset-y>
<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>
<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="addToCollection" v-if="isAdmin">
<v-list-item-title>Add to collection</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>
<v-list-item @click="markUnread" v-if="!isUnread">
<v-list-item-title>Mark as unread</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: 'SeriesActionsMenu',
props: {
series: {
type: Object,
required: true,
},
},
computed: {
isAdmin (): boolean {
return this.$store.getters.meAdmin
},
isRead (): boolean {
return this.series.booksReadCount === this.series.booksCount
},
isUnread (): boolean {
return this.series.booksUnreadCount === this.series.booksCount
},
},
methods: {
analyze () {
this.$komgaSeries.analyzeSeries(this.series)
},
refreshMetadata () {
this.$komgaSeries.refreshMetadata(this.series)
},
addToCollection () {
this.$emit('add-to-collection', true)
},
async markRead () {
await this.$komgaSeries.markAsRead(this.series.id)
this.$emit('mark-read', true)
},
async markUnread () {
await this.$komgaSeries.markAsUnread(this.series.id)
this.$emit('mark-unread', true)
},
},
})
</script>

View file

@ -35,3 +35,7 @@ export function bookPageThumbnailUrl (bookId: number, page: number): string {
export function seriesThumbnailUrl (seriesId: number): string {
return `${urls.originNoSlash}/api/v1/series/${seriesId}/thumbnail`
}
export function collectionThumbnailUrl (collectionId: number): string {
return `${urls.originNoSlash}/api/v1/collections/${collectionId}/thumbnail`
}

View file

@ -1,4 +1,3 @@
import './public-path'
import _, { LoDashStatic } from 'lodash'
import Vue from 'vue'
import VueCookies from 'vue-cookies'
@ -10,12 +9,14 @@ import App from './App.vue'
import actuator from './plugins/actuator.plugin'
import httpPlugin from './plugins/http.plugin'
import komgaBooks from './plugins/komga-books.plugin'
import komgaReferential from './plugins/komga-referential.plugin'
import komgaCollections from './plugins/komga-collections.plugin'
import komgaFileSystem from './plugins/komga-filesystem.plugin'
import komgaLibraries from './plugins/komga-libraries.plugin'
import komgaReferential from './plugins/komga-referential.plugin'
import komgaSeries from './plugins/komga-series.plugin'
import komgaUsers from './plugins/komga-users.plugin'
import vuetify from './plugins/vuetify'
import './public-path'
import router from './router'
import store from './store'
@ -27,6 +28,7 @@ Vue.use(require('vue-moment'))
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(komgaBooks, { http: Vue.prototype.$http })
Vue.use(komgaReferential, { http: Vue.prototype.$http })
Vue.use(komgaUsers, { store: store, http: Vue.prototype.$http })

View file

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

View file

@ -84,6 +84,19 @@ const router = new Router({
component: () => import(/* webpackChunkName: "browse-libraries" */ './views/BrowseLibraries.vue'),
props: (route) => ({ libraryId: Number(route.params.libraryId) }),
},
{
path: '/libraries/:libraryId/collections',
name: 'browse-collections',
beforeEnter: noLibraryGuard,
component: () => import(/* webpackChunkName: "browse-collections" */ './views/BrowseCollections.vue'),
props: (route) => ({ libraryId: Number(route.params.libraryId) }),
},
{
path: '/collections/:collectionId',
name: 'browse-collection',
component: () => import(/* webpackChunkName: "browse-collection" */ './views/BrowseCollection.vue'),
props: (route) => ({ collectionId: Number(route.params.collectionId) }),
},
{
path: '/series/:seriesId',
name: 'browse-series',

View file

@ -0,0 +1,92 @@
import { AxiosInstance } from 'axios'
const qs = require('qs')
const API_COLLECTIONS = '/api/v1/collections'
export default class KomgaCollectionsService {
private http: AxiosInstance
constructor (http: AxiosInstance) {
this.http = http
}
async getCollections (libraryIds?: number[]): Promise<CollectionDto[]> {
try {
const params = {} as any
if (libraryIds) {
params.library_id = libraryIds
}
return (await this.http.get(API_COLLECTIONS, {
params: params,
paramsSerializer: params => qs.stringify(params, { indices: false }),
})).data
} catch (e) {
let msg = 'An error occurred while trying to retrieve collections'
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async getOneCollection (collectionId: number): Promise<CollectionDto> {
try {
return (await this.http.get(`${API_COLLECTIONS}/${collectionId}`)).data
} catch (e) {
let msg = 'An error occurred while trying to retrieve collection'
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async postCollection (collection: CollectionCreationDto): Promise<CollectionDto> {
try {
return (await this.http.post(API_COLLECTIONS, collection)).data
} catch (e) {
let msg = `An error occurred while trying to add collection '${collection.name}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async patchCollection (collectionId: number, collection: CollectionUpdateDto) {
try {
await this.http.patch(`${API_COLLECTIONS}/${collectionId}`, collection)
} catch (e) {
let msg = `An error occurred while trying to update collection '${collectionId}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async deleteCollection (collectionId: number) {
try {
await this.http.delete(`${API_COLLECTIONS}/${collectionId}`)
} catch (e) {
let msg = `An error occurred while trying to delete collection '${collectionId}'`
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async getSeries (collectionId: number, pageRequest?: PageRequest): Promise<SeriesDto[]> {
try {
return (await this.http.get(`${API_COLLECTIONS}/${collectionId}/series`)).data
} catch (e) {
let msg = 'An error occurred while trying to retrieve series'
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
}

View file

@ -100,6 +100,18 @@ export default class KomgaSeriesService {
}
}
async getCollections (seriesId: number): Promise<CollectionDto[]> {
try {
return (await this.http.get(`${API_SERIES}/${seriesId}/collections`)).data
} catch (e) {
let msg = 'An error occurred while trying to retrieve collections'
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async analyzeSeries (series: SeriesDto) {
try {
await this.http.post(`${API_SERIES}/${series.id}/analyze`)

View file

@ -1,12 +1,18 @@
import { bookThumbnailUrl, seriesThumbnailUrl } from '@/functions/urls'
import { bookThumbnailUrl, collectionThumbnailUrl, seriesThumbnailUrl } from '@/functions/urls'
import { VueRouter } from 'vue-router/types/router'
function plural (count: number, singular: string, plural: string) {
return `${count} ${count === 1 ? singular : plural}`
}
export function createItem (item: BookDto | SeriesDto): Item<BookDto | SeriesDto> {
if ('seriesId' in item) {
export enum ItemTypes {
BOOK, SERIES, COLLECTION
}
export function createItem (item: BookDto | SeriesDto | CollectionDto): Item<BookDto | SeriesDto | CollectionDto> {
if ('seriesIds' in item) {
return new CollectionItem(item)
} else if ('seriesId' in item) {
return new BookItem(item)
} else if ('libraryId' in item) {
return new SeriesItem(item)
@ -16,7 +22,7 @@ export function createItem (item: BookDto | SeriesDto): Item<BookDto | SeriesDto
}
export abstract class Item<T> {
item: T;
item: T
constructor (item: T) {
this.item = item
@ -30,13 +36,15 @@ export abstract class Item<T> {
}
}
abstract thumbnailUrl(): string
abstract type (): ItemTypes
abstract title(): string
abstract thumbnailUrl (): string
abstract body(): string
abstract title (): string
abstract goto(router: VueRouter): void
abstract body (): string
abstract goto (router: VueRouter): void
}
export class BookItem extends Item<BookDto> {
@ -44,6 +52,10 @@ export class BookItem extends Item<BookDto> {
return bookThumbnailUrl(this.item.id)
}
type (): ItemTypes {
return ItemTypes.BOOK
}
title (): string {
const m = this.item.metadata
return `#${m.number} - ${m.title}`
@ -51,10 +63,7 @@ export class BookItem extends Item<BookDto> {
body (): string {
let c = this.item.media.pagesCount
return `
<div>${this.item.size}</div>
<div>${plural(c, 'Page', 'Pages')}</div>
`
return `<span>${plural(c, 'Page', 'Pages')}</span>`
}
goto (router: VueRouter): void {
@ -67,6 +76,10 @@ export class SeriesItem extends Item<SeriesDto> {
return seriesThumbnailUrl(this.item.id)
}
type (): ItemTypes {
return ItemTypes.SERIES
}
title (): string {
return this.item.metadata.title
}
@ -80,3 +93,26 @@ export class SeriesItem extends Item<SeriesDto> {
router.push({ name: 'browse-series', params: { seriesId: this.item.id.toString() } })
}
}
export class CollectionItem extends Item<CollectionDto> {
thumbnailUrl (): string {
return collectionThumbnailUrl(this.item.id)
}
type (): ItemTypes {
return ItemTypes.COLLECTION
}
title (): string {
return this.item.name
}
body (): string {
let c = this.item.seriesIds.length
return `<span>${c} Series</span>`
}
goto (router: VueRouter): void {
router.push({ name: 'browse-collection', params: { collectionId: this.item.id.toString() } })
}
}

View file

@ -0,0 +1,21 @@
interface CollectionDto {
id: number,
name: string,
ordered: boolean,
filtered: boolean,
seriesIds: number[],
createdDate: string,
lastModifiedDate: string
}
interface CollectionCreationDto {
name: string,
ordered: boolean,
seriesIds: number[]
}
interface CollectionUpdateDto {
name?: string,
ordered?: boolean,
seriesIds?: number[]
}

View file

@ -0,0 +1,277 @@
<template>
<div v-if="collection">
<toolbar-sticky v-if="!editElements && selected.length === 0">
<collection-actions-menu v-if="collection"
:collection="collection"
@deleted="afterDelete"
/>
<v-toolbar-title v-if="collection">
<span>{{ collection.name }}</span>
<badge class="mx-4">{{ collection.seriesIds.length }}</badge>
<span v-if="collection.ordered"
class="font-italic overline"
>(manual ordering)</span>
</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="editCollection" v-if="isAdmin">
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-icon v-on="on">mdi-pencil</v-icon>
</template>
<span>Edit collection</span>
</v-tooltip>
</v-btn>
</toolbar-sticky>
<!-- Edit elements sticky bar -->
<v-scroll-y-transition hide-on-leave>
<toolbar-sticky v-if="editElements" :elevation="5" color="white">
<v-btn icon @click="cancelEditElements">
<v-icon>mdi-close</v-icon>
</v-btn>
<v-btn icon color="primary" @click="doEditElements" :disabled="series.length === 0">
<v-icon>mdi-check</v-icon>
</v-btn>
</toolbar-sticky>
</v-scroll-y-transition>
<!-- Selection sticky bar -->
<v-scroll-y-transition hide-on-leave>
<toolbar-sticky v-if="selected.length > 0" :elevation="5" color="white">
<v-btn icon @click="selected=[]">
<v-icon>mdi-close</v-icon>
</v-btn>
<v-toolbar-title>
<span>{{ selected.length }} selected</span>
</v-toolbar-title>
<v-spacer/>
<v-btn icon @click="markSelectedRead()">
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-icon v-on="on">mdi-bookmark-check</v-icon>
</template>
<span>Mark as Read</span>
</v-tooltip>
</v-btn>
<v-btn icon @click="markSelectedUnread()">
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-icon v-on="on">mdi-bookmark-remove</v-icon>
</template>
<span>Mark as Unread</span>
</v-tooltip>
</v-btn>
<v-btn icon @click="addToCollection()">
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-icon v-on="on">mdi-playlist-plus</v-icon>
</template>
<span>Add to collection</span>
</v-tooltip>
</v-btn>
<v-btn icon @click="dialogEdit = true" v-if="isAdmin">
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-icon v-on="on">mdi-pencil</v-icon>
</template>
<span>Edit metadata</span>
</v-tooltip>
</v-btn>
</toolbar-sticky>
</v-scroll-y-transition>
<edit-series-dialog v-model="dialogEdit"
:series.sync="selectedSeries"
/>
<edit-series-dialog v-model="dialogEditSingle"
:series.sync="editSeriesSingle"
/>
<collection-add-to-dialog v-model="dialogAddToCollection"
:series="selectedSeries"
/>
<collection-edit-dialog v-model="dialogEditCollection"
:collection="collection"
@updated="loadCollection(collectionId)"
/>
<v-container fluid class="px-6">
<item-browser
:items.sync="series"
:selected.sync="selected"
:edit-function="singleEdit"
:draggable="editElements && collection.ordered"
:deletable="editElements"
/>
</v-container>
</div>
</template>
<script lang="ts">
import Badge from '@/components/Badge.vue'
import CollectionActionsMenu from '@/components/CollectionActionsMenu.vue'
import CollectionAddToDialog from '@/components/CollectionAddToDialog.vue'
import CollectionEditDialog from '@/components/CollectionEditDialog.vue'
import EditSeriesDialog from '@/components/EditSeriesDialog.vue'
import ItemBrowser from '@/components/ItemBrowser.vue'
import ToolbarSticky from '@/components/ToolbarSticky.vue'
import Vue from 'vue'
export default Vue.extend({
name: 'BrowseCollection',
components: {
ToolbarSticky,
ItemBrowser,
EditSeriesDialog,
CollectionAddToDialog,
CollectionEditDialog,
CollectionActionsMenu,
Badge,
},
data: () => {
return {
collection: undefined as CollectionDto | undefined,
series: [] as SeriesDto[],
seriesCopy: [] as SeriesDto[],
selectedSeries: [] as SeriesDto[],
editSeriesSingle: {} as SeriesDto,
selected: [],
dialogEdit: false,
dialogEditSingle: false,
dialogAddToCollection: false,
dialogEditCollection: false,
editElements: false,
}
},
props: {
collectionId: {
type: Number,
required: true,
},
},
watch: {
selected (val: number[]) {
this.selectedSeries = val.map(id => this.series.find(s => s.id === id))
.filter(x => x !== undefined) as SeriesDto[]
},
selectedSeries (val: SeriesDto[]) {
val.forEach(s => {
let index = this.series.findIndex(x => x.id === s.id)
if (index !== -1) {
this.series.splice(index, 1, s)
}
index = this.seriesCopy.findIndex(x => x.id === s.id)
if (index !== -1) {
this.seriesCopy.splice(index, 1, s)
}
})
},
editSeriesSingle (val: SeriesDto) {
let index = this.series.findIndex(x => x.id === val.id)
if (index !== -1) {
this.series.splice(index, 1, val)
}
index = this.seriesCopy.findIndex(x => x.id === val.id)
if (index !== -1) {
this.seriesCopy.splice(index, 1, val)
}
},
},
mounted () {
this.loadCollection(this.collectionId)
},
beforeRouteUpdate (to, from, next) {
if (to.params.collectionId !== from.params.collectionId) {
// reset
this.series = []
this.editElements = false
this.loadCollection(Number(to.params.collectionId))
}
next()
},
computed: {
isAdmin (): boolean {
return this.$store.getters.meAdmin
},
},
methods: {
async loadCollection (collectionId: number) {
this.collection = await this.$komgaCollections.getOneCollection(collectionId)
this.series = await this.$komgaCollections.getSeries(collectionId)
this.seriesCopy = [...this.series]
},
singleEdit (series: SeriesDto) {
this.editSeriesSingle = series
this.dialogEditSingle = true
},
async markSelectedRead () {
await Promise.all(this.selectedSeries.map(s =>
this.$komgaSeries.markAsRead(s.id),
))
this.selectedSeries = await Promise.all(this.selectedSeries.map(s =>
this.$komgaSeries.getOneSeries(s.id),
))
},
async markSelectedUnread () {
await Promise.all(this.selectedSeries.map(s =>
this.$komgaSeries.markAsUnread(s.id),
))
this.selectedSeries = await Promise.all(this.selectedSeries.map(s =>
this.$komgaSeries.getOneSeries(s.id),
))
},
addToCollection () {
this.dialogAddToCollection = true
},
startEditElements () {
this.editElements = true
},
cancelEditElements () {
this.editElements = false
this.series = [...this.seriesCopy]
},
doEditElements () {
this.editElements = false
const update = {
seriesIds: this.series.map(x => x.id),
} as CollectionUpdateDto
this.$komgaCollections.patchCollection(this.collectionId, update)
this.loadCollection(this.collectionId)
},
editCollection () {
this.dialogEditCollection = true
},
afterDelete () {
this.$router.push({ name: 'browse-collections', params: { libraryId: '0' } })
},
},
})
</script>

View file

@ -0,0 +1,92 @@
<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">{{ collections.length }}</badge>
</v-toolbar-title>
</toolbar-sticky>
<library-navigation :libraryId="libraryId"/>
<v-container fluid class="px-6">
<item-browser
:items="collections"
:selectable="false"
class="px-4"
/>
</v-container>
</div>
</template>
<script lang="ts">
import Badge from '@/components/Badge.vue'
import ItemBrowser from '@/components/ItemBrowser.vue'
import LibraryActionsMenu from '@/components/LibraryActionsMenu.vue'
import LibraryNavigation from '@/components/LibraryNavigation.vue'
import ToolbarSticky from '@/components/ToolbarSticky.vue'
import Vue from 'vue'
const cookiePageSize = 'pagesize'
export default Vue.extend({
name: 'BrowseCollections',
components: {
LibraryActionsMenu,
ToolbarSticky,
LibraryNavigation,
ItemBrowser,
Badge,
},
data: () => {
return {
library: undefined as LibraryDto | undefined,
collections: [] as CollectionDto[],
}
},
props: {
libraryId: {
type: Number,
default: 0,
},
},
mounted () {
this.loadLibrary(this.libraryId)
},
beforeRouteUpdate (to, from, next) {
if (to.params.libraryId !== from.params.libraryId) {
// reset
this.collections = []
this.loadLibrary(Number(to.params.libraryId))
}
next()
},
computed: {
isAdmin (): boolean {
return this.$store.getters.meAdmin
},
},
methods: {
async loadLibrary (libraryId: number) {
this.library = this.getLibraryLazy(libraryId)
const lib = libraryId !== 0 ? [libraryId] : undefined
this.collections = await this.$komgaCollections.getCollections(lib)
},
getLibraryLazy (libraryId: any): LibraryDto | undefined {
if (libraryId !== 0) {
return this.$store.getters.getLibraryById(libraryId)
} else {
return undefined
}
},
},
})
</script>

View file

@ -1,5 +1,5 @@
<template>
<div>
<div :style="$vuetify.breakpoint.name === 'xs' ? 'margin-bottom: 56px' : undefined">
<toolbar-sticky v-if="selected.length === 0">
<!-- Action menu -->
<library-actions-menu v-if="library"
@ -57,12 +57,30 @@
</v-tooltip>
</v-btn>
<v-btn icon @click="addToCollection()">
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-icon v-on="on">mdi-playlist-plus</v-icon>
</template>
<span>Add to collection</span>
</v-tooltip>
</v-btn>
<v-btn icon @click="dialogEdit = true" v-if="isAdmin">
<v-icon>mdi-pencil</v-icon>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-icon v-on="on">mdi-pencil</v-icon>
</template>
<span>Edit metadata</span>
</v-tooltip>
</v-btn>
</toolbar-sticky>
</v-scroll-y-transition>
<library-navigation v-if="collections.length > 0"
:libraryId="libraryId"
/>
<edit-series-dialog v-model="dialogEdit"
:series.sync="selectedSeries"
/>
@ -71,6 +89,10 @@
:series.sync="editSeriesSingle"
/>
<collection-add-to-dialog v-model="dialogAddToCollection"
:series="selectedSeries"
/>
<v-container fluid class="px-6">
<empty-state
v-if="totalPages === 0"
@ -102,11 +124,13 @@
<script lang="ts">
import Badge from '@/components/Badge.vue'
import CollectionAddToDialog from '@/components/CollectionAddToDialog.vue'
import EditSeriesDialog from '@/components/EditSeriesDialog.vue'
import EmptyState from '@/components/EmptyState.vue'
import FilterMenuButton from '@/components/FilterMenuButton.vue'
import ItemBrowser from '@/components/ItemBrowser.vue'
import LibraryActionsMenu from '@/components/LibraryActionsMenu.vue'
import LibraryNavigation from '@/components/LibraryNavigation.vue'
import PageSizeSelect from '@/components/PageSizeSelect.vue'
import SortMenuButton from '@/components/SortMenuButton.vue'
import ToolbarSticky from '@/components/ToolbarSticky.vue'
@ -128,7 +152,9 @@ export default Vue.extend({
Badge,
EditSeriesDialog,
ItemBrowser,
CollectionAddToDialog,
PageSizeSelect,
LibraryNavigation,
},
data: () => {
return {
@ -156,6 +182,8 @@ export default Vue.extend({
selected: [],
dialogEdit: false,
dialogEditSingle: false,
dialogAddToCollection: false,
collections: [] as CollectionDto[],
}
},
props: {
@ -212,6 +240,7 @@ export default Vue.extend({
this.totalPages = 1
this.totalElements = null
this.series = []
this.collections = []
this.loadLibrary(Number(to.params.libraryId))
@ -271,6 +300,10 @@ export default Vue.extend({
},
async loadLibrary (libraryId: number) {
this.library = this.getLibraryLazy(libraryId)
const lib = libraryId !== 0 ? [libraryId] : undefined
this.collections = await this.$komgaCollections.getCollections(lib)
await this.loadPage(libraryId, this.page, this.sortActive)
},
parseQuerySortOrDefault (querySort: any): SortActive {
@ -337,6 +370,9 @@ export default Vue.extend({
this.$komgaSeries.getOneSeries(s.id),
))
},
addToCollection () {
this.dialogAddToCollection = true
},
},
})
</script>

View file

@ -9,30 +9,12 @@
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<!-- Action menu -->
<v-menu offset-y>
<template v-slot:activator="{ on }">
<v-btn icon v-on="on">
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list>
<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="markRead()">
<v-list-item-title>Mark as read</v-list-item-title>
</v-list-item>
<v-list-item
@click="markUnread()">
<v-list-item-title>Mark as unread</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<series-actions-menu v-if="series"
:series.sync="series"
@add-to-collection="addToCollection"
@mark-read="markRead"
@mark-unread="markUnread"
/>
<v-toolbar-title>
<span v-if="$_.get(series, 'metadata.title')">{{ series.metadata.title }}</span>
@ -128,6 +110,35 @@
series.metadata.status.toLowerCase() }}
</v-col>
</v-row>
<v-row>
<v-col>
<v-expansion-panels v-model="collectionPanel">
<v-expansion-panel v-for="(c, i) in collections"
:key="i"
>
<v-expansion-panel-header>{{ c.name }} collection</v-expansion-panel-header>
<v-expansion-panel-content>
<horizontal-scroller>
<template v-slot:prepend>
<router-link class="overline"
:to="{name: 'browse-collection', params: {collectionId: c.id}}"
>Manage collection
</router-link>
</template>
<template v-slot:content>
<div v-for="(s, i) in collectionsContent[c.id]"
:key="i"
>
<item-card class="ma-2 card" :item="s"/>
</div>
</template>
</horizontal-scroller>
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
</v-col>
</v-row>
</v-col>
</v-row>
@ -155,18 +166,25 @@
</v-container>
<edit-series-dialog v-model="dialogEdit" :series.sync="series"/>
<collection-add-to-dialog v-model="dialogAddToCollection"
:series="series"
/>
</div>
</template>
<script lang="ts">
import Badge from '@/components/Badge.vue'
import CollectionAddToDialog from '@/components/CollectionAddToDialog.vue'
import EditBooksDialog from '@/components/EditBooksDialog.vue'
import EditSeriesDialog from '@/components/EditSeriesDialog.vue'
import EmptyState from '@/components/EmptyState.vue'
import FilterMenuButton from '@/components/FilterMenuButton.vue'
import HorizontalScroller from '@/components/HorizontalScroller.vue'
import ItemBrowser from '@/components/ItemBrowser.vue'
import ItemCard from '@/components/ItemCard.vue'
import PageSizeSelect from '@/components/PageSizeSelect.vue'
import SeriesActionsMenu from '@/components/SeriesActionsMenu.vue'
import SortMenuButton from '@/components/SortMenuButton.vue'
import ToolbarSticky from '@/components/ToolbarSticky.vue'
import { parseQueryFilter, parseQuerySort } from '@/functions/query-params'
@ -187,6 +205,9 @@ export default Vue.extend({
EditBooksDialog,
ItemBrowser,
PageSizeSelect,
SeriesActionsMenu,
CollectionAddToDialog,
HorizontalScroller,
ItemCard,
EmptyState,
},
@ -216,6 +237,10 @@ export default Vue.extend({
selected: [],
dialogEditBooks: false,
dialogEditBookSingle: false,
dialogAddToCollection: false,
collections: [] as CollectionDto[],
collectionsContent: [] as any[][],
collectionPanel: -1,
}
},
computed: {
@ -296,6 +321,8 @@ export default Vue.extend({
this.totalPages = 1
this.totalElements = null
this.books = []
this.collections = []
this.collectionPanel = -1
this.loadSeries(Number(to.params.seriesId))
@ -337,6 +364,10 @@ export default Vue.extend({
},
async loadSeries (seriesId: number) {
this.series = await this.$komgaSeries.getOneSeries(seriesId)
this.collections = await this.$komgaSeries.getCollections(seriesId)
for (const c of this.collections) {
this.collectionsContent[c.id] = await this.$komgaCollections.getSeries(c.id)
}
await this.loadPage(seriesId, this.page, this.sortActive)
},
parseQuerySortOrDefault (querySort: any): SortActive {
@ -401,12 +432,13 @@ export default Vue.extend({
))
this.loadSeries(this.seriesId)
},
addToCollection () {
this.dialogAddToCollection = true
},
async markRead () {
await this.$komgaSeries.markAsRead(this.seriesId)
this.loadSeries(this.seriesId)
},
async markUnread () {
await this.$komgaSeries.markAsUnread(this.seriesId)
this.loadSeries(this.seriesId)
},
},