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