diff --git a/next-ui/src/colada/books.ts b/next-ui/src/colada/books.ts
index 1918fd15..5dec0f05 100644
--- a/next-ui/src/colada/books.ts
+++ b/next-ui/src/colada/books.ts
@@ -1,7 +1,8 @@
-import { defineQueryOptions } from '@pinia/colada'
+import { defineMutation, defineQueryOptions, useMutation } from '@pinia/colada'
import { komgaClient } from '@/api/komga-client'
import type { components } from '@/generated/openapi/komga'
import type { PageRequest } from '@/types/PageRequest'
+import { seriesMetadataToDto } from '@/functions/series'
export const QUERY_KEYS_BOOKS = {
root: ['books'] as const,
@@ -51,3 +52,94 @@ export const bookDetailQuery = defineQueryOptions(({ bookId }: { bookId: string
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
}))
+
+export const useRefreshMetadataBook = defineMutation(() =>
+ useMutation({
+ mutation: (bookId: string) =>
+ komgaClient.POST('/api/v1/books/{bookId}/metadata/refresh', {
+ params: {
+ path: {
+ bookId: bookId,
+ },
+ },
+ }),
+ }),
+)
+
+export const useAnalyzeBook = defineMutation(() =>
+ useMutation({
+ mutation: (bookId: string) =>
+ komgaClient.POST('/api/v1/books/{bookId}/analyze', {
+ params: {
+ path: {
+ bookId: bookId,
+ },
+ },
+ }),
+ }),
+)
+
+export const useMarkBookRead = defineMutation(() =>
+ useMutation({
+ mutation: (bookId: string) =>
+ komgaClient.PATCH('/api/v1/books/{bookId}/read-progress', {
+ params: {
+ path: {
+ bookId: bookId,
+ },
+ },
+ body: { completed: true },
+ }),
+ }),
+)
+
+export const useMarkBookUnread = defineMutation(() =>
+ useMutation({
+ mutation: (bookId: string) =>
+ komgaClient.DELETE('/api/v1/books/{bookId}/read-progress', {
+ params: {
+ path: {
+ bookId: bookId,
+ },
+ },
+ }),
+ }),
+)
+
+export const useDeleteBook = defineMutation(() =>
+ useMutation({
+ mutation: (bookId: string) =>
+ komgaClient.DELETE('/api/v1/books/{bookId}/file', {
+ params: {
+ path: {
+ bookId: bookId,
+ },
+ },
+ }),
+ }),
+)
+
+export const useUpdateBookMetadata = defineMutation(() => {
+ // const queryCache = useQueryCache()
+ return useMutation({
+ mutation: ({
+ bookId,
+ metadata,
+ }: {
+ bookId: string
+ metadata: components['schemas']['BookMetadataDto']
+ }) =>
+ komgaClient.PATCH('/api/v1/books/{bookId}/metadata', {
+ params: {
+ path: {
+ bookId: bookId,
+ },
+ },
+ body: metadata,
+ }),
+ onSuccess: () => {
+ //TODO: check how to invalidate cache
+ // void queryCache.invalidateQueries({ key: QUERY_KEYS_LIBRARIES.root })
+ },
+ })
+})
diff --git a/next-ui/src/components.d.ts b/next-ui/src/components.d.ts
index f61f8b06..3e35e734 100644
--- a/next-ui/src/components.d.ts
+++ b/next-ui/src/components.d.ts
@@ -17,6 +17,12 @@ declare module 'vue' {
ApikeyGenerateDialog: typeof import('./components/apikey/GenerateDialog.vue')['default']
ApikeyTable: typeof import('./components/apikey/Table.vue')['default']
AppFooter: typeof import('./components/AppFooter.vue')['default']
+ BookCard: typeof import('./components/book/card/BookCard.vue')['default']
+ BookDeletionWarning: typeof import('./components/book/DeletionWarning.vue')['default']
+ BookMenu: typeof import('./components/book/menu/BookMenu.vue')['default']
+ BookMenuBottomSheet: typeof import('./components/book/menu/BookMenuBottomSheet.vue')['default']
+ BookMenuSeriesMenu: typeof import('./components/book/menu/SeriesMenu.vue')['default']
+ BookMenuSeriesMenuBottomSheet: typeof import('./components/book/menu/SeriesMenuBottomSheet.vue')['default']
BuildCommit: typeof import('./components/BuildCommit.vue')['default']
BuildVersion: typeof import('./components/BuildVersion.vue')['default']
DialogBookPicker: typeof import('./components/dialog/BookPicker.vue')['default']
diff --git a/next-ui/src/components/book/DeletionWarning.stories.ts b/next-ui/src/components/book/DeletionWarning.stories.ts
new file mode 100644
index 00000000..280aae08
--- /dev/null
+++ b/next-ui/src/components/book/DeletionWarning.stories.ts
@@ -0,0 +1,30 @@
+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: '',
+ }),
+ 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 book.',
+ },
+ },
+ },
+ args: {},
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {},
+}
diff --git a/next-ui/src/components/book/DeletionWarning.vue b/next-ui/src/components/book/DeletionWarning.vue
new file mode 100644
index 00000000..803cf754
--- /dev/null
+++ b/next-ui/src/components/book/DeletionWarning.vue
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/next-ui/src/components/book/card/BookCard.stories.ts b/next-ui/src/components/book/card/BookCard.stories.ts
new file mode 100644
index 00000000..0e8dccd6
--- /dev/null
+++ b/next-ui/src/components/book/card/BookCard.stories.ts
@@ -0,0 +1,142 @@
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import BookCard from './BookCard.vue'
+import { mockSeries1 } from '@/mocks/api/handlers/series'
+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 { mockBook } from '@/mocks/api/handlers/books'
+
+const meta = {
+ component: BookCard,
+ render: (args: object) => ({
+ components: { BookCard, DialogConfirmEditInstance, DialogConfirmInstance },
+ setup() {
+ return { args }
+ },
+ template: '',
+ }),
+ parameters: {
+ // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
+ docs: {
+ description: {
+ component: '',
+ },
+ },
+ },
+ args: {
+ book: mockBook,
+ onSelection: fn(),
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {},
+}
+export const Unread: Story = {
+ args: {
+ book: {
+ ...mockBook,
+ readProgress: undefined,
+ },
+ },
+}
+
+export const InProgress: Story = {
+ args: {
+ book: {
+ ...mockBook,
+ readProgress: {
+ ...mockBook.readProgress,
+ completed: false,
+ page: 25,
+ },
+ },
+ },
+}
+
+export const Oneshot: Story = {
+ args: {
+ book: {
+ ...mockBook,
+ oneshot: true,
+ },
+ },
+}
+
+export const Deleted: Story = {
+ args: {
+ book: {
+ ...mockBook,
+ deleted: true,
+ },
+ },
+}
+
+export const MediaError: Story = {
+ args: {
+ book: {
+ ...mockBook,
+ media: {
+ ...mockBook.media,
+ status: 'ERROR',
+ },
+ },
+ },
+}
+
+export const MediaUnsupported: Story = {
+ args: {
+ book: {
+ ...mockBook,
+ media: {
+ ...mockBook.media,
+ status: 'UNSUPPORTED',
+ },
+ },
+ },
+}
+
+export const MediaUnknown: Story = {
+ args: {
+ book: {
+ ...mockBook,
+ media: {
+ ...mockBook.media,
+ status: 'UNKNOWN',
+ },
+ },
+ },
+}
+
+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'))
+ },
+}
diff --git a/next-ui/src/components/book/card/BookCard.vue b/next-ui/src/components/book/card/BookCard.vue
new file mode 100644
index 00000000..9a520ede
--- /dev/null
+++ b/next-ui/src/components/book/card/BookCard.vue
@@ -0,0 +1,138 @@
+
+ emit('selection', val)"
+ @click-quick-action="showEditMetadataDialog()"
+ @card-long-press="bottomSheet = true"
+ />
+
+
+
+
+
diff --git a/next-ui/src/components/book/menu/BookMenu.mdx b/next-ui/src/components/book/menu/BookMenu.mdx
new file mode 100644
index 00000000..5b3b4125
--- /dev/null
+++ b/next-ui/src/components/book/menu/BookMenu.mdx
@@ -0,0 +1,9 @@
+import { Meta } from '@storybook/addon-docs/blocks';
+
+import * as Stories from './BookMenu.stories.ts';
+
+
+
+# BookMenu
+
+Action menu for books.
diff --git a/next-ui/src/components/book/menu/BookMenu.stories.ts b/next-ui/src/components/book/menu/BookMenu.stories.ts
new file mode 100644
index 00000000..14c52f24
--- /dev/null
+++ b/next-ui/src/components/book/menu/BookMenu.stories.ts
@@ -0,0 +1,96 @@
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import BookMenu from './BookMenu.vue'
+import { mockSeries1 } from '@/mocks/api/handlers/series'
+import { httpTyped } from '@/mocks/api/httpTyped'
+import { userRegular } from '@/mocks/api/handlers/users'
+import { expect } from 'storybook/test'
+import DialogConfirmInstance from '@/components/dialog/ConfirmInstance.vue'
+import DialogConfirmEditInstance from '@/components/dialog/ConfirmEditInstance.vue'
+import { mockBook } from '@/mocks/api/handlers/books'
+
+const meta = {
+ component: BookMenu,
+ render: (args: object) => ({
+ components: { BookMenu, DialogConfirmInstance, DialogConfirmEditInstance },
+ setup() {
+ return { args }
+ },
+ template:
+ '',
+ }),
+ parameters: {
+ // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
+ docs: {
+ description: {
+ component: '',
+ },
+ },
+ },
+ args: {
+ activator: '#IDce0b073e6b2146e688c1cd32b61f3fef',
+ book: mockBook,
+ },
+ play: async ({ canvas, userEvent }) => {
+ await expect(canvas.getByRole('button')).toBeEnabled()
+
+ await userEvent.click(canvas.getByRole('button'))
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Read: Story = {
+ args: {
+ book: {
+ ...mockBook,
+ readProgress: {
+ ...mockBook.readProgress,
+ completed: true,
+ },
+ },
+ },
+}
+
+export const Unread: Story = {
+ args: {
+ book: {
+ ...mockBook,
+ readProgress: undefined,
+ },
+ },
+}
+
+export const InProgress: Story = {
+ args: {
+ book: {
+ ...mockBook,
+ readProgress: {
+ ...mockBook.readProgress,
+ completed: false,
+ page: 25,
+ },
+ },
+ },
+}
+
+export const Oneshot: Story = {
+ args: {
+ book: {
+ ...mockBook,
+ oneshot: true,
+ },
+ },
+}
+
+export const NonAdmin: Story = {
+ args: {},
+ parameters: {
+ msw: {
+ handlers: [
+ httpTyped.get('/api/v2/users/me', ({ response }) => response(200).json(userRegular)),
+ ],
+ },
+ },
+}
diff --git a/next-ui/src/components/book/menu/BookMenu.vue b/next-ui/src/components/book/menu/BookMenu.vue
new file mode 100644
index 00000000..bb1ecccb
--- /dev/null
+++ b/next-ui/src/components/book/menu/BookMenu.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/next-ui/src/components/book/menu/BookMenuBottomSheet.mdx b/next-ui/src/components/book/menu/BookMenuBottomSheet.mdx
new file mode 100644
index 00000000..9d22a80e
--- /dev/null
+++ b/next-ui/src/components/book/menu/BookMenuBottomSheet.mdx
@@ -0,0 +1,9 @@
+import { Meta } from '@storybook/addon-docs/blocks';
+
+import * as Stories from './BookMenuBottomSheet.stories.ts';
+
+
+
+# BookMenuBottomSheet
+
+Action menu for books for touch screens.
diff --git a/next-ui/src/components/book/menu/BookMenuBottomSheet.stories.ts b/next-ui/src/components/book/menu/BookMenuBottomSheet.stories.ts
new file mode 100644
index 00000000..b09fe227
--- /dev/null
+++ b/next-ui/src/components/book/menu/BookMenuBottomSheet.stories.ts
@@ -0,0 +1,90 @@
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import BookMenuBottomSheet from './BookMenuBottomSheet.vue'
+import { mockSeries1 } from '@/mocks/api/handlers/series'
+import { httpTyped } from '@/mocks/api/httpTyped'
+import { userRegular } from '@/mocks/api/handlers/users'
+import DialogConfirmInstance from '@/components/dialog/ConfirmInstance.vue'
+import DialogConfirmEditInstance from '@/components/dialog/ConfirmEditInstance.vue'
+import { mockBook } from '@/mocks/api/handlers/books'
+
+const meta = {
+ component: BookMenuBottomSheet,
+ render: (args: object) => ({
+ components: { BookMenuBottomSheet, DialogConfirmInstance, DialogConfirmEditInstance },
+ setup() {
+ return { args }
+ },
+ template:
+ '',
+ }),
+ parameters: {
+ // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
+ docs: {
+ description: {
+ component: '',
+ },
+ },
+ },
+ args: {
+ modelValue: true,
+ book: mockBook,
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Read: Story = {
+ args: {
+ book: {
+ ...mockBook,
+ readProgress: {
+ ...mockBook.readProgress,
+ completed: true,
+ },
+ },
+ },
+}
+
+export const Unread: Story = {
+ args: {
+ book: {
+ ...mockBook,
+ readProgress: undefined,
+ },
+ },
+}
+
+export const InProgress: Story = {
+ args: {
+ book: {
+ ...mockBook,
+ readProgress: {
+ ...mockBook.readProgress,
+ completed: false,
+ page: 25,
+ },
+ },
+ },
+}
+
+export const Oneshot: Story = {
+ args: {
+ book: {
+ ...mockBook,
+ oneshot: true,
+ },
+ },
+}
+
+export const NonAdmin: Story = {
+ args: {},
+ parameters: {
+ msw: {
+ handlers: [
+ httpTyped.get('/api/v2/users/me', ({ response }) => response(200).json(userRegular)),
+ ],
+ },
+ },
+}
diff --git a/next-ui/src/components/book/menu/BookMenuBottomSheet.vue b/next-ui/src/components/book/menu/BookMenuBottomSheet.vue
new file mode 100644
index 00000000..5ab58560
--- /dev/null
+++ b/next-ui/src/components/book/menu/BookMenuBottomSheet.vue
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/next-ui/src/components/item/card/ItemCard.stories.ts b/next-ui/src/components/item/card/ItemCard.stories.ts
index 9ecd20fa..aae77c07 100644
--- a/next-ui/src/components/item/card/ItemCard.stories.ts
+++ b/next-ui/src/components/item/card/ItemCard.stories.ts
@@ -156,6 +156,12 @@ export const PreSelect: Story = {
},
}
+export const Progress: Story = {
+ args: {
+ progressPercent: 33,
+ },
+}
+
export const Big: Story = {
args: {
lines: [{ text: 'Line 1' }, { text: 'Line 2' }],
diff --git a/next-ui/src/components/item/card/ItemCard.vue b/next-ui/src/components/item/card/ItemCard.vue
index fe712b46..89a29a82 100644
--- a/next-ui/src/components/item/card/ItemCard.vue
+++ b/next-ui/src/components/item/card/ItemCard.vue
@@ -33,6 +33,7 @@
+
{{ topRight }}
+
+
+
@@ -209,6 +220,7 @@ const {
* Props to pass to the menu icon element.
*/
menuProps?: object
+ progressPercent?: number
}
>()
diff --git a/next-ui/src/components/series/card/SeriesCard.vue b/next-ui/src/components/series/card/SeriesCard.vue
index b350b796..3f1e5537 100644
--- a/next-ui/src/components/series/card/SeriesCard.vue
+++ b/next-ui/src/components/series/card/SeriesCard.vue
@@ -32,7 +32,8 @@ import { seriesThumbnailUrl } from '@/api/images'
import { useIntl } from 'vue-intl'
import type { ItemCardEmits, ItemCardLine, ItemCardProps, ItemCardTitle } from '@/types/ItemCard'
import { useCurrentUser } from '@/colada/users'
-import { useEditSeriesMetadataDialog } from '@/composables/series'
+
+import { useEditSeriesMetadataDialog } from '@/composables/series/useEditSeriesMetadataDialog'
const intl = useIntl()
diff --git a/next-ui/src/components/series/menu/SeriesMenu.vue b/next-ui/src/components/series/menu/SeriesMenu.vue
index 1aa531c0..7bc4f79e 100644
--- a/next-ui/src/components/series/menu/SeriesMenu.vue
+++ b/next-ui/src/components/series/menu/SeriesMenu.vue
@@ -40,7 +40,7 @@