From 6a73408c3ca711f532c6eb3d61ca4a6965d20be6 Mon Sep 17 00:00:00 2001 From: Gauthier Roebroeck Date: Mon, 15 Dec 2025 15:05:40 +0800 Subject: [PATCH] libraries/library menus --- next-ui/src/colada/libraries.ts | 72 +++++ next-ui/src/components.d.ts | 1 + next-ui/src/components/dialog/Confirm.mdx | 14 + .../src/components/dialog/Confirm.stories.ts | 57 ++++ next-ui/src/components/dialog/Confirm.vue | 51 ++- .../layout/app/drawer/Drawer.stories.ts | 5 +- .../app/drawer/menu/Libraries.stories.ts | 6 +- .../layout/app/drawer/menu/Libraries.vue | 4 +- .../components/library/DeletionWarning.mdx | 11 + .../library/DeletionWarning.stories.ts | 25 ++ .../components/library/DeletionWarning.vue | 36 +++ .../src/components/library/MenuLibraries.vue | 59 ++++ .../src/components/library/MenuLibrary.vue | 291 +++++++++++++++++- next-ui/src/mocks/api/handlers/libraries.ts | 21 ++ next-ui/src/pages/account/api-keys.vue | 2 + next-ui/src/pages/server/users.vue | 1 + next-ui/src/types/dialog.ts | 52 ++++ next-ui/src/utils/i18n/common-messages.ts | 16 + 18 files changed, 698 insertions(+), 26 deletions(-) create mode 100644 next-ui/src/components/dialog/Confirm.mdx create mode 100644 next-ui/src/components/dialog/Confirm.stories.ts create mode 100644 next-ui/src/components/library/DeletionWarning.mdx create mode 100644 next-ui/src/components/library/DeletionWarning.stories.ts create mode 100644 next-ui/src/components/library/DeletionWarning.vue 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 @@ @@ -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', + }), }