infinite scroll

This commit is contained in:
Gauthier Roebroeck 2026-03-25 11:06:32 +08:00
parent e040ef8f8a
commit 55908ca6d7
13 changed files with 234 additions and 86 deletions

View file

@ -94,6 +94,8 @@ declare module 'vue' {
PageHashMatchTable: typeof import('./components/pageHash/MatchTable.vue')['default']
PageHashUnknownTable: typeof import('./components/pageHash/UnknownTable.vue')['default']
PageSizeSelector: typeof import('./components/PageSizeSelector.vue')['default']
PagingSelector: typeof import('./components/PagingSelector.vue')['default']
PosterSizeSlider: typeof import('./components/PosterSizeSlider.vue')['default']
PresentationSelector: typeof import('./components/PresentationSelector.vue')['default']
ReleaseCard: typeof import('./components/release/Card.vue')['default']
RemoteFileList: typeof import('./components/RemoteFileList.vue')['default']

View file

@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import PagingSelector from './PagingSelector.vue'
import { fn } from 'storybook/test'
const meta = {
component: PagingSelector,
render: (args: object) => ({
components: { PagingSelector },
setup() {
return { args }
},
template: '<PagingSelector v-model="args.modelValue" v-bind="args" />',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
docs: {
description: {
component: '',
},
},
},
args: {
modelValue: 'scroll',
'onUpdate:modelValue': fn(),
},
} satisfies Meta<typeof PagingSelector>
export default meta
type Story = StoryObj<typeof meta>
export const Scroll: Story = {
args: {},
}
export const Paged: Story = {
args: {
modelValue: 'paged',
},
}

View file

@ -0,0 +1,28 @@
<template>
<v-btn-toggle
v-model="paging"
mandatory
variant="outlined"
density="comfortable"
color="primary"
>
<v-btn
icon="i-mdi:infinity"
value="scroll"
/>
<v-btn
icon="i-mdi:view-grid"
value="paged"
/>
</v-btn-toggle>
</template>
<script setup lang="ts">
import type { Paging } from '@/types/page'
const paging = defineModel<Paging>({ required: true })
</script>
<script lang="ts"></script>
<style scoped></style>

View file

@ -0,0 +1,24 @@
<template>
<v-slider
v-if="display.smAndUp.value"
v-model="appStore.gridCardWidth"
:min="130"
:max="200"
color="surface-darken"
hide-details
thumb-size="15"
max-width="80"
/>
</template>
<script setup lang="ts">
import { useDisplay } from 'vuetify'
import { useAppStore } from '@/stores/app'
const display = useDisplay()
const appStore = useAppStore()
</script>
<script lang="ts"></script>
<style scoped></style>

View file

@ -70,8 +70,7 @@ const { data: infiniteData, loadNextPage } = useInfiniteQuery({
})
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
getNextPageParam: (lastPage) =>
!lastPage?.last ? new PageRequest((lastPage?.number ?? 0) + 1, lastPage?.size) : null,
getNextPageParam: (lastPage, _, lastPageParam) => (!lastPage?.last ? lastPageParam.next() : null),
})
const infiniteItems = computed(() => {
const itemTypes = (infiniteData.value?.pages.flatMap((it) => it?.content ?? []) ?? []).map((it) =>

View file

@ -65,8 +65,7 @@ const { data: infiniteData, loadNextPage } = useInfiniteQuery({
})
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
getNextPageParam: (lastPage) =>
!lastPage?.last ? new PageRequest((lastPage?.number ?? 0) + 1, lastPage?.size) : null,
getNextPageParam: (lastPage, _, lastPageParam) => (!lastPage?.last ? lastPageParam.next() : null),
})
const infiniteItems = computed(() => {
const itemTypes = (infiniteData.value?.pages.flatMap((it) => it?.content ?? []) ?? []).map((it) =>

View file

@ -65,8 +65,7 @@ const { data: infiniteData, loadNextPage } = useInfiniteQuery({
})
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
getNextPageParam: (lastPage) =>
!lastPage?.last ? new PageRequest((lastPage?.number ?? 0) + 1, lastPage?.size) : null,
getNextPageParam: (lastPage, _, lastPageParam) => (!lastPage?.last ? lastPageParam.next() : null),
})
const infiniteItems = computed(() => {
const itemTypes = (infiniteData.value?.pages.flatMap((it) => it?.content ?? []) ?? []).map((it) =>

View file

@ -65,8 +65,7 @@ const { data: infiniteData, loadNextPage } = useInfiniteQuery({
})
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
getNextPageParam: (lastPage) =>
!lastPage?.last ? new PageRequest((lastPage?.number ?? 0) + 1, lastPage?.size) : null,
getNextPageParam: (lastPage, _, lastPageParam) => (!lastPage?.last ? lastPageParam.next() : null),
})
const infiniteItems = computed(() => {
const itemTypes = (infiniteData.value?.pages.flatMap((it) => it?.content ?? []) ?? []).map((it) =>

View file

@ -70,8 +70,7 @@ const { data: infiniteData, loadNextPage } = useInfiniteQuery({
})
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
getNextPageParam: (lastPage) =>
!lastPage?.last ? new PageRequest((lastPage?.number ?? 0) + 1, lastPage?.size) : null,
getNextPageParam: (lastPage, _, lastPageParam) => (!lastPage?.last ? lastPageParam.next() : null),
})
const infiniteItems = computed(() => {
const itemTypes = (infiniteData.value?.pages.flatMap((it) => it?.content ?? []) ?? []).map((it) =>

View file

@ -2,16 +2,7 @@
<v-app-bar>
<v-spacer />
<v-slider
v-if="display.smAndUp.value"
v-model="appStore.gridCardWidth"
:min="130"
:max="200"
color="surface-darken"
hide-details
thumb-size="15"
max-width="80"
/>
<PosterSizeSlider />
<PresentationSelector
v-if="display.smAndUp.value"
@ -21,11 +12,18 @@
/>
<PageSizeSelector
v-if="appStore.isBrowsingPaged"
v-model="appStore.browsingPageSize"
allow-unpaged
:sizes="[1, 10, 20]"
/>
<PagingSelector
v-model="appStore.browsingPaging"
class="px-2"
/>
<!-- We use padding end so that the badge is displayed properly, else it goes off screen -->
<v-badge
location="top right"
color="primary"
@ -196,70 +194,75 @@
</v-list>
</TempDrawer>
<template v-if="series">
<v-data-iterator
v-model="selectedItems"
return-object
:items="series.content"
:items-per-page="itemsPerPage"
:page="page1"
show-select
>
<template #default="{ items, toggleSelect, isSelected }">
<v-container
v-if="presentationModeEffective === 'grid'"
fluid
>
<v-row>
<v-col
v-for="(item, idx) in items"
:key="item.raw.id"
cols="auto"
>
<SeriesCard
stretch-poster
:series="item.raw"
:selected="isSelected(item)"
:pre-select="preSelect"
:width="display.xs.value ? undefined : appStore.gridCardWidth"
@selection="(_val, event) => toggleSelect(item, idx, event as MouseEvent)"
/>
</v-col>
</v-row>
</v-container>
<v-container
v-if="presentationModeEffective === 'list'"
fluid
>
<v-row
<v-data-iterator
v-model="selectedItems"
return-object
:items="seriesItems"
:items-per-page="appStore.browsingPaging === 'paged' ? itemsPerPage : -1"
:page="appStore.browsingPaging === 'paged' ? page1 : 1"
show-select
>
<template #default="{ items, toggleSelect, isSelected }">
<v-container
v-if="presentationModeEffective === 'grid'"
fluid
>
<v-row>
<v-col
v-for="(item, idx) in items"
:key="item.raw.id"
cols="auto"
>
<v-col>
<SeriesCardWide
stretch-poster
:series="item.raw"
:selected="isSelected(item)"
:pre-select="preSelect"
:width="appStore.gridCardWidth"
@selection="(_val, event) => toggleSelect(item, idx, event as MouseEvent)"
/>
</v-col>
</v-row>
</v-container>
</template>
</v-data-iterator>
<SeriesCard
stretch-poster
:series="item.raw"
:selected="isSelected(item)"
:pre-select="preSelect"
:width="display.xs.value ? undefined : appStore.gridCardWidth"
@selection="(_val, event) => toggleSelect(item, idx, event as MouseEvent)"
/>
</v-col>
</v-row>
</v-container>
<v-pagination
v-model="page1"
:length="pageCount"
></v-pagination>
</template>
<v-container
v-if="presentationModeEffective === 'list'"
fluid
>
<v-row
v-for="(item, idx) in items"
:key="item.raw.id"
>
<v-col>
<SeriesCardWide
stretch-poster
:series="item.raw"
:selected="isSelected(item)"
:pre-select="preSelect"
:width="appStore.gridCardWidth"
@selection="(_val, event) => toggleSelect(item, idx, event as MouseEvent)"
/>
</v-col>
</v-row>
</v-container>
</template>
</v-data-iterator>
<v-pagination
v-if="appStore.isBrowsingPaged"
v-model="page1"
:length="pageCount"
/>
<div
v-if="appStore.isBrowsingScroll && hasNextPage"
v-intersect="(isIntersecting: boolean) => (isIntersecting ? loadMore() : undefined)"
style="min-height: 40px"
></div>
</template>
<script lang="ts" setup>
import { useQuery } from '@pinia/colada'
import { useInfiniteQuery, useQuery } from '@pinia/colada'
import { seriesListQuery } from '@/colada/series'
import type { components } from '@/generated/openapi/komga'
import { PageRequest } from '@/types/PageRequest'
@ -295,6 +298,8 @@ import { useIntl } from 'vue-intl'
import { commonMessages } from '@/utils/i18n/common-messages'
import { useIntlFormatter } from '@/composables/intlFormatter'
import { sortSeries } from '@/types/sort'
import { komgaClient } from '@/api/komga-client'
import PosterSizeSlider from '@/components/PosterSizeSlider.vue'
const route = useRoute('/libraries/[id]/series')
const libraryId = route.params.id
@ -414,22 +419,57 @@ const conds = computed(() => ({
],
}))
// clear selection if filter changes
watch(conds, () => selectionStore.clear())
// clear selection if filter or paging changes
watch([conds, () => appStore.browsingPaging], () => selectionStore.clear())
const { data: series } = useQuery(() =>
seriesListQuery({
search: {
condition: conds.value as components['schemas']['AllOfSeries'],
},
const apiQuery = computed(() => ({
condition: conds.value as components['schemas']['AllOfSeries'],
}))
const { data: series } = useQuery(() => ({
...seriesListQuery({
search: { ...apiQuery.value },
pageRequest: PageRequest.FromPageSize(appStore.browsingPageSize, page0.value, sortActive.value),
}),
)
enabled: appStore.isBrowsingPaged,
}))
watch(series, (newSeries) => {
if (newSeries) pageCount.value = newSeries.totalPages ?? 0
})
const {
data: infiniteData,
loadNextPage,
hasNextPage,
} = useInfiniteQuery({
key: () => ['infinite_series', apiQuery.value, sortActive.value],
initialPageParam: new PageRequest(0, 50, sortActive.value),
query: ({ pageParam }) =>
komgaClient
.POST('/api/v1/series/list', {
body: apiQuery.value,
params: {
query: {
...pageParam,
},
},
})
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
getNextPageParam: (lastPage, _, lastPageParam) => (!lastPage?.last ? lastPageParam.next() : null),
enabled: appStore.isBrowsingScroll,
})
const infiniteSeries = computed(() => infiniteData.value?.pages.flatMap((it) => it?.content ?? []))
const seriesItems = computed(() =>
appStore.isBrowsingPaged ? series.value?.content : infiniteSeries.value,
)
function loadMore() {
void loadNextPage()
}
const filterDrawer = ref(false)
// shared model for all the expansion-panels, so only 1 is opened at the same time

View file

@ -2,7 +2,7 @@
import { defineStore } from 'pinia'
import { useDisplay } from 'vuetify'
import type { PresentationMode } from '@/types/libraries'
import type { PageSize } from '@/types/page'
import type { PageSize, Paging } from '@/types/page'
import type { Sort } from '@/types/PageRequest'
export const useAppStore = defineStore('app', {
@ -13,6 +13,7 @@ export const useAppStore = defineStore('app', {
rememberMe: false,
importBooksPath: '',
browsingPageSize: 20 as PageSize,
browsingPaging: 'scroll' as Paging,
/**
* Store the presentation mode per view.
* Use the getter to ensure a default value is always set.
@ -28,6 +29,12 @@ export const useAppStore = defineStore('app', {
reorderLibraries: false,
}),
getters: {
isBrowsingPaged(state) {
return state.browsingPaging === 'paged'
},
isBrowsingScroll(state) {
return state.browsingPaging === 'scroll'
},
getPresentationMode: (state) => (key: string, defaultValue: PresentationMode) => {
return computed({
get: () => state.presentationMode[key] ?? (state.presentationMode[key] = defaultValue),
@ -54,7 +61,9 @@ export const useAppStore = defineStore('app', {
'rememberMe',
'importBooksPath',
'browsingPageSize',
'browsingPaging',
'presentationMode',
'sortActive',
'gridCardWidth',
],
},

View file

@ -69,6 +69,15 @@ export class PageRequest {
)
}
public next(): PageRequest {
return new PageRequest(
this.page != undefined ? this.page + 1 : 0,
this.size,
this.sort,
this.unpaged,
)
}
constructor(page?: number, size?: number, sort?: Sort[] | string[], unpaged?: boolean) {
if (page && page < 0) throw new Error('page cannot be negative')
if (size && size < 0) throw new Error('size cannot be negative')

View file

@ -1,5 +1,6 @@
import * as v from 'valibot'
export type PageSize = 'unpaged' | number
export type Paging = 'scroll' | 'paged'
export const SchemaStrictlyPositive = v.fallback(v.pipe(v.string(), v.toNumber(), v.minValue(1)), 1)