diff --git a/next-ui/src/colada/libraries.ts b/next-ui/src/colada/libraries.ts
index 86e95e94..0be50509 100644
--- a/next-ui/src/colada/libraries.ts
+++ b/next-ui/src/colada/libraries.ts
@@ -90,3 +90,75 @@ export const useUpdateLibrary = defineMutation(() => {
},
})
})
+
+export const useDeleteLibrary = defineMutation(() => {
+ const queryCache = useQueryCache()
+ return useMutation({
+ mutation: (libraryId: string) =>
+ komgaClient.DELETE('/api/v1/libraries/{libraryId}', {
+ params: {
+ path: {
+ libraryId: libraryId,
+ },
+ },
+ }),
+ onSuccess: () => {
+ void queryCache.invalidateQueries({ key: QUERY_KEYS_LIBRARIES.root })
+ },
+ })
+})
+
+export const useRefreshMetadataLibrary = defineMutation(() =>
+ useMutation({
+ mutation: (libraryId: string) =>
+ komgaClient.POST('/api/v1/libraries/{libraryId}/metadata/refresh', {
+ params: {
+ path: {
+ libraryId: libraryId,
+ },
+ },
+ }),
+ }),
+)
+
+export const useEmptyTrashLibrary = defineMutation(() =>
+ useMutation({
+ mutation: (libraryId: string) =>
+ komgaClient.POST('/api/v1/libraries/{libraryId}/empty-trash', {
+ params: {
+ path: {
+ libraryId: libraryId,
+ },
+ },
+ }),
+ }),
+)
+
+export const useScanLibrary = defineMutation(() =>
+ useMutation({
+ mutation: ({ libraryId, deep = false }: { libraryId: string; deep?: boolean }) =>
+ komgaClient.POST('/api/v1/libraries/{libraryId}/scan', {
+ params: {
+ path: {
+ libraryId: libraryId,
+ },
+ query: {
+ deep: deep,
+ },
+ },
+ }),
+ }),
+)
+
+export const useAnalyzeLibrary = defineMutation(() =>
+ useMutation({
+ mutation: (libraryId: string) =>
+ komgaClient.POST('/api/v1/libraries/{libraryId}/analyze', {
+ params: {
+ path: {
+ libraryId: libraryId,
+ },
+ },
+ }),
+ }),
+)
diff --git a/next-ui/src/components.d.ts b/next-ui/src/components.d.ts
index b63e6e8b..6638d4fb 100644
--- a/next-ui/src/components.d.ts
+++ b/next-ui/src/components.d.ts
@@ -54,6 +54,7 @@ declare module 'vue' {
LayoutAppDrawerMenuMedia: typeof import('./components/layout/app/drawer/menu/Media.vue')['default']
LayoutAppDrawerMenuServer: typeof import('./components/layout/app/drawer/menu/Server.vue')['default']
LayoutAppDrawerReorderLibraries: typeof import('./components/layout/app/drawer/ReorderLibraries.vue')['default']
+ LibraryDeletionWarning: typeof import('./components/library/DeletionWarning.vue')['default']
LibraryFormCreateEdit: typeof import('./components/library/form/CreateEdit.vue')['default']
LibraryFormStepGeneral: typeof import('./components/library/form/StepGeneral.vue')['default']
LibraryFormStepMetadata: typeof import('./components/library/form/StepMetadata.vue')['default']
diff --git a/next-ui/src/components/dialog/Confirm.mdx b/next-ui/src/components/dialog/Confirm.mdx
new file mode 100644
index 00000000..3764f444
--- /dev/null
+++ b/next-ui/src/components/dialog/Confirm.mdx
@@ -0,0 +1,14 @@
+import { Meta } from '@storybook/addon-docs/blocks';
+
+import * as Stories from './Confirm.stories';
+
+
+
+# DialogConfirm
+
+A configurable confirmation dialog.
+
+Confirmation can be one of:
+- click the confirm button
+- check a checkbox, then click the confirm button
+- type a validation text, then click the confirm button
diff --git a/next-ui/src/components/dialog/Confirm.stories.ts b/next-ui/src/components/dialog/Confirm.stories.ts
new file mode 100644
index 00000000..8c19c68a
--- /dev/null
+++ b/next-ui/src/components/dialog/Confirm.stories.ts
@@ -0,0 +1,57 @@
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import Confirm from './Confirm.vue'
+import { fn } from 'storybook/test'
+
+const meta = {
+ component: Confirm,
+ render: (args: object) => ({
+ components: { Confirm },
+ setup() {
+ return { args }
+ },
+ template: '',
+ }),
+ parameters: {
+ // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
+ },
+ args: {
+ dialog: true,
+ onConfirm: fn(),
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Click: Story = {
+ args: {
+ ...meta.args,
+ mode: 'click',
+ },
+}
+
+export const Checkbox: Story = {
+ args: {
+ ...meta.args,
+ mode: 'checkbox',
+ color: 'warning',
+ },
+}
+
+export const TextInput: Story = {
+ args: {
+ ...meta.args,
+ mode: 'textinput',
+ },
+}
+
+export const TextInputCustomText: Story = {
+ args: {
+ ...meta.args,
+ title: 'dialog title',
+ subtitle: 'dialog subtitle',
+ validateText: "let's go",
+ mode: 'textinput',
+ },
+}
diff --git a/next-ui/src/components/dialog/Confirm.vue b/next-ui/src/components/dialog/Confirm.vue
index d46cbf94..b010a7f2 100644
--- a/next-ui/src/components/dialog/Confirm.vue
+++ b/next-ui/src/components/dialog/Confirm.vue
@@ -24,6 +24,7 @@
@@ -42,11 +45,26 @@
+
@@ -63,19 +81,19 @@
/>
@@ -85,9 +103,11 @@
diff --git a/next-ui/src/components/library/MenuLibraries.vue b/next-ui/src/components/library/MenuLibraries.vue
index 69b35433..1c3752c1 100644
--- a/next-ui/src/components/library/MenuLibraries.vue
+++ b/next-ui/src/components/library/MenuLibraries.vue
@@ -16,6 +16,11 @@
diff --git a/next-ui/src/components/library/MenuLibrary.vue b/next-ui/src/components/library/MenuLibrary.vue
index 2e40af1b..412f187b 100644
--- a/next-ui/src/components/library/MenuLibrary.vue
+++ b/next-ui/src/components/library/MenuLibrary.vue
@@ -29,15 +29,9 @@
>
@@ -50,13 +44,21 @@
import { useIntl } from 'vue-intl'
import { storeToRefs } from 'pinia'
import { useDialogsStore } from '@/stores/dialogs'
-import { useUpdateLibrary } from '@/colada/libraries'
+import {
+ useAnalyzeLibrary,
+ useDeleteLibrary,
+ useEmptyTrashLibrary,
+ useRefreshMetadataLibrary,
+ useScanLibrary,
+ useUpdateLibrary,
+} from '@/colada/libraries'
import { useMessagesStore } from '@/stores/messages'
import CreateEdit from '@/components/library/form/CreateEdit.vue'
import type { components } from '@/generated/openapi/komga'
import { useDisplay } from 'vuetify'
import type { ErrorCause } from '@/api/komga-client'
import { commonMessages } from '@/utils/i18n/common-messages'
+import LibraryDeletionWarning from '@/components/library/DeletionWarning.vue'
const { activatorId, library } = defineProps<{
activatorId: string
@@ -64,11 +66,11 @@ const { activatorId, library } = defineProps<{
}>()
const intl = useIntl()
-const { confirmEdit: dialogConfirmEdit } = storeToRefs(useDialogsStore())
+const { confirmEdit: dialogConfirmEdit, confirm: dialogConfirm } = storeToRefs(useDialogsStore())
const messagesStore = useMessagesStore()
const display = useDisplay()
-//region actions
+//region Actions
const actions = [
{
title: intl.formatMessage({
@@ -76,7 +78,70 @@ const actions = [
defaultMessage: 'Scan library files',
id: 'GCwZB2',
}),
- // onClick: () => (appStore.reorderLibraries = true),
+ onClick: () => scanLibrary(),
+ },
+]
+
+const manageActions = [
+ {
+ title: intl.formatMessage({
+ description: 'Library menu: manage > edit',
+ defaultMessage: 'Edit',
+ id: 'n4w2CE',
+ }),
+ onMouseenter: (event: Event) =>
+ (dialogConfirmEdit.value.activator = event.currentTarget as Element),
+ onClick: () => updateLibrary(),
+ },
+ {
+ title: intl.formatMessage({
+ description: 'Library menu: manage > deep scan',
+ defaultMessage: 'Deep scan',
+ id: 'foSDIW',
+ }),
+ onMouseenter: (event: Event) =>
+ (dialogConfirm.value.activator = event.currentTarget as Element),
+ onClick: () => scanDeep(),
+ },
+ {
+ title: intl.formatMessage({
+ description: 'Library menu: manage > refresh all metadata',
+ defaultMessage: 'Refresh all metadata',
+ id: 'OUyleX',
+ }),
+ onMouseenter: (event: Event) =>
+ (dialogConfirm.value.activator = event.currentTarget as Element),
+ onClick: () => refreshMetadata(),
+ },
+ {
+ title: intl.formatMessage({
+ description: 'Library menu: manage > empty trash',
+ defaultMessage: 'Empty trash',
+ id: 'sdNz1F',
+ }),
+ onMouseenter: (event: Event) =>
+ (dialogConfirm.value.activator = event.currentTarget as Element),
+ onClick: () => emptyTrash(),
+ },
+ {
+ title: intl.formatMessage({
+ description: 'Library menu: manage > analyze',
+ defaultMessage: 'Analyze',
+ id: 'E5ZMyt',
+ }),
+ onMouseenter: (event: Event) =>
+ (dialogConfirm.value.activator = event.currentTarget as Element),
+ onClick: () => analyzeLibrary(),
+ },
+ {
+ title: intl.formatMessage({
+ description: 'Library menu: manage > delete',
+ defaultMessage: 'Delete',
+ id: 'LFf8QB',
+ }),
+ onMouseenter: (event: Event) =>
+ (dialogConfirm.value.activator = event.currentTarget as Element),
+ onClick: () => deleteLibrary(),
},
]
//endregion
@@ -138,6 +203,206 @@ function updateLibrary() {
}
}
//endregion
+
+//region Refresh Metadata
+const { mutate: mutateRefreshMetadata } = useRefreshMetadataLibrary()
+
+function refreshMetadata() {
+ dialogConfirm.value.dialogProps = {
+ title: intl.formatMessage({
+ description: 'Library refresh metadata dialog: title',
+ defaultMessage: 'Refresh all metadata',
+ id: 'xiyw1J',
+ }),
+ subtitle: library.name,
+ maxWidth: 600,
+ mode: 'click',
+ color: 'primary',
+ okText: intl.formatMessage({
+ description: 'Library refresh metadata dialog: confirm button',
+ defaultMessage: 'Refresh',
+ id: 'i+kSy9',
+ }),
+ closeOnSave: true,
+ fullscreen: display.xs.value,
+ }
+ dialogConfirm.value.slotWarning = {
+ component: markRaw(
+ h(
+ 'div',
+ intl.formatMessage({
+ description: 'Library refresh metadata dialog: warning text',
+ defaultMessage:
+ 'Refreshes metadata for all the media files in the library. Depending on your library size, this may take a long time.',
+ id: 'vs88Ef',
+ }),
+ ),
+ ),
+ props: {},
+ }
+ dialogConfirm.value.callback = () => mutateRefreshMetadata(library.id)
+}
+//endregion
+
+//region Empty Trash
+const { mutate: mutateEmptyTrash } = useEmptyTrashLibrary()
+
+function emptyTrash() {
+ dialogConfirm.value.dialogProps = {
+ title: intl.formatMessage(commonMessages.dialogEmptyTrashTitle),
+ subtitle: library.name,
+ maxWidth: 600,
+ mode: 'click',
+ color: 'primary',
+ okText: intl.formatMessage(commonMessages.dialogEmptyTrashConfirm),
+ closeOnSave: true,
+ fullscreen: display.xs.value,
+ }
+ dialogConfirm.value.slotWarning = {
+ component: markRaw(h('div', intl.formatMessage(commonMessages.dialogEmptyTrashNotice))),
+ props: {},
+ }
+ dialogConfirm.value.callback = () => mutateEmptyTrash(library.id)
+}
+//endregion
+
+//region Analyze
+const { mutate: mutateAnalyze } = useAnalyzeLibrary()
+
+function analyzeLibrary() {
+ dialogConfirm.value.dialogProps = {
+ title: intl.formatMessage({
+ description: 'Library analyze dialog: title',
+ defaultMessage: 'Analyze library',
+ id: '5JGOUU',
+ }),
+ subtitle: library.name,
+ maxWidth: 600,
+ mode: 'click',
+ color: 'primary',
+ okText: intl.formatMessage({
+ description: 'Library analyze dialog: confirm button',
+ defaultMessage: 'Analyze',
+ id: 'jN3N1Q',
+ }),
+ closeOnSave: true,
+ fullscreen: display.xs.value,
+ }
+ dialogConfirm.value.slotWarning = {
+ component: markRaw(
+ h(
+ 'div',
+ intl.formatMessage({
+ description: 'Library empty trash dialog: warning text',
+ defaultMessage:
+ 'Analyzes all the media files in the library. The analysis captures information about the media. Depending on your library size, this may take a long time.',
+ id: '8xonXJ',
+ }),
+ ),
+ ),
+ props: {},
+ }
+ dialogConfirm.value.callback = () => mutateAnalyze(library.id)
+}
+//endregion
+
+//region Scan
+const { mutate: mutateScan } = useScanLibrary()
+
+function scanLibrary() {
+ mutateScan({ libraryId: library.id })
+}
+
+function scanDeep() {
+ dialogConfirm.value.dialogProps = {
+ title: intl.formatMessage({
+ description: 'Library deep scan dialog: title',
+ defaultMessage: 'Deep scan',
+ id: 'hV3EW+',
+ }),
+ subtitle: library.name,
+ maxWidth: 600,
+ mode: 'click',
+ color: 'primary',
+ okText: intl.formatMessage({
+ description: 'Library deep scan: confirm button',
+ defaultMessage: 'Deep scan',
+ id: 'OxqfKF',
+ }),
+ closeOnSave: true,
+ fullscreen: display.xs.value,
+ }
+ dialogConfirm.value.slotWarning = {
+ component: markRaw(
+ h(
+ 'div',
+ intl.formatMessage({
+ description: 'Library deep scan dialog: warning text',
+ defaultMessage:
+ 'Performs a deep scan of the library files. Depending on your library size, this may take a long time.',
+ id: 'y3nPgO',
+ }),
+ ),
+ ),
+ props: {},
+ }
+ dialogConfirm.value.callback = () => mutateScan({ libraryId: library.id, deep: true })
+}
+//endregion
+
+//region Delete
+const { mutateAsync: mutateDelete } = useDeleteLibrary()
+
+function deleteLibrary() {
+ dialogConfirm.value.dialogProps = {
+ title: intl.formatMessage({
+ description: 'Library delete dialog: title',
+ defaultMessage: 'Delete library',
+ id: '3T1ln7',
+ }),
+ subtitle: library.name,
+ maxWidth: 600,
+ mode: 'textinput',
+ color: 'error',
+ validateText: library.name,
+ okText: intl.formatMessage({
+ description: 'Library delete dialog: confirm button',
+ defaultMessage: 'Delete',
+ id: '/5Gb4y',
+ }),
+ closeOnSave: true,
+ fullscreen: display.xs.value,
+ }
+ dialogConfirm.value.slotWarning = {
+ component: markRaw(LibraryDeletionWarning),
+ props: {},
+ }
+ dialogConfirm.value.callback = () => {
+ mutateDelete(library.id)
+ .then(() => {
+ messagesStore.messages.push({
+ text: intl.formatMessage(
+ {
+ description: 'Snackbar notification shown upon successful library deletion',
+ defaultMessage: 'Library deleted: {library}',
+ id: 'PvKF7E',
+ },
+ {
+ library: library.name,
+ },
+ ),
+ })
+ })
+ .catch((error) => {
+ messagesStore.messages.push({
+ text:
+ (error?.cause as ErrorCause)?.message ||
+ intl.formatMessage(commonMessages.networkError),
+ })
+ })
+ }
+}
+//endregion
diff --git a/next-ui/src/mocks/api/handlers/libraries.ts b/next-ui/src/mocks/api/handlers/libraries.ts
index 933eb8e8..75fd6c5d 100644
--- a/next-ui/src/mocks/api/handlers/libraries.ts
+++ b/next-ui/src/mocks/api/handlers/libraries.ts
@@ -96,4 +96,25 @@ export const librariesHandlers = [
return response(204).empty()
}),
+ httpTyped.delete('/api/v1/libraries/{libraryId}', ({ params, response }) => {
+ const libraryId = params['libraryId']
+
+ const existing = libraries.find((it) => it.id === libraryId)
+
+ if (!existing) {
+ return response.untyped(response404NotFound())
+ }
+
+ libraries.splice(libraries.indexOf(existing), 1)
+
+ return response(204).empty()
+ }),
+ httpTyped.post('/api/v1/libraries/{libraryId}/metadata/refresh', ({ response }) =>
+ response(202).empty(),
+ ),
+ httpTyped.post('/api/v1/libraries/{libraryId}/analyze', ({ response }) => response(202).empty()),
+ httpTyped.post('/api/v1/libraries/{libraryId}/empty-trash', ({ response }) =>
+ response(202).empty(),
+ ),
+ httpTyped.post('/api/v1/libraries/{libraryId}/scan', ({ response }) => response(202).empty()),
]
diff --git a/next-ui/src/pages/account/api-keys.vue b/next-ui/src/pages/account/api-keys.vue
index 9edef810..c7c77901 100644
--- a/next-ui/src/pages/account/api-keys.vue
+++ b/next-ui/src/pages/account/api-keys.vue
@@ -79,6 +79,7 @@ function showDialog(action: ACTION, apiKey?: components['schemas']['ApiKeyDto'])
subtitle: apiKey?.comment,
maxWidth: 600,
validateText: apiKey?.comment,
+ mode: 'textinput',
okText: intl.formatMessage({
description: 'Delete API Key dialog: confirmation button text',
defaultMessage: 'Delete',
@@ -103,6 +104,7 @@ function showDialog(action: ACTION, apiKey?: components['schemas']['ApiKeyDto'])
subtitle: apiKey?.comment,
maxWidth: 600,
validateText: apiKey?.comment,
+ mode: 'textinput',
okText: intl.formatMessage({
description: 'Force Sync API Key dialog: confirmation button text',
defaultMessage: 'I understand',
diff --git a/next-ui/src/pages/server/users.vue b/next-ui/src/pages/server/users.vue
index 4a5817c2..4c85e04d 100644
--- a/next-ui/src/pages/server/users.vue
+++ b/next-ui/src/pages/server/users.vue
@@ -154,6 +154,7 @@ function showDialog(action: ACTION, user?: components['schemas']['UserDto']) {
subtitle: user?.email,
maxWidth: 600,
validateText: user?.email,
+ mode: 'textinput',
okText: intl.formatMessage({
description: 'Delete user dialog: confirmation button text',
defaultMessage: 'Delete',
diff --git a/next-ui/src/types/dialog.ts b/next-ui/src/types/dialog.ts
index 57d0ba3f..2e0bf4ea 100644
--- a/next-ui/src/types/dialog.ts
+++ b/next-ui/src/types/dialog.ts
@@ -1,25 +1,77 @@
type DialogBaseProps = {
+ /**
+ * Title of the dialog.
+ */
title?: string
+ /**
+ * Subtitle of the dialog.
+ */
subtitle?: string
+ /**
+ * Maximum width of the dialog.
+ */
maxWidth?: string | number
+ /**
+ * Activator for the dialog.
+ */
activator?: Element | string
+ /**
+ * Loading indicator, applies to the dialog's card.
+ */
loading?: boolean
+ /**
+ * Whether the dialog should be displayed in full screen.
+ */
fullscreen?: boolean
+ /**
+ * Whether the dialog is scrollable.
+ */
scrollable?: boolean
+ /**
+ * Controls the dialog's visibility.
+ */
shown?: boolean
}
type DialogConfirmBaseProps = DialogBaseProps & {
+ /**
+ * Text for the confirmation button.
+ */
okText?: string
+ /**
+ * Whether the dialog should close itself on save.
+ * If disabled, the dialog should be closed inside the callback function.
+ */
closeOnSave?: boolean
}
export type DialogSimpleProps = DialogBaseProps
export type DialogConfirmProps = DialogConfirmBaseProps & {
+ /**
+ * Text that needs to be typed to validate. Only shown when `mode` is set to `textinput`.
+ */
validateText?: string
+ /**
+ * Label shown next to the confirmation checkbox. Only shown when `mode` is set to `checkbox`.
+ */
+ checkboxLabel?: string
+ /**
+ * Confirmation mechanism:
+ * - `textinput`: requires typing the content of 'validateText' into a text field.
+ * - `checkbox`: requires checking a checkbox.
+ * - `click`: no extra validation, just click on the confirm button of the dialog.
+ */
+ mode?: 'textinput' | 'checkbox' | 'click'
+ /**
+ * Color used for the checkbox and confirm button. Defaults to `error`.
+ */
+ color?: string
}
export type DialogConfirmEditProps = DialogConfirmBaseProps & {
+ /**
+ * CSS classes applied to the card.
+ */
cardTextClass?: string
}
diff --git a/next-ui/src/utils/i18n/common-messages.ts b/next-ui/src/utils/i18n/common-messages.ts
index f0b7c4d6..22733f1b 100644
--- a/next-ui/src/utils/i18n/common-messages.ts
+++ b/next-ui/src/utils/i18n/common-messages.ts
@@ -36,4 +36,20 @@ export const commonMessages = {
defaultMessage: 'Can consume lots of resources on large libraries or slow hardware',
id: 'uoc99F',
}),
+ dialogEmptyTrashTitle: defineMessage({
+ description: 'Library empty trash dialog: title',
+ defaultMessage: 'Empty trash',
+ id: 'ELttw/',
+ }),
+ dialogEmptyTrashConfirm: defineMessage({
+ description: 'Library empty trash dialog: confirm button',
+ defaultMessage: 'Empty trash',
+ id: '7M1pUf',
+ }),
+ dialogEmptyTrashNotice: defineMessage({
+ description: 'Library empty trash dialog: warning text',
+ defaultMessage:
+ "By default the media server doesn't remove information for media right away. This helps if a drive is temporarily disconnected. When you empty the trash for a library, all information about missing media is deleted.",
+ id: 'kDc7YL',
+ }),
}