book card and menu

This commit is contained in:
Gauthier Roebroeck 2026-01-19 15:48:16 +08:00
parent 38c143bb88
commit a0cb321d76
24 changed files with 1248 additions and 87 deletions

View file

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

View file

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

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

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

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

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

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

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

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

View file

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

View file

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