mirror of
https://github.com/gotson/komga.git
synced 2026-05-08 21:00:16 +02:00
infinite scroll
This commit is contained in:
parent
e040ef8f8a
commit
55908ca6d7
13 changed files with 234 additions and 86 deletions
2
next-ui/src/components.d.ts
vendored
2
next-ui/src/components.d.ts
vendored
|
|
@ -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']
|
||||
|
|
|
|||
40
next-ui/src/components/PagingSelector.stories.ts
Normal file
40
next-ui/src/components/PagingSelector.stories.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
28
next-ui/src/components/PagingSelector.vue
Normal file
28
next-ui/src/components/PagingSelector.vue
Normal 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>
|
||||
24
next-ui/src/components/PosterSizeSlider.vue
Normal file
24
next-ui/src/components/PosterSizeSlider.vue
Normal 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>
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue