mirror of
https://github.com/gotson/komga.git
synced 2026-05-08 12:35:30 +02:00
browse readlists
This commit is contained in:
parent
c13f55efea
commit
eaf4ca9149
27 changed files with 846 additions and 40 deletions
|
|
@ -1,25 +1,30 @@
|
|||
import { ApiBaseUrl } from '@/api/base'
|
||||
|
||||
export function seriesThumbnailUrl(seriesId?: string): string | undefined {
|
||||
export function seriesPosterUrl(seriesId?: string): string | undefined {
|
||||
if (seriesId) return `${ApiBaseUrl.noSlash}/api/v1/series/${seriesId}/thumbnail`
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function bookThumbnailUrl(bookId?: string): string | undefined {
|
||||
export function bookPosterUrl(bookId?: string): string | undefined {
|
||||
if (bookId) return `${ApiBaseUrl.noSlash}/api/v1/books/${bookId}/thumbnail`
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function collectionPosterUrl(collectionId?: string): string | undefined {
|
||||
if (collectionId) return `${ApiBaseUrl.noSlash}/api/v1/collections/${collectionId}/thumbnail`
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function readListPosterUrl(readList?: string): string | undefined {
|
||||
if (readList) return `${ApiBaseUrl.noSlash}/api/v1/readlists/${readList}/thumbnail`
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function bookPageThumbnailUrl(bookId?: string, page?: number): string | undefined {
|
||||
if (bookId && page) return `${ApiBaseUrl.noSlash}/api/v1/books/${bookId}/pages/${page}/thumbnail`
|
||||
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
|
||||
|
|
|
|||
|
|
@ -48,3 +48,41 @@ export const useCreateReadList = defineMutation(() => {
|
|||
}),
|
||||
})
|
||||
})
|
||||
|
||||
export const useUpdateReadList = defineMutation(() => {
|
||||
// const queryCache = useQueryCache()
|
||||
return useMutation({
|
||||
mutation: ({
|
||||
readListId,
|
||||
data,
|
||||
}: {
|
||||
readListId: string
|
||||
data: components['schemas']['ReadListUpdateDto']
|
||||
}) =>
|
||||
komgaClient.PATCH('/api/v1/readlists/{id}', {
|
||||
params: {
|
||||
path: {
|
||||
id: readListId,
|
||||
},
|
||||
},
|
||||
body: data,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
//TODO: check how to invalidate cache
|
||||
// void queryCache.invalidateQueries({ key: QUERY_KEYS_LIBRARIES.root })
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
export const useDeleteReadList = defineMutation(() =>
|
||||
useMutation({
|
||||
mutation: (readListId: string) =>
|
||||
komgaClient.DELETE('/api/v1/readlists/{id}', {
|
||||
params: {
|
||||
path: {
|
||||
id: readListId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
4
next-ui/src/components.d.ts
vendored
4
next-ui/src/components.d.ts
vendored
|
|
@ -110,6 +110,10 @@ declare module 'vue' {
|
|||
PagingSelector: typeof import('./components/PagingSelector.vue')['default']
|
||||
PosterSizeSlider: typeof import('./components/PosterSizeSlider.vue')['default']
|
||||
PresentationSelector: typeof import('./components/PresentationSelector.vue')['default']
|
||||
ReadlistCard: typeof import('./components/readlist/card/ReadlistCard.vue')['default']
|
||||
ReadlistDeletionWarning: typeof import('./components/readlist/DeletionWarning.vue')['default']
|
||||
ReadlistMenu: typeof import('./components/readlist/menu/ReadlistMenu.vue')['default']
|
||||
ReadlistMenuBottomSheet: typeof import('./components/readlist/menu/ReadlistMenuBottomSheet.vue')['default']
|
||||
ReleaseCard: typeof import('./components/release/Card.vue')['default']
|
||||
RemoteFileList: typeof import('./components/RemoteFileList.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
:id="id"
|
||||
:title="titleAndLines.title"
|
||||
:lines="titleAndLines.lines"
|
||||
:poster-url="bookThumbnailUrl(book.id)"
|
||||
:poster-url="bookPosterUrl(book.id)"
|
||||
:top-right-icon="isRead ? 'i-mdi:check' : undefined"
|
||||
:progress-percent="isRead ? undefined : progressPercent"
|
||||
fab-icon="i-mdi:play"
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import type { components } from '@/generated/openapi/komga'
|
||||
import { bookThumbnailUrl } from '@/api/images'
|
||||
import { bookPosterUrl } from '@/api/images'
|
||||
import { useIntl } from 'vue-intl'
|
||||
import type { ItemCardEmits, ItemCardLine, ItemCardProps, ItemCardTitle } from '@/types/ItemCard'
|
||||
import { useCurrentUser } from '@/colada/users'
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
:id="id"
|
||||
:title="title"
|
||||
:lines="lines"
|
||||
:poster-url="collectionThumbnailUrl(collection.id)"
|
||||
:poster-url="collectionPosterUrl(collection.id)"
|
||||
:quick-action-icon="quickActionIcon"
|
||||
:quick-action-props="quickActionProps"
|
||||
:menu-icon="menuIcon"
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import type { components } from '@/generated/openapi/komga'
|
||||
import { collectionThumbnailUrl } from '@/api/images'
|
||||
import { collectionPosterUrl } from '@/api/images'
|
||||
import { useIntl } from 'vue-intl'
|
||||
import type { ItemCardEmits, ItemCardLine, ItemCardProps, ItemCardTitle } from '@/types/ItemCard'
|
||||
import { useCurrentUser } from '@/colada/users'
|
||||
|
|
@ -53,8 +53,11 @@ const lines = computed<ItemCardLine[]>(() => [
|
|||
text: intl.formatMessage(
|
||||
{
|
||||
description: 'Collection card subtitle: count of series',
|
||||
defaultMessage: '{count} series',
|
||||
id: 'P3UD90',
|
||||
defaultMessage: `{count, plural,
|
||||
one {# series}
|
||||
other {# series}
|
||||
}`,
|
||||
id: '0J3Gvp',
|
||||
},
|
||||
{ count: collection.seriesIds.length },
|
||||
),
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@
|
|||
width="52"
|
||||
height="75"
|
||||
contain
|
||||
:src="bookThumbnailUrl(item.id)"
|
||||
:src="bookPosterUrl(item.id)"
|
||||
lazy-src="@/assets/cover.svg"
|
||||
class="me-2"
|
||||
/>
|
||||
|
|
@ -93,7 +93,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import type { components } from '@/generated/openapi/komga'
|
||||
import { bookThumbnailUrl } from '@/api/images'
|
||||
import { bookPosterUrl } from '@/api/images'
|
||||
import { useIntl } from 'vue-intl'
|
||||
|
||||
const intl = useIntl()
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@
|
|||
width="52"
|
||||
height="75"
|
||||
contain
|
||||
:src="seriesThumbnailUrl(s.id)"
|
||||
:src="seriesPosterUrl(s.id)"
|
||||
lazy-src="@/assets/cover.svg"
|
||||
class="me-2"
|
||||
/>
|
||||
|
|
@ -109,7 +109,7 @@
|
|||
import { useQuery } from '@pinia/colada'
|
||||
import { seriesListQuery } from '@/colada/series'
|
||||
import type { components } from '@/generated/openapi/komga'
|
||||
import { seriesThumbnailUrl } from '@/api/images'
|
||||
import { seriesPosterUrl } from '@/api/images'
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import { useLibraries } from '@/colada/libraries'
|
||||
import { PageRequest } from '@/types/PageRequest'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ItemCardWide from './ItemCardWide.vue'
|
||||
import { seriesThumbnailUrl } from '@/api/images'
|
||||
import { seriesPosterUrl } from '@/api/images'
|
||||
import { delay, http } from 'msw'
|
||||
import { fn } from 'storybook/test'
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ const meta = {
|
|||
args: {
|
||||
title: 'Card title',
|
||||
text: 'Card content',
|
||||
posterUrl: seriesThumbnailUrl('id'),
|
||||
posterUrl: seriesPosterUrl('id'),
|
||||
width: 150,
|
||||
onSelection: fn(),
|
||||
onClickFab: fn(),
|
||||
|
|
@ -39,7 +39,7 @@ const meta = {
|
|||
},
|
||||
argTypes: {
|
||||
posterUrl: {
|
||||
options: [seriesThumbnailUrl('id'), seriesThumbnailUrl('idL')],
|
||||
options: [seriesPosterUrl('id'), seriesPosterUrl('idL')],
|
||||
control: { type: 'radio' },
|
||||
},
|
||||
},
|
||||
|
|
@ -80,7 +80,7 @@ export const TopRightIcon: Story = {
|
|||
export const LandscapeStretched: Story = {
|
||||
args: {
|
||||
topRightIcon: 'i-mdi:check',
|
||||
posterUrl: seriesThumbnailUrl('idL'),
|
||||
posterUrl: seriesPosterUrl('idL'),
|
||||
stretchPoster: true,
|
||||
},
|
||||
}
|
||||
|
|
@ -88,7 +88,7 @@ export const LandscapeStretched: Story = {
|
|||
export const LandscapeNotStretched: Story = {
|
||||
args: {
|
||||
topRightIcon: 'i-mdi:check',
|
||||
posterUrl: seriesThumbnailUrl('idL'),
|
||||
posterUrl: seriesPosterUrl('idL'),
|
||||
stretchPoster: false,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ItemCard from './ItemCard.vue'
|
||||
import { seriesThumbnailUrl } from '@/api/images'
|
||||
import { seriesPosterUrl } from '@/api/images'
|
||||
import { delay, http } from 'msw'
|
||||
import { fn } from 'storybook/test'
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ const meta = {
|
|||
},
|
||||
args: {
|
||||
title: { text: 'Card title' },
|
||||
posterUrl: seriesThumbnailUrl('id'),
|
||||
posterUrl: seriesPosterUrl('id'),
|
||||
width: 150,
|
||||
onSelection: fn(),
|
||||
onClickFab: fn(),
|
||||
|
|
@ -35,7 +35,7 @@ const meta = {
|
|||
},
|
||||
argTypes: {
|
||||
posterUrl: {
|
||||
options: [seriesThumbnailUrl('id'), seriesThumbnailUrl('idL')],
|
||||
options: [seriesPosterUrl('id'), seriesPosterUrl('idL')],
|
||||
control: { type: 'radio' },
|
||||
},
|
||||
},
|
||||
|
|
@ -95,7 +95,7 @@ export const TopRightIcon: Story = {
|
|||
export const LandscapeStretched: Story = {
|
||||
args: {
|
||||
topRightIcon: 'i-mdi:check',
|
||||
posterUrl: seriesThumbnailUrl('idL'),
|
||||
posterUrl: seriesPosterUrl('idL'),
|
||||
stretchPoster: true,
|
||||
},
|
||||
}
|
||||
|
|
@ -103,7 +103,7 @@ export const LandscapeStretched: Story = {
|
|||
export const LandscapeNotStretched: Story = {
|
||||
args: {
|
||||
topRightIcon: 'i-mdi:check',
|
||||
posterUrl: seriesThumbnailUrl('idL'),
|
||||
posterUrl: seriesPosterUrl('idL'),
|
||||
stretchPoster: false,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
31
next-ui/src/components/readlist/DeletionWarning.stories.ts
Normal file
31
next-ui/src/components/readlist/DeletionWarning.stories.ts
Normal 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: {},
|
||||
}
|
||||
36
next-ui/src/components/readlist/DeletionWarning.vue
Normal file
36
next-ui/src/components/readlist/DeletionWarning.vue
Normal 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: 'Readlist deletion warning notice',
|
||||
defaultMessage:
|
||||
'The read list will be deleted<ul><li>Series and books will not be deleted.</li></ul><b>This action cannot be undone.</b>',
|
||||
id: 'xSNdDU',
|
||||
})
|
||||
</script>
|
||||
66
next-ui/src/components/readlist/card/ReadlistCard.stories.ts
Normal file
66
next-ui/src/components/readlist/card/ReadlistCard.stories.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ReadlistCard from './ReadlistCard.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 { mockReadList1 } from '@/mocks/api/handlers/readlists'
|
||||
|
||||
const meta = {
|
||||
component: ReadlistCard,
|
||||
render: (args: object) => ({
|
||||
components: { ReadlistCard, DialogConfirmEditInstance, DialogConfirmInstance },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: '<ReadlistCard 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: {
|
||||
readList: mockReadList1,
|
||||
onSelection: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof ReadlistCard>
|
||||
|
||||
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'))
|
||||
},
|
||||
}
|
||||
90
next-ui/src/components/readlist/card/ReadlistCard.vue
Normal file
90
next-ui/src/components/readlist/card/ReadlistCard.vue
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<template>
|
||||
<ItemCard
|
||||
:id="id"
|
||||
:title="title"
|
||||
:lines="lines"
|
||||
:poster-url="readListPosterUrl(readList.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"
|
||||
/>
|
||||
<ReadlistMenu
|
||||
:read-list="readList"
|
||||
:activator="menuActivator"
|
||||
/>
|
||||
<ReadlistMenuBottomSheet
|
||||
v-model="bottomSheet"
|
||||
:read-list="readList"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { components } from '@/generated/openapi/komga'
|
||||
import { readListPosterUrl } from '@/api/images'
|
||||
import { useIntl } from 'vue-intl'
|
||||
import type { ItemCardEmits, ItemCardLine, ItemCardProps, ItemCardTitle } from '@/types/ItemCard'
|
||||
import { useCurrentUser } from '@/colada/users'
|
||||
import ReadlistMenu from '@/components/readlist/menu/ReadlistMenu.vue'
|
||||
import ReadlistMenuBottomSheet from '@/components/readlist/menu/ReadlistMenuBottomSheet.vue'
|
||||
import { useEditReadListDialog } from '@/composables/readlist/useEditReadListDialog'
|
||||
|
||||
const intl = useIntl()
|
||||
|
||||
const id = useId()
|
||||
|
||||
const { readList, ...props } = defineProps<
|
||||
{
|
||||
readList: components['schemas']['ReadListDto']
|
||||
} & ItemCardProps
|
||||
>()
|
||||
const emit = defineEmits<ItemCardEmits>()
|
||||
|
||||
const bottomSheet = ref(false)
|
||||
|
||||
const title = computed<ItemCardTitle>(() => ({ text: readList.name, lines: 2 }))
|
||||
|
||||
const lines = computed<ItemCardLine[]>(() => [
|
||||
{
|
||||
text: intl.formatMessage(
|
||||
{
|
||||
description: 'Readlist card subtitle: count of books',
|
||||
defaultMessage: `{count, plural,
|
||||
one {# book}
|
||||
other {# books}
|
||||
}`,
|
||||
id: 'WLfi1+',
|
||||
},
|
||||
{ count: readList.bookIds.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: prepareEditDialog,
|
||||
showDialog: showEditDialog,
|
||||
activator: editActivator,
|
||||
} = useEditReadListDialog()
|
||||
|
||||
function showEditMetadataDialog() {
|
||||
prepareEditDialog(readList)
|
||||
showEditDialog()
|
||||
}
|
||||
|
||||
const menuActivator = ref()
|
||||
</script>
|
||||
9
next-ui/src/components/readlist/menu/ReadlistMenu.mdx
Normal file
9
next-ui/src/components/readlist/menu/ReadlistMenu.mdx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
import * as Stories from './ReadlistMenu.stories.ts';
|
||||
|
||||
<Meta of={Stories} />
|
||||
|
||||
# ReadlistMenu
|
||||
|
||||
Action menu for read list.
|
||||
54
next-ui/src/components/readlist/menu/ReadlistMenu.stories.ts
Normal file
54
next-ui/src/components/readlist/menu/ReadlistMenu.stories.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ReadlistMenu from './ReadlistMenu.vue'
|
||||
import { expect } from 'storybook/test'
|
||||
import DialogConfirmInstance from '@/components/dialog/ConfirmInstance.vue'
|
||||
import DialogConfirmEditInstance from '@/components/dialog/ConfirmEditInstance.vue'
|
||||
import { mockReadList1 } from '@/mocks/api/handlers/readlists'
|
||||
|
||||
const meta = {
|
||||
component: ReadlistMenu,
|
||||
render: (args: object) => ({
|
||||
components: { ReadlistMenu, DialogConfirmInstance, DialogConfirmEditInstance },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template:
|
||||
'<v-icon-btn id="IDce0b073e6b2146e688c1cd32b61f3fef" icon="i-mdi:dots-vertical"/><ReadlistMenu 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',
|
||||
readList: mockReadList1,
|
||||
},
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
await expect(canvas.getByRole('button')).toBeEnabled()
|
||||
|
||||
await userEvent.click(canvas.getByRole('button'))
|
||||
},
|
||||
} satisfies Meta<typeof ReadlistMenu>
|
||||
|
||||
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)),
|
||||
// ],
|
||||
// },
|
||||
// },
|
||||
// }
|
||||
27
next-ui/src/components/readlist/menu/ReadlistMenu.vue
Normal file
27
next-ui/src/components/readlist/menu/ReadlistMenu.vue
Normal 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 { useReadListActions } from '@/composables/readlist/useReadListActions'
|
||||
|
||||
const { activator, readList } = defineProps<{
|
||||
activator: string | Element
|
||||
readList: components['schemas']['ReadListDto']
|
||||
}>()
|
||||
|
||||
const { manageActions } = useReadListActions(readList)
|
||||
</script>
|
||||
|
||||
<script lang="ts"></script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
import * as Stories from './ReadlistMenuBottomSheet.stories.ts';
|
||||
|
||||
<Meta of={Stories} />
|
||||
|
||||
# ReadlistMenuBottomSheet
|
||||
|
||||
Action menu for read list for touch screens.
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ReadlistMenuBottomSheet from './ReadlistMenuBottomSheet.vue'
|
||||
import DialogConfirmInstance from '@/components/dialog/ConfirmInstance.vue'
|
||||
import DialogConfirmEditInstance from '@/components/dialog/ConfirmEditInstance.vue'
|
||||
import { mockReadList1 } from '@/mocks/api/handlers/readlists'
|
||||
|
||||
const meta = {
|
||||
component: ReadlistMenuBottomSheet,
|
||||
render: (args: object) => ({
|
||||
components: { ReadlistMenuBottomSheet, DialogConfirmInstance, DialogConfirmEditInstance },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template:
|
||||
'<ReadlistMenuBottomSheet 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,
|
||||
readList: mockReadList1,
|
||||
},
|
||||
} satisfies Meta<typeof ReadlistMenuBottomSheet>
|
||||
|
||||
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)),
|
||||
// ],
|
||||
// },
|
||||
// },
|
||||
// }
|
||||
|
|
@ -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 { useReadListActions } from '@/composables/readlist/useReadListActions'
|
||||
|
||||
const isShown = defineModel<boolean>({ default: false })
|
||||
|
||||
const { readList } = defineProps<{
|
||||
readList: components['schemas']['ReadListDto']
|
||||
}>()
|
||||
|
||||
function afterClick() {
|
||||
isShown.value = false
|
||||
}
|
||||
const { manageActions } = useReadListActions(readList, afterClick)
|
||||
</script>
|
||||
|
||||
<script lang="ts"></script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
<ItemCardWide
|
||||
:title="series.metadata.title"
|
||||
:text="series.metadata.summary"
|
||||
:poster-url="seriesThumbnailUrl(series.id)"
|
||||
:poster-url="seriesPosterUrl(series.id)"
|
||||
:top-right="unreadCount"
|
||||
:top-right-icon="isRead ? 'i-mdi:check' : undefined"
|
||||
:quick-action-icon="quickActionIcon"
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import type { components } from '@/generated/openapi/komga'
|
||||
import { seriesThumbnailUrl } from '@/api/images'
|
||||
import { seriesPosterUrl } from '@/api/images'
|
||||
import type { ItemCardEmits, ItemCardProps } from '@/types/ItemCard'
|
||||
import { useCurrentUser } from '@/colada/users'
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
:id="id"
|
||||
:title="title"
|
||||
:lines="lines"
|
||||
:poster-url="seriesThumbnailUrl(series.id)"
|
||||
:poster-url="seriesPosterUrl(series.id)"
|
||||
:top-right="unreadCount"
|
||||
:top-right-icon="isRead ? 'i-mdi:check' : undefined"
|
||||
fab-icon="i-mdi:play"
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import type { components } from '@/generated/openapi/komga'
|
||||
import { seriesThumbnailUrl } from '@/api/images'
|
||||
import { seriesPosterUrl } from '@/api/images'
|
||||
import { useIntl } from 'vue-intl'
|
||||
import type { ItemCardEmits, ItemCardLine, ItemCardProps, ItemCardTitle } from '@/types/ItemCard'
|
||||
import { useCurrentUser } from '@/colada/users'
|
||||
|
|
@ -85,8 +85,11 @@ const lines = computed<ItemCardLine[]>(() => {
|
|||
text: intl.formatMessage(
|
||||
{
|
||||
description: 'Series card subtitle: count of books',
|
||||
defaultMessage: '{count} books',
|
||||
id: 'bJsa/f',
|
||||
defaultMessage: `{count, plural,
|
||||
one {# book}
|
||||
other {# books}
|
||||
}`,
|
||||
id: 'cGOJnB',
|
||||
},
|
||||
{ count: series.booksCount },
|
||||
),
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ export function useEditCollectionDialog() {
|
|||
) => {
|
||||
setLoading(true)
|
||||
|
||||
const updatedData = dialogConfirmEdit.value.record as components['schemas']['CollectionDto']
|
||||
const updatedData = dialogConfirmEdit.value
|
||||
.record as components['schemas']['CollectionUpdateDto']
|
||||
|
||||
mutateUpdateCollection({ collectionId: collection.id, data: updatedData })
|
||||
.then(() => {
|
||||
|
|
|
|||
88
next-ui/src/composables/readlist/useEditReadListDialog.ts
Normal file
88
next-ui/src/composables/readlist/useEditReadListDialog.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
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 { useUpdateReadList } from '@/colada/readlists'
|
||||
|
||||
export function useEditReadListDialog() {
|
||||
const { confirmEdit: dialogConfirmEdit } = storeToRefs(useDialogsStore())
|
||||
const intl = useIntl()
|
||||
const display = useDisplay()
|
||||
const messagesStore = useMessagesStore()
|
||||
const { mutateAsync: mutateUpdate } = useUpdateReadList()
|
||||
|
||||
const prepareDialog = (readList: components['schemas']['ReadListDto']) => {
|
||||
dialogConfirmEdit.value.dialogProps = {
|
||||
title: intl.formatMessage({
|
||||
description: 'Edit readlist dialog title',
|
||||
defaultMessage: 'Edit read list',
|
||||
id: 'bDNZqj',
|
||||
}),
|
||||
subtitle: readList.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 = readList
|
||||
dialogConfirmEdit.value.callback = (
|
||||
hideDialog: () => void,
|
||||
setLoading: (isLoading: boolean) => void,
|
||||
) => {
|
||||
setLoading(true)
|
||||
|
||||
const updatedData = dialogConfirmEdit.value
|
||||
.record as components['schemas']['ReadListUpdateDto']
|
||||
|
||||
mutateUpdate({ readListId: readList.id, data: updatedData })
|
||||
.then(() => {
|
||||
hideDialog()
|
||||
messagesStore.messages.push({
|
||||
text: intl.formatMessage(
|
||||
{
|
||||
description: 'Snackbar notification shown upon successful readlist update',
|
||||
defaultMessage: 'Read list updated: {readlist}',
|
||||
id: 'IIqDdQ',
|
||||
},
|
||||
{
|
||||
readlist: 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,
|
||||
}
|
||||
}
|
||||
128
next-ui/src/composables/readlist/useReadListActions.ts
Normal file
128
next-ui/src/composables/readlist/useReadListActions.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
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 ReadListDeletionWarning from '@/components/readlist/DeletionWarning.vue'
|
||||
import { ReadListAction } from '@/types/readlist'
|
||||
import { useEditReadListDialog } from '@/composables/readlist/useEditReadListDialog'
|
||||
import { useDeleteReadList } from '@/colada/readlists'
|
||||
|
||||
export function useReadListActions(
|
||||
readList: components['schemas']['ReadListDto'],
|
||||
callback: (action: ReadListAction) => 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: 'Readlist menu: manage > edit',
|
||||
defaultMessage: 'Edit',
|
||||
id: 'ITAI2D',
|
||||
}),
|
||||
action: ReadListAction.EDIT,
|
||||
onMouseenter: (event: Event) => (editActivator.value = event.currentTarget as Element),
|
||||
onClick: () => {
|
||||
edit()
|
||||
callback(ReadListAction.EDIT)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isAdmin.value
|
||||
? [
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'Readlist menu: manage > delete',
|
||||
defaultMessage: 'Delete',
|
||||
id: 'YP5tSl',
|
||||
}),
|
||||
action: ReadListAction.DELETE,
|
||||
onMouseenter: (event: Event) =>
|
||||
(dialogConfirm.value.activator = event.currentTarget as Element),
|
||||
onClick: () => {
|
||||
deleteReadList()
|
||||
callback(ReadListAction.DELETE)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
])
|
||||
|
||||
//region Edit collection
|
||||
const { prepareDialog: showEditDialog, activator: editActivator } = useEditReadListDialog()
|
||||
|
||||
function edit() {
|
||||
showEditDialog(readList)
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Delete
|
||||
const { mutateAsync: mutateDelete } = useDeleteReadList()
|
||||
|
||||
function deleteReadList() {
|
||||
dialogConfirm.value.dialogProps = {
|
||||
title: intl.formatMessage({
|
||||
description: 'Readlist delete dialog: title',
|
||||
defaultMessage: 'Delete read list',
|
||||
id: 'a5jT6x',
|
||||
}),
|
||||
subtitle: readList.name,
|
||||
maxWidth: 600,
|
||||
mode: 'checkbox',
|
||||
color: 'error',
|
||||
okText: intl.formatMessage({
|
||||
description: 'Readlist delete dialog: confirm button',
|
||||
defaultMessage: 'Delete',
|
||||
id: 'vTqlcG',
|
||||
}),
|
||||
closeOnSave: true,
|
||||
fullscreen: display.xs.value,
|
||||
}
|
||||
dialogConfirm.value.slotWarning = {
|
||||
component: markRaw(ReadListDeletionWarning),
|
||||
props: {},
|
||||
}
|
||||
dialogConfirm.value.callback = () => {
|
||||
mutateDelete(readList.id)
|
||||
.then(() => {
|
||||
messagesStore.messages.push({
|
||||
text: intl.formatMessage(
|
||||
{
|
||||
description: 'Snackbar notification shown upon successful readlist deletion',
|
||||
defaultMessage: 'Read list deleted: {readlist}',
|
||||
id: 'Oj3xqB',
|
||||
},
|
||||
{
|
||||
readlist: readList.name,
|
||||
},
|
||||
),
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
messagesStore.messages.push({
|
||||
text:
|
||||
(error?.cause as ErrorCause)?.message ||
|
||||
intl.formatMessage(commonMessages.networkError),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
return {
|
||||
// actions: actions,
|
||||
manageActions: manageActions,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { httpTyped } from '@/mocks/api/httpTyped'
|
||||
import { mockPage } from '@/mocks/api/pageable'
|
||||
import { PageRequest } from '@/types/PageRequest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import mockThumbnailUrl from '@/assets/mock-thumbnail.jpg'
|
||||
|
||||
export const matchCbl = {
|
||||
readListMatch: { name: "Jupiter's Legacy", errorCode: '' },
|
||||
|
|
@ -146,7 +148,7 @@ export const garbledCbl = {
|
|||
message: 'ERR_1015',
|
||||
}
|
||||
|
||||
const rl1 = {
|
||||
export const mockReadList1 = {
|
||||
id: '02AQZYKBS00J8',
|
||||
name: 'Readlist example',
|
||||
summary: 'An example read list to show off how it works in Komga.',
|
||||
|
|
@ -157,7 +159,7 @@ const rl1 = {
|
|||
filtered: false,
|
||||
}
|
||||
|
||||
const rl2 = {
|
||||
const mockReadList2 = {
|
||||
id: '02AQZYKBS00J8',
|
||||
name: 'Elfes',
|
||||
summary: 'Elfes readlist',
|
||||
|
|
@ -168,7 +170,7 @@ const rl2 = {
|
|||
filtered: false,
|
||||
}
|
||||
|
||||
const readlists = [rl1, rl2]
|
||||
const readlists = [mockReadList1, mockReadList2]
|
||||
|
||||
export const readListsHandlers = [
|
||||
httpTyped.get('/api/v1/readlists', ({ query, response }) => {
|
||||
|
|
@ -201,4 +203,14 @@ export const readListsHandlers = [
|
|||
httpTyped.post('/api/v1/readlists/match/comicrack', ({ response }) => {
|
||||
return response(200).json(matchCbl)
|
||||
}),
|
||||
http.get('*/api/v1/readlists/*/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',
|
||||
},
|
||||
})
|
||||
}),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,12 +1,128 @@
|
|||
<template>
|
||||
READLISTS
|
||||
<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 }">
|
||||
<ReadlistCard
|
||||
stretch-poster
|
||||
:read-list="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 { readListsListQuery } from '@/colada/readlists'
|
||||
|
||||
const route = useRoute('/libraries/[id]/readlists')
|
||||
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(() => ({
|
||||
...readListsListQuery({
|
||||
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_readlists', { libraryIds: libraryIds.value }],
|
||||
initialPageParam: new PageRequest(0, 50),
|
||||
query: ({ pageParam }) =>
|
||||
komgaClient
|
||||
.GET('/api/v1/readlists', {
|
||||
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
|
||||
|
|
|
|||
4
next-ui/src/types/readlist.ts
Normal file
4
next-ui/src/types/readlist.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export enum ReadListAction {
|
||||
EDIT,
|
||||
DELETE,
|
||||
}
|
||||
Loading…
Reference in a new issue