more item card stuff

This commit is contained in:
Gauthier Roebroeck 2026-01-14 16:08:44 +08:00
parent fa46c73fdb
commit 554309e88c
25 changed files with 1015 additions and 13 deletions

View file

@ -12,6 +12,7 @@
"@pinia/colada": "^0.21.1",
"@pinia/colada-plugin-auto-refetch": "^0.2.4",
"@pinia/colada-plugin-delay": "^0.1.4",
"@vueuse/components": "^14.1.0",
"@vueuse/core": "^14.1.0",
"@vueuse/router": "^14.1.0",
"core-js": "^3.47.0",
@ -4427,6 +4428,19 @@
"vuetify": "^3.0.0"
}
},
"node_modules/@vueuse/components": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@vueuse/components/-/components-14.1.0.tgz",
"integrity": "sha512-SDRJUAv3H7/PMh+KkYpq0d5KMzpKOfqx4qcV4xyN4mZOLPw8NkiWu+yDcfXwI8h1uCqhRNz2cdeaLa+IuaehFw==",
"license": "MIT",
"dependencies": {
"@vueuse/core": "14.1.0",
"@vueuse/shared": "14.1.0"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/core": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.1.0.tgz",

View file

@ -33,6 +33,7 @@
"@pinia/colada": "^0.21.1",
"@pinia/colada-plugin-auto-refetch": "^0.2.4",
"@pinia/colada-plugin-delay": "^0.1.4",
"@vueuse/components": "^14.1.0",
"@vueuse/core": "^14.1.0",
"@vueuse/router": "^14.1.0",
"core-js": "^3.47.0",

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_SERIES = {
root: ['series'] as const,
@ -16,7 +17,7 @@ export const seriesListQuery = defineQueryOptions(
pageRequest,
}: {
search: components['schemas']['SeriesSearch']
pause: boolean
pause?: boolean
pageRequest?: PageRequest
}) => ({
key: QUERY_KEYS_SERIES.bySearch({ search: search, pageRequest: pageRequest }),
@ -51,3 +52,93 @@ export const seriesDetailQuery = defineQueryOptions(({ seriesId }: { seriesId: s
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
}))
export const useRefreshMetadataSeries = defineMutation(() =>
useMutation({
mutation: (seriesId: string) =>
komgaClient.POST('/api/v1/series/{seriesId}/metadata/refresh', {
params: {
path: {
seriesId: seriesId,
},
},
}),
}),
)
export const useAnalyzeSeries = defineMutation(() =>
useMutation({
mutation: (seriesId: string) =>
komgaClient.POST('/api/v1/series/{seriesId}/analyze', {
params: {
path: {
seriesId: seriesId,
},
},
}),
}),
)
export const useDeleteSeries = defineMutation(() =>
useMutation({
mutation: (seriesId: string) =>
komgaClient.DELETE('/api/v1/series/{seriesId}/file', {
params: {
path: {
seriesId: seriesId,
},
},
}),
}),
)
export const useMarkSeriesRead = defineMutation(() =>
useMutation({
mutation: (seriesId: string) =>
komgaClient.POST('/api/v1/series/{seriesId}/read-progress', {
params: {
path: {
seriesId: seriesId,
},
},
}),
}),
)
export const useMarkSeriesUnread = defineMutation(() =>
useMutation({
mutation: (seriesId: string) =>
komgaClient.DELETE('/api/v1/series/{seriesId}/read-progress', {
params: {
path: {
seriesId: seriesId,
},
},
}),
}),
)
export const useUpdateSeriesMetadata = defineMutation(() => {
// const queryCache = useQueryCache()
return useMutation({
mutation: ({
seriesId,
metadata,
}: {
seriesId: string
metadata: components['schemas']['SeriesMetadataDto']
}) =>
komgaClient.PATCH('/api/v1/series/{seriesId}/metadata', {
params: {
path: {
seriesId: seriesId,
},
},
body: seriesMetadataToDto(metadata),
}),
onSuccess: () => {
//TODO: check how to invalidate cache
// void queryCache.invalidateQueries({ key: QUERY_KEYS_LIBRARIES.root })
},
})
})

View file

@ -42,8 +42,8 @@ declare module 'vue' {
ImportBooksDirectorySelection: typeof import('./components/import/books/DirectorySelection.vue')['default']
ImportBooksTransientBooksTable: typeof import('./components/import/books/TransientBooksTable.vue')['default']
ImportReadlistTable: typeof import('./components/import/readlist/Table.vue')['default']
ItemCard: typeof import('./components/item/Card/ItemCard.vue')['default']
ItemCardSeries: typeof import('./components/item/Card/Series.vue')['default']
ItemCard: typeof import('./components/item/card/ItemCard.vue')['default']
ItemCardSeries: typeof import('./components/item/card/Series.vue')['default']
LayoutAppBar: typeof import('./components/layout/app/Bar.vue')['default']
LayoutAppDrawer: typeof import('./components/layout/app/drawer/Drawer.vue')['default']
LayoutAppDrawerFooter: typeof import('./components/layout/app/drawer/Footer.vue')['default']
@ -77,6 +77,11 @@ declare module 'vue' {
RemoteFileList: typeof import('./components/RemoteFileList.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SeriesDeletionWarning: typeof import('./components/series/DeletionWarning.vue')['default']
SeriesFormEditMetadata: typeof import('./components/series/form/EditMetadata.vue')['default']
SeriesMenu: typeof import('./components/series/menu/SeriesMenu.vue')['default']
SeriesMenuBottomSheet: typeof import('./components/series/menu/SeriesMenuBottomSheet.vue')['default']
'SeriesMenuBottomSheet.stories': typeof import('./components/series/menu/SeriesMenuBottomSheet.stories.ts')['default']
ServerSettings: typeof import('./components/server/Settings.vue')['default']
SnackQueue: typeof import('./components/SnackQueue.vue')['default']
ThemeSelector: typeof import('./components/ThemeSelector.vue')['default']

View file

@ -94,6 +94,15 @@ export const QuickActionIcon: Story = {
},
}
export const MenuIcon: Story = {
args: {
menuIcon: 'i-mdi:menu',
},
play: ({ canvas, userEvent }) => {
userEvent.hover(canvas.getByRole('img'))
},
}
export const SelectableHover: Story = {
args: {},
play: ({ canvas, userEvent }) => {

View file

@ -1,5 +1,8 @@
<template>
<v-card :width="width">
<v-card
v-on-long-press.prevent="onCardLongPress"
:width="width"
>
<v-hover
v-slot="{ props }"
v-model="isHovering"
@ -52,7 +55,7 @@
>
<!-- Top right number / icon -->
<v-icon-btn
v-if="!disableSelection"
v-if="!hideSelection"
:icon="
isPreSelect && !isHovering
? 'i-mdi:checkbox-blank-circle-outline'
@ -89,6 +92,18 @@
color="white"
class="bottom-0 left-0 position-absolute"
@click="emit('clickQuickAction')"
@mouseenter="(event: Event) => quickActionMouseEnter(event)"
/>
<!-- Bottom right menu icon -->
<v-icon-btn
v-if="isHovering && menuIcon && !hideMenu"
:icon="menuIcon"
variant="plain"
color="white"
class="bottom-0 right-0 position-absolute"
@click="emit('clickMenu')"
@mouseenter="(event: Event) => menuMouseEnter(event)"
/>
</v-overlay>
</div>
@ -121,6 +136,14 @@
<script setup lang="ts">
import type { ItemCardEmits, ItemCardLine, ItemCardProps, ItemCardTitle } from '@/types/ItemCard'
import { vOnLongPress } from '@vueuse/components'
import { usePrimaryInput } from '@/composables/device'
const { isTouchPrimary } = usePrimaryInput()
function onCardLongPress() {
if (isTouchPrimary.value) emit('cardLongPress')
}
const {
stretchPoster = true,
@ -129,6 +152,10 @@ const {
selected = false,
preSelect = false,
fabIcon,
quickActionIcon,
quickActionMouseEnter = () => {},
menuIcon,
menuMouseEnter = () => {},
} = defineProps<
ItemCardProps & {
/**
@ -165,6 +192,20 @@ const {
* Icon displayed in the bottom-left corner.
*/
quickActionIcon?: string
/**
* Callback function called when the mouse enters the quick action button.
* @param event
*/
quickActionMouseEnter?: (event: Event) => void
/**
* Icon displayed in the bottom-right corner.
*/
menuIcon?: string
/**
* Callback function called when the mouse enters the menu button.
* @param event
*/
menuMouseEnter?: (event: Event) => void
}
>()
@ -172,19 +213,27 @@ const emit = defineEmits<
ItemCardEmits & {
clickFab: []
clickQuickAction: []
clickMenu: []
cardLongPress: []
}
>()
const isHovering = ref(false)
const overlayDisabled = computed(() => {
return disableSelection && !fabIcon
return disableSelection && !fabIcon && !quickActionIcon && !menuIcon
})
const isPreSelect = computed(() => preSelect && !selected && !disableSelection)
const overlayTransparent = computed(() => (selected || isPreSelect.value) && !isHovering.value)
const overlayShown = computed(() => isHovering.value || overlayTransparent.value || preSelect)
const hideFab = computed(() => selected || isPreSelect.value)
const hideQuickAction = computed(() => selected || isPreSelect.value)
const overlayTransparent = computed(
() => isTouchPrimary.value || ((selected || isPreSelect.value) && !isHovering.value),
)
const overlayShown = computed(() => isHovering.value || isPreSelect.value || selected)
const hideSelection = computed(
() => disableSelection || (isTouchPrimary.value && !isPreSelect.value && !selected),
)
const hideFab = computed(() => selected || isPreSelect.value || isTouchPrimary.value)
const hideQuickAction = computed(() => selected || isPreSelect.value || isTouchPrimary.value)
const hideMenu = computed(() => selected || isPreSelect.value || isTouchPrimary.value)
</script>
<style lang="scss">

View file

@ -5,15 +5,17 @@ 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'
const meta = {
component: Series,
render: (args: object) => ({
components: { Series },
components: { Series, DialogConfirmEditInstance, DialogConfirmInstance },
setup() {
return { args }
},
template: '<Series v-bind="args" />',
template: '<Series v-bind="args" /><DialogConfirmEditInstance/><DialogConfirmInstance/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout

View file

@ -7,8 +7,23 @@
:top-right-icon="isRead ? 'i-mdi:check' : undefined"
fab-icon="i-mdi:play"
:quick-action-icon="quickActionIcon"
:quick-action-mouse-enter="
(event: Event) => (editMetadataActivator = event.currentTarget as Element)
"
:menu-icon="menuIcon"
:menu-mouse-enter="(event: Event) => (menuActivator = event.currentTarget as Element)"
v-bind="props"
@selection="(val) => emit('selection', val)"
@click-quick-action="showEditMetadataDialog()"
@card-long-press="bottomSheet = true"
/>
<SeriesMenu
:series="series"
:activator="menuActivator"
/>
<SeriesMenuBottomSheet
v-model="bottomSheet"
:series="series"
/>
</template>
@ -18,6 +33,7 @@ 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'
const intl = useIntl()
@ -28,6 +44,8 @@ const { series, ...props } = defineProps<
>()
const emit = defineEmits<ItemCardEmits>()
const bottomSheet = ref(false)
const isRead = computed(() => series.booksCount === series.booksReadCount)
const unreadCount = computed(() =>
series.oneshot ? undefined : series.booksUnreadCount + series.booksInProgressCount,
@ -76,4 +94,14 @@ const lines = computed<ItemCardLine[]>(() => {
const { isAdmin } = useCurrentUser()
const quickActionIcon = computed(() => (isAdmin.value ? 'i-mdi:pencil' : undefined))
const menuIcon = computed(() => (isAdmin.value ? 'i-mdi:dots-vertical' : undefined))
const { showDialog: showEditSeriesMetadataDialog, activator: editMetadataActivator } =
useEditSeriesMetadataDialog()
function showEditMetadataDialog() {
showEditSeriesMetadataDialog(series)
}
const menuActivator = ref()
</script>

View file

@ -0,0 +1,31 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import DeletionWarning from './DeletionWarning.vue'
const meta = {
component: DeletionWarning,
render: (args: object) => ({
components: { DeletionWarning },
setup() {
return { args }
},
template: '<DeletionWarning />',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
docs: {
description: {
component:
'Warning shown within a confirmation dialog before deleting a particular series.',
},
},
},
args: {},
} satisfies Meta<typeof DeletionWarning>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}

View file

@ -0,0 +1,36 @@
<template>
<v-alert
type="warning"
variant="tonal"
class="mb-4"
>
<FormattedMessage :message-descriptor="message">
<template #ul="Content">
<ul class="ps-8">
<component :is="Content" />
</ul>
</template>
<template #li="Content">
<li>
<component :is="Content" />
</li>
</template>
<template #b="Content">
<div class="font-weight-bold mt-4">
<component :is="Content" />
</div>
</template>
</FormattedMessage>
</v-alert>
</template>
<script setup lang="ts">
import { defineMessage } from 'vue-intl'
const message = defineMessage({
description: 'Series files deletion warning notice',
defaultMessage:
'The series files will be deleted<ul><li>Book media files will be deleted from disk.</li><li>Series and books will de deleted, along with their metadata and read progress.</li></ul><b>This action cannot be undone.</b>',
id: 'dfslqC',
})
</script>

View file

@ -0,0 +1,30 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import CreateEdit from './EditMetadata.vue'
const meta = {
component: CreateEdit,
render: (args: object) => ({
components: { CreateEdit },
setup() {
return { args }
},
template: '<CreateEdit :model-value="args.modelValue" v-bind="args"/>',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
docs: {
description: {
component: '',
},
},
},
args: {},
} satisfies Meta<typeof CreateEdit>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}

View file

@ -0,0 +1,5 @@
<template>
<EmptyStateConstruction />
</template>
<script setup lang="ts"></script>

View file

@ -0,0 +1,9 @@
import { Meta } from '@storybook/addon-docs/blocks';
import * as Stories from './SeriesMenu.stories.ts';
<Meta of={Stories} />
# SeriesMenu
Action menu for series.

View file

@ -0,0 +1,80 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import SeriesMenu from './SeriesMenu.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'
const meta = {
component: SeriesMenu,
render: (args: object) => ({
components: { SeriesMenu, DialogConfirmInstance, DialogConfirmEditInstance },
setup() {
return { args }
},
template:
'<v-icon-btn id="IDce0b073e6b2146e688c1cd32b61f3fef" icon="i-mdi:dots-vertical"/><SeriesMenu 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',
series: mockSeries1,
},
play: async ({ canvas, userEvent }) => {
await expect(canvas.getByRole('button')).toBeEnabled()
await userEvent.click(canvas.getByRole('button'))
},
} satisfies Meta<typeof SeriesMenu>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}
export const Read: Story = {
args: {
series: {
...mockSeries1,
booksCount: 5,
booksReadCount: 5,
booksUnreadCount: 0,
booksInProgressCount: 0,
},
},
}
export const Unread: Story = {
args: {
series: {
...mockSeries1,
booksCount: 5,
booksReadCount: 0,
booksUnreadCount: 5,
booksInProgressCount: 0,
},
},
}
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: 'Series menu: manage',
defaultMessage: 'Manage series',
id: 'Ougw+k',
})
"
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 { useSeriesActions } from '@/composables/series'
const { activator, series } = defineProps<{
activator: string | Element
series: components['schemas']['SeriesDto']
}>()
const { actions, manageActions } = useSeriesActions(series)
</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 './SeriesMenuBottomSheet.stories.ts';
<Meta of={Stories} />
# SeriesMenuBottomSheet
Action menu for series for touch screens.

View file

@ -0,0 +1,74 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import SeriesMenuBottomSheet from './SeriesMenuBottomSheet.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'
const meta = {
component: SeriesMenuBottomSheet,
render: (args: object) => ({
components: { SeriesMenuBottomSheet, DialogConfirmInstance, DialogConfirmEditInstance },
setup() {
return { args }
},
template:
'<SeriesMenuBottomSheet 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,
series: mockSeries1,
},
} satisfies Meta<typeof SeriesMenuBottomSheet>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}
export const Read: Story = {
args: {
series: {
...mockSeries1,
booksCount: 5,
booksReadCount: 5,
booksUnreadCount: 0,
booksInProgressCount: 0,
},
},
}
export const Unread: Story = {
args: {
series: {
...mockSeries1,
booksCount: 5,
booksReadCount: 0,
booksUnreadCount: 5,
booksInProgressCount: 0,
},
},
}
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 { useSeriesActions } from '@/composables/series'
const isShown = defineModel<boolean>({ default: false })
const { series } = defineProps<{
series: components['schemas']['SeriesDto']
}>()
function afterClick() {
isShown.value = false
}
const { actions, manageActions } = useSeriesActions(series, afterClick)
</script>
<script lang="ts"></script>
<style scoped></style>

View file

@ -0,0 +1,12 @@
import { useMediaQuery } from '@vueuse/core'
export function usePrimaryInput() {
const hasNoHover = useMediaQuery('(hover: none)')
const hasCoarsePointer = useMediaQuery('(pointer: coarse)')
const isTouchPrimary = computed(() => hasCoarsePointer.value || hasNoHover.value)
return {
isTouchPrimary: isTouchPrimary,
}
}

View file

@ -0,0 +1,343 @@
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'
import { storeToRefs } from 'pinia'
import { useIntl } from 'vue-intl'
import { useDisplay } from 'vuetify'
import { useMessagesStore } from '@/stores/messages'
import {
useAnalyzeSeries,
useDeleteSeries,
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 showDialog = (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),
})
return {
showDialog: showDialog,
activator: activatorRef,
}
}
export function useSeriesActions(
series: components['schemas']['SeriesDto'],
callback: (action: SeriesAction) => void = () => {},
) {
const { isAdmin } = useCurrentUser()
const intl = useIntl()
const { confirm: dialogConfirm } = storeToRefs(useDialogsStore())
const messagesStore = useMessagesStore()
const display = useDisplay()
const actions = computed(() => [
...(isAdmin.value
? [
{
title: intl.formatMessage({
description: 'Series menu: add to collection',
defaultMessage: 'Add to collection',
id: 'BAKokv',
}),
action: SeriesAction.ADD_TO_COLLECTION,
onClick: () => {
todo()
callback(SeriesAction.ADD_TO_COLLECTION)
},
},
]
: []),
...(isAdmin.value
? [
{
title: intl.formatMessage({
description: 'Series menu: add to read list',
defaultMessage: 'Add to read list',
id: '9eEylZ',
}),
action: SeriesAction.ADD_TO_READLIST,
onClick: () => {
todo()
callback(SeriesAction.ADD_TO_READLIST)
},
},
]
: []),
...(series.booksReadCount === series.booksCount
? []
: [
{
title: intl.formatMessage({
description: 'Series menu: mark as read',
defaultMessage: 'Mark as read',
id: 'SZWIZ7',
}),
action: SeriesAction.MARK_READ,
onClick: () => {
markRead()
callback(SeriesAction.MARK_READ)
},
},
]),
...(series.booksUnreadCount === series.booksCount
? []
: [
{
title: intl.formatMessage({
description: 'Series menu: mark as unread',
defaultMessage: 'Mark as unread',
id: 'JL33DG',
}),
action: SeriesAction.MARK_UNREAD,
onClick: () => {
markUnread()
callback(SeriesAction.MARK_UNREAD)
},
},
]),
])
const manageActions = computed(() => [
...(isAdmin.value
? [
{
title: intl.formatMessage({
description: 'Series menu: manage > edit metadata',
defaultMessage: 'Edit metadata',
id: 'O839kY',
}),
action: SeriesAction.EDIT_METADATA,
onMouseenter: (event: Event) =>
(editMetadataActivator.value = event.currentTarget as Element),
onClick: () => {
updateSeriesMetadata()
callback(SeriesAction.EDIT_METADATA)
},
},
]
: []),
...(isAdmin.value
? [
{
title: intl.formatMessage({
description: 'Series menu: manage > refresh metadata',
defaultMessage: 'Refresh metadata',
id: 'JFtKtC',
}),
action: SeriesAction.REFRESH_METADATA,
onClick: () => {
refreshMetadata()
callback(SeriesAction.REFRESH_METADATA)
},
},
]
: []),
...(isAdmin.value
? [
{
title: intl.formatMessage({
description: 'Series menu: manage > analyze',
defaultMessage: 'Analyze',
id: 'AI60X8',
}),
action: SeriesAction.ANALYZE,
onClick: () => {
analyzeSeries()
callback(SeriesAction.ANALYZE)
},
},
]
: []),
...(isAdmin.value
? [
{
title: intl.formatMessage({
description: 'Series menu: manage > delete file',
defaultMessage: 'Delete files',
id: 'J+H9cC',
}),
action: SeriesAction.DELETE_FILES,
onMouseenter: (event: Event) =>
(dialogConfirm.value.activator = event.currentTarget as Element),
onClick: () => {
deleteSeries()
callback(SeriesAction.DELETE_FILES)
},
},
]
: []),
])
//region Update Series metadata
const { showDialog: showEditSeriesMetadataDialog, activator: editMetadataActivator } =
useEditSeriesMetadataDialog()
function updateSeriesMetadata() {
showEditSeriesMetadataDialog(series)
}
//endregion
//region Refresh Metadata
const { mutate: mutateRefreshMetadata } = useRefreshMetadataSeries()
function refreshMetadata() {
mutateRefreshMetadata(series.id)
}
//endregion
//region Analyze
const { mutate: mutateAnalyze } = useAnalyzeSeries()
function analyzeSeries() {
mutateAnalyze(series.id)
}
//endregion
function todo() {}
//region Mark read
const { mutate: mutateMarkRead } = useMarkSeriesRead()
function markRead() {
mutateMarkRead(series.id)
}
//endregion
//region Mark unread
const { mutate: mutateMarkUnread } = useMarkSeriesUnread()
function markUnread() {
mutateMarkUnread(series.id)
}
//endregion
//region Delete
const { mutateAsync: mutateDelete } = useDeleteSeries()
function deleteSeries() {
dialogConfirm.value.dialogProps = {
title: intl.formatMessage({
description: 'Series delete dialog: title',
defaultMessage: 'Delete series files',
id: 'Xxu514',
}),
subtitle: series.metadata.title,
maxWidth: 600,
mode: 'checkbox',
color: 'error',
okText: intl.formatMessage({
description: 'Series delete dialog: confirm button',
defaultMessage: 'Delete files',
id: 'DfakWW',
}),
closeOnSave: true,
fullscreen: display.xs.value,
}
dialogConfirm.value.slotWarning = {
component: markRaw(SeriesDeletionWarning),
props: {},
}
dialogConfirm.value.callback = () => {
mutateDelete(series.id)
.then(() => {
messagesStore.messages.push({
text: intl.formatMessage(
{
description: 'Snackbar notification shown upon successful series files deletion',
defaultMessage: 'Series files deleted: {series}',
id: 'aSDxrt',
},
{
series: series.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,14 @@
import type { components } from '@/generated/openapi/komga'
export function seriesMetadataToDto(
metadata: components['schemas']['SeriesMetadataDto'],
): components['schemas']['SeriesMetadataUpdateDto'] {
return Object.assign({}, metadata, {
readingDirection: metadata.readingDirection as
| 'LEFT_TO_RIGHT'
| 'RIGHT_TO_LEFT'
| 'VERTICAL'
| 'WEBTOON',
status: metadata.status as 'ENDED' | 'ONGOING' | 'ABANDONED' | 'HIATUS',
})
}

View file

@ -150,4 +150,16 @@ export const seriesHandlers = [
},
})
}),
httpTyped.patch('/api/v1/series/{seriesId}/metadata', ({ response }) => response(204).empty()),
httpTyped.post('/api/v1/series/{seriesId}/analyze', ({ response }) => response(202).empty()),
httpTyped.post('/api/v1/series/{seriesId}/metadata/refresh', ({ response }) =>
response(202).empty(),
),
httpTyped.delete('/api/v1/series/{seriesId}/file', ({ response }) => response(202).empty()),
httpTyped.post('/api/v1/series/{seriesId}/read-progress', ({ response }) =>
response(204).empty(),
),
httpTyped.delete('/api/v1/series/{seriesId}/read-progress', ({ response }) =>
response(204).empty(),
),
]

View file

@ -14,3 +14,14 @@ html {
.link-underline:hover {
text-decoration: underline;
}
// This requires the following CSS variables to be set:
// --lines: the number of lines to display
// --line-height: the line height
.force-line-count {
display: -webkit-box !important;
-webkit-line-clamp: var(--lines, 2);
-webkit-box-orient: vertical;
overflow: hidden !important;
height: calc(var(--line-height) * var(--lines, 1) * 1rem);
}

View file

@ -22,3 +22,33 @@ export type ItemCardProps = {
export type ItemCardEmits = {
selection: [selected: boolean]
}
export type ItemCardTitle = {
/**
* Text to display.
*/
text: string
/**
* Number of lines.
*/
lines?: number
}
export type ItemCardLine = {
/**
* Text to display.
*/
text?: string
/**
* Classes to apply.
*/
classes?: string
/**
* Number of lines of text.
*/
lines?: number
/**
* Whether the container will be shown even if `text` is empty.
*/
allowEmpty?: boolean
}

View file

@ -0,0 +1,10 @@
export enum SeriesAction {
ADD_TO_COLLECTION,
ADD_TO_READLIST,
MARK_READ,
MARK_UNREAD,
EDIT_METADATA,
REFRESH_METADATA,
ANALYZE,
DELETE_FILES,
}