diff --git a/komga-webui/src/components/HorizontalScroller.vue b/komga-webui/src/components/HorizontalScroller.vue
index 0cddacb55..2137cc9e6 100644
--- a/komga-webui/src/components/HorizontalScroller.vue
+++ b/komga-webui/src/components/HorizontalScroller.vue
@@ -22,7 +22,10 @@
@scroll="computeScrollability"
v-resize="computeScrollability"
>
-
+
+
+
+
@@ -44,20 +47,35 @@ export default Vue.extend({
adjustment: 100,
}
},
+ props: {
+ tick: {
+ type: Number,
+ default: 0,
+ },
+ },
mounted() {
this.container = this.$refs[this.id] as HTMLElement
this.computeScrollability()
},
+ watch: {
+ tick() {
+ setTimeout(this.computeScrollability, 200)
+ },
+ },
methods: {
computeScrollability() {
- if (this.container !== undefined) {
+ if (this.container) {
+ let scrollPercent: number
if (this.$vuetify.rtl) {
this.canScrollBackward = Math.round(this.container.scrollLeft) < 0
this.canScrollForward = (Math.round(this.container.scrollLeft) - this.container.clientWidth) > -this.container.scrollWidth
+ scrollPercent = (Math.round(this.container.scrollLeft) - this.container.clientWidth) / -this.container.scrollWidth
} else {
this.canScrollBackward = Math.round(this.container.scrollLeft) > 0
this.canScrollForward = (Math.round(this.container.scrollLeft) + this.container.clientWidth) < this.container.scrollWidth
+ scrollPercent = (Math.round(this.container.scrollLeft) + this.container.clientWidth) / this.container.scrollWidth
}
+ this.$emit('scroll-changed', scrollPercent)
}
},
doScroll(direction: string) {
diff --git a/komga-webui/src/types/pageLoader.ts b/komga-webui/src/types/pageLoader.ts
new file mode 100644
index 000000000..6e1077977
--- /dev/null
+++ b/komga-webui/src/types/pageLoader.ts
@@ -0,0 +1,43 @@
+export class PageLoader {
+ private readonly pageable: PageRequest
+ private readonly loader: (pageRequest: PageRequest) => Promise>
+
+ private currentPage = undefined as unknown as Page
+ private loadedPages: number[] = []
+ public readonly items: T[] = []
+
+ get page() {
+ return this.currentPage?.number || 0
+ }
+ get hasNextPage() {
+ return !this.currentPage ? false : !this.currentPage.last
+ }
+
+ constructor(pageable: PageRequest, loader: (pageRequest: PageRequest) => Promise>) {
+ this.pageable = pageable
+ this.loader = loader
+ }
+
+ async loadNext(): Promise {
+ // load first page if nothing has been loaded yet
+ if (!this.currentPage) {
+ this.loadedPages.push(this.pageable.page || 0)
+ this.currentPage = await this.loader(this.pageable)
+ this.items.push(...this.currentPage.content)
+ return true
+ }
+ // if the last page has been loaded, do nothing
+ else if (this.currentPage.last) return false
+ else {
+ const nextPage = this.currentPage.number + 1
+ if (!this.loadedPages.includes(nextPage)) {
+ this.loadedPages.push(nextPage)
+ const pageable = Object.assign({}, this.pageable, {page: nextPage})
+ this.currentPage = await this.loader(pageable)
+ this.items.push(...this.currentPage.content)
+ return true
+ }
+ return false
+ }
+ }
+}
diff --git a/komga-webui/src/views/Dashboard.vue b/komga-webui/src/views/Dashboard.vue
index 9e24865e4..89b5ad133 100644
--- a/komga-webui/src/views/Dashboard.vue
+++ b/komga-webui/src/views/Dashboard.vue
@@ -49,12 +49,17 @@
>
-
+ scrollChanged(loaderInProgressBooks, percent)"
+ >
{{ $t('dashboard.keep_reading') }}
-
-
+ scrollChanged(loaderOnDeckBooks, percent)"
+ >
{{ $t('dashboard.on_deck') }}
-
-
+ scrollChanged(loaderRecentlyReleasedBooks, percent)"
+ >
{{ $t('dashboard.recently_released_books') }}
-
-
+ scrollChanged(loaderLatestBooks, percent)"
+ >
{{ $t('dashboard.recently_added_books') }}
-
-
+ scrollChanged(loaderNewSeries, percent)"
+ >
{{ $t('dashboard.recently_added_series') }}
-
-
+ scrollChanged(loaderUpdatedSeries, percent)"
+ >
{{ $t('dashboard.recently_updated_series') }}
-
-
+ scrollChanged(loaderRecentlyReadBooks, percent)"
+ >
{{ $t('dashboard.recently_read_books') }}
- ,
+ loaderUpdatedSeries: undefined as unknown as PageLoader,
+ loaderLatestBooks: undefined as unknown as PageLoader,
+ loaderInProgressBooks: undefined as unknown as PageLoader,
+ loaderOnDeckBooks: undefined as unknown as PageLoader,
+ loaderRecentlyReleasedBooks: undefined as unknown as PageLoader,
+ loaderRecentlyReadBooks: undefined as unknown as PageLoader,
selectedSeries: [] as SeriesDto[],
selectedBooks: [] as BookDto[],
}
@@ -263,19 +299,22 @@ export default Vue.extend({
return this.$vuetify.breakpoint.name === 'xs' ? 120 : 150
},
allEmpty(): boolean {
- return this.newSeries.length === 0 &&
- this.updatedSeries.length === 0 &&
- this.latestBooks.length === 0 &&
- this.inProgressBooks.length === 0 &&
- this.onDeckBooks.length === 0 &&
- this.recentlyReleasedBooks.length === 0 &&
- this.recentlyReadBooks.length === 0
+ return this.loaderNewSeries?.items.length === 0 &&
+ this.loaderUpdatedSeries?.items.length === 0 &&
+ this.loaderLatestBooks?.items.length === 0 &&
+ this.loaderInProgressBooks?.items.length === 0 &&
+ this.loaderOnDeckBooks?.items.length === 0 &&
+ this.loaderRecentlyReleasedBooks?.items.length === 0 &&
+ this.loaderRecentlyReadBooks?.items.length === 0
},
individualLibrary(): boolean {
return this.libraryId !== LIBRARIES_ALL
},
},
methods: {
+ async scrollChanged(loader: PageLoader, percent: number) {
+ if (percent > 0.95) await loader.loadNext()
+ },
getRequestLibraryId(libraryId: string): string | undefined {
return libraryId !== LIBRARIES_ALL ? libraryId : undefined
},
@@ -290,11 +329,11 @@ export default Vue.extend({
}
},
readProgressChanged(event: ReadProgressSseDto) {
- if (this.inProgressBooks.some(b => b.id === event.bookId)) this.reload()
- else if (this.latestBooks.some(b => b.id === event.bookId)) this.reload()
- else if (this.onDeckBooks.some(b => b.id === event.bookId)) this.reload()
- else if (this.recentlyReleasedBooks.some(b => b.id === event.bookId)) this.reload()
- else if (this.recentlyReadBooks.some(b => b.id === event.bookId)) this.reload()
+ if (this.loaderInProgressBooks?.items.some(b => b.id === event.bookId)) this.reload()
+ else if (this.loaderLatestBooks?.items.some(b => b.id === event.bookId)) this.reload()
+ else if (this.loaderOnDeckBooks?.items.some(b => b.id === event.bookId)) this.reload()
+ else if (this.loaderRecentlyReleasedBooks?.items.some(b => b.id === event.bookId)) this.reload()
+ else if (this.loaderRecentlyReadBooks?.items.some(b => b.id === event.bookId)) this.reload()
},
reload: throttle(function (this: any) {
this.loadAll(this.libraryId)
@@ -304,55 +343,49 @@ export default Vue.extend({
this.library = this.getLibraryLazy(libraryId)
this.selectedSeries = []
this.selectedBooks = []
- Promise.all([this.loadInProgressBooks(libraryId),
- this.loadOnDeckBooks(libraryId),
- this.loadRecentlyReleasedBooks(libraryId),
- this.loadLatestBooks(libraryId),
- this.loadNewSeries(libraryId),
- this.loadUpdatedSeries(libraryId),
- this.loadRecentlyReadBooks(libraryId),
- ]).then(x => {
+
+ this.loaderInProgressBooks = new PageLoader(
+ {sort: ['readProgress.lastModified,desc']},
+ (pageable: PageRequest) => this.$komgaBooks.getBooks(this.getRequestLibraryId(libraryId), pageable, undefined, undefined, [ReadStatus.IN_PROGRESS]),
+ )
+ this.loaderOnDeckBooks = new PageLoader(
+ {},
+ () => this.$komgaBooks.getBooksOnDeck(this.getRequestLibraryId(libraryId)),
+ )
+ this.loaderLatestBooks = new PageLoader(
+ {sort: ['metadata.title,desc']},
+ (pageable: PageRequest) => this.$komgaBooks.getBooks(this.getRequestLibraryId(libraryId), pageable),
+ )
+ this.loaderRecentlyReleasedBooks = new PageLoader(
+ {sort: ['metadata.releaseDate,desc']},
+ (pageable: PageRequest) => this.$komgaBooks.getBooks(this.getRequestLibraryId(libraryId), pageable, undefined, undefined, undefined, subMonths(new Date(), 1)),
+ )
+ this.loaderRecentlyReadBooks = new PageLoader(
+ {sort: ['readProgress.lastModified,desc']},
+ (pageable: PageRequest) => this.$komgaBooks.getBooks(this.getRequestLibraryId(libraryId), pageable, undefined, undefined, [ReadStatus.READ]),
+ )
+
+ this.loaderNewSeries = new PageLoader(
+ {},
+ () => this.$komgaSeries.getNewSeries(this.getRequestLibraryId(libraryId)),
+ )
+ this.loaderUpdatedSeries = new PageLoader(
+ {},
+ () => this.$komgaSeries.getUpdatedSeries(this.getRequestLibraryId(libraryId)),
+ )
+
+ Promise.all([
+ this.loaderInProgressBooks.loadNext(),
+ this.loaderOnDeckBooks.loadNext(),
+ this.loaderRecentlyReleasedBooks.loadNext(),
+ this.loaderLatestBooks.loadNext(),
+ this.loaderNewSeries.loadNext(),
+ this.loaderUpdatedSeries.loadNext(),
+ this.loaderRecentlyReadBooks.loadNext(),
+ ]).then(() => {
this.loading = false
})
},
- async loadNewSeries(libraryId: string) {
- this.newSeries = (await this.$komgaSeries.getNewSeries(this.getRequestLibraryId(libraryId))).content
- },
- async loadUpdatedSeries(libraryId: string) {
- this.updatedSeries = (await this.$komgaSeries.getUpdatedSeries(this.getRequestLibraryId(libraryId))).content
- },
- async loadLatestBooks(libraryId: string) {
- const pageRequest = {
- sort: ['createdDate,desc'],
- } as PageRequest
-
- this.latestBooks = (await this.$komgaBooks.getBooks(this.getRequestLibraryId(libraryId), pageRequest)).content
- },
- async loadRecentlyReleasedBooks(libraryId: string) {
- const pageRequest = {
- sort: ['metadata.releaseDate,desc'],
- } as PageRequest
-
- const releasedAfter = subMonths(new Date(), 1)
- this.recentlyReleasedBooks = (await this.$komgaBooks.getBooks(this.getRequestLibraryId(libraryId), pageRequest, undefined, undefined, undefined, releasedAfter)).content
- },
- async loadRecentlyReadBooks(libraryId: string) {
- const pageRequest = {
- sort: ['readProgress.lastModified,desc'],
- } as PageRequest
-
- this.recentlyReadBooks = (await this.$komgaBooks.getBooks(this.getRequestLibraryId(libraryId), pageRequest, undefined, undefined, [ReadStatus.READ])).content
- },
- async loadInProgressBooks(libraryId: string) {
- const pageRequest = {
- sort: ['readProgress.lastModified,desc'],
- } as PageRequest
-
- this.inProgressBooks = (await this.$komgaBooks.getBooks(this.getRequestLibraryId(libraryId), pageRequest, undefined, undefined, [ReadStatus.IN_PROGRESS])).content
- },
- async loadOnDeckBooks(libraryId: string) {
- this.onDeckBooks = (await this.$komgaBooks.getBooksOnDeck(this.getRequestLibraryId(libraryId))).content
- },
singleEditSeries(series: SeriesDto) {
this.$store.dispatch('dialogUpdateSeries', series)
},
diff --git a/komga-webui/src/views/Search.vue b/komga-webui/src/views/Search.vue
index 7ab0960d0..6bc226810 100644
--- a/komga-webui/src/views/Search.vue
+++ b/komga-webui/src/views/Search.vue
@@ -53,12 +53,17 @@
-
+ scrollChanged(loaderSeries, percent)"
+ >
{{ $t('common.series') }}
-
-
+ scrollChanged(loaderBooks, percent)"
+ >
{{ $t('common.books') }}
-
-
+ scrollChanged(loaderCollections, percent)"
+ >
{{ $t('common.collections') }}
-
-
+ scrollChanged(loaderReadLists, percent)"
+ >
{{ $t('common.readlists') }}
- {
return {
- series: [] as SeriesDto[],
- books: [] as BookDto[],
- collections: [] as CollectionDto[],
- readLists: [] as ReadListDto[],
- pageSize: 50,
+ loaderSeries: undefined as unknown as PageLoader,
+ loaderBooks: undefined as unknown as PageLoader,
+ loaderCollections: undefined as unknown as PageLoader,
+ loaderReadLists: undefined as unknown as PageLoader,
+ pageSize: 20,
loading: false,
selectedSeries: [] as SeriesDto[],
selectedBooks: [] as BookDto[],
@@ -176,7 +197,7 @@ export default Vue.extend({
selectedReadLists: [] as ReadListDto[],
}
},
- created () {
+ created() {
this.$eventHub.$on(LIBRARY_DELETED, this.reloadResults)
this.$eventHub.$on(SERIES_CHANGED, this.seriesChanged)
this.$eventHub.$on(SERIES_DELETED, this.seriesChanged)
@@ -191,7 +212,7 @@ export default Vue.extend({
this.$eventHub.$on(READPROGRESS_SERIES_CHANGED, this.readProgressSeriesChanged)
this.$eventHub.$on(READPROGRESS_SERIES_DELETED, this.readProgressSeriesChanged)
},
- beforeDestroy () {
+ beforeDestroy() {
this.$eventHub.$off(LIBRARY_DELETED, this.reloadResults)
this.$eventHub.$off(SERIES_CHANGED, this.seriesChanged)
this.$eventHub.$off(SERIES_DELETED, this.seriesChanged)
@@ -223,60 +244,67 @@ export default Vue.extend({
isAdmin(): boolean {
return this.$store.getters.meAdmin
},
- fixedCardWidth (): number {
+ fixedCardWidth(): number {
return this.$vuetify.breakpoint.name === 'xs' ? 120 : 150
},
- showToolbar (): boolean {
+ showToolbar(): boolean {
return this.selectedSeries.length === 0 && this.selectedBooks.length === 0 && this.selectedCollections.length === 0 && this.selectedReadLists.length === 0
},
- emptyResults (): boolean {
- return !this.loading && this.series.length === 0 && this.books.length === 0 && this.collections.length === 0 && this.readLists.length === 0
+ emptyResults(): boolean {
+ return !this.loading &&
+ this.loaderSeries?.items.length === 0 &&
+ this.loaderBooks?.items.length === 0 &&
+ this.loaderCollections?.items.length === 0 &&
+ this.loaderReadLists?.items.length === 0
},
},
methods: {
- seriesChanged(event: SeriesSseDto){
- if(this.series.map(x => x.id).includes(event.seriesId)){
+ async scrollChanged(loader: PageLoader, percent: number) {
+ if (percent > 0.95) await loader.loadNext()
+ },
+ seriesChanged(event: SeriesSseDto) {
+ if (this.loaderSeries?.items.map(x => x.id).includes(event.seriesId)) {
this.reloadResults()
}
},
- bookChanged(event: BookSseDto){
- if(this.books.map(x => x.id).includes(event.bookId)){
+ bookChanged(event: BookSseDto) {
+ if (this.loaderBooks?.items.map(x => x.id).includes(event.bookId)) {
this.reloadResults()
}
},
- readProgressChanged(event: ReadProgressSseDto){
- if(this.books.map(x => x.id).includes(event.bookId)){
+ readProgressChanged(event: ReadProgressSseDto) {
+ if (this.loaderBooks?.items.map(x => x.id).includes(event.bookId)) {
this.reloadResults()
}
},
- readProgressSeriesChanged(event: ReadProgressSeriesSseDto){
- if(this.series.map(x => x.id).includes(event.seriesId)){
+ readProgressSeriesChanged(event: ReadProgressSeriesSseDto) {
+ if (this.loaderSeries?.items.map(x => x.id).includes(event.seriesId)) {
this.reloadResults()
}
},
- collectionChanged (event: CollectionSseDto) {
- if (this.collections.map(x => x.id).includes(event.collectionId)) {
+ collectionChanged(event: CollectionSseDto) {
+ if (this.loaderCollections?.items.map(x => x.id).includes(event.collectionId)) {
this.reloadResults()
}
},
- readListChanged (event: ReadListSseDto) {
- if (this.readLists.map(x => x.id).includes(event.readListId)) {
+ readListChanged(event: ReadListSseDto) {
+ if (this.loaderReadLists?.items.map(x => x.id).includes(event.readListId)) {
this.reloadResults()
}
},
- singleEditSeries (series: SeriesDto) {
+ singleEditSeries(series: SeriesDto) {
this.$store.dispatch('dialogUpdateSeries', series)
},
- singleEditBook (book: BookDto) {
+ singleEditBook(book: BookDto) {
this.$store.dispatch('dialogUpdateBooks', book)
},
- singleEditCollection (collection: CollectionDto) {
+ singleEditCollection(collection: CollectionDto) {
this.$store.dispatch('dialogEditCollection', collection)
},
- singleEditReadList (readList: ReadListDto) {
+ singleEditReadList(readList: ReadListDto) {
this.$store.dispatch('dialogEditReadList', readList)
},
- async markSelectedSeriesRead () {
+ async markSelectedSeriesRead() {
await Promise.all(this.selectedSeries.map(s =>
this.$komgaSeries.markAsRead(s.id),
))
@@ -284,7 +312,7 @@ export default Vue.extend({
this.$komgaSeries.getOneSeries(s.id),
))
},
- async markSelectedSeriesUnread () {
+ async markSelectedSeriesUnread() {
await Promise.all(this.selectedSeries.map(s =>
this.$komgaSeries.markAsUnread(s.id),
))
@@ -292,33 +320,33 @@ export default Vue.extend({
this.$komgaSeries.getOneSeries(s.id),
))
},
- addToCollection () {
+ addToCollection() {
this.$store.dispatch('dialogAddSeriesToCollection', this.selectedSeries)
},
- addToReadList () {
+ addToReadList() {
this.$store.dispatch('dialogAddBooksToReadList', this.selectedBooks)
},
- editMultipleSeries () {
+ editMultipleSeries() {
this.$store.dispatch('dialogUpdateSeries', this.selectedSeries)
},
- editMultipleBooks () {
+ editMultipleBooks() {
this.$store.dispatch('dialogUpdateBooks', this.selectedBooks)
},
bulkEditMultipleBooks() {
this.$store.dispatch('dialogUpdateBulkBooks', this.selectedBooks)
},
- deleteCollections () {
+ deleteCollections() {
this.$store.dispatch('dialogDeleteCollection', this.selectedCollections)
},
- deleteReadLists () {
+ deleteReadLists() {
this.$store.dispatch('dialogDeleteReadList', this.selectedReadLists)
},
- async markSelectedBooksRead () {
+ async markSelectedBooksRead() {
await Promise.all(this.selectedBooks.map(b =>
- this.$komgaBooks.updateReadProgress(b.id, { completed: true }),
+ this.$komgaBooks.updateReadProgress(b.id, {completed: true}),
))
},
- async markSelectedBooksUnread () {
+ async markSelectedBooksUnread() {
await Promise.all(this.selectedBooks.map(b =>
this.$komgaBooks.deleteReadProgress(b.id),
))
@@ -326,7 +354,7 @@ export default Vue.extend({
reloadResults: throttle(function (this: any) {
this.loadResults(this.$route.query.q.toString())
}, 500),
- async loadResults (search: string) {
+ async loadResults(search: string) {
this.selectedBooks = []
this.selectedSeries = []
this.selectedCollections = []
@@ -334,17 +362,24 @@ export default Vue.extend({
if (search) {
this.loading = true
- this.series = (await this.$komgaSeries.getSeries(undefined, { size: this.pageSize }, search)).content
- this.books = (await this.$komgaBooks.getBooks(undefined, { size: this.pageSize }, search)).content
- this.collections = (await this.$komgaCollections.getCollections(undefined, { size: this.pageSize }, search)).content
- this.readLists = (await this.$komgaReadLists.getReadLists(undefined, { size: this.pageSize }, search)).content
+ this.loaderSeries = new PageLoader({size: this.pageSize}, (pageable: PageRequest) => this.$komgaSeries.getSeries(undefined, pageable, search))
+ this.loaderBooks = new PageLoader({size: this.pageSize}, (pageable: PageRequest) => this.$komgaBooks.getBooks(undefined, pageable, search))
+ this.loaderCollections = new PageLoader({size: this.pageSize}, (pageable: PageRequest) => this.$komgaCollections.getCollections(undefined, pageable, search))
+ this.loaderReadLists = new PageLoader({size: this.pageSize}, (pageable: PageRequest) => this.$komgaReadLists.getReadLists(undefined, pageable, search))
- this.loading = false
+ Promise.all([
+ this.loaderSeries.loadNext(),
+ this.loaderBooks.loadNext(),
+ this.loaderCollections.loadNext(),
+ this.loaderReadLists.loadNext(),
+ ]).then(() => {
+ this.loading = false
+ })
} else {
- this.series = []
- this.books = []
- this.collections = []
- this.readLists = []
+ this.loaderSeries = null as unknown as PageLoader
+ this.loaderBooks = null as unknown as PageLoader
+ this.loaderCollections = null as unknown as PageLoader
+ this.loaderReadLists = null as unknown as PageLoader
}
},
},
diff --git a/komga-webui/tests/unit/types/pageLoader.spec.ts b/komga-webui/tests/unit/types/pageLoader.spec.ts
new file mode 100644
index 000000000..1530d2c5d
--- /dev/null
+++ b/komga-webui/tests/unit/types/pageLoader.spec.ts
@@ -0,0 +1,49 @@
+import {PageLoader} from '@/types/pageLoader'
+
+describe('PageLoader', () => {
+ const pageRequest = {} as PageRequest
+ const loader = (pageable: PageRequest) => Promise.resolve({
+ content: [1, 2, 3],
+ last: true,
+ number: 0,
+ } as Page)
+
+ test('given page loader when loading next then it should return true', async () => {
+ const pageLoader = new PageLoader(pageRequest, loader)
+
+ expect(await pageLoader.loadNext()).toBeTruthy()
+ expect(pageLoader.items).toStrictEqual([1, 2, 3])
+ })
+
+ test('given page loader on last page when loading next then it should return false', async () => {
+ const pageLoader = new PageLoader(pageRequest, loader)
+
+ expect(await pageLoader.loadNext()).toBeTruthy()
+ expect(pageLoader.items).toStrictEqual([1, 2, 3])
+ expect(await pageLoader.loadNext()).toBeFalsy()
+ })
+
+ test('given page loader when loading next then it should return true until last page is reached', async () => {
+ const loader = (pageable: PageRequest) => Promise.resolve({
+ content: pageable.page === 1 ? [4, 5, 6] : [1, 2, 3],
+ last: pageable.page === 1,
+ number: pageable.page || 0,
+ } as Page)
+ const pageLoader = new PageLoader(pageRequest, loader)
+
+ expect(await pageLoader.loadNext()).toBeTruthy()
+ expect(pageLoader.items).toStrictEqual([1, 2, 3])
+ expect(await pageLoader.loadNext()).toBeTruthy()
+ expect(pageLoader.items).toStrictEqual([1, 2, 3, 4, 5, 6])
+ expect(await pageLoader.loadNext()).toBeFalsy()
+ })
+
+ test('given exception when loading next then exception is rethrown', async () => {
+ const loader = (pageable: PageRequest) => {
+ throw new Error('boom')
+ }
+ const pageLoader = new PageLoader(pageRequest, loader)
+
+ await expect(pageLoader.loadNext()).rejects.toEqual(new Error('boom'))
+ })
+})