mirror of
https://github.com/gotson/komga.git
synced 2026-05-07 20:15:47 +02:00
browse collections
This commit is contained in:
parent
ad421020fb
commit
c13f55efea
23 changed files with 813 additions and 26 deletions
|
|
@ -15,6 +15,11 @@ export function bookPageThumbnailUrl(bookId?: string, page?: number): string | u
|
|||
return undefined
|
||||
}
|
||||
|
||||
export function collectionThumbnailUrl(collectionId?: string): string | undefined {
|
||||
if (collectionId) return `${ApiBaseUrl.noSlash}/api/v1/collections/${collectionId}/thumbnail`
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function pageHashKnownThumbnailUrl(hash?: string): string | undefined {
|
||||
if (hash) return `${ApiBaseUrl.noSlash}/api/v1/page-hashes/${hash}/thumbnail`
|
||||
return undefined
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { defineQueryOptions } from '@pinia/colada'
|
||||
import { defineMutation, defineQueryOptions, useMutation } from '@pinia/colada'
|
||||
import { komgaClient } from '@/api/komga-client'
|
||||
import type { PageRequest } from '@/types/PageRequest'
|
||||
import type { components } from '@/generated/openapi/komga'
|
||||
|
||||
export const QUERY_KEYS_COLLECTIONS = {
|
||||
root: ['collections'] as const,
|
||||
|
|
@ -52,3 +53,41 @@ export const collectionDetailQuery = defineQueryOptions(
|
|||
.then((res) => res.data),
|
||||
}),
|
||||
)
|
||||
|
||||
export const useUpdateCollection = defineMutation(() => {
|
||||
// const queryCache = useQueryCache()
|
||||
return useMutation({
|
||||
mutation: ({
|
||||
collectionId,
|
||||
data,
|
||||
}: {
|
||||
collectionId: string
|
||||
data: components['schemas']['CollectionUpdateDto']
|
||||
}) =>
|
||||
komgaClient.PATCH('/api/v1/collections/{id}', {
|
||||
params: {
|
||||
path: {
|
||||
id: collectionId,
|
||||
},
|
||||
},
|
||||
body: data,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
//TODO: check how to invalidate cache
|
||||
// void queryCache.invalidateQueries({ key: QUERY_KEYS_LIBRARIES.root })
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
export const useDeleteCollection = defineMutation(() =>
|
||||
useMutation({
|
||||
mutation: (collectionId: string) =>
|
||||
komgaClient.DELETE('/api/v1/collections/{id}', {
|
||||
params: {
|
||||
path: {
|
||||
id: collectionId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
4
next-ui/src/components.d.ts
vendored
4
next-ui/src/components.d.ts
vendored
|
|
@ -24,6 +24,10 @@ declare module 'vue' {
|
|||
BuildCommit: typeof import('./components/BuildCommit.vue')['default']
|
||||
BuildVersion: typeof import('./components/BuildVersion.vue')['default']
|
||||
ChipCount: typeof import('./components/ChipCount.vue')['default']
|
||||
CollectionCard: typeof import('./components/collection/card/CollectionCard.vue')['default']
|
||||
CollectionDeletionWarning: typeof import('./components/collection/DeletionWarning.vue')['default']
|
||||
CollectionMenu: typeof import('./components/collection/menu/CollectionMenu.vue')['default']
|
||||
CollectionMenuBottomSheet: typeof import('./components/collection/menu/CollectionMenuBottomSheet.vue')['default']
|
||||
DialogBookPicker: typeof import('./components/dialog/BookPicker.vue')['default']
|
||||
DialogConfirm: typeof import('./components/dialog/Confirm.vue')['default']
|
||||
DialogConfirmEdit: typeof import('./components/dialog/ConfirmEdit.vue')['default']
|
||||
|
|
|
|||
31
next-ui/src/components/collection/DeletionWarning.stories.ts
Normal file
31
next-ui/src/components/collection/DeletionWarning.stories.ts
Normal 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: {},
|
||||
}
|
||||
36
next-ui/src/components/collection/DeletionWarning.vue
Normal file
36
next-ui/src/components/collection/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: 'Collection deletion warning notice',
|
||||
defaultMessage:
|
||||
'The collection will be deleted<ul><li>Series and books will not be deleted.</li></ul><b>This action cannot be undone.</b>',
|
||||
id: 'jD87k9',
|
||||
})
|
||||
</script>
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import CollectionCard from './CollectionCard.vue'
|
||||
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 { mockCollection } from '@/mocks/api/handlers/collections'
|
||||
|
||||
const meta = {
|
||||
component: CollectionCard,
|
||||
render: (args: object) => ({
|
||||
components: { CollectionCard, DialogConfirmEditInstance, DialogConfirmInstance },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template:
|
||||
'<CollectionCard 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: {
|
||||
collection: mockCollection,
|
||||
onSelection: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof CollectionCard>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
}
|
||||
|
||||
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'))
|
||||
},
|
||||
}
|
||||
87
next-ui/src/components/collection/card/CollectionCard.vue
Normal file
87
next-ui/src/components/collection/card/CollectionCard.vue
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<template>
|
||||
<ItemCard
|
||||
:id="id"
|
||||
:title="title"
|
||||
:lines="lines"
|
||||
:poster-url="collectionThumbnailUrl(collection.id)"
|
||||
:quick-action-icon="quickActionIcon"
|
||||
:quick-action-props="quickActionProps"
|
||||
:menu-icon="menuIcon"
|
||||
:menu-props="menuProps"
|
||||
v-bind="props"
|
||||
@selection="(val, event) => emit('selection', val, event)"
|
||||
@click-quick-action="showEditMetadataDialog()"
|
||||
@card-long-press="bottomSheet = true"
|
||||
/>
|
||||
<CollectionMenu
|
||||
:collection="collection"
|
||||
:activator="menuActivator"
|
||||
/>
|
||||
<CollectionMenuBottomSheet
|
||||
v-model="bottomSheet"
|
||||
:collection="collection"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { components } from '@/generated/openapi/komga'
|
||||
import { collectionThumbnailUrl } from '@/api/images'
|
||||
import { useIntl } from 'vue-intl'
|
||||
import type { ItemCardEmits, ItemCardLine, ItemCardProps, ItemCardTitle } from '@/types/ItemCard'
|
||||
import { useCurrentUser } from '@/colada/users'
|
||||
import { useEditCollectionDialog } from '@/composables/collection/useEditCollectionDialog'
|
||||
import CollectionMenu from '@/components/collection/menu/CollectionMenu.vue'
|
||||
import CollectionMenuBottomSheet from '@/components/collection/menu/CollectionMenuBottomSheet.vue'
|
||||
|
||||
const intl = useIntl()
|
||||
|
||||
const id = useId()
|
||||
|
||||
const { collection, ...props } = defineProps<
|
||||
{
|
||||
collection: components['schemas']['CollectionDto']
|
||||
} & ItemCardProps
|
||||
>()
|
||||
const emit = defineEmits<ItemCardEmits>()
|
||||
|
||||
const bottomSheet = ref(false)
|
||||
|
||||
const title = computed<ItemCardTitle>(() => ({ text: collection.name, lines: 2 }))
|
||||
|
||||
const lines = computed<ItemCardLine[]>(() => [
|
||||
{
|
||||
text: intl.formatMessage(
|
||||
{
|
||||
description: 'Collection card subtitle: count of series',
|
||||
defaultMessage: '{count} series',
|
||||
id: 'P3UD90',
|
||||
},
|
||||
{ count: collection.seriesIds.length },
|
||||
),
|
||||
},
|
||||
])
|
||||
|
||||
const { isAdmin } = useCurrentUser()
|
||||
const quickActionIcon = computed(() => (isAdmin.value ? 'i-mdi:pencil' : undefined))
|
||||
const quickActionProps = computed(() => ({
|
||||
id: `${id}_quick`,
|
||||
onmouseenter: () => (editActivator.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: prepareEditCollectionDialog,
|
||||
showDialog: showEditCollectionDialog,
|
||||
activator: editActivator,
|
||||
} = useEditCollectionDialog()
|
||||
|
||||
function showEditMetadataDialog() {
|
||||
prepareEditCollectionDialog(collection)
|
||||
showEditCollectionDialog()
|
||||
}
|
||||
|
||||
const menuActivator = ref()
|
||||
</script>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
import * as Stories from './CollectionMenu.stories.ts';
|
||||
|
||||
<Meta of={Stories} />
|
||||
|
||||
# CollectionMenu
|
||||
|
||||
Action menu for collection.
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import CollectionMenu from './CollectionMenu.vue'
|
||||
import { expect } from 'storybook/test'
|
||||
import DialogConfirmInstance from '@/components/dialog/ConfirmInstance.vue'
|
||||
import DialogConfirmEditInstance from '@/components/dialog/ConfirmEditInstance.vue'
|
||||
import { mockCollection } from '@/mocks/api/handlers/collections'
|
||||
|
||||
const meta = {
|
||||
component: CollectionMenu,
|
||||
render: (args: object) => ({
|
||||
components: { CollectionMenu, DialogConfirmInstance, DialogConfirmEditInstance },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template:
|
||||
'<v-icon-btn id="IDce0b073e6b2146e688c1cd32b61f3fef" icon="i-mdi:dots-vertical"/><CollectionMenu 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',
|
||||
collection: mockCollection,
|
||||
},
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
await expect(canvas.getByRole('button')).toBeEnabled()
|
||||
|
||||
await userEvent.click(canvas.getByRole('button'))
|
||||
},
|
||||
} satisfies Meta<typeof CollectionMenu>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
}
|
||||
|
||||
// export const NonAdmin: Story = {
|
||||
// args: {},
|
||||
// parameters: {
|
||||
// msw: {
|
||||
// handlers: [
|
||||
// httpTyped.get('/api/v2/users/me', ({ response }) => response(200).json(userRegular)),
|
||||
// ],
|
||||
// },
|
||||
// },
|
||||
// }
|
||||
27
next-ui/src/components/collection/menu/CollectionMenu.vue
Normal file
27
next-ui/src/components/collection/menu/CollectionMenu.vue
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<template>
|
||||
<v-menu :activator="activator">
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="(action, i) in manageActions"
|
||||
:key="i"
|
||||
v-bind="action"
|
||||
/>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { components } from '@/generated/openapi/komga'
|
||||
import { useCollectionActions } from '@/composables/collection/useCollectionActions'
|
||||
|
||||
const { activator, collection } = defineProps<{
|
||||
activator: string | Element
|
||||
collection: components['schemas']['CollectionDto']
|
||||
}>()
|
||||
|
||||
const { manageActions } = useCollectionActions(collection)
|
||||
</script>
|
||||
|
||||
<script lang="ts"></script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
import * as Stories from './CollectionMenuBottomSheet.stories.ts';
|
||||
|
||||
<Meta of={Stories} />
|
||||
|
||||
# CollectionMenuBottomSheet
|
||||
|
||||
Action menu for collection for touch screens.
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import CollectionMenuBottomSheet from './CollectionMenuBottomSheet.vue'
|
||||
import DialogConfirmInstance from '@/components/dialog/ConfirmInstance.vue'
|
||||
import DialogConfirmEditInstance from '@/components/dialog/ConfirmEditInstance.vue'
|
||||
import { mockCollection } from '@/mocks/api/handlers/collections'
|
||||
|
||||
const meta = {
|
||||
component: CollectionMenuBottomSheet,
|
||||
render: (args: object) => ({
|
||||
components: { CollectionMenuBottomSheet, DialogConfirmInstance, DialogConfirmEditInstance },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template:
|
||||
'<CollectionMenuBottomSheet 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,
|
||||
collection: mockCollection,
|
||||
},
|
||||
} satisfies Meta<typeof CollectionMenuBottomSheet>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
}
|
||||
|
||||
// export const NonAdmin: Story = {
|
||||
// args: {},
|
||||
// parameters: {
|
||||
// msw: {
|
||||
// handlers: [
|
||||
// httpTyped.get('/api/v2/users/me', ({ response }) => response(200).json(userRegular)),
|
||||
// ],
|
||||
// },
|
||||
// },
|
||||
// }
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<v-bottom-sheet
|
||||
v-model="isShown"
|
||||
inset
|
||||
>
|
||||
<v-list>
|
||||
<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 { useCollectionActions } from '@/composables/collection/useCollectionActions'
|
||||
|
||||
const isShown = defineModel<boolean>({ default: false })
|
||||
|
||||
const { collection } = defineProps<{
|
||||
collection: components['schemas']['CollectionDto']
|
||||
}>()
|
||||
|
||||
function afterClick() {
|
||||
isShown.value = false
|
||||
}
|
||||
const { manageActions } = useCollectionActions(collection, afterClick)
|
||||
</script>
|
||||
|
||||
<script lang="ts"></script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
<v-col
|
||||
v-for="(item, idx) in internalItems"
|
||||
:key="idx"
|
||||
:cols="presentationMode === 'grid' ? 'auto' : 12"
|
||||
:cols="presentationMode === 'grid' ? (display.xs.value ? 6 : 'auto') : 12"
|
||||
>
|
||||
<slot
|
||||
:item="item.raw"
|
||||
|
|
@ -45,8 +45,10 @@ import { useAppStore } from '@/stores/app'
|
|||
import { useSelectionStore } from '@/stores/selection'
|
||||
import type { PresentationMode } from '@/types/libraries'
|
||||
import { useItemsPerPage } from '@/composables/pagination'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const display = useDisplay()
|
||||
|
||||
const page1 = defineModel<number>('page1', { required: true })
|
||||
|
||||
|
|
|
|||
129
next-ui/src/composables/collection/useCollectionActions.ts
Normal file
129
next-ui/src/composables/collection/useCollectionActions.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import type { components } from '@/generated/openapi/komga'
|
||||
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 { useCurrentUser } from '@/colada/users'
|
||||
import CollectionDeletionWarning from '@/components/collection/DeletionWarning.vue'
|
||||
import { CollectionAction } from '@/types/collection'
|
||||
import { useEditCollectionDialog } from '@/composables/collection/useEditCollectionDialog'
|
||||
import { useDeleteCollection } from '@/colada/collections'
|
||||
|
||||
export function useCollectionActions(
|
||||
collection: components['schemas']['CollectionDto'],
|
||||
callback: (action: CollectionAction) => void = () => {},
|
||||
) {
|
||||
const { isAdmin } = useCurrentUser()
|
||||
const intl = useIntl()
|
||||
const { confirm: dialogConfirm } = storeToRefs(useDialogsStore())
|
||||
const messagesStore = useMessagesStore()
|
||||
const display = useDisplay()
|
||||
|
||||
const manageActions = computed(() => [
|
||||
...(isAdmin.value
|
||||
? [
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'Collection menu: manage > edit',
|
||||
defaultMessage: 'Edit',
|
||||
id: '39afQA',
|
||||
}),
|
||||
action: CollectionAction.EDIT,
|
||||
onMouseenter: (event: Event) => (editActivator.value = event.currentTarget as Element),
|
||||
onClick: () => {
|
||||
edit()
|
||||
callback(CollectionAction.EDIT)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isAdmin.value
|
||||
? [
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'Collection menu: manage > delete',
|
||||
defaultMessage: 'Delete',
|
||||
id: 'Ekh3wO',
|
||||
}),
|
||||
action: CollectionAction.DELETE,
|
||||
onMouseenter: (event: Event) =>
|
||||
(dialogConfirm.value.activator = event.currentTarget as Element),
|
||||
onClick: () => {
|
||||
deleteCollection()
|
||||
callback(CollectionAction.DELETE)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
])
|
||||
|
||||
//region Edit collection
|
||||
const { prepareDialog: showEditCollectionDialog, activator: editActivator } =
|
||||
useEditCollectionDialog()
|
||||
|
||||
function edit() {
|
||||
showEditCollectionDialog(collection)
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Delete
|
||||
const { mutateAsync: mutateDelete } = useDeleteCollection()
|
||||
|
||||
function deleteCollection() {
|
||||
dialogConfirm.value.dialogProps = {
|
||||
title: intl.formatMessage({
|
||||
description: 'Collection delete dialog: title',
|
||||
defaultMessage: 'Delete collection',
|
||||
id: 'k6BCzW',
|
||||
}),
|
||||
subtitle: collection.name,
|
||||
maxWidth: 600,
|
||||
mode: 'checkbox',
|
||||
color: 'error',
|
||||
okText: intl.formatMessage({
|
||||
description: 'Collection delete dialog: confirm button',
|
||||
defaultMessage: 'Delete',
|
||||
id: 'rDBhmQ',
|
||||
}),
|
||||
closeOnSave: true,
|
||||
fullscreen: display.xs.value,
|
||||
}
|
||||
dialogConfirm.value.slotWarning = {
|
||||
component: markRaw(CollectionDeletionWarning),
|
||||
props: {},
|
||||
}
|
||||
dialogConfirm.value.callback = () => {
|
||||
mutateDelete(collection.id)
|
||||
.then(() => {
|
||||
messagesStore.messages.push({
|
||||
text: intl.formatMessage(
|
||||
{
|
||||
description: 'Snackbar notification shown upon successful collection deletion',
|
||||
defaultMessage: 'Collection deleted: {collection}',
|
||||
id: 'HdsnFp',
|
||||
},
|
||||
{
|
||||
collection: collection.name,
|
||||
},
|
||||
),
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
messagesStore.messages.push({
|
||||
text:
|
||||
(error?.cause as ErrorCause)?.message ||
|
||||
intl.formatMessage(commonMessages.networkError),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
return {
|
||||
// actions: actions,
|
||||
manageActions: manageActions,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
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 { useUpdateCollection } from '@/colada/collections'
|
||||
|
||||
export function useEditCollectionDialog() {
|
||||
const { confirmEdit: dialogConfirmEdit } = storeToRefs(useDialogsStore())
|
||||
const intl = useIntl()
|
||||
const display = useDisplay()
|
||||
const messagesStore = useMessagesStore()
|
||||
const { mutateAsync: mutateUpdateCollection } = useUpdateCollection()
|
||||
|
||||
const prepareDialog = (collection: components['schemas']['CollectionDto']) => {
|
||||
dialogConfirmEdit.value.dialogProps = {
|
||||
title: intl.formatMessage({
|
||||
description: 'Edit collection dialog title',
|
||||
defaultMessage: 'Edit collection',
|
||||
id: 'YVQ49g',
|
||||
}),
|
||||
subtitle: collection.name,
|
||||
maxWidth: 600,
|
||||
okText: 'Save',
|
||||
cardTextClass: 'px-0',
|
||||
closeOnSave: false,
|
||||
scrollable: true,
|
||||
fullscreen: display.xs.value,
|
||||
}
|
||||
dialogConfirmEdit.value.slot = {
|
||||
component: markRaw(EditMetadata),
|
||||
}
|
||||
dialogConfirmEdit.value.record = collection
|
||||
dialogConfirmEdit.value.callback = (
|
||||
hideDialog: () => void,
|
||||
setLoading: (isLoading: boolean) => void,
|
||||
) => {
|
||||
setLoading(true)
|
||||
|
||||
const updatedData = dialogConfirmEdit.value.record as components['schemas']['CollectionDto']
|
||||
|
||||
mutateUpdateCollection({ collectionId: collection.id, data: updatedData })
|
||||
.then(() => {
|
||||
hideDialog()
|
||||
messagesStore.messages.push({
|
||||
text: intl.formatMessage(
|
||||
{
|
||||
description: 'Snackbar notification shown upon successful collection update',
|
||||
defaultMessage: 'Collection updated: {collection}',
|
||||
id: 'E0cw62',
|
||||
},
|
||||
{
|
||||
collection: updatedData.name,
|
||||
},
|
||||
),
|
||||
})
|
||||
})
|
||||
.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,
|
||||
}
|
||||
}
|
||||
|
|
@ -31,7 +31,10 @@ export function useGetLibrariesById(libraryId: MaybeRefOrGetter<LibraryId>) {
|
|||
return libs
|
||||
})
|
||||
|
||||
const libIds = computed(() => libs.value?.map((it) => it.id))
|
||||
|
||||
return {
|
||||
libraries: libs,
|
||||
libraryIds: libIds,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { httpTyped } from '@/mocks/api/httpTyped'
|
||||
import { PageRequest } from '@/types/PageRequest'
|
||||
import { mockPage } from '@/mocks/api/pageable'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import mockThumbnailUrl from '@/assets/mock-thumbnail.jpg'
|
||||
|
||||
const collection1 = {
|
||||
export const mockCollection = {
|
||||
id: '026801S4HWRZA',
|
||||
name: 'Golden Age',
|
||||
ordered: true,
|
||||
|
|
@ -12,7 +14,7 @@ const collection1 = {
|
|||
filtered: false,
|
||||
}
|
||||
|
||||
const collections = [collection1]
|
||||
const collections = [mockCollection]
|
||||
|
||||
export const collectionsHandlers = [
|
||||
httpTyped.get('/api/v1/collections', ({ query, response }) => {
|
||||
|
|
@ -28,20 +30,14 @@ export const collectionsHandlers = [
|
|||
mockPage(selected, new PageRequest(Number(query.get('page')), Number(query.get('size')))),
|
||||
)
|
||||
}),
|
||||
// httpTyped.get('/api/v1/series/{seriesId}', ({ params, response }) => {
|
||||
// if (params.seriesId === '404') return response(404).empty()
|
||||
// return response(200).json(
|
||||
// Object.assign({}, series1, { metadata: { title: `Series ${params.seriesId}` } }),
|
||||
// )
|
||||
// }),
|
||||
// http.get('*/api/v1/series/*/thumbnail', async () => {
|
||||
// // Get an ArrayBuffer from reading the file from disk or fetching it.
|
||||
// const buffer = await fetch(mockThumbnailUrl).then((response) => response.arrayBuffer())
|
||||
//
|
||||
// return HttpResponse.arrayBuffer(buffer, {
|
||||
// headers: {
|
||||
// 'content-type': 'image/jpg',
|
||||
// },
|
||||
// })
|
||||
// }),
|
||||
http.get('*/api/v1/collections/*/thumbnail', async () => {
|
||||
// Get an ArrayBuffer from reading the file from disk or fetching it.
|
||||
const buffer = await fetch(mockThumbnailUrl).then((response) => response.arrayBuffer())
|
||||
|
||||
return HttpResponse.arrayBuffer(buffer, {
|
||||
headers: {
|
||||
'content-type': 'image/jpg',
|
||||
},
|
||||
})
|
||||
}),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@
|
|||
:book="item"
|
||||
:selected="isSelected"
|
||||
:pre-select="preSelect"
|
||||
:width="display.xs.value ? undefined : appStore.gridCardWidth"
|
||||
:width="display.xs.value ? 'auto' : appStore.gridCardWidth"
|
||||
@selection="(_val, event) => toggleSelect(event as MouseEvent)"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,128 @@
|
|||
<template>
|
||||
COLLECTIONS
|
||||
<EmptyStateConstruction />
|
||||
<v-app-bar>
|
||||
<ChipCount :count="totalElements" />
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<PosterSizeSlider />
|
||||
|
||||
<PageSizeSelector
|
||||
v-if="isBrowsingPaged"
|
||||
v-model="appStore.browsingPageSize"
|
||||
allow-unpaged
|
||||
:sizes="[1, 10, 20]"
|
||||
/>
|
||||
|
||||
<PagingSelector
|
||||
v-model="appStore.browsingPaging"
|
||||
class="px-2"
|
||||
/>
|
||||
</v-app-bar>
|
||||
|
||||
<ItemBrowser
|
||||
v-model:page1="page1"
|
||||
:items="dataItems"
|
||||
:presentation-mode="'grid'"
|
||||
:has-next-page="hasNextPage"
|
||||
:page-count="pageCount"
|
||||
@load-next-page="loadNextPage()"
|
||||
>
|
||||
<template #default="{ item, isSelected, preSelect, toggleSelect }">
|
||||
<CollectionCard
|
||||
stretch-poster
|
||||
:collection="item"
|
||||
:selected="isSelected"
|
||||
:pre-select="preSelect"
|
||||
:width="display.xs.value ? 'auto' : appStore.gridCardWidth"
|
||||
@selection="(_val, event) => toggleSelect(event as MouseEvent)"
|
||||
/>
|
||||
</template>
|
||||
</ItemBrowser>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
//
|
||||
import { useInfiniteQuery, useQuery } from '@pinia/colada'
|
||||
import { PageRequest } from '@/types/PageRequest'
|
||||
import { useGetLibrariesById } from '@/composables/libraries'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { usePagination } from '@/composables/pagination'
|
||||
import { useSelectionStore } from '@/stores/selection'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { komgaClient } from '@/api/komga-client'
|
||||
import PosterSizeSlider from '@/components/PosterSizeSlider.vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ChipCount from '@/components/ChipCount.vue'
|
||||
import { collectionsListQuery } from '@/colada/collections'
|
||||
|
||||
const route = useRoute('/libraries/[id]/collections')
|
||||
const libraryId = route.params.id
|
||||
const { libraryIds } = useGetLibrariesById(libraryId)
|
||||
|
||||
const display = useDisplay()
|
||||
const appStore = useAppStore()
|
||||
const { isBrowsingScroll, isBrowsingPaged } = storeToRefs(appStore)
|
||||
|
||||
const { page0, page1, pageCount } = usePagination()
|
||||
|
||||
const selectionStore = useSelectionStore()
|
||||
|
||||
// clear selection if paging changes
|
||||
watch(
|
||||
() => appStore.browsingPaging,
|
||||
() => selectionStore.clear(),
|
||||
)
|
||||
|
||||
const { data: dataPaged } = useQuery(() => ({
|
||||
...collectionsListQuery({
|
||||
libraryIds: libraryIds.value,
|
||||
pageRequest: PageRequest.FromPageSize(appStore.browsingPageSize, page0.value),
|
||||
}),
|
||||
enabled: isBrowsingPaged.value,
|
||||
}))
|
||||
|
||||
watch(dataPaged, (newDataPaged) => {
|
||||
if (newDataPaged) pageCount.value = newDataPaged.totalPages ?? 0
|
||||
})
|
||||
|
||||
const {
|
||||
data: dataInfinite,
|
||||
loadNextPage,
|
||||
hasNextPage,
|
||||
} = useInfiniteQuery({
|
||||
key: () => ['infinite_collections', { libraryIds: libraryIds.value }],
|
||||
initialPageParam: new PageRequest(0, 50),
|
||||
query: ({ pageParam }) =>
|
||||
komgaClient
|
||||
.GET('/api/v1/collections', {
|
||||
params: {
|
||||
query: {
|
||||
page: pageParam.page,
|
||||
size: pageParam.size,
|
||||
libraryIds: libraryIds.value,
|
||||
},
|
||||
},
|
||||
})
|
||||
// unwrap the openapi-fetch structure on success
|
||||
.then((res) => res.data),
|
||||
getNextPageParam: (lastPage, _, lastPageParam) => (!lastPage?.last ? lastPageParam.next() : null),
|
||||
enabled: isBrowsingScroll,
|
||||
})
|
||||
const dataInfiniteFlat = computed(() =>
|
||||
dataInfinite.value?.pages.flatMap((it) => it?.content ?? []),
|
||||
)
|
||||
|
||||
const dataItems = computed(() =>
|
||||
isBrowsingPaged.value ? dataPaged.value?.content : dataInfiniteFlat.value,
|
||||
)
|
||||
const totalElements = computed(() =>
|
||||
isBrowsingPaged.value
|
||||
? dataPaged.value?.totalElements
|
||||
: dataInfinite.value?.pages?.[0]?.totalElements,
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss"></style>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
requiresRole: USER
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@
|
|||
:series="item"
|
||||
:selected="isSelected"
|
||||
:pre-select="preSelect"
|
||||
:width="display.xs.value ? undefined : appStore.gridCardWidth"
|
||||
:width="display.xs.value ? 'auto' : appStore.gridCardWidth"
|
||||
@selection="(_val, event) => toggleSelect(event as MouseEvent)"
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ export type ItemCardProps = {
|
|||
*
|
||||
* Defaults to `150`.
|
||||
*/
|
||||
width?: number
|
||||
width?: number | 'auto'
|
||||
/**
|
||||
* Disable card selection.
|
||||
*/
|
||||
|
|
|
|||
4
next-ui/src/types/collection.ts
Normal file
4
next-ui/src/types/collection.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export enum CollectionAction {
|
||||
EDIT,
|
||||
DELETE,
|
||||
}
|
||||
Loading…
Reference in a new issue