diff --git a/komga-webui/src/plugins/persisted-state.ts b/komga-webui/src/plugins/persisted-state.ts index 8b5e877f4..c43a4a489 100644 --- a/komga-webui/src/plugins/persisted-state.ts +++ b/komga-webui/src/plugins/persisted-state.ts @@ -23,6 +23,9 @@ export const persistedModule: Module = { collection: { filter: {}, }, + readList: { + filter: {}, + }, library: { filter: {}, sort: {}, @@ -34,6 +37,9 @@ export const persistedModule: Module = { getCollectionFilter: (state) => (id: string) => { return state.collection.filter[id] }, + getReadListFilter: (state) => (id: string) => { + return state.readList.filter[id] + }, getLibraryFilter: (state) => (id: string) => { return state.library.filter[id] }, @@ -81,6 +87,9 @@ export const persistedModule: Module = { setCollectionFilter(state, {id, filter}) { state.collection.filter[id] = filter }, + setReadListFilter(state, {id, filter}) { + state.readList.filter[id] = filter + }, setLibraryFilter(state, {id, filter}) { state.library.filter[id] = filter }, diff --git a/komga-webui/src/services/komga-readlists.service.ts b/komga-webui/src/services/komga-readlists.service.ts index 3909be638..e7d259a41 100644 --- a/komga-webui/src/services/komga-readlists.service.ts +++ b/komga-webui/src/services/komga-readlists.service.ts @@ -1,5 +1,5 @@ import {AxiosInstance} from 'axios' -import {BookDto} from '@/types/komga-books' +import {AuthorDto, BookDto} from '@/types/komga-books' const qs = require('qs') @@ -97,11 +97,19 @@ export default class KomgaReadListsService { } } - async getBooks(readListId: string, pageRequest?: PageRequest): Promise> { + async getBooks(readListId: string, pageRequest?: PageRequest, + libraryId?: string[], readStatus?: string[], + tag?: string[], authors?: AuthorDto[]): Promise> { try { - const params = {...pageRequest} + const params = {...pageRequest} as any + if (libraryId) params.library_id = libraryId + if (readStatus) params.read_status = readStatus + if (tag) params.tag = tag + if (authors) params.author = authors.map(a => `${a.name},${a.role}`) + return (await this.http.get(`${API_READLISTS}/${readListId}/books`, { params: params, + paramsSerializer: params => qs.stringify(params, { indices: false }), })).data } catch (e) { let msg = 'An error occurred while trying to retrieve books' diff --git a/komga-webui/src/services/komga-referential.service.ts b/komga-webui/src/services/komga-referential.service.ts index 465a3b763..724401db4 100644 --- a/komga-webui/src/services/komga-referential.service.ts +++ b/komga-webui/src/services/komga-referential.service.ts @@ -11,7 +11,7 @@ export default class KomgaReferentialService { this.http = http } - async getAuthors(search?: string, role?: string, libraryId?: string, collectionId?: string, seriesId?: string): Promise> { + async getAuthors(search?: string, role?: string, libraryId?: string, collectionId?: string, seriesId?: string, readListId?: string): Promise> { try { const params = {} as any if (search) params.search = search @@ -19,6 +19,7 @@ export default class KomgaReferentialService { if (libraryId) params.library_id = libraryId if (collectionId) params.collection_id = collectionId if (seriesId) params.series_id = seriesId + if (readListId) params.readlist_id = readListId return (await this.http.get('/api/v2/authors', { params: params, @@ -102,10 +103,11 @@ export default class KomgaReferentialService { } } - async getBookTags(seriesId?: string): Promise { + async getBookTags(seriesId?: string, readListId?: string): Promise { try { const params = {} as any if (seriesId) params.series_id = seriesId + if (readListId) params.readlist_id = readListId return (await this.http.get('/api/v1/tags/book', { params: params, diff --git a/komga-webui/src/views/BrowseReadList.vue b/komga-webui/src/views/BrowseReadList.vue index 70f04f75b..149b650f9 100644 --- a/komga-webui/src/views/BrowseReadList.vue +++ b/komga-webui/src/views/BrowseReadList.vue @@ -33,6 +33,10 @@ + + mdi-filter-variant + + + + + + + + @@ -98,11 +122,19 @@ import { import Vue from 'vue' import ReadListActionsMenu from '@/components/menus/ReadListActionsMenu.vue' import MultiSelectBar from '@/components/bars/MultiSelectBar.vue' -import {BookDto, ReadProgressUpdateDto} from '@/types/komga-books' +import {AuthorDto, BookDto, ReadProgressUpdateDto} from '@/types/komga-books' import {ContextOrigin} from '@/types/context' import {BookSseDto, ReadListSseDto, ReadProgressSseDto} from '@/types/komga-sse' import {throttle} from 'lodash' import ReadMore from '@/components/ReadMore.vue' +import FilterDrawer from '@/components/FilterDrawer.vue' +import FilterPanels from '@/components/FilterPanels.vue' +import FilterList from '@/components/FilterList.vue' +import {ReadStatus, replaceCompositeReadStatus} from '@/types/enum-books' +import {authorRoles} from '@/types/author-roles' +import {LibraryDto} from '@/types/komga-libraries' +import {mergeFilterParams, toNameValue} from '@/functions/filter' +import {Location} from 'vue-router' export default Vue.extend({ name: 'BrowseReadList', @@ -111,6 +143,9 @@ export default Vue.extend({ ItemBrowser, ReadListActionsMenu, MultiSelectBar, + FilterDrawer, + FilterPanels, + FilterList, ReadMore, }, data: () => { @@ -120,6 +155,13 @@ export default Vue.extend({ booksCopy: [] as BookDto[], selectedBooks: [] as BookDto[], editElements: false, + filters: {} as FiltersActive, + filterUnwatch: null as any, + drawer: false, + filterOptions: { + library: [] as NameValue[], + tag: [] as NameValue[], + }, } }, props: { @@ -128,7 +170,7 @@ export default Vue.extend({ required: true, }, }, - created () { + created() { this.$eventHub.$on(READLIST_CHANGED, this.readListChanged) this.$eventHub.$on(READLIST_DELETED, this.readListDeleted) this.$eventHub.$on(BOOK_CHANGED, this.bookChanged) @@ -136,7 +178,7 @@ export default Vue.extend({ this.$eventHub.$on(READPROGRESS_CHANGED, this.readProgressChanged) this.$eventHub.$on(READPROGRESS_DELETED, this.readProgressChanged) }, - beforeDestroy () { + beforeDestroy() { this.$eventHub.$off(READLIST_CHANGED, this.readListChanged) this.$eventHub.$off(READLIST_DELETED, this.readListDeleted) this.$eventHub.$off(BOOK_CHANGED, this.bookChanged) @@ -144,92 +186,213 @@ export default Vue.extend({ this.$eventHub.$off(READPROGRESS_CHANGED, this.readProgressChanged) this.$eventHub.$off(READPROGRESS_DELETED, this.readProgressChanged) }, - mounted () { + async mounted() { + await this.resetParams(this.$route, this.readListId) + this.loadReadList(this.readListId) + + this.setWatches() }, - beforeRouteUpdate (to, from, next) { + async beforeRouteUpdate(to, from, next) { if (to.params.readListId !== from.params.readListId) { + this.unsetWatches() + // reset + await this.resetParams(this.$route, this.readListId) this.books = [] this.editElements = false this.loadReadList(to.params.readListId) + + this.setWatches() } next() }, computed: { - isAdmin (): boolean { + filterOptionsList(): FiltersOptions { + return { + readStatus: { + values: [ + {name: this.$i18n.t('filter.unread').toString(), value: ReadStatus.UNREAD_AND_IN_PROGRESS}, + {name: this.$t('filter.in_progress').toString(), value: ReadStatus.IN_PROGRESS}, + {name: this.$t('filter.read').toString(), value: ReadStatus.READ}, + ], + }, + } as FiltersOptions + }, + filterOptionsPanel(): FiltersOptions { + const r = { + library: {name: this.$t('filter.library').toString(), values: this.filterOptions.library}, + tag: {name: this.$t('filter.tag').toString(), values: this.filterOptions.tag}, + } as FiltersOptions + authorRoles.forEach((role: string) => { + r[role] = { + name: this.$t(`author_roles.${role}`).toString(), + search: async search => { + return (await this.$komgaReferential.getAuthors(search, role, undefined, undefined, undefined, this.readListId)) + .content + .map(x => x.name) + }, + } + }) + return r + }, + isAdmin(): boolean { return this.$store.getters.meAdmin }, + filterActive(): boolean { + return Object.keys(this.filters).some(x => this.filters[x].length !== 0) + }, }, methods: { - readListChanged (event: ReadListSseDto) { + resetFilters() { + this.drawer = false + for (const prop in this.filters) { + this.$set(this.filters, prop, []) + } + this.$store.commit('setReadListFilter', {id: this.readListId, filter: this.filters}) + this.updateRouteAndReload() + }, + async resetParams(route: any, readListId: string) { + // load dynamic filters + this.$set(this.filterOptions, 'library', this.$store.state.komgaLibraries.libraries.map((x: LibraryDto) => ({ + name: x.name, + value: x.id, + }))) + + const tags = await this.$komgaReferential.getBookTags(undefined, readListId) + this.$set(this.filterOptions, 'tag', toNameValue(tags)) + + // get filter from query params or local storage and validate with available filter values + let activeFilters: any + if (route.query.readStatus || route.query.tag || route.query.library || authorRoles.some(role => role in route.query)) { + activeFilters = { + readStatus: route.query.readStatus || [], + library: route.query.library || [], + tag: route.query.tag || [], + } + authorRoles.forEach((role: string) => { + activeFilters[role] = route.query[role] || [] + }) + } else { + activeFilters = this.$store.getters.getReadListFilter(route.params.readListId) || {} as FiltersActive + } + this.filters = this.validateFilters(activeFilters) + }, + validateFilters(filters: FiltersActive): FiltersActive { + const validFilter = { + readStatus: filters.readStatus?.filter(x => Object.keys(ReadStatus).includes(x)) || [], + library: filters.library?.filter(x => this.filterOptions.library.map(n => n.value).includes(x)) || [], + tag: filters.tag?.filter(x => this.filterOptions.tag.map(n => n.value).includes(x)) || [], + } as any + authorRoles.forEach((role: string) => { + validFilter[role] = filters[role] || [] + }) + return validFilter + }, + setWatches() { + this.filterUnwatch = this.$watch('filters', (val) => { + this.$store.commit('setReadListFilter', {id: this.readListId, filter: val}) + this.updateRouteAndReload() + }) + }, + unsetWatches() { + this.filterUnwatch() + }, + updateRouteAndReload() { + this.unsetWatches() + + this.updateRoute() + this.loadBooks(this.readListId) + + this.setWatches() + }, + updateRoute() { + const loc = { + name: this.$route.name, + params: {readListId: this.$route.params.readListId}, + query: {}, + } as Location + mergeFilterParams(this.filters, loc.query) + this.$router.replace(loc).catch((_: any) => { + }) + }, + readListChanged(event: ReadListSseDto) { if (event.readListId === this.readListId) { this.loadReadList(this.readListId) } }, - readListDeleted (event: ReadListSseDto) { + readListDeleted(event: ReadListSseDto) { if (event.readListId === this.readListId) { this.$router.push({name: 'browse-readlists', params: {libraryId: 'all'}}) } }, - async loadReadList (readListId: string) { + async loadReadList(readListId: string) { this.$komgaReadLists.getOneReadList(readListId) - .then(v => this.readList = v) + .then(v => this.readList = v) await this.loadBooks(readListId) }, - async loadBooks (readListId: string) { - this.books = (await this.$komgaReadLists.getBooks(readListId, { unpaged: true } as PageRequest)).content - this.books.forEach((x: BookDto) => x.context = { origin: ContextOrigin.READLIST, id: readListId }) + async loadBooks(readListId: string) { + let authorsFilter = [] as AuthorDto[] + authorRoles.forEach((role: string) => { + if (role in this.filters) this.filters[role].forEach((name: string) => authorsFilter.push({ + name: name, + role: role, + })) + }) + + this.books = (await this.$komgaReadLists.getBooks(readListId, {unpaged: true} as PageRequest, this.filters.library, replaceCompositeReadStatus(this.filters.readStatus), this.filters.tag, authorsFilter)).content + this.books.forEach((x: BookDto) => x.context = {origin: ContextOrigin.READLIST, id: readListId}) this.booksCopy = [...this.books] this.selectedBooks = [] }, reloadBooks: throttle(function (this: any) { this.loadBooks(this.readListId) }, 1000), - editSingleBook (book: BookDto) { + editSingleBook(book: BookDto) { this.$store.dispatch('dialogUpdateBooks', book) }, - editMultipleBooks () { + editMultipleBooks() { this.$store.dispatch('dialogUpdateBooks', this.selectedBooks) }, - async markSelectedRead () { + async markSelectedRead() { await Promise.all(this.selectedBooks.map(b => - this.$komgaBooks.updateReadProgress(b.id, { completed: true } as ReadProgressUpdateDto), + this.$komgaBooks.updateReadProgress(b.id, {completed: true} as ReadProgressUpdateDto), )) this.selectedBooks = [] }, - async markSelectedUnread () { + async markSelectedUnread() { await Promise.all(this.selectedBooks.map(b => this.$komgaBooks.deleteReadProgress(b.id), )) this.selectedBooks = [] }, - addToReadList () { + addToReadList() { this.$store.dispatch('dialogAddBooksToReadList', this.selectedBooks) }, - startEditElements () { + startEditElements() { + this.filters = {} this.editElements = true }, - cancelEditElements () { + cancelEditElements() { this.editElements = false this.books = [...this.booksCopy] }, - doEditElements () { + doEditElements() { this.editElements = false const update = { bookIds: this.books.map(x => x.id), } as ReadListUpdateDto this.$komgaReadLists.patchReadList(this.readListId, update) }, - editReadList () { + editReadList() { this.$store.dispatch('dialogEditReadList', this.readList) }, - bookChanged (event: BookSseDto) { + bookChanged(event: BookSseDto) { if (this.books.some(b => b.id === event.bookId)) this.reloadBooks() }, - readProgressChanged(event: ReadProgressSseDto){ + readProgressChanged(event: ReadProgressSseDto) { if (this.books.some(b => b.id === event.bookId)) this.reloadBooks() }, },