mirror of
https://github.com/gotson/komga.git
synced 2026-05-08 12:35:30 +02:00
parent
c2f940336a
commit
2f8255a05f
24 changed files with 1440 additions and 82 deletions
22
komga-webui/package-lock.json
generated
22
komga-webui/package-lock.json
generated
|
|
@ -2898,6 +2898,15 @@
|
||||||
"integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==",
|
"integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==",
|
||||||
"dev": true
|
"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": {
|
"@types/vuelidate": {
|
||||||
"version": "0.7.10",
|
"version": "0.7.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/vuelidate/-/vuelidate-0.7.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/vuelidate/-/vuelidate-0.7.10.tgz",
|
||||||
|
|
@ -15505,6 +15514,11 @@
|
||||||
"is-plain-obj": "^1.0.0"
|
"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": {
|
"source-list-map": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/vue-typed-mixins/-/vue-typed-mixins-0.2.0.tgz",
|
||||||
"integrity": "sha512-0OxuinandPWv3nm5k/reYkuKtX3jjPZ40Sy9roJz0ih8PUzmI7zSRiXFEJ62LsyRegw9Tqy+qMkajk7ipKP8Vg=="
|
"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": {
|
"vuelidate": {
|
||||||
"version": "0.7.5",
|
"version": "0.7.5",
|
||||||
"resolved": "https://registry.npmjs.org/vuelidate/-/vuelidate-0.7.5.tgz",
|
"resolved": "https://registry.npmjs.org/vuelidate/-/vuelidate-0.7.5.tgz",
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
"vue-moment": "^4.1.0",
|
"vue-moment": "^4.1.0",
|
||||||
"vue-router": "^3.1.5",
|
"vue-router": "^3.1.5",
|
||||||
"vue-typed-mixins": "^0.2.0",
|
"vue-typed-mixins": "^0.2.0",
|
||||||
|
"vuedraggable": "^2.23.2",
|
||||||
"vuelidate": "^0.7.5",
|
"vuelidate": "^0.7.5",
|
||||||
"vuetify": "^2.2.14",
|
"vuetify": "^2.2.14",
|
||||||
"vuex": "^3.1.2",
|
"vuex": "^3.1.2",
|
||||||
|
|
@ -31,6 +32,7 @@
|
||||||
"@mdi/font": "^4.9.95",
|
"@mdi/font": "^4.9.95",
|
||||||
"@types/jest": "^25.1.3",
|
"@types/jest": "^25.1.3",
|
||||||
"@types/lodash": "^4.14.149",
|
"@types/lodash": "^4.14.149",
|
||||||
|
"@types/vuedraggable": "^2.23.1",
|
||||||
"@types/vuelidate": "^0.7.10",
|
"@types/vuelidate": "^0.7.10",
|
||||||
"@vue/cli-plugin-babel": "^4.2.2",
|
"@vue/cli-plugin-babel": "^4.2.2",
|
||||||
"@vue/cli-plugin-eslint": "^4.2.2",
|
"@vue/cli-plugin-eslint": "^4.2.2",
|
||||||
|
|
|
||||||
64
komga-webui/src/components/CollectionActionsMenu.vue
Normal file
64
komga-webui/src/components/CollectionActionsMenu.vue
Normal 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>
|
||||||
174
komga-webui/src/components/CollectionAddToDialog.vue
Normal file
174
komga-webui/src/components/CollectionAddToDialog.vue
Normal 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>
|
||||||
114
komga-webui/src/components/CollectionDeleteDialog.vue
Normal file
114
komga-webui/src/components/CollectionDeleteDialog.vue
Normal 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>
|
||||||
154
komga-webui/src/components/CollectionEditDialog.vue
Normal file
154
komga-webui/src/components/CollectionEditDialog.vue
Normal 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>
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<div style="position: relative">
|
<div style="position: relative">
|
||||||
|
<div style="min-height: 36px">
|
||||||
<slot name="prepend"/>
|
<slot name="prepend"/>
|
||||||
|
</div>
|
||||||
<div style="position: absolute; top: 0; right: 0">
|
<div style="position: absolute; top: 0; right: 0">
|
||||||
<v-btn icon
|
<v-btn icon
|
||||||
:disabled="!canScrollLeft"
|
:disabled="!canScrollLeft"
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,65 @@
|
||||||
<template>
|
<template>
|
||||||
<v-item-group multiple v-model="selectedItems">
|
<v-item-group multiple v-model="selectedItems">
|
||||||
<v-row justify="start" ref="content" v-resize="onResize" v-if="hasItems">
|
<div v-if="hasItems"
|
||||||
|
ref="content"
|
||||||
|
v-resize="onResize"
|
||||||
|
>
|
||||||
|
<draggable v-model="localItems"
|
||||||
|
class="d-flex flex-wrap"
|
||||||
|
v-bind="dragOptions"
|
||||||
|
>
|
||||||
|
<transition-group type="transition" :name="!draggable ? 'flip-list' : null"
|
||||||
|
class="d-flex flex-wrap"
|
||||||
|
>
|
||||||
<v-item
|
<v-item
|
||||||
v-for="(item, index) in items"
|
v-for="item in localItems"
|
||||||
:key="index"
|
:key="item.id"
|
||||||
class="my-3 mx-2"
|
class="my-3 mx-2"
|
||||||
v-slot:default="{ toggle, active }" :value="$_.get(item, 'id', 0)"
|
v-slot:default="{ toggle, active }" :value="$_.get(item, 'id', 0)"
|
||||||
>
|
>
|
||||||
<slot name="item"
|
<slot name="item">
|
||||||
v-bind:data="{ toggle, active, item, index, itemWidth, preselect: shouldPreselect(), editItem }">
|
<div style="position: relative"
|
||||||
|
:class="draggable ? 'draggable-item' : undefined"
|
||||||
|
>
|
||||||
<item-card
|
<item-card
|
||||||
:item="item"
|
:item="item"
|
||||||
:width="itemWidth"
|
:width="itemWidth"
|
||||||
:selected="active"
|
:selected="active"
|
||||||
:preselect="shouldPreselect()"
|
:no-link="draggable || deletable"
|
||||||
:onEdit="editItem"
|
:preselect="shouldPreselect"
|
||||||
:onSelected="toggle"
|
:onEdit="(draggable || deletable) ? undefined : editFunction"
|
||||||
|
:onSelected="(draggable || deletable) ? undefined : selectable ? toggle: undefined"
|
||||||
></item-card>
|
></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>
|
</slot>
|
||||||
</v-item>
|
</v-item>
|
||||||
</v-row>
|
</transition-group>
|
||||||
|
</draggable>
|
||||||
|
</div>
|
||||||
<v-row v-else justify="center">
|
<v-row v-else justify="center">
|
||||||
<slot name="empty"></slot>
|
<slot name="empty"></slot>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
@ -30,18 +70,23 @@
|
||||||
import ItemCard from '@/components/ItemCard.vue'
|
import ItemCard from '@/components/ItemCard.vue'
|
||||||
import { computeCardWidth } from '@/functions/grid-utilities'
|
import { computeCardWidth } from '@/functions/grid-utilities'
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
|
import draggable from 'vuedraggable'
|
||||||
|
|
||||||
export default Vue.extend({
|
export default Vue.extend({
|
||||||
name: 'ItemBrowser',
|
name: 'ItemBrowser',
|
||||||
components: { ItemCard },
|
components: { ItemCard, draggable },
|
||||||
props: {
|
props: {
|
||||||
items: {
|
items: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
selectable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
selected: {
|
selected: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
default: () => [],
|
||||||
},
|
},
|
||||||
editFunction: {
|
editFunction: {
|
||||||
type: Function,
|
type: Function,
|
||||||
|
|
@ -49,10 +94,19 @@ export default Vue.extend({
|
||||||
resizeFunction: {
|
resizeFunction: {
|
||||||
type: Function,
|
type: Function,
|
||||||
},
|
},
|
||||||
|
draggable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
deletable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data: () => {
|
data: () => {
|
||||||
return {
|
return {
|
||||||
selectedItems: [],
|
selectedItems: [],
|
||||||
|
localItems: [],
|
||||||
width: 150,
|
width: 150,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -69,6 +123,18 @@ export default Vue.extend({
|
||||||
},
|
},
|
||||||
immediate: true,
|
immediate: true,
|
||||||
},
|
},
|
||||||
|
items: {
|
||||||
|
handler () {
|
||||||
|
this.localItems = this.items as []
|
||||||
|
},
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
localItems: {
|
||||||
|
handler () {
|
||||||
|
this.$emit('update:items', this.localItems)
|
||||||
|
},
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
hasItems (): boolean {
|
hasItems (): boolean {
|
||||||
|
|
@ -77,25 +143,46 @@ export default Vue.extend({
|
||||||
itemWidth (): number {
|
itemWidth (): number {
|
||||||
return this.width
|
return this.width
|
||||||
},
|
},
|
||||||
itemHeight (): number {
|
|
||||||
return this.width / 0.7071 + 116
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
shouldPreselect (): boolean {
|
shouldPreselect (): boolean {
|
||||||
return this.selectedItems.length > 0
|
return this.selectedItems.length > 0
|
||||||
},
|
},
|
||||||
editItem (item: any) {
|
dragOptions (): any {
|
||||||
this.editFunction(item)
|
return {
|
||||||
|
animation: 200,
|
||||||
|
group: 'item-cards',
|
||||||
|
disabled: !this.draggable,
|
||||||
|
ghostClass: 'ghost',
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
onResize () {
|
onResize () {
|
||||||
const content = this.$refs.content as HTMLElement
|
const content = this.$refs.content as HTMLElement
|
||||||
this.width = computeCardWidth(content.clientWidth, this.$vuetify.breakpoint.name)
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.ghost * {
|
||||||
|
opacity: 0.5;
|
||||||
|
background: #c8ebfb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draggable-item * {
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flip-list-move {
|
||||||
|
transition: transform 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-delete * {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -92,14 +92,14 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getReadProgress, getReadProgressPercentage } from '@/functions/book-progress'
|
import { getReadProgress, getReadProgressPercentage } from '@/functions/book-progress'
|
||||||
import { ReadStatus } from '@/types/enum-books'
|
import { ReadStatus } from '@/types/enum-books'
|
||||||
import { createItem, Item } from '@/types/items'
|
import { createItem, Item, ItemTypes } from '@/types/items'
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
|
|
||||||
export default Vue.extend({
|
export default Vue.extend({
|
||||||
name: 'ItemCard',
|
name: 'ItemCard',
|
||||||
props: {
|
props: {
|
||||||
item: {
|
item: {
|
||||||
type: Object as () => BookDto | SeriesDto,
|
type: Object as () => BookDto | SeriesDto | CollectionDto,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
// hide the bottom part of the card
|
// hide the bottom part of the card
|
||||||
|
|
@ -145,12 +145,12 @@ export default Vue.extend({
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
canReadPages (): boolean {
|
canReadPages (): boolean {
|
||||||
return this.$store.getters.mePageStreaming
|
return this.$store.getters.mePageStreaming && this.computedItem.type() === ItemTypes.BOOK
|
||||||
},
|
},
|
||||||
overlay (): boolean {
|
overlay (): boolean {
|
||||||
return this.onEdit !== undefined || this.onSelected !== undefined || this.bookReady || this.canReadPages
|
return this.onEdit !== undefined || this.onSelected !== undefined || this.bookReady || this.canReadPages
|
||||||
},
|
},
|
||||||
computedItem (): Item<BookDto | SeriesDto> {
|
computedItem (): Item<BookDto | SeriesDto | CollectionDto> {
|
||||||
return createItem(this.item)
|
return createItem(this.item)
|
||||||
},
|
},
|
||||||
disableHover (): boolean {
|
disableHover (): boolean {
|
||||||
|
|
@ -169,24 +169,24 @@ export default Vue.extend({
|
||||||
return this.computedItem.body()
|
return this.computedItem.body()
|
||||||
},
|
},
|
||||||
isInProgress (): boolean {
|
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
|
return false
|
||||||
},
|
},
|
||||||
isUnread (): boolean {
|
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
|
return false
|
||||||
},
|
},
|
||||||
unreadCount (): number | undefined {
|
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
|
return undefined
|
||||||
},
|
},
|
||||||
readProgressPercentage (): number {
|
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
|
return 0
|
||||||
},
|
},
|
||||||
bookReady (): boolean {
|
bookReady (): boolean {
|
||||||
if ('seriesId' in this.item) {
|
if (this.computedItem.type() === ItemTypes.BOOK) {
|
||||||
return this.item.media.status === 'READY'
|
return (this.item as BookDto).media.status === 'READY'
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ import LibraryDeleteDialog from '@/components/LibraryDeleteDialog.vue'
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
|
|
||||||
export default Vue.extend({
|
export default Vue.extend({
|
||||||
name: 'library-actions-menu',
|
name: 'LibraryActionsMenu',
|
||||||
components: { LibraryDeleteDialog },
|
components: { LibraryDeleteDialog },
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
34
komga-webui/src/components/LibraryNavigation.vue
Normal file
34
komga-webui/src/components/LibraryNavigation.vue
Normal 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>
|
||||||
71
komga-webui/src/components/SeriesActionsMenu.vue
Normal file
71
komga-webui/src/components/SeriesActionsMenu.vue
Normal 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>
|
||||||
|
|
@ -35,3 +35,7 @@ export function bookPageThumbnailUrl (bookId: number, page: number): string {
|
||||||
export function seriesThumbnailUrl (seriesId: number): string {
|
export function seriesThumbnailUrl (seriesId: number): string {
|
||||||
return `${urls.originNoSlash}/api/v1/series/${seriesId}/thumbnail`
|
return `${urls.originNoSlash}/api/v1/series/${seriesId}/thumbnail`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function collectionThumbnailUrl (collectionId: number): string {
|
||||||
|
return `${urls.originNoSlash}/api/v1/collections/${collectionId}/thumbnail`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import './public-path'
|
|
||||||
import _, { LoDashStatic } from 'lodash'
|
import _, { LoDashStatic } from 'lodash'
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import VueCookies from 'vue-cookies'
|
import VueCookies from 'vue-cookies'
|
||||||
|
|
@ -10,12 +9,14 @@ import App from './App.vue'
|
||||||
import actuator from './plugins/actuator.plugin'
|
import actuator from './plugins/actuator.plugin'
|
||||||
import httpPlugin from './plugins/http.plugin'
|
import httpPlugin from './plugins/http.plugin'
|
||||||
import komgaBooks from './plugins/komga-books.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 komgaFileSystem from './plugins/komga-filesystem.plugin'
|
||||||
import komgaLibraries from './plugins/komga-libraries.plugin'
|
import komgaLibraries from './plugins/komga-libraries.plugin'
|
||||||
|
import komgaReferential from './plugins/komga-referential.plugin'
|
||||||
import komgaSeries from './plugins/komga-series.plugin'
|
import komgaSeries from './plugins/komga-series.plugin'
|
||||||
import komgaUsers from './plugins/komga-users.plugin'
|
import komgaUsers from './plugins/komga-users.plugin'
|
||||||
import vuetify from './plugins/vuetify'
|
import vuetify from './plugins/vuetify'
|
||||||
|
import './public-path'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import store from './store'
|
import store from './store'
|
||||||
|
|
||||||
|
|
@ -27,6 +28,7 @@ Vue.use(require('vue-moment'))
|
||||||
Vue.use(httpPlugin)
|
Vue.use(httpPlugin)
|
||||||
Vue.use(komgaFileSystem, { http: Vue.prototype.$http })
|
Vue.use(komgaFileSystem, { http: Vue.prototype.$http })
|
||||||
Vue.use(komgaSeries, { 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(komgaBooks, { http: Vue.prototype.$http })
|
||||||
Vue.use(komgaReferential, { http: Vue.prototype.$http })
|
Vue.use(komgaReferential, { http: Vue.prototype.$http })
|
||||||
Vue.use(komgaUsers, { store: store, http: Vue.prototype.$http })
|
Vue.use(komgaUsers, { store: store, http: Vue.prototype.$http })
|
||||||
|
|
|
||||||
17
komga-webui/src/plugins/komga-collections.plugin.ts
Normal file
17
komga-webui/src/plugins/komga-collections.plugin.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -84,6 +84,19 @@ const router = new Router({
|
||||||
component: () => import(/* webpackChunkName: "browse-libraries" */ './views/BrowseLibraries.vue'),
|
component: () => import(/* webpackChunkName: "browse-libraries" */ './views/BrowseLibraries.vue'),
|
||||||
props: (route) => ({ libraryId: Number(route.params.libraryId) }),
|
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',
|
path: '/series/:seriesId',
|
||||||
name: 'browse-series',
|
name: 'browse-series',
|
||||||
|
|
|
||||||
92
komga-webui/src/services/komga-collections.service.ts
Normal file
92
komga-webui/src/services/komga-collections.service.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
async analyzeSeries (series: SeriesDto) {
|
||||||
try {
|
try {
|
||||||
await this.http.post(`${API_SERIES}/${series.id}/analyze`)
|
await this.http.post(`${API_SERIES}/${series.id}/analyze`)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
import { bookThumbnailUrl, seriesThumbnailUrl } from '@/functions/urls'
|
import { bookThumbnailUrl, collectionThumbnailUrl, seriesThumbnailUrl } from '@/functions/urls'
|
||||||
import { VueRouter } from 'vue-router/types/router'
|
import { VueRouter } from 'vue-router/types/router'
|
||||||
|
|
||||||
function plural (count: number, singular: string, plural: string) {
|
function plural (count: number, singular: string, plural: string) {
|
||||||
return `${count} ${count === 1 ? singular : plural}`
|
return `${count} ${count === 1 ? singular : plural}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createItem (item: BookDto | SeriesDto): Item<BookDto | SeriesDto> {
|
export enum ItemTypes {
|
||||||
if ('seriesId' in item) {
|
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)
|
return new BookItem(item)
|
||||||
} else if ('libraryId' in item) {
|
} else if ('libraryId' in item) {
|
||||||
return new SeriesItem(item)
|
return new SeriesItem(item)
|
||||||
|
|
@ -16,7 +22,7 @@ export function createItem (item: BookDto | SeriesDto): Item<BookDto | SeriesDto
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class Item<T> {
|
export abstract class Item<T> {
|
||||||
item: T;
|
item: T
|
||||||
|
|
||||||
constructor (item: T) {
|
constructor (item: T) {
|
||||||
this.item = item
|
this.item = item
|
||||||
|
|
@ -30,6 +36,8 @@ export abstract class Item<T> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abstract type (): ItemTypes
|
||||||
|
|
||||||
abstract thumbnailUrl (): string
|
abstract thumbnailUrl (): string
|
||||||
|
|
||||||
abstract title (): string
|
abstract title (): string
|
||||||
|
|
@ -44,6 +52,10 @@ export class BookItem extends Item<BookDto> {
|
||||||
return bookThumbnailUrl(this.item.id)
|
return bookThumbnailUrl(this.item.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type (): ItemTypes {
|
||||||
|
return ItemTypes.BOOK
|
||||||
|
}
|
||||||
|
|
||||||
title (): string {
|
title (): string {
|
||||||
const m = this.item.metadata
|
const m = this.item.metadata
|
||||||
return `#${m.number} - ${m.title}`
|
return `#${m.number} - ${m.title}`
|
||||||
|
|
@ -51,10 +63,7 @@ export class BookItem extends Item<BookDto> {
|
||||||
|
|
||||||
body (): string {
|
body (): string {
|
||||||
let c = this.item.media.pagesCount
|
let c = this.item.media.pagesCount
|
||||||
return `
|
return `<span>${plural(c, 'Page', 'Pages')}</span>`
|
||||||
<div>${this.item.size}</div>
|
|
||||||
<div>${plural(c, 'Page', 'Pages')}</div>
|
|
||||||
`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
goto (router: VueRouter): void {
|
goto (router: VueRouter): void {
|
||||||
|
|
@ -67,6 +76,10 @@ export class SeriesItem extends Item<SeriesDto> {
|
||||||
return seriesThumbnailUrl(this.item.id)
|
return seriesThumbnailUrl(this.item.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type (): ItemTypes {
|
||||||
|
return ItemTypes.SERIES
|
||||||
|
}
|
||||||
|
|
||||||
title (): string {
|
title (): string {
|
||||||
return this.item.metadata.title
|
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() } })
|
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() } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
21
komga-webui/src/types/komga-collections.ts
Normal file
21
komga-webui/src/types/komga-collections.ts
Normal 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[]
|
||||||
|
}
|
||||||
277
komga-webui/src/views/BrowseCollection.vue
Normal file
277
komga-webui/src/views/BrowseCollection.vue
Normal 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>
|
||||||
92
komga-webui/src/views/BrowseCollections.vue
Normal file
92
komga-webui/src/views/BrowseCollections.vue
Normal 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>
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div :style="$vuetify.breakpoint.name === 'xs' ? 'margin-bottom: 56px' : undefined">
|
||||||
<toolbar-sticky v-if="selected.length === 0">
|
<toolbar-sticky v-if="selected.length === 0">
|
||||||
<!-- Action menu -->
|
<!-- Action menu -->
|
||||||
<library-actions-menu v-if="library"
|
<library-actions-menu v-if="library"
|
||||||
|
|
@ -57,12 +57,30 @@
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</v-btn>
|
</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-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>
|
</v-btn>
|
||||||
</toolbar-sticky>
|
</toolbar-sticky>
|
||||||
</v-scroll-y-transition>
|
</v-scroll-y-transition>
|
||||||
|
|
||||||
|
<library-navigation v-if="collections.length > 0"
|
||||||
|
:libraryId="libraryId"
|
||||||
|
/>
|
||||||
|
|
||||||
<edit-series-dialog v-model="dialogEdit"
|
<edit-series-dialog v-model="dialogEdit"
|
||||||
:series.sync="selectedSeries"
|
:series.sync="selectedSeries"
|
||||||
/>
|
/>
|
||||||
|
|
@ -71,6 +89,10 @@
|
||||||
:series.sync="editSeriesSingle"
|
:series.sync="editSeriesSingle"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<collection-add-to-dialog v-model="dialogAddToCollection"
|
||||||
|
:series="selectedSeries"
|
||||||
|
/>
|
||||||
|
|
||||||
<v-container fluid class="px-6">
|
<v-container fluid class="px-6">
|
||||||
<empty-state
|
<empty-state
|
||||||
v-if="totalPages === 0"
|
v-if="totalPages === 0"
|
||||||
|
|
@ -102,11 +124,13 @@
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Badge from '@/components/Badge.vue'
|
import Badge from '@/components/Badge.vue'
|
||||||
|
import CollectionAddToDialog from '@/components/CollectionAddToDialog.vue'
|
||||||
import EditSeriesDialog from '@/components/EditSeriesDialog.vue'
|
import EditSeriesDialog from '@/components/EditSeriesDialog.vue'
|
||||||
import EmptyState from '@/components/EmptyState.vue'
|
import EmptyState from '@/components/EmptyState.vue'
|
||||||
import FilterMenuButton from '@/components/FilterMenuButton.vue'
|
import FilterMenuButton from '@/components/FilterMenuButton.vue'
|
||||||
import ItemBrowser from '@/components/ItemBrowser.vue'
|
import ItemBrowser from '@/components/ItemBrowser.vue'
|
||||||
import LibraryActionsMenu from '@/components/LibraryActionsMenu.vue'
|
import LibraryActionsMenu from '@/components/LibraryActionsMenu.vue'
|
||||||
|
import LibraryNavigation from '@/components/LibraryNavigation.vue'
|
||||||
import PageSizeSelect from '@/components/PageSizeSelect.vue'
|
import PageSizeSelect from '@/components/PageSizeSelect.vue'
|
||||||
import SortMenuButton from '@/components/SortMenuButton.vue'
|
import SortMenuButton from '@/components/SortMenuButton.vue'
|
||||||
import ToolbarSticky from '@/components/ToolbarSticky.vue'
|
import ToolbarSticky from '@/components/ToolbarSticky.vue'
|
||||||
|
|
@ -128,7 +152,9 @@ export default Vue.extend({
|
||||||
Badge,
|
Badge,
|
||||||
EditSeriesDialog,
|
EditSeriesDialog,
|
||||||
ItemBrowser,
|
ItemBrowser,
|
||||||
|
CollectionAddToDialog,
|
||||||
PageSizeSelect,
|
PageSizeSelect,
|
||||||
|
LibraryNavigation,
|
||||||
},
|
},
|
||||||
data: () => {
|
data: () => {
|
||||||
return {
|
return {
|
||||||
|
|
@ -156,6 +182,8 @@ export default Vue.extend({
|
||||||
selected: [],
|
selected: [],
|
||||||
dialogEdit: false,
|
dialogEdit: false,
|
||||||
dialogEditSingle: false,
|
dialogEditSingle: false,
|
||||||
|
dialogAddToCollection: false,
|
||||||
|
collections: [] as CollectionDto[],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
|
|
@ -212,6 +240,7 @@ export default Vue.extend({
|
||||||
this.totalPages = 1
|
this.totalPages = 1
|
||||||
this.totalElements = null
|
this.totalElements = null
|
||||||
this.series = []
|
this.series = []
|
||||||
|
this.collections = []
|
||||||
|
|
||||||
this.loadLibrary(Number(to.params.libraryId))
|
this.loadLibrary(Number(to.params.libraryId))
|
||||||
|
|
||||||
|
|
@ -271,6 +300,10 @@ export default Vue.extend({
|
||||||
},
|
},
|
||||||
async loadLibrary (libraryId: number) {
|
async loadLibrary (libraryId: number) {
|
||||||
this.library = this.getLibraryLazy(libraryId)
|
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)
|
await this.loadPage(libraryId, this.page, this.sortActive)
|
||||||
},
|
},
|
||||||
parseQuerySortOrDefault (querySort: any): SortActive {
|
parseQuerySortOrDefault (querySort: any): SortActive {
|
||||||
|
|
@ -337,6 +370,9 @@ export default Vue.extend({
|
||||||
this.$komgaSeries.getOneSeries(s.id),
|
this.$komgaSeries.getOneSeries(s.id),
|
||||||
))
|
))
|
||||||
},
|
},
|
||||||
|
addToCollection () {
|
||||||
|
this.dialogAddToCollection = true
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -9,30 +9,12 @@
|
||||||
<v-icon>mdi-arrow-left</v-icon>
|
<v-icon>mdi-arrow-left</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<!-- Action menu -->
|
<series-actions-menu v-if="series"
|
||||||
<v-menu offset-y>
|
:series.sync="series"
|
||||||
<template v-slot:activator="{ on }">
|
@add-to-collection="addToCollection"
|
||||||
<v-btn icon v-on="on">
|
@mark-read="markRead"
|
||||||
<v-icon>mdi-dots-vertical</v-icon>
|
@mark-unread="markUnread"
|
||||||
</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>
|
|
||||||
|
|
||||||
<v-toolbar-title>
|
<v-toolbar-title>
|
||||||
<span v-if="$_.get(series, 'metadata.title')">{{ series.metadata.title }}</span>
|
<span v-if="$_.get(series, 'metadata.title')">{{ series.metadata.title }}</span>
|
||||||
|
|
@ -128,6 +110,35 @@
|
||||||
series.metadata.status.toLowerCase() }}
|
series.metadata.status.toLowerCase() }}
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</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-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
|
|
@ -155,18 +166,25 @@
|
||||||
</v-container>
|
</v-container>
|
||||||
|
|
||||||
<edit-series-dialog v-model="dialogEdit" :series.sync="series"/>
|
<edit-series-dialog v-model="dialogEdit" :series.sync="series"/>
|
||||||
|
|
||||||
|
<collection-add-to-dialog v-model="dialogAddToCollection"
|
||||||
|
:series="series"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Badge from '@/components/Badge.vue'
|
import Badge from '@/components/Badge.vue'
|
||||||
|
import CollectionAddToDialog from '@/components/CollectionAddToDialog.vue'
|
||||||
import EditBooksDialog from '@/components/EditBooksDialog.vue'
|
import EditBooksDialog from '@/components/EditBooksDialog.vue'
|
||||||
import EditSeriesDialog from '@/components/EditSeriesDialog.vue'
|
import EditSeriesDialog from '@/components/EditSeriesDialog.vue'
|
||||||
import EmptyState from '@/components/EmptyState.vue'
|
import EmptyState from '@/components/EmptyState.vue'
|
||||||
import FilterMenuButton from '@/components/FilterMenuButton.vue'
|
import FilterMenuButton from '@/components/FilterMenuButton.vue'
|
||||||
|
import HorizontalScroller from '@/components/HorizontalScroller.vue'
|
||||||
import ItemBrowser from '@/components/ItemBrowser.vue'
|
import ItemBrowser from '@/components/ItemBrowser.vue'
|
||||||
import ItemCard from '@/components/ItemCard.vue'
|
import ItemCard from '@/components/ItemCard.vue'
|
||||||
import PageSizeSelect from '@/components/PageSizeSelect.vue'
|
import PageSizeSelect from '@/components/PageSizeSelect.vue'
|
||||||
|
import SeriesActionsMenu from '@/components/SeriesActionsMenu.vue'
|
||||||
import SortMenuButton from '@/components/SortMenuButton.vue'
|
import SortMenuButton from '@/components/SortMenuButton.vue'
|
||||||
import ToolbarSticky from '@/components/ToolbarSticky.vue'
|
import ToolbarSticky from '@/components/ToolbarSticky.vue'
|
||||||
import { parseQueryFilter, parseQuerySort } from '@/functions/query-params'
|
import { parseQueryFilter, parseQuerySort } from '@/functions/query-params'
|
||||||
|
|
@ -187,6 +205,9 @@ export default Vue.extend({
|
||||||
EditBooksDialog,
|
EditBooksDialog,
|
||||||
ItemBrowser,
|
ItemBrowser,
|
||||||
PageSizeSelect,
|
PageSizeSelect,
|
||||||
|
SeriesActionsMenu,
|
||||||
|
CollectionAddToDialog,
|
||||||
|
HorizontalScroller,
|
||||||
ItemCard,
|
ItemCard,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
},
|
},
|
||||||
|
|
@ -216,6 +237,10 @@ export default Vue.extend({
|
||||||
selected: [],
|
selected: [],
|
||||||
dialogEditBooks: false,
|
dialogEditBooks: false,
|
||||||
dialogEditBookSingle: false,
|
dialogEditBookSingle: false,
|
||||||
|
dialogAddToCollection: false,
|
||||||
|
collections: [] as CollectionDto[],
|
||||||
|
collectionsContent: [] as any[][],
|
||||||
|
collectionPanel: -1,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -296,6 +321,8 @@ export default Vue.extend({
|
||||||
this.totalPages = 1
|
this.totalPages = 1
|
||||||
this.totalElements = null
|
this.totalElements = null
|
||||||
this.books = []
|
this.books = []
|
||||||
|
this.collections = []
|
||||||
|
this.collectionPanel = -1
|
||||||
|
|
||||||
this.loadSeries(Number(to.params.seriesId))
|
this.loadSeries(Number(to.params.seriesId))
|
||||||
|
|
||||||
|
|
@ -337,6 +364,10 @@ export default Vue.extend({
|
||||||
},
|
},
|
||||||
async loadSeries (seriesId: number) {
|
async loadSeries (seriesId: number) {
|
||||||
this.series = await this.$komgaSeries.getOneSeries(seriesId)
|
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)
|
await this.loadPage(seriesId, this.page, this.sortActive)
|
||||||
},
|
},
|
||||||
parseQuerySortOrDefault (querySort: any): SortActive {
|
parseQuerySortOrDefault (querySort: any): SortActive {
|
||||||
|
|
@ -401,12 +432,13 @@ export default Vue.extend({
|
||||||
))
|
))
|
||||||
this.loadSeries(this.seriesId)
|
this.loadSeries(this.seriesId)
|
||||||
},
|
},
|
||||||
|
addToCollection () {
|
||||||
|
this.dialogAddToCollection = true
|
||||||
|
},
|
||||||
async markRead () {
|
async markRead () {
|
||||||
await this.$komgaSeries.markAsRead(this.seriesId)
|
|
||||||
this.loadSeries(this.seriesId)
|
this.loadSeries(this.seriesId)
|
||||||
},
|
},
|
||||||
async markUnread () {
|
async markUnread () {
|
||||||
await this.$komgaSeries.markAsUnread(this.seriesId)
|
|
||||||
this.loadSeries(this.seriesId)
|
this.loadSeries(this.seriesId)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue