browse collections

This commit is contained in:
Gauthier Roebroeck 2026-04-01 16:03:42 +08:00
parent ad421020fb
commit c13f55efea
23 changed files with 813 additions and 26 deletions

View file

@ -15,6 +15,11 @@ export function bookPageThumbnailUrl(bookId?: string, page?: number): string | u
return undefined
}
export function collectionThumbnailUrl(collectionId?: string): string | undefined {
if (collectionId) return `${ApiBaseUrl.noSlash}/api/v1/collections/${collectionId}/thumbnail`
return undefined
}
export function pageHashKnownThumbnailUrl(hash?: string): string | undefined {
if (hash) return `${ApiBaseUrl.noSlash}/api/v1/page-hashes/${hash}/thumbnail`
return undefined

View file

@ -1,6 +1,7 @@
import { defineQueryOptions } from '@pinia/colada'
import { defineMutation, defineQueryOptions, useMutation } from '@pinia/colada'
import { komgaClient } from '@/api/komga-client'
import type { PageRequest } from '@/types/PageRequest'
import type { components } from '@/generated/openapi/komga'
export const QUERY_KEYS_COLLECTIONS = {
root: ['collections'] as const,
@ -52,3 +53,41 @@ export const collectionDetailQuery = defineQueryOptions(
.then((res) => res.data),
}),
)
export const useUpdateCollection = defineMutation(() => {
// const queryCache = useQueryCache()
return useMutation({
mutation: ({
collectionId,
data,
}: {
collectionId: string
data: components['schemas']['CollectionUpdateDto']
}) =>
komgaClient.PATCH('/api/v1/collections/{id}', {
params: {
path: {
id: collectionId,
},
},
body: data,
}),
onSuccess: () => {
//TODO: check how to invalidate cache
// void queryCache.invalidateQueries({ key: QUERY_KEYS_LIBRARIES.root })
},
})
})
export const useDeleteCollection = defineMutation(() =>
useMutation({
mutation: (collectionId: string) =>
komgaClient.DELETE('/api/v1/collections/{id}', {
params: {
path: {
id: collectionId,
},
},
}),
}),
)

View file

@ -24,6 +24,10 @@ declare module 'vue' {
BuildCommit: typeof import('./components/BuildCommit.vue')['default']
BuildVersion: typeof import('./components/BuildVersion.vue')['default']
ChipCount: typeof import('./components/ChipCount.vue')['default']
CollectionCard: typeof import('./components/collection/card/CollectionCard.vue')['default']
CollectionDeletionWarning: typeof import('./components/collection/DeletionWarning.vue')['default']
CollectionMenu: typeof import('./components/collection/menu/CollectionMenu.vue')['default']
CollectionMenuBottomSheet: typeof import('./components/collection/menu/CollectionMenuBottomSheet.vue')['default']
DialogBookPicker: typeof import('./components/dialog/BookPicker.vue')['default']
DialogConfirm: typeof import('./components/dialog/Confirm.vue')['default']
DialogConfirmEdit: typeof import('./components/dialog/ConfirmEdit.vue')['default']

View file

@ -0,0 +1,31 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import DeletionWarning from './DeletionWarning.vue'
const meta = {
component: DeletionWarning,
render: (args: object) => ({
components: { DeletionWarning },
setup() {
return { args }
},
template: '<DeletionWarning />',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
docs: {
description: {
component:
'Warning shown within a confirmation dialog before deleting a particular series.',
},
},
},
args: {},
} satisfies Meta<typeof DeletionWarning>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}

View file

@ -0,0 +1,36 @@
<template>
<v-alert
type="warning"
variant="tonal"
class="mb-4"
>
<FormattedMessage :message-descriptor="message">
<template #ul="Content">
<ul class="ps-8">
<component :is="Content" />
</ul>
</template>
<template #li="Content">
<li>
<component :is="Content" />
</li>
</template>
<template #b="Content">
<div class="font-weight-bold mt-4">
<component :is="Content" />
</div>
</template>
</FormattedMessage>
</v-alert>
</template>
<script setup lang="ts">
import { defineMessage } from 'vue-intl'
const message = defineMessage({
description: 'Collection deletion warning notice',
defaultMessage:
'The collection will be deleted<ul><li>Series and books will not be deleted.</li></ul><b>This action cannot be undone.</b>',
id: 'jD87k9',
})
</script>

View file

@ -0,0 +1,67 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import CollectionCard from './CollectionCard.vue'
import { fn } from 'storybook/test'
import { httpTyped } from '@/mocks/api/httpTyped'
import { userRegular } from '@/mocks/api/handlers/users'
import DialogConfirmEditInstance from '@/components/dialog/ConfirmEditInstance.vue'
import DialogConfirmInstance from '@/components/dialog/ConfirmInstance.vue'
import { mockCollection } from '@/mocks/api/handlers/collections'
const meta = {
component: CollectionCard,
render: (args: object) => ({
components: { CollectionCard, DialogConfirmEditInstance, DialogConfirmInstance },
setup() {
return { args }
},
template:
'<CollectionCard v-bind="args" /><DialogConfirmEditInstance/><DialogConfirmInstance/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
docs: {
description: {
component: '',
},
},
},
args: {
collection: mockCollection,
onSelection: fn(),
},
} satisfies Meta<typeof CollectionCard>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}
export const Selected: Story = {
args: {
selected: true,
},
}
export const Hover: Story = {
args: {},
play: ({ canvas, userEvent }) => {
userEvent.hover(canvas.getByRole('img'))
},
}
export const HoverNonAdmin: Story = {
args: {},
parameters: {
msw: {
handlers: [
httpTyped.get('/api/v2/users/me', ({ response }) => response(200).json(userRegular)),
],
},
},
play: ({ canvas, userEvent }) => {
userEvent.hover(canvas.getByRole('img'))
},
}

View file

@ -0,0 +1,87 @@
<template>
<ItemCard
:id="id"
:title="title"
:lines="lines"
:poster-url="collectionThumbnailUrl(collection.id)"
:quick-action-icon="quickActionIcon"
:quick-action-props="quickActionProps"
:menu-icon="menuIcon"
:menu-props="menuProps"
v-bind="props"
@selection="(val, event) => emit('selection', val, event)"
@click-quick-action="showEditMetadataDialog()"
@card-long-press="bottomSheet = true"
/>
<CollectionMenu
:collection="collection"
:activator="menuActivator"
/>
<CollectionMenuBottomSheet
v-model="bottomSheet"
:collection="collection"
/>
</template>
<script setup lang="ts">
import type { components } from '@/generated/openapi/komga'
import { collectionThumbnailUrl } from '@/api/images'
import { useIntl } from 'vue-intl'
import type { ItemCardEmits, ItemCardLine, ItemCardProps, ItemCardTitle } from '@/types/ItemCard'
import { useCurrentUser } from '@/colada/users'
import { useEditCollectionDialog } from '@/composables/collection/useEditCollectionDialog'
import CollectionMenu from '@/components/collection/menu/CollectionMenu.vue'
import CollectionMenuBottomSheet from '@/components/collection/menu/CollectionMenuBottomSheet.vue'
const intl = useIntl()
const id = useId()
const { collection, ...props } = defineProps<
{
collection: components['schemas']['CollectionDto']
} & ItemCardProps
>()
const emit = defineEmits<ItemCardEmits>()
const bottomSheet = ref(false)
const title = computed<ItemCardTitle>(() => ({ text: collection.name, lines: 2 }))
const lines = computed<ItemCardLine[]>(() => [
{
text: intl.formatMessage(
{
description: 'Collection card subtitle: count of series',
defaultMessage: '{count} series',
id: 'P3UD90',
},
{ count: collection.seriesIds.length },
),
},
])
const { isAdmin } = useCurrentUser()
const quickActionIcon = computed(() => (isAdmin.value ? 'i-mdi:pencil' : undefined))
const quickActionProps = computed(() => ({
id: `${id}_quick`,
onmouseenter: () => (editActivator.value = `#${id}_quick`),
}))
const menuIcon = computed(() => (isAdmin.value ? 'i-mdi:dots-vertical' : undefined))
const menuProps = computed(() => ({
onmouseenter: (event: Event) => (menuActivator.value = event.currentTarget as Element),
}))
const {
prepareDialog: prepareEditCollectionDialog,
showDialog: showEditCollectionDialog,
activator: editActivator,
} = useEditCollectionDialog()
function showEditMetadataDialog() {
prepareEditCollectionDialog(collection)
showEditCollectionDialog()
}
const menuActivator = ref()
</script>

View file

@ -0,0 +1,9 @@
import { Meta } from '@storybook/addon-docs/blocks';
import * as Stories from './CollectionMenu.stories.ts';
<Meta of={Stories} />
# CollectionMenu
Action menu for collection.

View file

@ -0,0 +1,54 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import CollectionMenu from './CollectionMenu.vue'
import { expect } from 'storybook/test'
import DialogConfirmInstance from '@/components/dialog/ConfirmInstance.vue'
import DialogConfirmEditInstance from '@/components/dialog/ConfirmEditInstance.vue'
import { mockCollection } from '@/mocks/api/handlers/collections'
const meta = {
component: CollectionMenu,
render: (args: object) => ({
components: { CollectionMenu, DialogConfirmInstance, DialogConfirmEditInstance },
setup() {
return { args }
},
template:
'<v-icon-btn id="IDce0b073e6b2146e688c1cd32b61f3fef" icon="i-mdi:dots-vertical"/><CollectionMenu v-bind="args" /><DialogConfirmInstance/><DialogConfirmEditInstance/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
docs: {
description: {
component: '',
},
},
},
args: {
activator: '#IDce0b073e6b2146e688c1cd32b61f3fef',
collection: mockCollection,
},
play: async ({ canvas, userEvent }) => {
await expect(canvas.getByRole('button')).toBeEnabled()
await userEvent.click(canvas.getByRole('button'))
},
} satisfies Meta<typeof CollectionMenu>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}
// export const NonAdmin: Story = {
// args: {},
// parameters: {
// msw: {
// handlers: [
// httpTyped.get('/api/v2/users/me', ({ response }) => response(200).json(userRegular)),
// ],
// },
// },
// }

View file

@ -0,0 +1,27 @@
<template>
<v-menu :activator="activator">
<v-list density="compact">
<v-list-item
v-for="(action, i) in manageActions"
:key="i"
v-bind="action"
/>
</v-list>
</v-menu>
</template>
<script setup lang="ts">
import type { components } from '@/generated/openapi/komga'
import { useCollectionActions } from '@/composables/collection/useCollectionActions'
const { activator, collection } = defineProps<{
activator: string | Element
collection: components['schemas']['CollectionDto']
}>()
const { manageActions } = useCollectionActions(collection)
</script>
<script lang="ts"></script>
<style scoped></style>

View file

@ -0,0 +1,9 @@
import { Meta } from '@storybook/addon-docs/blocks';
import * as Stories from './CollectionMenuBottomSheet.stories.ts';
<Meta of={Stories} />
# CollectionMenuBottomSheet
Action menu for collection for touch screens.

View file

@ -0,0 +1,48 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import CollectionMenuBottomSheet from './CollectionMenuBottomSheet.vue'
import DialogConfirmInstance from '@/components/dialog/ConfirmInstance.vue'
import DialogConfirmEditInstance from '@/components/dialog/ConfirmEditInstance.vue'
import { mockCollection } from '@/mocks/api/handlers/collections'
const meta = {
component: CollectionMenuBottomSheet,
render: (args: object) => ({
components: { CollectionMenuBottomSheet, DialogConfirmInstance, DialogConfirmEditInstance },
setup() {
return { args }
},
template:
'<CollectionMenuBottomSheet v-bind="args" /><DialogConfirmInstance/><DialogConfirmEditInstance/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
docs: {
description: {
component: '',
},
},
},
args: {
modelValue: true,
collection: mockCollection,
},
} satisfies Meta<typeof CollectionMenuBottomSheet>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}
// export const NonAdmin: Story = {
// args: {},
// parameters: {
// msw: {
// handlers: [
// httpTyped.get('/api/v2/users/me', ({ response }) => response(200).json(userRegular)),
// ],
// },
// },
// }

View file

@ -0,0 +1,34 @@
<template>
<v-bottom-sheet
v-model="isShown"
inset
>
<v-list>
<v-list-item
v-for="(action, i) in manageActions"
:key="i"
v-bind="action"
/>
</v-list>
</v-bottom-sheet>
</template>
<script setup lang="ts">
import type { components } from '@/generated/openapi/komga'
import { useCollectionActions } from '@/composables/collection/useCollectionActions'
const isShown = defineModel<boolean>({ default: false })
const { collection } = defineProps<{
collection: components['schemas']['CollectionDto']
}>()
function afterClick() {
isShown.value = false
}
const { manageActions } = useCollectionActions(collection, afterClick)
</script>
<script lang="ts"></script>
<style scoped></style>

View file

@ -13,7 +13,7 @@
<v-col
v-for="(item, idx) in internalItems"
:key="idx"
:cols="presentationMode === 'grid' ? 'auto' : 12"
:cols="presentationMode === 'grid' ? (display.xs.value ? 6 : 'auto') : 12"
>
<slot
:item="item.raw"
@ -45,8 +45,10 @@ import { useAppStore } from '@/stores/app'
import { useSelectionStore } from '@/stores/selection'
import type { PresentationMode } from '@/types/libraries'
import { useItemsPerPage } from '@/composables/pagination'
import { useDisplay } from 'vuetify'
const appStore = useAppStore()
const display = useDisplay()
const page1 = defineModel<number>('page1', { required: true })

View file

@ -0,0 +1,129 @@
import type { components } from '@/generated/openapi/komga'
import type { ErrorCause } from '@/api/komga-client'
import { commonMessages } from '@/utils/i18n/common-messages'
import { useDialogsStore } from '@/stores/dialogs'
import { storeToRefs } from 'pinia'
import { useIntl } from 'vue-intl'
import { useDisplay } from 'vuetify'
import { useMessagesStore } from '@/stores/messages'
import { useCurrentUser } from '@/colada/users'
import CollectionDeletionWarning from '@/components/collection/DeletionWarning.vue'
import { CollectionAction } from '@/types/collection'
import { useEditCollectionDialog } from '@/composables/collection/useEditCollectionDialog'
import { useDeleteCollection } from '@/colada/collections'
export function useCollectionActions(
collection: components['schemas']['CollectionDto'],
callback: (action: CollectionAction) => void = () => {},
) {
const { isAdmin } = useCurrentUser()
const intl = useIntl()
const { confirm: dialogConfirm } = storeToRefs(useDialogsStore())
const messagesStore = useMessagesStore()
const display = useDisplay()
const manageActions = computed(() => [
...(isAdmin.value
? [
{
title: intl.formatMessage({
description: 'Collection menu: manage > edit',
defaultMessage: 'Edit',
id: '39afQA',
}),
action: CollectionAction.EDIT,
onMouseenter: (event: Event) => (editActivator.value = event.currentTarget as Element),
onClick: () => {
edit()
callback(CollectionAction.EDIT)
},
},
]
: []),
...(isAdmin.value
? [
{
title: intl.formatMessage({
description: 'Collection menu: manage > delete',
defaultMessage: 'Delete',
id: 'Ekh3wO',
}),
action: CollectionAction.DELETE,
onMouseenter: (event: Event) =>
(dialogConfirm.value.activator = event.currentTarget as Element),
onClick: () => {
deleteCollection()
callback(CollectionAction.DELETE)
},
},
]
: []),
])
//region Edit collection
const { prepareDialog: showEditCollectionDialog, activator: editActivator } =
useEditCollectionDialog()
function edit() {
showEditCollectionDialog(collection)
}
//endregion
//region Delete
const { mutateAsync: mutateDelete } = useDeleteCollection()
function deleteCollection() {
dialogConfirm.value.dialogProps = {
title: intl.formatMessage({
description: 'Collection delete dialog: title',
defaultMessage: 'Delete collection',
id: 'k6BCzW',
}),
subtitle: collection.name,
maxWidth: 600,
mode: 'checkbox',
color: 'error',
okText: intl.formatMessage({
description: 'Collection delete dialog: confirm button',
defaultMessage: 'Delete',
id: 'rDBhmQ',
}),
closeOnSave: true,
fullscreen: display.xs.value,
}
dialogConfirm.value.slotWarning = {
component: markRaw(CollectionDeletionWarning),
props: {},
}
dialogConfirm.value.callback = () => {
mutateDelete(collection.id)
.then(() => {
messagesStore.messages.push({
text: intl.formatMessage(
{
description: 'Snackbar notification shown upon successful collection deletion',
defaultMessage: 'Collection deleted: {collection}',
id: 'HdsnFp',
},
{
collection: collection.name,
},
),
})
})
.catch((error) => {
messagesStore.messages.push({
text:
(error?.cause as ErrorCause)?.message ||
intl.formatMessage(commonMessages.networkError),
})
})
}
}
//endregion
return {
// actions: actions,
manageActions: manageActions,
}
}

View file

@ -0,0 +1,87 @@
import { storeToRefs } from 'pinia'
import { useDialogsStore } from '@/stores/dialogs'
import { useIntl } from 'vue-intl'
import { useDisplay } from 'vuetify/framework'
import { useMessagesStore } from '@/stores/messages'
import type { components } from '@/generated/openapi/komga'
import EditMetadata from '@/components/series/form/EditMetadata.vue'
import type { ErrorCause } from '@/api/komga-client'
import { commonMessages } from '@/utils/i18n/common-messages'
import { useUpdateCollection } from '@/colada/collections'
export function useEditCollectionDialog() {
const { confirmEdit: dialogConfirmEdit } = storeToRefs(useDialogsStore())
const intl = useIntl()
const display = useDisplay()
const messagesStore = useMessagesStore()
const { mutateAsync: mutateUpdateCollection } = useUpdateCollection()
const prepareDialog = (collection: components['schemas']['CollectionDto']) => {
dialogConfirmEdit.value.dialogProps = {
title: intl.formatMessage({
description: 'Edit collection dialog title',
defaultMessage: 'Edit collection',
id: 'YVQ49g',
}),
subtitle: collection.name,
maxWidth: 600,
okText: 'Save',
cardTextClass: 'px-0',
closeOnSave: false,
scrollable: true,
fullscreen: display.xs.value,
}
dialogConfirmEdit.value.slot = {
component: markRaw(EditMetadata),
}
dialogConfirmEdit.value.record = collection
dialogConfirmEdit.value.callback = (
hideDialog: () => void,
setLoading: (isLoading: boolean) => void,
) => {
setLoading(true)
const updatedData = dialogConfirmEdit.value.record as components['schemas']['CollectionDto']
mutateUpdateCollection({ collectionId: collection.id, data: updatedData })
.then(() => {
hideDialog()
messagesStore.messages.push({
text: intl.formatMessage(
{
description: 'Snackbar notification shown upon successful collection update',
defaultMessage: 'Collection updated: {collection}',
id: 'E0cw62',
},
{
collection: updatedData.name,
},
),
})
})
.catch((error) => {
messagesStore.messages.push({
text:
(error?.cause as ErrorCause)?.message ||
intl.formatMessage(commonMessages.networkError),
})
setLoading(false)
})
}
}
const activatorRef = computed({
get: () => dialogConfirmEdit.value.activator,
set: (val) => (dialogConfirmEdit.value.activator = val),
})
function showDialog() {
dialogConfirmEdit.value.dialogProps.shown = true
}
return {
prepareDialog: prepareDialog,
activator: activatorRef,
showDialog: showDialog,
}
}

View file

@ -31,7 +31,10 @@ export function useGetLibrariesById(libraryId: MaybeRefOrGetter<LibraryId>) {
return libs
})
const libIds = computed(() => libs.value?.map((it) => it.id))
return {
libraries: libs,
libraryIds: libIds,
}
}

View file

@ -1,8 +1,10 @@
import { httpTyped } from '@/mocks/api/httpTyped'
import { PageRequest } from '@/types/PageRequest'
import { mockPage } from '@/mocks/api/pageable'
import { http, HttpResponse } from 'msw'
import mockThumbnailUrl from '@/assets/mock-thumbnail.jpg'
const collection1 = {
export const mockCollection = {
id: '026801S4HWRZA',
name: 'Golden Age',
ordered: true,
@ -12,7 +14,7 @@ const collection1 = {
filtered: false,
}
const collections = [collection1]
const collections = [mockCollection]
export const collectionsHandlers = [
httpTyped.get('/api/v1/collections', ({ query, response }) => {
@ -28,20 +30,14 @@ export const collectionsHandlers = [
mockPage(selected, new PageRequest(Number(query.get('page')), Number(query.get('size')))),
)
}),
// httpTyped.get('/api/v1/series/{seriesId}', ({ params, response }) => {
// if (params.seriesId === '404') return response(404).empty()
// return response(200).json(
// Object.assign({}, series1, { metadata: { title: `Series ${params.seriesId}` } }),
// )
// }),
// http.get('*/api/v1/series/*/thumbnail', async () => {
// // Get an ArrayBuffer from reading the file from disk or fetching it.
// const buffer = await fetch(mockThumbnailUrl).then((response) => response.arrayBuffer())
//
// return HttpResponse.arrayBuffer(buffer, {
// headers: {
// 'content-type': 'image/jpg',
// },
// })
// }),
http.get('*/api/v1/collections/*/thumbnail', async () => {
// Get an ArrayBuffer from reading the file from disk or fetching it.
const buffer = await fetch(mockThumbnailUrl).then((response) => response.arrayBuffer())
return HttpResponse.arrayBuffer(buffer, {
headers: {
'content-type': 'image/jpg',
},
})
}),
]

View file

@ -126,7 +126,7 @@
:book="item"
:selected="isSelected"
:pre-select="preSelect"
:width="display.xs.value ? undefined : appStore.gridCardWidth"
:width="display.xs.value ? 'auto' : appStore.gridCardWidth"
@selection="(_val, event) => toggleSelect(event as MouseEvent)"
/>
</template>

View file

@ -1,12 +1,128 @@
<template>
COLLECTIONS
<EmptyStateConstruction />
<v-app-bar>
<ChipCount :count="totalElements" />
<v-spacer />
<PosterSizeSlider />
<PageSizeSelector
v-if="isBrowsingPaged"
v-model="appStore.browsingPageSize"
allow-unpaged
:sizes="[1, 10, 20]"
/>
<PagingSelector
v-model="appStore.browsingPaging"
class="px-2"
/>
</v-app-bar>
<ItemBrowser
v-model:page1="page1"
:items="dataItems"
:presentation-mode="'grid'"
:has-next-page="hasNextPage"
:page-count="pageCount"
@load-next-page="loadNextPage()"
>
<template #default="{ item, isSelected, preSelect, toggleSelect }">
<CollectionCard
stretch-poster
:collection="item"
:selected="isSelected"
:pre-select="preSelect"
:width="display.xs.value ? 'auto' : appStore.gridCardWidth"
@selection="(_val, event) => toggleSelect(event as MouseEvent)"
/>
</template>
</ItemBrowser>
</template>
<script lang="ts" setup>
//
import { useInfiniteQuery, useQuery } from '@pinia/colada'
import { PageRequest } from '@/types/PageRequest'
import { useGetLibrariesById } from '@/composables/libraries'
import { useAppStore } from '@/stores/app'
import { usePagination } from '@/composables/pagination'
import { useSelectionStore } from '@/stores/selection'
import { useDisplay } from 'vuetify'
import { komgaClient } from '@/api/komga-client'
import PosterSizeSlider from '@/components/PosterSizeSlider.vue'
import { storeToRefs } from 'pinia'
import ChipCount from '@/components/ChipCount.vue'
import { collectionsListQuery } from '@/colada/collections'
const route = useRoute('/libraries/[id]/collections')
const libraryId = route.params.id
const { libraryIds } = useGetLibrariesById(libraryId)
const display = useDisplay()
const appStore = useAppStore()
const { isBrowsingScroll, isBrowsingPaged } = storeToRefs(appStore)
const { page0, page1, pageCount } = usePagination()
const selectionStore = useSelectionStore()
// clear selection if paging changes
watch(
() => appStore.browsingPaging,
() => selectionStore.clear(),
)
const { data: dataPaged } = useQuery(() => ({
...collectionsListQuery({
libraryIds: libraryIds.value,
pageRequest: PageRequest.FromPageSize(appStore.browsingPageSize, page0.value),
}),
enabled: isBrowsingPaged.value,
}))
watch(dataPaged, (newDataPaged) => {
if (newDataPaged) pageCount.value = newDataPaged.totalPages ?? 0
})
const {
data: dataInfinite,
loadNextPage,
hasNextPage,
} = useInfiniteQuery({
key: () => ['infinite_collections', { libraryIds: libraryIds.value }],
initialPageParam: new PageRequest(0, 50),
query: ({ pageParam }) =>
komgaClient
.GET('/api/v1/collections', {
params: {
query: {
page: pageParam.page,
size: pageParam.size,
libraryIds: libraryIds.value,
},
},
})
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
getNextPageParam: (lastPage, _, lastPageParam) => (!lastPage?.last ? lastPageParam.next() : null),
enabled: isBrowsingScroll,
})
const dataInfiniteFlat = computed(() =>
dataInfinite.value?.pages.flatMap((it) => it?.content ?? []),
)
const dataItems = computed(() =>
isBrowsingPaged.value ? dataPaged.value?.content : dataInfiniteFlat.value,
)
const totalElements = computed(() =>
isBrowsingPaged.value
? dataPaged.value?.totalElements
: dataInfinite.value?.pages?.[0]?.totalElements,
)
</script>
<style lang="scss"></style>
<route lang="yaml">
meta:
requiresRole: USER

View file

@ -195,7 +195,7 @@
:series="item"
:selected="isSelected"
:pre-select="preSelect"
:width="display.xs.value ? undefined : appStore.gridCardWidth"
:width="display.xs.value ? 'auto' : appStore.gridCardWidth"
@selection="(_val, event) => toggleSelect(event as MouseEvent)"
/>

View file

@ -4,7 +4,7 @@ export type ItemCardProps = {
*
* Defaults to `150`.
*/
width?: number
width?: number | 'auto'
/**
* Disable card selection.
*/

View file

@ -0,0 +1,4 @@
export enum CollectionAction {
EDIT,
DELETE,
}