diff --git a/next-ui/src/colada/libraries.ts b/next-ui/src/colada/libraries.ts index cb55ed23..e5f85485 100644 --- a/next-ui/src/colada/libraries.ts +++ b/next-ui/src/colada/libraries.ts @@ -1,7 +1,13 @@ -import { defineQuery, useQuery } from '@pinia/colada' +import { defineMutation, defineQuery, useMutation, useQuery, useQueryCache } from '@pinia/colada' import { komgaClient } from '@/api/komga-client' import { useClientSettingsUser } from '@/colada/client-settings' import { combinePromises } from '@/colada/utils' +import type { components } from '@/generated/openapi/komga' +import { QUERY_KEYS_USERS } from '@/colada/users' + +export const QUERY_KEYS_LIBRARIES = { + root: ['libraries'] as const, +} export const useLibraries = defineQuery(() => { const { @@ -10,7 +16,7 @@ export const useLibraries = defineQuery(() => { refetch: refetchLibraries, ...rest } = useQuery({ - key: () => ['libraries'], + key: () => QUERY_KEYS_LIBRARIES.root, query: () => komgaClient .GET('/api/v1/libraries') @@ -54,3 +60,34 @@ export const useLibraries = defineQuery(() => { ...rest, } }) + +export const useCreateLibrary = defineMutation(() => { + const queryCache = useQueryCache() + return useMutation({ + mutation: (library: components['schemas']['LibraryCreationDto']) => + komgaClient.POST('/api/v1/libraries', { + body: library, + }), + onSuccess: () => { + void queryCache.invalidateQueries({ key: QUERY_KEYS_LIBRARIES.root }) + }, + }) +}) + +export const useUpdateLibrary = defineMutation(() => { + const queryCache = useQueryCache() + return useMutation({ + mutation: (library: components['schemas']['LibraryDto']) => + komgaClient.PATCH('/api/v1/libraries/{libraryId}', { + params: { + path: { + libraryId: library.id, + }, + }, + body: library, + }), + onSuccess: () => { + void queryCache.invalidateQueries({ key: QUERY_KEYS_LIBRARIES.root }) + }, + }) +}) diff --git a/next-ui/src/components.d.ts b/next-ui/src/components.d.ts index 4f400227..15639833 100644 --- a/next-ui/src/components.d.ts +++ b/next-ui/src/components.d.ts @@ -54,6 +54,13 @@ 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'] + LibraryFormCreateEdit: typeof import('./components/library/form/CreateEdit.vue')['default'] + LibraryFormGeneral: typeof import('./components/library/form/General.vue')['default'] + LibraryFormStepGeneral: typeof import('./components/library/form/StepGeneral.vue')['default'] + LibraryFormStepMetadata: typeof import('./components/library/form/StepMetadata.vue')['default'] + LibraryFormStepOptions: typeof import('./components/library/form/StepOptions.vue')['default'] + LibraryFormStepScanner: typeof import('./components/library/form/StepScanner.vue')['default'] + LibraryMenuLibraries: typeof import('./components/library/MenuLibraries.vue')['default'] LocaleSelector: typeof import('./components/LocaleSelector.vue')['default'] MenuLibraries: typeof import('./components/menu/MenuLibraries.vue')['default'] PageHashKnownTable: typeof import('./components/pageHash/KnownTable.vue')['default'] diff --git a/next-ui/src/components/dialog/Confirm.vue b/next-ui/src/components/dialog/Confirm.vue index 4668549f..dad95064 100644 --- a/next-ui/src/components/dialog/Confirm.vue +++ b/next-ui/src/components/dialog/Confirm.vue @@ -64,7 +64,14 @@ - + @@ -99,12 +101,83 @@ diff --git a/next-ui/src/components/menu/MenuLibraries.vue b/next-ui/src/components/library/MenuLibraries.vue similarity index 100% rename from next-ui/src/components/menu/MenuLibraries.vue rename to next-ui/src/components/library/MenuLibraries.vue diff --git a/next-ui/src/components/library/form/CreateEdit.stories.ts b/next-ui/src/components/library/form/CreateEdit.stories.ts new file mode 100644 index 00000000..9a070319 --- /dev/null +++ b/next-ui/src/components/library/form/CreateEdit.stories.ts @@ -0,0 +1,67 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import CreateEdit from './CreateEdit.vue' +import { ScanInterval } from '@/types/ScanInterval' +import { SeriesCover } from '@/types/SeriesCover' +import { getLibraryDefaults } from '@/modules/libraries' + +const meta = { + component: CreateEdit, + render: (args: object) => ({ + components: { CreateEdit }, + setup() { + return { args } + }, + template: '', + }), + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + }, + args: {}, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Create: Story = { + args: { + createMode: true, + modelValue: getLibraryDefaults(), + }, +} + +export const Edit: Story = { + args: { + createMode: false, + modelValue: { + analyzeDimensions: false, + convertToCbz: false, + emptyTrashAfterScan: false, + hashFiles: false, + hashKoreader: false, + hashPages: false, + importBarcodeIsbn: false, + importComicInfoBook: false, + importComicInfoCollection: false, + importComicInfoReadList: false, + importComicInfoSeries: false, + importComicInfoSeriesAppendVolume: false, + importEpubBook: false, + importEpubSeries: false, + importLocalArtwork: false, + importMylarSeries: false, + name: 'Existing', + oneshotsDirectory: '_oneshots', + repairExtensions: false, + root: '/comics', + scanCbx: true, + scanDirectoryExclusions: [], + scanEpub: true, + scanForceModifiedTime: false, + scanInterval: ScanInterval.DAILY, + scanOnStartup: false, + scanPdf: true, + seriesCover: SeriesCover.FIRST, + }, + }, +} diff --git a/next-ui/src/components/library/form/CreateEdit.vue b/next-ui/src/components/library/form/CreateEdit.vue new file mode 100644 index 00000000..b80a4c14 --- /dev/null +++ b/next-ui/src/components/library/form/CreateEdit.vue @@ -0,0 +1,127 @@ + + + diff --git a/next-ui/src/components/library/form/StepGeneral.stories.ts b/next-ui/src/components/library/form/StepGeneral.stories.ts new file mode 100644 index 00000000..14c411de --- /dev/null +++ b/next-ui/src/components/library/form/StepGeneral.stories.ts @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import StepGeneral from './StepGeneral.vue' + +const meta = { + component: StepGeneral, + render: (args: object) => ({ + components: { StepGeneral }, + setup() { + return { args } + }, + template: '', + }), + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + }, + args: {}, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + modelValue: { + name: '', + root: '', + }, + }, +} diff --git a/next-ui/src/components/library/form/StepGeneral.vue b/next-ui/src/components/library/form/StepGeneral.vue new file mode 100644 index 00000000..8c63382a --- /dev/null +++ b/next-ui/src/components/library/form/StepGeneral.vue @@ -0,0 +1,82 @@ + + + diff --git a/next-ui/src/components/library/form/StepMetadata.stories.ts b/next-ui/src/components/library/form/StepMetadata.stories.ts new file mode 100644 index 00000000..c41ca219 --- /dev/null +++ b/next-ui/src/components/library/form/StepMetadata.stories.ts @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import StepMetadata from './StepMetadata.vue' + +const meta = { + component: StepMetadata, + render: (args: object) => ({ + components: { StepMetadata }, + setup() { + return { args } + }, + template: '', + }), + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + }, + args: {}, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + modelValue: { + importBarcodeIsbn: false, + importComicInfoBook: false, + importComicInfoCollection: false, + importComicInfoReadList: false, + importComicInfoSeries: false, + importComicInfoSeriesAppendVolume: false, + importEpubBook: false, + importEpubSeries: false, + importLocalArtwork: false, + importMylarSeries: false, + }, + }, +} diff --git a/next-ui/src/components/library/form/StepMetadata.vue b/next-ui/src/components/library/form/StepMetadata.vue new file mode 100644 index 00000000..0c2363b5 --- /dev/null +++ b/next-ui/src/components/library/form/StepMetadata.vue @@ -0,0 +1,231 @@ + + + diff --git a/next-ui/src/components/library/form/StepOptions.stories.ts b/next-ui/src/components/library/form/StepOptions.stories.ts new file mode 100644 index 00000000..5762df6a --- /dev/null +++ b/next-ui/src/components/library/form/StepOptions.stories.ts @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import StepOptions from './StepOptions.vue' +import { SeriesCover } from '@/types/SeriesCover' +import { fn } from 'storybook/test' + +const meta = { + component: StepOptions, + render: (args: object) => ({ + components: { StepOptions }, + setup() { + return { args } + }, + template: '', + }), + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + }, + args: {}, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + modelValue: { + analyzeDimensions: false, + convertToCbz: false, + hashFiles: false, + hashKoreader: false, + hashPages: false, + repairExtensions: false, + seriesCover: SeriesCover.FIRST, + }, + }, +} diff --git a/next-ui/src/components/library/form/StepOptions.vue b/next-ui/src/components/library/form/StepOptions.vue new file mode 100644 index 00000000..6be055b4 --- /dev/null +++ b/next-ui/src/components/library/form/StepOptions.vue @@ -0,0 +1,217 @@ + + + diff --git a/next-ui/src/components/library/form/StepScanner.stories.ts b/next-ui/src/components/library/form/StepScanner.stories.ts new file mode 100644 index 00000000..0234100e --- /dev/null +++ b/next-ui/src/components/library/form/StepScanner.stories.ts @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import StepScanner from './StepScanner.vue' +import { ScanInterval } from '@/types/ScanInterval' + +const meta = { + component: StepScanner, + render: (args: object) => ({ + components: { StepScanner }, + setup() { + return { args } + }, + template: '', + }), + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + }, + args: {}, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + modelValue: { + emptyTrashAfterScan: false, + oneshotsDirectory: '_oneshots', + scanCbx: true, + scanDirectoryExclusions: ['#recycle', '@eaDir', '@Recycle'], + scanEpub: true, + scanForceModifiedTime: false, + scanInterval: ScanInterval.DAILY, + scanOnStartup: false, + scanPdf: true, + }, + }, +} diff --git a/next-ui/src/components/library/form/StepScanner.vue b/next-ui/src/components/library/form/StepScanner.vue new file mode 100644 index 00000000..9915d5e5 --- /dev/null +++ b/next-ui/src/components/library/form/StepScanner.vue @@ -0,0 +1,216 @@ + + + diff --git a/next-ui/src/mocks/api/handlers/libraries.ts b/next-ui/src/mocks/api/handlers/libraries.ts index bff87ec7..933eb8e8 100644 --- a/next-ui/src/mocks/api/handlers/libraries.ts +++ b/next-ui/src/mocks/api/handlers/libraries.ts @@ -1,5 +1,6 @@ import { httpTyped } from '@/mocks/api/httpTyped' import type { components } from '@/generated/openapi/komga' +import { response400BadRequest, response404NotFound } from '@/mocks/api/handlers' export const libraries = [ { @@ -69,4 +70,30 @@ export const libraries = [ export const librariesHandlers = [ httpTyped.get('/api/v1/libraries', ({ response }) => response(200).json(libraries)), + httpTyped.post('/api/v1/libraries', async ({ request, response }) => { + const body = await request.json() + + if (libraries.some((it) => it.id === body.name)) { + return response.untyped(response400BadRequest()) + } + + const lib = Object.assign({}, body, { unavailable: false, id: body.name }) + libraries.push(lib) + + return response(200).json(lib) + }), + httpTyped.patch('/api/v1/libraries/{libraryId}', async ({ request, params, response }) => { + const body = await request.json() + const libraryId = params['libraryId'] + + const existing = libraries.find((it) => it.id === libraryId) + + if (!existing) { + return response.untyped(response404NotFound()) + } + + libraries[libraries.indexOf(existing)] = Object.assign({}, existing, body) + + return response(204).empty() + }), ] diff --git a/next-ui/src/modules/libraries.ts b/next-ui/src/modules/libraries.ts new file mode 100644 index 00000000..68876369 --- /dev/null +++ b/next-ui/src/modules/libraries.ts @@ -0,0 +1,36 @@ +import { ScanInterval } from '@/types/ScanInterval' +import { SeriesCover } from '@/types/SeriesCover' +import type { components } from '@/generated/openapi/komga' + +export function getLibraryDefaults(): components['schemas']['LibraryCreationDto'] { + return { + analyzeDimensions: true, + convertToCbz: false, + emptyTrashAfterScan: false, + hashFiles: true, + hashKoreader: false, + hashPages: false, + importBarcodeIsbn: false, + importComicInfoBook: true, + importComicInfoCollection: true, + importComicInfoReadList: true, + importComicInfoSeries: true, + importComicInfoSeriesAppendVolume: true, + importEpubBook: true, + importEpubSeries: true, + importLocalArtwork: true, + importMylarSeries: true, + name: '', + oneshotsDirectory: '_oneshots', + repairExtensions: false, + root: '', + scanCbx: true, + scanDirectoryExclusions: ['#recycle', '@eaDir', '@Recycle'], + scanEpub: true, + scanForceModifiedTime: false, + scanInterval: ScanInterval.EVERY_6H, + scanOnStartup: false, + scanPdf: true, + seriesCover: SeriesCover.FIRST, + } +} diff --git a/next-ui/src/plugins/vuetify.ts b/next-ui/src/plugins/vuetify.ts index 11d5e043..351ae945 100644 --- a/next-ui/src/plugins/vuetify.ts +++ b/next-ui/src/plugins/vuetify.ts @@ -14,7 +14,8 @@ import { md3 } from 'vuetify/blueprints' // Labs import { VFileUpload } from 'vuetify/labs/VFileUpload' -import { VIconBtn } from 'vuetify/labs/components' +import { VIconBtn } from 'vuetify/labs/VIconBtn' +import { VStepperVertical, VStepperVerticalItem } from 'vuetify/labs/VStepperVertical' import { createRulesPlugin } from 'vuetify/labs/rules' import { availableLocales, currentLocale, fallbackLocale } from '@/utils/i18n/locale-helper' @@ -72,6 +73,8 @@ export const vuetify = createVuetify({ components: { VFileUpload, VIconBtn, + VStepperVertical, + VStepperVerticalItem, }, }) diff --git a/next-ui/src/types/ScanInterval.ts b/next-ui/src/types/ScanInterval.ts new file mode 100644 index 00000000..c4d3a635 --- /dev/null +++ b/next-ui/src/types/ScanInterval.ts @@ -0,0 +1,43 @@ +import { defineMessages } from 'vue-intl' + +export enum ScanInterval { + DISABLED = 'DISABLED', + HOURLY = 'HOURLY', + EVERY_6H = 'EVERY_6H', + EVERY_12H = 'EVERY_12H', + DAILY = 'DAILY', + WEEKLY = 'WEEKLY', +} + +export const scanIntervalMessages = defineMessages({ + [ScanInterval.DISABLED]: { + description: 'Scan interval: DISABLED', + defaultMessage: 'Disabled', + id: '8M9T3g', + }, + [ScanInterval.HOURLY]: { + description: 'Scan interval: HOURLY', + defaultMessage: 'Hourly', + id: 'rBFh/c', + }, + [ScanInterval.EVERY_6H]: { + description: 'Scan interval: EVERY_6H', + defaultMessage: 'Every 6 hours', + id: '4d2F5w', + }, + [ScanInterval.EVERY_12H]: { + description: 'Scan interval: EVERY_12H', + defaultMessage: 'Every 12 hours', + id: '5yu0g9', + }, + [ScanInterval.DAILY]: { + description: 'Scan interval: DAILY', + defaultMessage: 'Daily', + id: 'qLk+cl', + }, + [ScanInterval.WEEKLY]: { + description: 'Scan interval: WEEKLY', + defaultMessage: 'Weekly', + id: 'T6pXCK', + }, +}) diff --git a/next-ui/src/types/SeriesCover.ts b/next-ui/src/types/SeriesCover.ts new file mode 100644 index 00000000..7d8883bc --- /dev/null +++ b/next-ui/src/types/SeriesCover.ts @@ -0,0 +1,31 @@ +import { defineMessages } from 'vue-intl' + +export enum SeriesCover { + FIRST = 'FIRST', + FIRST_UNREAD_OR_FIRST = 'FIRST_UNREAD_OR_FIRST', + FIRST_UNREAD_OR_LAST = 'FIRST_UNREAD_OR_LAST', + LAST = 'LAST', +} + +export const seriesCoverMessages = defineMessages({ + [SeriesCover.FIRST]: { + description: 'Series cover: FIRST', + defaultMessage: 'First', + id: 'j7cvLm', + }, + [SeriesCover.FIRST_UNREAD_OR_FIRST]: { + description: 'Series cover: FIRST_UNREAD_OR_FIRST', + defaultMessage: 'First unread, else first', + id: 'woVEgl', + }, + [SeriesCover.FIRST_UNREAD_OR_LAST]: { + description: 'Series cover: FIRST_UNREAD_OR_LAST', + defaultMessage: 'First unread, else last', + id: 'kLu/vI', + }, + [SeriesCover.LAST]: { + description: 'Series cover: LAST', + defaultMessage: 'Last', + id: 'pkqPAO', + }, +}) diff --git a/next-ui/src/utils/i18n/common-messages.ts b/next-ui/src/utils/i18n/common-messages.ts index d8c039e9..f0b7c4d6 100644 --- a/next-ui/src/utils/i18n/common-messages.ts +++ b/next-ui/src/utils/i18n/common-messages.ts @@ -31,4 +31,9 @@ export const commonMessages = { defaultMessage: 'Change Password', id: 'dHyAgE', }), + resourceIntensive: defineMessage({ + description: 'Resource intensive analysis warning', + defaultMessage: 'Can consume lots of resources on large libraries or slow hardware', + id: 'uoc99F', + }), }