-
+
+
+
-
-
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+ mdi-drag-horizontal
+
+
+
+
+
+
+ mdi-delete
+
+
+
+
+
+
+
+
+
@@ -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)
+ },
},
})
diff --git a/komga-webui/src/components/ItemCard.vue b/komga-webui/src/components/ItemCard.vue
index 65ab35d08..571d1591c 100644
--- a/komga-webui/src/components/ItemCard.vue
+++ b/komga-webui/src/components/ItemCard.vue
@@ -92,14 +92,14 @@
+
+
diff --git a/komga-webui/src/components/SeriesActionsMenu.vue b/komga-webui/src/components/SeriesActionsMenu.vue
new file mode 100644
index 000000000..a554d50a4
--- /dev/null
+++ b/komga-webui/src/components/SeriesActionsMenu.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+ mdi-dots-vertical
+
+
+
+
+ Analyze
+
+
+ Refresh metadata
+
+
+ Add to collection
+
+
+ Mark as read
+
+
+ Mark as unread
+
+
+
+
+
+
diff --git a/komga-webui/src/functions/urls.ts b/komga-webui/src/functions/urls.ts
index 393100289..c95f352fa 100644
--- a/komga-webui/src/functions/urls.ts
+++ b/komga-webui/src/functions/urls.ts
@@ -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`
+}
diff --git a/komga-webui/src/main.ts b/komga-webui/src/main.ts
index 3bb1520d0..1941801c0 100644
--- a/komga-webui/src/main.ts
+++ b/komga-webui/src/main.ts
@@ -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 })
diff --git a/komga-webui/src/plugins/komga-collections.plugin.ts b/komga-webui/src/plugins/komga-collections.plugin.ts
new file mode 100644
index 000000000..801ec57b5
--- /dev/null
+++ b/komga-webui/src/plugins/komga-collections.plugin.ts
@@ -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;
+ }
+}
diff --git a/komga-webui/src/router.ts b/komga-webui/src/router.ts
index 83a6570ee..bd5410a47 100644
--- a/komga-webui/src/router.ts
+++ b/komga-webui/src/router.ts
@@ -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',
diff --git a/komga-webui/src/services/komga-collections.service.ts b/komga-webui/src/services/komga-collections.service.ts
new file mode 100644
index 000000000..885f48863
--- /dev/null
+++ b/komga-webui/src/services/komga-collections.service.ts
@@ -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
{
+ 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 {
+ 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 {
+ 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 {
+ 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)
+ }
+ }
+}
diff --git a/komga-webui/src/services/komga-series.service.ts b/komga-webui/src/services/komga-series.service.ts
index 8d20d93d2..1f9831ad3 100644
--- a/komga-webui/src/services/komga-series.service.ts
+++ b/komga-webui/src/services/komga-series.service.ts
@@ -100,6 +100,18 @@ export default class KomgaSeriesService {
}
}
+ async getCollections (seriesId: number): Promise {
+ 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`)
diff --git a/komga-webui/src/types/items.ts b/komga-webui/src/types/items.ts
index 20d619db0..a5ff71940 100644
--- a/komga-webui/src/types/items.ts
+++ b/komga-webui/src/types/items.ts
@@ -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 {
- if ('seriesId' in item) {
+export enum ItemTypes {
+ BOOK, SERIES, COLLECTION
+}
+
+export function createItem (item: BookDto | SeriesDto | CollectionDto): Item {
+ 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 {
- item: T;
+ item: T
constructor (item: T) {
this.item = item
@@ -30,13 +36,15 @@ export abstract class Item {
}
}
- 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 {
@@ -44,6 +52,10 @@ export class BookItem extends Item {
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 {
body (): string {
let c = this.item.media.pagesCount
- return `
- ${this.item.size}
- ${plural(c, 'Page', 'Pages')}
- `
+ return `${plural(c, 'Page', 'Pages')}`
}
goto (router: VueRouter): void {
@@ -67,6 +76,10 @@ export class SeriesItem extends Item {
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 {
router.push({ name: 'browse-series', params: { seriesId: this.item.id.toString() } })
}
}
+
+export class CollectionItem extends Item {
+ 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 `${c} Series`
+ }
+
+ goto (router: VueRouter): void {
+ router.push({ name: 'browse-collection', params: { collectionId: this.item.id.toString() } })
+ }
+}
diff --git a/komga-webui/src/types/komga-collections.ts b/komga-webui/src/types/komga-collections.ts
new file mode 100644
index 000000000..c388d1082
--- /dev/null
+++ b/komga-webui/src/types/komga-collections.ts
@@ -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[]
+}
diff --git a/komga-webui/src/views/BrowseCollection.vue b/komga-webui/src/views/BrowseCollection.vue
new file mode 100644
index 000000000..cf4a9ef20
--- /dev/null
+++ b/komga-webui/src/views/BrowseCollection.vue
@@ -0,0 +1,277 @@
+
+
+
+
+
+
+
+ {{ collection.name }}
+ {{ collection.seriesIds.length }}
+ (manual ordering)
+
+
+
+
+
+
+
+ mdi-playlist-edit
+
+ Edit elements
+
+
+
+
+
+
+ mdi-pencil
+
+ Edit collection
+
+
+
+
+
+
+
+
+
+ mdi-close
+
+
+
+ mdi-check
+
+
+
+
+
+
+
+
+
+ mdi-close
+
+
+ {{ selected.length }} selected
+
+
+
+
+
+
+
+ mdi-bookmark-check
+
+ Mark as Read
+
+
+
+
+
+
+ mdi-bookmark-remove
+
+ Mark as Unread
+
+
+
+
+
+
+ mdi-playlist-plus
+
+ Add to collection
+
+
+
+
+
+
+ mdi-pencil
+
+ Edit metadata
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/komga-webui/src/views/BrowseCollections.vue b/komga-webui/src/views/BrowseCollections.vue
new file mode 100644
index 000000000..cd435f1a7
--- /dev/null
+++ b/komga-webui/src/views/BrowseCollections.vue
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+ {{ library ? library.name : 'All libraries' }}
+ {{ collections.length }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/komga-webui/src/views/BrowseLibraries.vue b/komga-webui/src/views/BrowseLibraries.vue
index afa329d7f..100eea760 100644
--- a/komga-webui/src/views/BrowseLibraries.vue
+++ b/komga-webui/src/views/BrowseLibraries.vue
@@ -1,5 +1,5 @@
-
+
+
+
+
+ mdi-playlist-plus
+
+ Add to collection
+
+
+
- mdi-pencil
+
+
+ mdi-pencil
+
+ Edit metadata
+
+
+
@@ -71,6 +89,10 @@
:series.sync="editSeriesSingle"
/>
+
+
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
+ },
},
})
diff --git a/komga-webui/src/views/BrowseSeries.vue b/komga-webui/src/views/BrowseSeries.vue
index 8f5a8b3ba..645f37060 100644
--- a/komga-webui/src/views/BrowseSeries.vue
+++ b/komga-webui/src/views/BrowseSeries.vue
@@ -9,30 +9,12 @@
mdi-arrow-left
-
-
-
-
- mdi-dots-vertical
-
-
-
-
- Analyze
-
-
- Refresh metadata
-
-
- Mark as read
-
-
- Mark as unread
-
-
-
+
{{ series.metadata.title }}
@@ -128,6 +110,35 @@
series.metadata.status.toLowerCase() }}
+
+
+
+
+
+ {{ c.name }} collection
+
+
+
+ Manage collection
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -155,18 +166,25 @@
+
+