mirror of
https://github.com/gotson/komga.git
synced 2026-05-08 04:22:28 +02:00
book card and menu
This commit is contained in:
parent
38c143bb88
commit
a0cb321d76
24 changed files with 1248 additions and 87 deletions
|
|
@ -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 })
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
|
|||
6
next-ui/src/components.d.ts
vendored
6
next-ui/src/components.d.ts
vendored
|
|
@ -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']
|
||||
|
|
|
|||
30
next-ui/src/components/book/DeletionWarning.stories.ts
Normal file
30
next-ui/src/components/book/DeletionWarning.stories.ts
Normal file
|
|
@ -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: '<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 book.',
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {},
|
||||
} satisfies Meta<typeof DeletionWarning>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
}
|
||||
36
next-ui/src/components/book/DeletionWarning.vue
Normal file
36
next-ui/src/components/book/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: 'Series files deletion warning notice',
|
||||
defaultMessage:
|
||||
'The book files will be deleted<ul><li>Book media file will be deleted from disk.</li><li>Book sidecar files will be deleted from disk.</li><li>Book will de deleted, along with its metadata and read progress.</li></ul><b>This action cannot be undone.</b>',
|
||||
id: 'sDGhrD',
|
||||
})
|
||||
</script>
|
||||
142
next-ui/src/components/book/card/BookCard.stories.ts
Normal file
142
next-ui/src/components/book/card/BookCard.stories.ts
Normal file
|
|
@ -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: '<BookCard 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: {
|
||||
book: mockBook,
|
||||
onSelection: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof BookCard>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
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'))
|
||||
},
|
||||
}
|
||||
138
next-ui/src/components/book/card/BookCard.vue
Normal file
138
next-ui/src/components/book/card/BookCard.vue
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<template>
|
||||
<ItemCard
|
||||
:id="id"
|
||||
:title="titleAndLines.title"
|
||||
:lines="titleAndLines.lines"
|
||||
:poster-url="bookThumbnailUrl(book.id)"
|
||||
:top-right-icon="isRead ? 'i-mdi:check' : undefined"
|
||||
:progress-percent="isRead ? undefined : progressPercent"
|
||||
fab-icon="i-mdi:play"
|
||||
:quick-action-icon="quickActionIcon"
|
||||
:quick-action-props="quickActionProps"
|
||||
:menu-icon="menuIcon"
|
||||
:menu-props="menuProps"
|
||||
v-bind="props"
|
||||
@selection="(val) => emit('selection', val)"
|
||||
@click-quick-action="showEditMetadataDialog()"
|
||||
@card-long-press="bottomSheet = true"
|
||||
/>
|
||||
<BookMenu
|
||||
:book="book"
|
||||
:activator="menuActivator"
|
||||
/>
|
||||
<BookMenuBottomSheet
|
||||
v-model="bottomSheet"
|
||||
:book="book"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { components } from '@/generated/openapi/komga'
|
||||
import { bookThumbnailUrl } from '@/api/images'
|
||||
import { useIntl } from 'vue-intl'
|
||||
import type { ItemCardEmits, ItemCardLine, ItemCardProps, ItemCardTitle } from '@/types/ItemCard'
|
||||
import { useCurrentUser } from '@/colada/users'
|
||||
import { MediaStatus, mediaStatusMessages } from '@/types/MediaStatus'
|
||||
|
||||
import { useBookReadProgress } from '@/composables/book/useBookReadProgress'
|
||||
import { useEditBookMetadataDialog } from '@/composables/book/useEditBookMetadataDialog'
|
||||
|
||||
const intl = useIntl()
|
||||
|
||||
const id = useId()
|
||||
|
||||
const {
|
||||
book,
|
||||
showSeries = true,
|
||||
...props
|
||||
} = defineProps<
|
||||
{
|
||||
book: components['schemas']['BookDto']
|
||||
showSeries?: boolean
|
||||
} & ItemCardProps
|
||||
>()
|
||||
const emit = defineEmits<ItemCardEmits>()
|
||||
|
||||
const bottomSheet = ref(false)
|
||||
|
||||
const isRead = computed(() => book.readProgress?.completed)
|
||||
const progressPercent = useBookReadProgress(book)
|
||||
|
||||
const titleAndLines = computed<{ title: ItemCardTitle; lines: ItemCardLine[] }>(() => {
|
||||
let footer: ItemCardLine
|
||||
|
||||
if (book.deleted)
|
||||
footer = {
|
||||
text: intl.formatMessage({
|
||||
description: 'Book card subtitle: unavailable',
|
||||
defaultMessage: 'Unavailable',
|
||||
id: 'nhrFtV',
|
||||
}),
|
||||
classes: 'text-error',
|
||||
}
|
||||
else if (book.media.status === MediaStatus.ERROR.valueOf())
|
||||
footer = {
|
||||
text: intl.formatMessage(mediaStatusMessages[MediaStatus.ERROR]),
|
||||
classes: 'text-error',
|
||||
}
|
||||
else if (book.media.status === MediaStatus.UNSUPPORTED.valueOf())
|
||||
footer = {
|
||||
text: intl.formatMessage(mediaStatusMessages[MediaStatus.UNSUPPORTED]),
|
||||
classes: 'text-warning',
|
||||
}
|
||||
else if (book.media.status === MediaStatus.UNKNOWN.valueOf())
|
||||
footer = {
|
||||
text: intl.formatMessage(mediaStatusMessages[MediaStatus.UNKNOWN]),
|
||||
}
|
||||
else
|
||||
footer = {
|
||||
text: intl.formatMessage(
|
||||
{
|
||||
description: 'Book card subtitle: count of pages',
|
||||
defaultMessage: '{count} pages',
|
||||
id: 'BSFC7R',
|
||||
},
|
||||
{ count: book.media.pagesCount },
|
||||
),
|
||||
}
|
||||
|
||||
if (book.oneshot) {
|
||||
return {
|
||||
title: { text: book.metadata.title, lines: 2 },
|
||||
lines: [footer],
|
||||
}
|
||||
} else {
|
||||
const numberedTitle = `${book.metadata.number} - ${book.metadata.title}`
|
||||
if (showSeries)
|
||||
return {
|
||||
title: { text: book.seriesTitle, lines: 1 },
|
||||
lines: [{ text: numberedTitle, lines: 1 }, footer],
|
||||
}
|
||||
else return { title: { text: numberedTitle, lines: 2 }, lines: [footer] }
|
||||
}
|
||||
})
|
||||
|
||||
const { isAdmin } = useCurrentUser()
|
||||
const quickActionIcon = computed(() => (isAdmin.value ? 'i-mdi:pencil' : undefined))
|
||||
const quickActionProps = computed(() => ({
|
||||
id: `${id}_quick`,
|
||||
onmouseenter: () => (editMetadataActivator.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: prepareEditBookMetadataDialog,
|
||||
showDialog: showEditBookMetadataDialog,
|
||||
activator: editMetadataActivator,
|
||||
} = useEditBookMetadataDialog()
|
||||
|
||||
function showEditMetadataDialog() {
|
||||
prepareEditBookMetadataDialog(book)
|
||||
showEditBookMetadataDialog()
|
||||
}
|
||||
|
||||
const menuActivator = ref()
|
||||
</script>
|
||||
9
next-ui/src/components/book/menu/BookMenu.mdx
Normal file
9
next-ui/src/components/book/menu/BookMenu.mdx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
import * as Stories from './BookMenu.stories.ts';
|
||||
|
||||
<Meta of={Stories} />
|
||||
|
||||
# BookMenu
|
||||
|
||||
Action menu for books.
|
||||
96
next-ui/src/components/book/menu/BookMenu.stories.ts
Normal file
96
next-ui/src/components/book/menu/BookMenu.stories.ts
Normal file
|
|
@ -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:
|
||||
'<v-icon-btn id="IDce0b073e6b2146e688c1cd32b61f3fef" icon="i-mdi:dots-vertical"/><BookMenu 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',
|
||||
book: mockBook,
|
||||
},
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
await expect(canvas.getByRole('button')).toBeEnabled()
|
||||
|
||||
await userEvent.click(canvas.getByRole('button'))
|
||||
},
|
||||
} satisfies Meta<typeof BookMenu>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
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)),
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
55
next-ui/src/components/book/menu/BookMenu.vue
Normal file
55
next-ui/src/components/book/menu/BookMenu.vue
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<template>
|
||||
<v-menu :activator="activator">
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="(action, i) in actions"
|
||||
:key="i"
|
||||
v-bind="action"
|
||||
/>
|
||||
|
||||
<v-list-item
|
||||
v-if="manageActions.length > 0"
|
||||
:title="
|
||||
$formatMessage({
|
||||
description: 'Book menu: manage',
|
||||
defaultMessage: 'Manage book',
|
||||
id: 'E8yw5g',
|
||||
})
|
||||
"
|
||||
append-icon="i-mdi:menu-right"
|
||||
>
|
||||
<v-menu
|
||||
activator="parent"
|
||||
open-on-click
|
||||
open-on-hover
|
||||
location="end"
|
||||
submenu
|
||||
>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="(action, i) in manageActions"
|
||||
:key="i"
|
||||
v-bind="action"
|
||||
/>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { components } from '@/generated/openapi/komga'
|
||||
import { useBookActions } from '@/composables/book/useBookActions'
|
||||
|
||||
const { activator, book } = defineProps<{
|
||||
activator: string | Element
|
||||
book: components['schemas']['BookDto']
|
||||
}>()
|
||||
|
||||
const { actions, manageActions } = useBookActions(book)
|
||||
</script>
|
||||
|
||||
<script lang="ts"></script>
|
||||
|
||||
<style scoped></style>
|
||||
9
next-ui/src/components/book/menu/BookMenuBottomSheet.mdx
Normal file
9
next-ui/src/components/book/menu/BookMenuBottomSheet.mdx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
import * as Stories from './BookMenuBottomSheet.stories.ts';
|
||||
|
||||
<Meta of={Stories} />
|
||||
|
||||
# BookMenuBottomSheet
|
||||
|
||||
Action menu for books for touch screens.
|
||||
|
|
@ -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:
|
||||
'<BookMenuBottomSheet 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,
|
||||
book: mockBook,
|
||||
},
|
||||
} satisfies Meta<typeof BookMenuBottomSheet>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
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)),
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
42
next-ui/src/components/book/menu/BookMenuBottomSheet.vue
Normal file
42
next-ui/src/components/book/menu/BookMenuBottomSheet.vue
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<template>
|
||||
<v-bottom-sheet
|
||||
v-model="isShown"
|
||||
inset
|
||||
>
|
||||
<v-list>
|
||||
<v-list-item
|
||||
v-for="(action, i) in actions"
|
||||
:key="i"
|
||||
v-bind="action"
|
||||
/>
|
||||
|
||||
<v-divider v-if="manageActions.length > 0" />
|
||||
|
||||
<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 { useBookActions } from '@/composables/book/useBookActions'
|
||||
|
||||
const isShown = defineModel<boolean>({ default: false })
|
||||
|
||||
const { book } = defineProps<{
|
||||
book: components['schemas']['BookDto']
|
||||
}>()
|
||||
|
||||
function afterClick() {
|
||||
isShown.value = false
|
||||
}
|
||||
const { actions, manageActions } = useBookActions(book, afterClick)
|
||||
</script>
|
||||
|
||||
<script lang="ts"></script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -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' }],
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
<!-- This will just show lazy-src without the v-progress -->
|
||||
<template #error></template>
|
||||
|
||||
<!-- Top-right icon -->
|
||||
<div
|
||||
v-if="topRightIcon || topRight"
|
||||
class="top-0 right-0 position-absolute translucent text-white px-2 py-1 font-weight-bold text-caption"
|
||||
|
|
@ -44,6 +45,16 @@
|
|||
/>
|
||||
<template v-else>{{ topRight }}</template>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<v-progress-linear
|
||||
v-if="progressPercent"
|
||||
:model-value="progressPercent"
|
||||
color="primary"
|
||||
height="10"
|
||||
class="position-absolute bottom-0"
|
||||
style="top: unset"
|
||||
/>
|
||||
</v-img>
|
||||
|
||||
<!-- The overlay is outside the image, so that we can scale transform the image only -->
|
||||
|
|
@ -209,6 +220,7 @@ const {
|
|||
* Props to pass to the menu icon element.
|
||||
*/
|
||||
menuProps?: object
|
||||
progressPercent?: number
|
||||
}
|
||||
>()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import type { components } from '@/generated/openapi/komga'
|
||||
import { useSeriesActions } from '@/composables/series'
|
||||
import { useSeriesActions } from '@/composables/series/useSeriesActions'
|
||||
|
||||
const { activator, series } = defineProps<{
|
||||
activator: string | Element
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import type { components } from '@/generated/openapi/komga'
|
||||
import { useSeriesActions } from '@/composables/series'
|
||||
import { useSeriesActions } from '@/composables/series/useSeriesActions'
|
||||
|
||||
const isShown = defineModel<boolean>({ default: false })
|
||||
|
||||
|
|
|
|||
270
next-ui/src/composables/book/useBookActions.ts
Normal file
270
next-ui/src/composables/book/useBookActions.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import type { components } from '@/generated/openapi/komga'
|
||||
import { useCurrentUser } from '@/colada/users'
|
||||
import { useIntl } from 'vue-intl'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useDialogsStore } from '@/stores/dialogs'
|
||||
import { useMessagesStore } from '@/stores/messages'
|
||||
import { useDisplay } from 'vuetify/framework'
|
||||
import BookDeletionWarning from '@/components/book/DeletionWarning.vue'
|
||||
import type { ErrorCause } from '@/api/komga-client'
|
||||
import { commonMessages } from '@/utils/i18n/common-messages'
|
||||
import { BookAction } from '@/types/book'
|
||||
import {
|
||||
useAnalyzeBook,
|
||||
useDeleteBook,
|
||||
useMarkBookRead,
|
||||
useMarkBookUnread,
|
||||
useRefreshMetadataBook,
|
||||
} from '@/colada/books'
|
||||
import { useEditSeriesMetadataDialog } from '@/composables/series/useEditSeriesMetadataDialog'
|
||||
import { useEditBookMetadataDialog } from '@/composables/book/useEditBookMetadataDialog'
|
||||
|
||||
export function useBookActions(
|
||||
book: components['schemas']['BookDto'],
|
||||
callback: (action: BookAction) => void = () => {},
|
||||
) {
|
||||
const { isAdmin } = useCurrentUser()
|
||||
const intl = useIntl()
|
||||
const { confirm: dialogConfirm } = storeToRefs(useDialogsStore())
|
||||
const messagesStore = useMessagesStore()
|
||||
const display = useDisplay()
|
||||
|
||||
const actions = computed(() => [
|
||||
...(isAdmin.value && book.oneshot
|
||||
? [
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'Book menu: add to collection',
|
||||
defaultMessage: 'Add to collection',
|
||||
id: 'yNNH8a',
|
||||
}),
|
||||
action: BookAction.ADD_TO_COLLECTION,
|
||||
onClick: () => {
|
||||
todo()
|
||||
callback(BookAction.ADD_TO_COLLECTION)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isAdmin.value
|
||||
? [
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'Book menu: add to read list',
|
||||
defaultMessage: 'Add to read list',
|
||||
id: 'Q6H+z7',
|
||||
}),
|
||||
action: BookAction.ADD_TO_READLIST,
|
||||
onClick: () => {
|
||||
todo()
|
||||
callback(BookAction.ADD_TO_READLIST)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!book.readProgress?.completed
|
||||
? [
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'Book menu: mark as read',
|
||||
defaultMessage: 'Mark as read',
|
||||
id: 'lFGLru',
|
||||
}),
|
||||
action: BookAction.MARK_READ,
|
||||
onClick: () => {
|
||||
markRead()
|
||||
callback(BookAction.MARK_READ)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!!book.readProgress
|
||||
? [
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'Book menu: mark as unread',
|
||||
defaultMessage: 'Mark as unread',
|
||||
id: 'a+9yUi',
|
||||
}),
|
||||
action: BookAction.MARK_UNREAD,
|
||||
onClick: () => {
|
||||
markUnread()
|
||||
callback(BookAction.MARK_UNREAD)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
])
|
||||
|
||||
const manageActions = computed(() => [
|
||||
...(isAdmin.value
|
||||
? [
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'Book menu: manage > edit metadata',
|
||||
defaultMessage: 'Edit metadata',
|
||||
id: 'M5GJZQ',
|
||||
}),
|
||||
action: BookAction.EDIT_METADATA,
|
||||
onMouseenter: (event: Event) =>
|
||||
(editMetadataActivator.value = event.currentTarget as Element),
|
||||
onClick: () => {
|
||||
updateBookMetadata()
|
||||
callback(BookAction.EDIT_METADATA)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isAdmin.value
|
||||
? [
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'Book menu: manage > refresh metadata',
|
||||
defaultMessage: 'Refresh metadata',
|
||||
id: 'BdFv4r',
|
||||
}),
|
||||
action: BookAction.REFRESH_METADATA,
|
||||
onClick: () => {
|
||||
refreshMetadata()
|
||||
callback(BookAction.REFRESH_METADATA)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isAdmin.value
|
||||
? [
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'Book menu: manage > analyze',
|
||||
defaultMessage: 'Analyze',
|
||||
id: 'J0TxGf',
|
||||
}),
|
||||
action: BookAction.ANALYZE,
|
||||
onClick: () => {
|
||||
analyzeBook()
|
||||
callback(BookAction.ANALYZE)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isAdmin.value
|
||||
? [
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'Book menu: manage > delete file',
|
||||
defaultMessage: 'Delete file',
|
||||
id: 'vXhkpo',
|
||||
}),
|
||||
action: BookAction.DELETE_FILES,
|
||||
onMouseenter: (event: Event) =>
|
||||
(dialogConfirm.value.activator = event.currentTarget as Element),
|
||||
onClick: () => {
|
||||
deleteBook()
|
||||
callback(BookAction.DELETE_FILES)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
])
|
||||
|
||||
//region Update Series metadata
|
||||
const { prepareDialog: showEditBookMetadataDialog, activator: editMetadataActivator } =
|
||||
useEditBookMetadataDialog()
|
||||
|
||||
function updateBookMetadata() {
|
||||
showEditBookMetadataDialog(book)
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Refresh Metadata
|
||||
const { mutate: mutateRefreshMetadata } = useRefreshMetadataBook()
|
||||
|
||||
function refreshMetadata() {
|
||||
mutateRefreshMetadata(book.id)
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Analyze
|
||||
const { mutate: mutateAnalyze } = useAnalyzeBook()
|
||||
|
||||
function analyzeBook() {
|
||||
mutateAnalyze(book.id)
|
||||
}
|
||||
//endregion
|
||||
|
||||
function todo() {}
|
||||
|
||||
//region Mark read
|
||||
const { mutate: mutateMarkRead } = useMarkBookRead()
|
||||
|
||||
function markRead() {
|
||||
mutateMarkRead(book.id)
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Mark unread
|
||||
const { mutate: mutateMarkUnread } = useMarkBookUnread()
|
||||
|
||||
function markUnread() {
|
||||
mutateMarkUnread(book.id)
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Delete
|
||||
const { mutateAsync: mutateDelete } = useDeleteBook()
|
||||
|
||||
function deleteBook() {
|
||||
dialogConfirm.value.dialogProps = {
|
||||
title: intl.formatMessage({
|
||||
description: 'Book delete dialog: title',
|
||||
defaultMessage: 'Delete book files',
|
||||
id: 'NhIart',
|
||||
}),
|
||||
subtitle: book.metadata.title,
|
||||
maxWidth: 600,
|
||||
mode: 'checkbox',
|
||||
color: 'error',
|
||||
okText: intl.formatMessage({
|
||||
description: 'Book delete dialog: confirm button',
|
||||
defaultMessage: 'Delete files',
|
||||
id: '8CVWWg',
|
||||
}),
|
||||
closeOnSave: true,
|
||||
fullscreen: display.xs.value,
|
||||
}
|
||||
dialogConfirm.value.slotWarning = {
|
||||
component: markRaw(BookDeletionWarning),
|
||||
props: {},
|
||||
}
|
||||
dialogConfirm.value.callback = () => {
|
||||
mutateDelete(book.id)
|
||||
.then(() => {
|
||||
messagesStore.messages.push({
|
||||
text: intl.formatMessage(
|
||||
{
|
||||
description: 'Snackbar notification shown upon successful book files deletion',
|
||||
defaultMessage: 'Book files deleted: {book}',
|
||||
id: 'ccDES8',
|
||||
},
|
||||
{
|
||||
book: book.metadata.title,
|
||||
},
|
||||
),
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
messagesStore.messages.push({
|
||||
text:
|
||||
(error?.cause as ErrorCause)?.message ||
|
||||
intl.formatMessage(commonMessages.networkError),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
return {
|
||||
actions: actions,
|
||||
manageActions: manageActions,
|
||||
}
|
||||
}
|
||||
12
next-ui/src/composables/book/useBookReadProgress.ts
Normal file
12
next-ui/src/composables/book/useBookReadProgress.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { components } from '@/generated/openapi/komga'
|
||||
|
||||
export function useBookReadProgress(book: MaybeRefOrGetter<components['schemas']['BookDto']>) {
|
||||
return computed(() => {
|
||||
const b = toValue(book)
|
||||
if (b.readProgress?.completed) return 100
|
||||
if (b.readProgress?.completed === false) {
|
||||
return (b.readProgress?.page / b.media.pagesCount) * 100
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
88
next-ui/src/composables/book/useEditBookMetadataDialog.ts
Normal file
88
next-ui/src/composables/book/useEditBookMetadataDialog.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 { useUpdateBookMetadata } from '@/colada/books'
|
||||
|
||||
export function useEditBookMetadataDialog() {
|
||||
const { confirmEdit: dialogConfirmEdit } = storeToRefs(useDialogsStore())
|
||||
const intl = useIntl()
|
||||
const display = useDisplay()
|
||||
const messagesStore = useMessagesStore()
|
||||
const { mutateAsync: mutateUpdateSeriesMetadata } = useUpdateBookMetadata()
|
||||
|
||||
const prepareDialog = (book: components['schemas']['BookDto']) => {
|
||||
dialogConfirmEdit.value.dialogProps = {
|
||||
title: intl.formatMessage({
|
||||
description: 'Edit book metadata dialog title',
|
||||
defaultMessage: 'Edit book metadata',
|
||||
id: 'mtUacw',
|
||||
}),
|
||||
subtitle: book.metadata.title,
|
||||
maxWidth: 600,
|
||||
okText: 'Save',
|
||||
cardTextClass: 'px-0',
|
||||
closeOnSave: false,
|
||||
scrollable: true,
|
||||
fullscreen: display.xs.value,
|
||||
}
|
||||
dialogConfirmEdit.value.slot = {
|
||||
component: markRaw(EditMetadata),
|
||||
}
|
||||
dialogConfirmEdit.value.record = book.metadata
|
||||
dialogConfirmEdit.value.callback = (
|
||||
hideDialog: () => void,
|
||||
setLoading: (isLoading: boolean) => void,
|
||||
) => {
|
||||
setLoading(true)
|
||||
|
||||
const updatedMetadata = dialogConfirmEdit.value
|
||||
.record as components['schemas']['BookMetadataDto']
|
||||
|
||||
mutateUpdateSeriesMetadata({ bookId: book.id, metadata: updatedMetadata })
|
||||
.then(() => {
|
||||
hideDialog()
|
||||
messagesStore.messages.push({
|
||||
text: intl.formatMessage(
|
||||
{
|
||||
description: 'Snackbar notification shown upon successful book metadata update',
|
||||
defaultMessage: 'Book metadata updated: {book}',
|
||||
id: 'P8Ox+D',
|
||||
},
|
||||
{
|
||||
book: updatedMetadata.title,
|
||||
},
|
||||
),
|
||||
})
|
||||
})
|
||||
.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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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 { useUpdateSeriesMetadata } from '@/colada/series'
|
||||
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'
|
||||
|
||||
export function useEditSeriesMetadataDialog() {
|
||||
const { confirmEdit: dialogConfirmEdit } = storeToRefs(useDialogsStore())
|
||||
const intl = useIntl()
|
||||
const display = useDisplay()
|
||||
const messagesStore = useMessagesStore()
|
||||
const { mutateAsync: mutateUpdateSeriesMetadata } = useUpdateSeriesMetadata()
|
||||
|
||||
const prepareDialog = (series: components['schemas']['SeriesDto']) => {
|
||||
dialogConfirmEdit.value.dialogProps = {
|
||||
title: intl.formatMessage({
|
||||
description: 'Edit series metadata dialog title',
|
||||
defaultMessage: 'Edit series metadata',
|
||||
id: '1bxWGd',
|
||||
}),
|
||||
subtitle: series.metadata.title,
|
||||
maxWidth: 600,
|
||||
okText: 'Save',
|
||||
cardTextClass: 'px-0',
|
||||
closeOnSave: false,
|
||||
scrollable: true,
|
||||
fullscreen: display.xs.value,
|
||||
}
|
||||
dialogConfirmEdit.value.slot = {
|
||||
component: markRaw(EditMetadata),
|
||||
}
|
||||
dialogConfirmEdit.value.record = series.metadata
|
||||
dialogConfirmEdit.value.callback = (
|
||||
hideDialog: () => void,
|
||||
setLoading: (isLoading: boolean) => void,
|
||||
) => {
|
||||
setLoading(true)
|
||||
|
||||
const updatedMetadata = dialogConfirmEdit.value
|
||||
.record as components['schemas']['SeriesMetadataDto']
|
||||
|
||||
mutateUpdateSeriesMetadata({ seriesId: series.id, metadata: updatedMetadata })
|
||||
.then(() => {
|
||||
hideDialog()
|
||||
messagesStore.messages.push({
|
||||
text: intl.formatMessage(
|
||||
{
|
||||
description: 'Snackbar notification shown upon successful series metadata update',
|
||||
defaultMessage: 'Series metadata updated: {series}',
|
||||
id: 'gEBeQv',
|
||||
},
|
||||
{
|
||||
series: updatedMetadata.title,
|
||||
},
|
||||
),
|
||||
})
|
||||
})
|
||||
.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,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
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 { useDialogsStore } from '@/stores/dialogs'
|
||||
|
|
@ -13,89 +12,11 @@ import {
|
|||
useMarkSeriesRead,
|
||||
useMarkSeriesUnread,
|
||||
useRefreshMetadataSeries,
|
||||
useUpdateSeriesMetadata,
|
||||
} from '@/colada/series'
|
||||
import { useCurrentUser } from '@/colada/users'
|
||||
import { SeriesAction } from '@/types/series'
|
||||
import SeriesDeletionWarning from '@/components/series/DeletionWarning.vue'
|
||||
|
||||
export function useEditSeriesMetadataDialog() {
|
||||
const { confirmEdit: dialogConfirmEdit } = storeToRefs(useDialogsStore())
|
||||
const intl = useIntl()
|
||||
const display = useDisplay()
|
||||
const messagesStore = useMessagesStore()
|
||||
const { mutateAsync: mutateUpdateSeriesMetadata } = useUpdateSeriesMetadata()
|
||||
|
||||
const prepareDialog = (series: components['schemas']['SeriesDto']) => {
|
||||
dialogConfirmEdit.value.dialogProps = {
|
||||
title: intl.formatMessage({
|
||||
description: 'Edit series metadata dialog title',
|
||||
defaultMessage: 'Edit series metadata',
|
||||
id: '1bxWGd',
|
||||
}),
|
||||
subtitle: series.metadata.title,
|
||||
maxWidth: 600,
|
||||
okText: 'Save',
|
||||
cardTextClass: 'px-0',
|
||||
closeOnSave: false,
|
||||
scrollable: true,
|
||||
fullscreen: display.xs.value,
|
||||
}
|
||||
dialogConfirmEdit.value.slot = {
|
||||
component: markRaw(EditMetadata),
|
||||
}
|
||||
dialogConfirmEdit.value.record = series.metadata
|
||||
dialogConfirmEdit.value.callback = (
|
||||
hideDialog: () => void,
|
||||
setLoading: (isLoading: boolean) => void,
|
||||
) => {
|
||||
setLoading(true)
|
||||
|
||||
const updatedMetadata = dialogConfirmEdit.value
|
||||
.record as components['schemas']['SeriesMetadataDto']
|
||||
|
||||
mutateUpdateSeriesMetadata({ seriesId: series.id, metadata: updatedMetadata })
|
||||
.then(() => {
|
||||
hideDialog()
|
||||
messagesStore.messages.push({
|
||||
text: intl.formatMessage(
|
||||
{
|
||||
description: 'Snackbar notification shown upon successful series metadata update',
|
||||
defaultMessage: 'Series metadata updated: {series}',
|
||||
id: 'gEBeQv',
|
||||
},
|
||||
{
|
||||
series: updatedMetadata.title,
|
||||
},
|
||||
),
|
||||
})
|
||||
})
|
||||
.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,
|
||||
}
|
||||
}
|
||||
import { useEditSeriesMetadataDialog } from '@/composables/series/useEditSeriesMetadataDialog'
|
||||
|
||||
export function useSeriesActions(
|
||||
series: components['schemas']['SeriesDto'],
|
||||
|
|
@ -4,7 +4,7 @@ import { PageRequest } from '@/types/PageRequest'
|
|||
import { http, HttpResponse } from 'msw'
|
||||
import mockThumbnailUrl from '@/assets/mock-thumbnail.jpg'
|
||||
|
||||
const book = {
|
||||
export const mockBook = {
|
||||
id: '05RKH8CC8B4RW',
|
||||
seriesId: '57',
|
||||
seriesTitle: 'Super Duck',
|
||||
|
|
@ -63,7 +63,7 @@ const book = {
|
|||
|
||||
export function mockBooks(count: number) {
|
||||
return [...Array(count).keys()].map((index) =>
|
||||
Object.assign({}, book, {
|
||||
Object.assign({}, mockBook, {
|
||||
id: `BOOK${index + 1}`,
|
||||
name: `Book ${index + 1}`,
|
||||
number: index + 1,
|
||||
|
|
@ -91,7 +91,7 @@ export const booksHandlers = [
|
|||
httpTyped.get('/api/v1/books/{bookId}', ({ params, response }) => {
|
||||
if (params.bookId === '404') return response(404).empty()
|
||||
return response(200).json(
|
||||
Object.assign({}, book, { metadata: { title: `Book ${params.bookId}` } }),
|
||||
Object.assign({}, mockBook, { metadata: { title: `Book ${params.bookId}` } }),
|
||||
)
|
||||
}),
|
||||
httpTyped.post('/api/v1/books/import', ({ response }) => {
|
||||
|
|
@ -107,4 +107,12 @@ export const booksHandlers = [
|
|||
},
|
||||
})
|
||||
}),
|
||||
httpTyped.patch('/api/v1/books/{bookId}/metadata', ({ response }) => response(204).empty()),
|
||||
httpTyped.post('/api/v1/books/{bookId}/analyze', ({ response }) => response(202).empty()),
|
||||
httpTyped.post('/api/v1/books/{bookId}/metadata/refresh', ({ response }) =>
|
||||
response(202).empty(),
|
||||
),
|
||||
httpTyped.delete('/api/v1/books/{bookId}/file', ({ response }) => response(202).empty()),
|
||||
httpTyped.patch('/api/v1/books/{bookId}/read-progress', ({ response }) => response(204).empty()),
|
||||
httpTyped.delete('/api/v1/books/{bookId}/read-progress', ({ response }) => response(204).empty()),
|
||||
]
|
||||
|
|
|
|||
10
next-ui/src/types/book.ts
Normal file
10
next-ui/src/types/book.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export enum BookAction {
|
||||
ADD_TO_COLLECTION,
|
||||
ADD_TO_READLIST,
|
||||
MARK_READ,
|
||||
MARK_UNREAD,
|
||||
EDIT_METADATA,
|
||||
REFRESH_METADATA,
|
||||
ANALYZE,
|
||||
DELETE_FILES,
|
||||
}
|
||||
Loading…
Reference in a new issue