browse readlists

This commit is contained in:
Gauthier Roebroeck 2026-04-01 16:59:16 +08:00
parent c13f55efea
commit eaf4ca9149
27 changed files with 846 additions and 40 deletions

View file

@ -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

View file

@ -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,
},
},
}),
}),
)

View file

@ -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']

View file

@ -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'

View file

@ -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 },
),

View file

@ -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()

View file

@ -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'

View file

@ -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,
},
}

View file

@ -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,
},
}

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: '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>

View 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'))
},
}

View 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>

View 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.

View 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)),
// ],
// },
// },
// }

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 { 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>

View file

@ -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.

View file

@ -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)),
// ],
// },
// },
// }

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 { 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>

View file

@ -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'

View file

@ -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 },
),

View file

@ -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(() => {

View 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,
}
}

View 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,
}
}

View file

@ -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',
},
})
}),
]

View file

@ -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

View file

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