diff --git a/next-ui/src/api/images.ts b/next-ui/src/api/images.ts index d468b0bf3..4b2b595dd 100644 --- a/next-ui/src/api/images.ts +++ b/next-ui/src/api/images.ts @@ -1,3 +1,13 @@ +export function seriesThumbnailUrl(seriesId?: string): string | undefined { + if (seriesId) return `${import.meta.env.VITE_KOMGA_API_URL}/api/v1/series/${seriesId}/thumbnail` + return undefined +} + +export function bookThumbnailUrl(bookId?: string): string | undefined { + if (bookId) return `${import.meta.env.VITE_KOMGA_API_URL}/api/v1/books/${bookId}/thumbnail` + return undefined +} + export function pageHashKnownThumbnailUrl(hash?: string): string | undefined { if (hash) return `${import.meta.env.VITE_KOMGA_API_URL}/api/v1/page-hashes/${hash}/thumbnail` return undefined diff --git a/next-ui/src/assets/cover-logo.svg b/next-ui/src/assets/cover-logo.svg new file mode 100644 index 000000000..893a6ed5e --- /dev/null +++ b/next-ui/src/assets/cover-logo.svg @@ -0,0 +1,144 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/next-ui/src/assets/cover.svg b/next-ui/src/assets/cover.svg index 893a6ed5e..8ae5d702a 100644 --- a/next-ui/src/assets/cover.svg +++ b/next-ui/src/assets/cover.svg @@ -105,40 +105,4 @@ x="1.7763568e-14" y="-2.3789293e-05" transform="matrix(1.3333334,0,0,1.3333333,0,7.9297629e-6)"/> - - - - - - - - - diff --git a/next-ui/src/assets/mock-thumbnail.jpg b/next-ui/src/assets/mock-thumbnail.jpg new file mode 100644 index 000000000..27837fad8 Binary files /dev/null and b/next-ui/src/assets/mock-thumbnail.jpg differ diff --git a/next-ui/src/colada/books.ts b/next-ui/src/colada/books.ts index 60fe36918..8988c4519 100644 --- a/next-ui/src/colada/books.ts +++ b/next-ui/src/colada/books.ts @@ -1,11 +1,35 @@ import { defineQueryOptions } from '@pinia/colada' import { komgaClient } from '@/api/komga-client' +import type { components } from '@/generated/openapi/komga' export const QUERY_KEYS_BOOKS = { root: ['books'] as const, + bySearch: (search: components['schemas']['BookSearch']) => + [...QUERY_KEYS_BOOKS.root, JSON.stringify(search)] as const, byId: (bookId: string) => [...QUERY_KEYS_BOOKS.root, bookId] as const, } +export const bookListQuery = defineQueryOptions( + ({ + search, + pause = false, + }: { + search: components['schemas']['BookSearch'] + pause?: boolean + }) => ({ + key: QUERY_KEYS_BOOKS.bySearch(search), + query: () => + komgaClient + .POST('/api/v1/books/list', { + body: search, + }) + // unwrap the openapi-fetch structure on success + .then((res) => res.data), + enabled: !pause, + placeholderData: (previousData: any) => previousData, // eslint-disable-line @typescript-eslint/no-explicit-any + }), +) + export const bookDetailQuery = defineQueryOptions(({ bookId }: { bookId: string }) => ({ key: QUERY_KEYS_BOOKS.byId(bookId), query: () => diff --git a/next-ui/src/colada/series.ts b/next-ui/src/colada/series.ts index 548cb1ddb..3e5b61c11 100644 --- a/next-ui/src/colada/series.ts +++ b/next-ui/src/colada/series.ts @@ -1,11 +1,35 @@ import { defineQueryOptions } from '@pinia/colada' import { komgaClient } from '@/api/komga-client' +import type { components } from '@/generated/openapi/komga' export const QUERY_KEYS_SERIES = { root: ['series'] as const, + bySearch: (search: components['schemas']['SeriesSearch']) => + [...QUERY_KEYS_SERIES.root, JSON.stringify(search)] as const, byId: (seriesId: string) => [...QUERY_KEYS_SERIES.root, seriesId] as const, } +export const seriesListQuery = defineQueryOptions( + ({ + search, + pause = false, + }: { + search: components['schemas']['SeriesSearch'] + pause: boolean + }) => ({ + key: QUERY_KEYS_SERIES.bySearch(search), + query: () => + komgaClient + .POST('/api/v1/series/list', { + body: search, + }) + // unwrap the openapi-fetch structure on success + .then((res) => res.data), + enabled: !pause, + placeholderData: (previousData: any) => previousData, // eslint-disable-line @typescript-eslint/no-explicit-any + }), +) + export const seriesDetailQuery = defineQueryOptions(({ seriesId }: { seriesId: string }) => ({ key: QUERY_KEYS_SERIES.byId(seriesId), query: () => diff --git a/next-ui/src/colada/transient-books.ts b/next-ui/src/colada/transient-books.ts new file mode 100644 index 000000000..512613fcc --- /dev/null +++ b/next-ui/src/colada/transient-books.ts @@ -0,0 +1,39 @@ +import { defineQueryOptions } from '@pinia/colada' +import { komgaClient } from '@/api/komga-client' + +export const QUERY_KEYS_TRANSIENT_BOOKS = { + root: ['transient-books'] as const, + byPath: (path: string) => [...QUERY_KEYS_TRANSIENT_BOOKS.root, path] as const, + byId: (transientBookId: string) => [...QUERY_KEYS_TRANSIENT_BOOKS.root, transientBookId] as const, +} + +export const transientBooksScan = defineQueryOptions(({ path }: { path: string }) => ({ + key: QUERY_KEYS_TRANSIENT_BOOKS.byPath(path), + enabled: path.length > 0, + query: () => + komgaClient + .POST('/api/v1/transient-books', { + body: { + path: path, + }, + }) + // unwrap the openapi-fetch structure on success + .then((res) => res.data), +})) + +export const transientBookAnalyze = defineQueryOptions( + ({ transientBookId }: { transientBookId: string }) => ({ + key: QUERY_KEYS_TRANSIENT_BOOKS.byId(transientBookId), + query: () => + komgaClient + .POST('/api/v1/transient-books/{id}/analyze', { + params: { + path: { + id: transientBookId, + }, + }, + }) + // unwrap the openapi-fetch structure on success + .then((res) => res.data), + }), +) diff --git a/next-ui/src/components.d.ts b/next-ui/src/components.d.ts index 957882da9..b43034fba 100644 --- a/next-ui/src/components.d.ts +++ b/next-ui/src/components.d.ts @@ -12,8 +12,10 @@ declare module 'vue' { ApikeyDeletionWarning: typeof import('./components/apikey/DeletionWarning.vue')['default'] ApikeyForceSyncWarning: typeof import('./components/apikey/ForceSyncWarning.vue')['default'] AppFooter: typeof import('./components/AppFooter.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'] + DialogFileNamePicker: typeof import('./components/dialog/FileNamePicker.vue')['default'] EmptyStateNetworkError: typeof import('./components/EmptyStateNetworkError.vue')['default'] FormattedMessage: typeof import('./components/FormattedMessage.ts')['default'] FragmentApikeyGenerateDialog: typeof import('./fragments/fragment/apikey/GenerateDialog.vue')['default'] @@ -22,6 +24,7 @@ declare module 'vue' { FragmentBuildVersion: typeof import('./fragments/fragment/BuildVersion.vue')['default'] FragmentDialogConfirm: typeof import('./fragments/fragment/dialog/Confirm.vue')['default'] FragmentDialogConfirmEdit: typeof import('./fragments/fragment/dialog/ConfirmEdit.vue')['default'] + FragmentDialogSeriesPicker: typeof import('./fragments/fragment/dialog/SeriesPicker.vue')['default'] FragmentHistoryExpandBookConverted: typeof import('./fragments/fragment/history/expand/BookConverted.vue')['default'] FragmentHistoryExpandBookFileDeleted: typeof import('./fragments/fragment/history/expand/BookFileDeleted.vue')['default'] FragmentHistoryExpandBookImported: typeof import('./fragments/fragment/history/expand/BookImported.vue')['default'] @@ -29,13 +32,16 @@ declare module 'vue' { FragmentHistoryExpandSeriesDirectoryDeleted: typeof import('./fragments/fragment/history/expand/SeriesDirectoryDeleted.vue')['default'] FragmentHistoryExpandTable: typeof import('./fragments/fragment/history/expand/Table.vue')['default'] FragmentHistoryTable: typeof import('./fragments/fragment/history/Table.vue')['default'] + FragmentImportBooksTransientBooksTable: typeof import('./fragments/fragment/import/books/TransientBooksTable.vue')['default'] FragmentLocaleSelector: typeof import('./fragments/fragment/LocaleSelector.vue')['default'] + FragmentRemoteFileList: typeof import('./fragments/fragment/RemoteFileList.vue')['default'] FragmentSnackQueue: typeof import('./fragments/fragment/SnackQueue.vue')['default'] FragmentThemeSelector: typeof import('./fragments/fragment/ThemeSelector.vue')['default'] FragmentUserAuthenticationActivityTable: typeof import('./fragments/fragment/user/AuthenticationActivityTable.vue')['default'] FragmentUserFormCreateEdit: typeof import('./fragments/fragment/user/form/CreateEdit.vue')['default'] FragmentUserTable: typeof import('./fragments/fragment/user/Table.vue')['default'] HelloWorld: typeof import('./components/HelloWorld.vue')['default'] + ImportBooksDirectorySelection: typeof import('./components/import/books/DirectorySelection.vue')['default'] LayoutAppBar: typeof import('./fragments/layout/app/Bar.vue')['default'] LayoutAppDrawer: typeof import('./fragments/layout/app/drawer/Drawer.vue')['default'] LayoutAppDrawerFooter: typeof import('./fragments/layout/app/drawer/Footer.vue')['default'] diff --git a/next-ui/src/components/dialog/BookPicker.mdx b/next-ui/src/components/dialog/BookPicker.mdx new file mode 100644 index 000000000..e5828d779 --- /dev/null +++ b/next-ui/src/components/dialog/BookPicker.mdx @@ -0,0 +1,9 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + +import * as Stories from './BookPicker.stories'; + + + +# DialogBookPicker + +Pick book from the provided selection. Can also be filtered. diff --git a/next-ui/src/components/dialog/BookPicker.stories.ts b/next-ui/src/components/dialog/BookPicker.stories.ts new file mode 100644 index 000000000..7041156ca --- /dev/null +++ b/next-ui/src/components/dialog/BookPicker.stories.ts @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import BookPicker from './BookPicker.vue' +import { fn } from 'storybook/test' +import { mockBooks } from '@/mocks/api/handlers/books' + +const meta = { + component: BookPicker, + render: (args: object) => ({ + components: { BookPicker }, + setup() { + return { args } + }, + template: '', + }), + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + }, + args: { + dialog: true, + onSelectedBook: fn(), + books: mockBooks(5), + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + ...meta.args, + }, +} +export const PresetFilter: Story = { + args: { + filter: '3', + ...meta.args, + }, +} + +export const LargeList: Story = { + args: { + ...meta.args, + books: mockBooks(500), + }, +} diff --git a/next-ui/src/components/dialog/BookPicker.vue b/next-ui/src/components/dialog/BookPicker.vue new file mode 100644 index 000000000..15e19cfa0 --- /dev/null +++ b/next-ui/src/components/dialog/BookPicker.vue @@ -0,0 +1,174 @@ + + + + diff --git a/next-ui/src/components/dialog/FileNamePicker.mdx b/next-ui/src/components/dialog/FileNamePicker.mdx new file mode 100644 index 000000000..35b91c152 --- /dev/null +++ b/next-ui/src/components/dialog/FileNamePicker.mdx @@ -0,0 +1,9 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + +import * as Stories from './FileNamePicker.stories'; + + + +# DialogFileNamePicker + +Displays file names for series books for easy picking. diff --git a/next-ui/src/components/dialog/FileNamePicker.stories.ts b/next-ui/src/components/dialog/FileNamePicker.stories.ts new file mode 100644 index 000000000..ca42cf176 --- /dev/null +++ b/next-ui/src/components/dialog/FileNamePicker.stories.ts @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import FileNamePicker from './FileNamePicker.vue' +import { fn } from 'storybook/test' +import { mockBooks } from '@/mocks/api/handlers/books' + +const meta = { + component: FileNamePicker, + render: (args: object) => ({ + components: { FileNamePicker }, + setup() { + return { args } + }, + template: '', + }), + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + }, + args: { + dialog: true, + existingName: 'existing filename.cbz', + onSelectedName: fn(), + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + seriesBooks: mockBooks(5), + }, +} + +export const LargeList: Story = { + args: { + seriesBooks: mockBooks(1000), + }, +} + +export const NoBooks: Story = { + args: {}, +} diff --git a/next-ui/src/components/dialog/FileNamePicker.vue b/next-ui/src/components/dialog/FileNamePicker.vue new file mode 100644 index 000000000..bfedaaf6b --- /dev/null +++ b/next-ui/src/components/dialog/FileNamePicker.vue @@ -0,0 +1,193 @@ + + + + diff --git a/next-ui/src/components/import/books/DirectorySelection.mdx b/next-ui/src/components/import/books/DirectorySelection.mdx new file mode 100644 index 000000000..0d14dc942 --- /dev/null +++ b/next-ui/src/components/import/books/DirectorySelection.mdx @@ -0,0 +1,11 @@ +import { Canvas, Meta } from '@storybook/addon-docs/blocks'; + +import * as Stories from './DirectorySelection.stories'; + + + +# ImportBookDirectorySelection + +Directory can be selected using the *Browse* button, or input directly. + + diff --git a/next-ui/src/components/import/books/DirectorySelection.stories.ts b/next-ui/src/components/import/books/DirectorySelection.stories.ts new file mode 100644 index 000000000..afbfad1c8 --- /dev/null +++ b/next-ui/src/components/import/books/DirectorySelection.stories.ts @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import DirectorySelection from './DirectorySelection.vue' +import DialogConfirmEdit from '@/fragments/fragment/dialog/ConfirmEdit.vue' +import { useAppStore } from '@/stores/app' +import { fn } from 'storybook/test' + +const meta = { + component: DirectorySelection, + render: (args: object) => ({ + components: { DirectorySelection, DialogConfirmEdit }, + setup() { + return { args } + }, + template: '', + }), + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + }, + args: { + onScan: fn(), + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: {}, +} + +export const PresetPath: Story = { + play: () => { + const appStore = useAppStore() + appStore.importBooksPath = '/comics' + }, +} diff --git a/next-ui/src/components/import/books/DirectorySelection.vue b/next-ui/src/components/import/books/DirectorySelection.vue new file mode 100644 index 000000000..f0408c77f --- /dev/null +++ b/next-ui/src/components/import/books/DirectorySelection.vue @@ -0,0 +1,97 @@ + + + diff --git a/next-ui/src/composables/errorCodeFormatter.ts b/next-ui/src/composables/errorCodeFormatter.ts new file mode 100644 index 000000000..7318d6fc7 --- /dev/null +++ b/next-ui/src/composables/errorCodeFormatter.ts @@ -0,0 +1,18 @@ +import { useIntl } from 'vue-intl' +import { errorCodeMessages } from '@/utils/i18n/enum/error-codes' + +export function useErrorCodeFormatter() { + const intl = useIntl() + + function convertErrorCodes(message: string): string { + const match = message.match(/ERR_\d{4}/g) + let r = message + match?.forEach((errorCode) => { + if (errorCodeMessages[errorCode]) + r = r.replace(errorCode, intl.formatMessage(errorCodeMessages[errorCode])) + }) + return r + } + + return { convertErrorCodes } +} diff --git a/next-ui/src/fragments/fragment/RemoteFileList.mdx b/next-ui/src/fragments/fragment/RemoteFileList.mdx new file mode 100644 index 000000000..23c1421cc --- /dev/null +++ b/next-ui/src/fragments/fragment/RemoteFileList.mdx @@ -0,0 +1,11 @@ +import { Canvas, Meta } from '@storybook/addon-docs/blocks'; + +import * as Stories from './RemoteFileList.stories'; + + + +# FragmentRemoteFileList + +A remote directory browser, to browse directories on a remote server. + + diff --git a/next-ui/src/fragments/fragment/RemoteFileList.stories.ts b/next-ui/src/fragments/fragment/RemoteFileList.stories.ts new file mode 100644 index 000000000..a9b1797d5 --- /dev/null +++ b/next-ui/src/fragments/fragment/RemoteFileList.stories.ts @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import { http, delay } from 'msw' +import RemoteFileList from './RemoteFileList.vue' +import { response401Unauthorized } from '@/mocks/api/handlers' + +const meta = { + component: RemoteFileList, + render: (args: object) => ({ + components: { RemoteFileList }, + 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: {}, +} + +export const PresetPath: Story = { + args: { + modelValue: '/comics', + }, +} + +export const Loading: Story = { + parameters: { + msw: { + handlers: [http.all('*', async () => await delay(2_000))], + }, + }, +} + +export const Error: Story = { + parameters: { + msw: { + handlers: [http.all('*', response401Unauthorized)], + }, + }, +} diff --git a/next-ui/src/fragments/fragment/RemoteFileList.vue b/next-ui/src/fragments/fragment/RemoteFileList.vue new file mode 100644 index 000000000..25fbc0828 --- /dev/null +++ b/next-ui/src/fragments/fragment/RemoteFileList.vue @@ -0,0 +1,79 @@ + + + diff --git a/next-ui/src/fragments/fragment/dialog/SeriesPicker.mdx b/next-ui/src/fragments/fragment/dialog/SeriesPicker.mdx new file mode 100644 index 000000000..76f4ae249 --- /dev/null +++ b/next-ui/src/fragments/fragment/dialog/SeriesPicker.mdx @@ -0,0 +1,9 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + +import * as Stories from './SeriesPicker.stories'; + + + +# FragmentDialogSeriesPicker + +Search and pick series. diff --git a/next-ui/src/fragments/fragment/dialog/SeriesPicker.stories.ts b/next-ui/src/fragments/fragment/dialog/SeriesPicker.stories.ts new file mode 100644 index 000000000..23c97bcd7 --- /dev/null +++ b/next-ui/src/fragments/fragment/dialog/SeriesPicker.stories.ts @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import { http, delay } from 'msw' +import SeriesPicker from './SeriesPicker.vue' +import { response401Unauthorized } from '@/mocks/api/handlers' +import { fn } from 'storybook/test' + +const meta = { + component: SeriesPicker, + render: (args: object) => ({ + components: { SeriesPicker }, + setup() { + return { args } + }, + template: '', + }), + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + }, + args: { + dialog: true, + onSelectedSeries: fn(), + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: {}, +} + +export const PresetSearch: Story = { + args: { + searchString: 'd', + }, +} + +export const NoResults: Story = { + args: { + searchString: 'not found', + }, +} + +export const Loading: Story = { + parameters: { + msw: { + handlers: [http.all('*', async () => await delay(2_000))], + }, + }, +} + +export const Error: Story = { + args: { + searchString: 'd', + }, + parameters: { + msw: { + handlers: [http.all('*', response401Unauthorized)], + }, + }, +} diff --git a/next-ui/src/fragments/fragment/dialog/SeriesPicker.vue b/next-ui/src/fragments/fragment/dialog/SeriesPicker.vue new file mode 100644 index 000000000..bae5d5c26 --- /dev/null +++ b/next-ui/src/fragments/fragment/dialog/SeriesPicker.vue @@ -0,0 +1,180 @@ + + + + diff --git a/next-ui/src/fragments/fragment/import/books/TransientBooksTable.mdx b/next-ui/src/fragments/fragment/import/books/TransientBooksTable.mdx new file mode 100644 index 000000000..83a4e5a02 --- /dev/null +++ b/next-ui/src/fragments/fragment/import/books/TransientBooksTable.mdx @@ -0,0 +1,15 @@ +import { Canvas, Meta } from '@storybook/addon-docs/blocks'; + +import * as Stories from './TransientBooksTable.stories'; + + + +# FragmentImportBooksTransientBooksTable + +Data table displaying books to import. +- When books come into view, they will be analyzed. +- Only `READY` books can be selected. +- If a book has a `seriesId` returned by the server, it will be automatically selected. +- If a book has a book number returned by the server, it will be automatically selected for upgrade. + + diff --git a/next-ui/src/fragments/fragment/import/books/TransientBooksTable.stories.ts b/next-ui/src/fragments/fragment/import/books/TransientBooksTable.stories.ts new file mode 100644 index 000000000..d3b3184aa --- /dev/null +++ b/next-ui/src/fragments/fragment/import/books/TransientBooksTable.stories.ts @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import { http, delay } from 'msw' +import TransientBooksTable from './TransientBooksTable.vue' +import { response401Unauthorized } from '@/mocks/api/handlers' +import { scanned } from '@/mocks/api/handlers/transient-books' +import SnackQueue from '@/fragments/fragment/SnackQueue.vue' + +const meta = { + component: TransientBooksTable, + subcomponents: { SnackQueue }, + render: (args: object) => ({ + components: { TransientBooksTable, SnackQueue }, + 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: { + books: scanned, + }, +} + +export const Empty: Story = { + args: {}, +} + +export const Loading: Story = { + args: { + books: scanned, + }, + parameters: { + msw: { + handlers: [http.all('*', async () => await delay(2_000))], + }, + }, +} + +export const ErrorOnImport: Story = { + args: { + books: scanned, + }, + parameters: { + msw: { + handlers: [http.all('*/v1/books/import', response401Unauthorized)], + }, + }, +} diff --git a/next-ui/src/fragments/fragment/import/books/TransientBooksTable.vue b/next-ui/src/fragments/fragment/import/books/TransientBooksTable.vue new file mode 100644 index 000000000..a09e6347a --- /dev/null +++ b/next-ui/src/fragments/fragment/import/books/TransientBooksTable.vue @@ -0,0 +1,559 @@ + + + + + diff --git a/next-ui/src/mocks/api/handlers.ts b/next-ui/src/mocks/api/handlers.ts index 62c07dcfd..91a549b49 100644 --- a/next-ui/src/mocks/api/handlers.ts +++ b/next-ui/src/mocks/api/handlers.ts @@ -10,21 +10,28 @@ import { claimHandlers } from '@/mocks/api/handlers/claim' import { historyHandlers } from '@/mocks/api/handlers/history' import { seriesHandlers } from '@/mocks/api/handlers/series' import { booksHandlers } from '@/mocks/api/handlers/books' +import { filesystemHandlers } from '@/mocks/api/handlers/filesystem' +import { transientBooksHandlers } from '@/mocks/api/handlers/transient-books' export const handlers = [ ...actuatorHandlers, ...announcementHandlers, ...booksHandlers, ...claimHandlers, + ...filesystemHandlers, ...historyHandlers, ...librariesHandlers, ...referentialHandlers, ...releasesHandlers, ...seriesHandlers, ...settingsHandlers, + ...transientBooksHandlers, ...usersHandlers, ] +export const response400BadRequest = () => + HttpResponse.json({ error: 'Bad Request' }, { status: 400 }) + export const response404NotFound = () => HttpResponse.json({ error: 'NotFound' }, { status: 404 }) export const response401Unauthorized = () => diff --git a/next-ui/src/mocks/api/handlers/books.ts b/next-ui/src/mocks/api/handlers/books.ts index 7f4a2f3e2..60f181a84 100644 --- a/next-ui/src/mocks/api/handlers/books.ts +++ b/next-ui/src/mocks/api/handlers/books.ts @@ -1,4 +1,8 @@ import { httpTyped } from '@/mocks/api/httpTyped' +import { mockPage } from '@/mocks/api/pageable' +import { PageRequest } from '@/types/PageRequest' +import { http, HttpResponse } from 'msw' +import mockThumbnailUrl from '@/assets/mock-thumbnail.jpg' const book = { id: '05RKH8CC8B4RW', @@ -57,11 +61,50 @@ const book = { oneshot: false, } +export function mockBooks(count: number) { + return [...Array(count).keys()].map((index) => + Object.assign({}, book, { + id: `BOOK${index + 1}`, + name: `Book ${index + 1}`, + number: index + 1, + metadata: { + title: `Book ${index + 1}`, + number: `${index + 1}`, + numberSort: index + 1, + ...(index % 2 === 0 && { + releaseDate: `19${String(index).slice(-2).padStart(2, '0')}-05-10`, + }), + }, + }), + ) +} + export const booksHandlers = [ + httpTyped.post('/api/v1/books/list', ({ query, response }) => { + return response(200).json( + mockPage( + mockBooks(50), + new PageRequest(Number(query.get('page')), Number(query.get('size')), query.getAll('sort')), + ), + ) + }), httpTyped.get('/api/v1/books/{bookId}', ({ params, response }) => { if (params.bookId === '404') return response(404).empty() return response(200).json( Object.assign({}, book, { metadata: { title: `Book ${params.bookId}` } }), ) }), + httpTyped.post('/api/v1/books/import', ({ response }) => { + return response(202).empty() + }), + http.get('*/api/v1/books/*/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', + }, + }) + }), ] diff --git a/next-ui/src/mocks/api/handlers/filesystem.ts b/next-ui/src/mocks/api/handlers/filesystem.ts new file mode 100644 index 000000000..129360f4d --- /dev/null +++ b/next-ui/src/mocks/api/handlers/filesystem.ts @@ -0,0 +1,61 @@ +import { httpTyped } from '@/mocks/api/httpTyped' +import { response400BadRequest } from '@/mocks/api/handlers' + +const emptyPath = { directories: [{ type: 'directory', name: '/', path: '/' }], files: [] } + +const rootSlash = { + parent: '', + directories: [ + { type: 'directory', name: 'Applications', path: '/Applications' }, + { type: 'directory', name: 'bin', path: '/bin' }, + { type: 'directory', name: 'cores', path: '/cores' }, + { type: 'directory', name: 'comics', path: '/comics' }, + { type: 'directory', name: 'dev', path: '/dev' }, + { type: 'directory', name: 'etc', path: '/etc' }, + { type: 'directory', name: 'home', path: '/home' }, + { type: 'directory', name: 'Library', path: '/Library' }, + { type: 'directory', name: 'opt', path: '/opt' }, + { type: 'directory', name: 'private', path: '/private' }, + { type: 'directory', name: 'sbin', path: '/sbin' }, + { type: 'directory', name: 'System', path: '/System' }, + { type: 'directory', name: 'tmp', path: '/tmp' }, + { type: 'directory', name: 'Users', path: '/Users' }, + { type: 'directory', name: 'usr', path: '/usr' }, + { type: 'directory', name: 'var', path: '/var' }, + { type: 'directory', name: 'Volumes', path: '/Volumes' }, + ], + files: [], +} + +const comics = { + parent: '/', + directories: [ + { type: 'directory', name: '_oneshots', path: '/comics/_oneshots' }, + { type: 'directory', name: 'Golden Age', path: '/comics/Golden Age' }, + { type: 'directory', name: 'Wika', path: '/comics/Wika' }, + { type: 'directory', name: 'Zorro', path: '/comics/Zorro' }, + ], + files: [], +} + +const empty = { parent: '/', directories: [], files: [] } + +export const filesystemHandlers = [ + httpTyped.post('/api/v1/filesystem', async ({ request, response }) => { + const data = await request.json() + + if (data?.path === '') { + return response(200).json(emptyPath) + } else if (data?.path === '/') { + return response(200).json(rootSlash) + } else if (data?.path === '/comics') { + return response(200).json(comics) + } else if ( + [...rootSlash.directories, ...comics.directories].some((it) => it.path === data?.path) + ) { + return response(200).json(empty) + } + + return response.untyped(response400BadRequest()) + }), +] diff --git a/next-ui/src/mocks/api/handlers/history.ts b/next-ui/src/mocks/api/handlers/history.ts index e1061ce64..00b7a01b4 100644 --- a/next-ui/src/mocks/api/handlers/history.ts +++ b/next-ui/src/mocks/api/handlers/history.ts @@ -2,7 +2,7 @@ import { httpTyped } from '@/mocks/api/httpTyped' import { mockPage } from '@/mocks/api/pageable' import { PageRequest } from '@/types/PageRequest' import { http, HttpResponse } from 'msw' -import logoUrl from '@/assets/logo.svg' +import mockThumbnailUrl from '@/assets/mock-thumbnail.jpg' export const historyBookImported = { id: 'H1', @@ -114,11 +114,11 @@ export const historyHandlers = [ ), http.get('*/api/v1/page-hashes/*/thumbnail', async () => { // Get an ArrayBuffer from reading the file from disk or fetching it. - const buffer = await fetch(logoUrl).then((response) => response.arrayBuffer()) + const buffer = await fetch(mockThumbnailUrl).then((response) => response.arrayBuffer()) return HttpResponse.arrayBuffer(buffer, { headers: { - 'content-type': 'image/svg+xml', + 'content-type': 'image/jpg', }, }) }), diff --git a/next-ui/src/mocks/api/handlers/series.ts b/next-ui/src/mocks/api/handlers/series.ts index 998817226..36aedb578 100644 --- a/next-ui/src/mocks/api/handlers/series.ts +++ b/next-ui/src/mocks/api/handlers/series.ts @@ -1,8 +1,12 @@ import { httpTyped } from '@/mocks/api/httpTyped' +import { http, HttpResponse } from 'msw' +import mockThumbnailUrl from '@/assets/mock-thumbnail.jpg' +import { PageRequest } from '@/types/PageRequest' +import { mockPage } from '@/mocks/api/pageable' -const series = { +const series1 = { id: '57', - libraryId: '56', + libraryId: '1', name: 'Super Duck', url: '/books/Super Duck', created: new Date('2020-07-05T12:11:50Z'), @@ -56,11 +60,94 @@ const series = { oneshot: false, } +const series2 = { + id: '63', + libraryId: '2', + name: 'Space Adventures', + url: '/books/Space Adventures', + created: new Date('2020-07-05T12:11:50Z'), + lastModified: new Date('2020-07-05T12:11:50Z'), + fileLastModified: new Date('2020-03-05T11:57:31Z'), + booksCount: 4, + booksReadCount: 0, + booksUnreadCount: 3, + booksInProgressCount: 1, + metadata: { + status: 'ENDED', + statusLock: true, + title: 'Space Adventures', + titleLock: false, + titleSort: 'Space Adventures', + titleSortLock: false, + summary: + 'Supersophisticated androids that can pass for human? Robots that turn on their creators to take control of their world? Strange alien armies secretly infiltrating the earth? Men rocketing through the galaxy as easily as taking an average Sunday drive in the country? Come on, that stuff is just a bunch of science fiction, right?\n\nYou bet it is! Published every two months, Charlton Comics presented a new collection of short stories about mankind’s long-dreamed-of exploration of the rest of the solar system…and beyond!\n\nThis series is notable for its many stories by Steve Ditko (creator of The Amazing Spider-Man), and for the first appearance of Captain Atom.', + summaryLock: true, + readingDirection: 'LEFT_TO_RIGHT', + readingDirectionLock: true, + publisher: 'Charlton', + publisherLock: true, + ageRatingLock: false, + language: 'en', + languageLock: true, + genres: ['science fiction'], + genresLock: true, + tags: [], + tagsLock: false, + totalBookCount: 70, + totalBookCountLock: true, + sharingLabels: [], + sharingLabelsLock: false, + links: [], + linksLock: false, + alternateTitles: [], + alternateTitlesLock: false, + created: new Date('2020-07-05T12:11:50Z'), + lastModified: new Date('2023-07-22T11:14:45Z'), + }, + booksMetadata: { + authors: [], + tags: [], + releaseDate: '2018-07-10', + summary: '', + summaryNumber: '', + created: new Date('2021-01-11T09:59:23Z'), + lastModified: new Date('2025-04-08T02:55:19Z'), + }, + deleted: false, + oneshot: false, +} + +const series = [series1, series2] + export const seriesHandlers = [ + httpTyped.post('/api/v1/series/list', async ({ query, request, response }) => { + const body = await request.json() + + const selectedSeries = body.fullTextSearch + ? series.filter((it) => !!it.metadata.title.match(new RegExp(body.fullTextSearch!, 'i'))) + : series + + return response(200).json( + mockPage( + selectedSeries, + new PageRequest(Number(query.get('page')), Number(query.get('size')), query.getAll('sort')), + ), + ) + }), httpTyped.get('/api/v1/series/{seriesId}', ({ params, response }) => { if (params.seriesId === '404') return response(404).empty() return response(200).json( - Object.assign({}, series, { metadata: { title: `Series ${params.seriesId}` } }), + 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', + }, + }) + }), ] diff --git a/next-ui/src/mocks/api/handlers/transient-books.ts b/next-ui/src/mocks/api/handlers/transient-books.ts new file mode 100644 index 000000000..8a55f9c28 --- /dev/null +++ b/next-ui/src/mocks/api/handlers/transient-books.ts @@ -0,0 +1,1105 @@ +import { httpTyped } from '@/mocks/api/httpTyped' +import { response404NotFound } from '@/mocks/api/handlers' + +export const scanned = [ + { + id: '0N1BHX5RKQTR2', + name: 'Star Trek_ The Next Generation (1988)', + url: '/comics/Star Trek_ The Next Generation (1988).zip', + fileLastModified: new Date('2022-02-08T17:46:48'), + sizeBytes: 98957727, + size: '94.4 MiB', + status: 'UNKNOWN', + mediaType: '', + pages: [], + files: [], + comment: '', + }, + { + id: '0N1BHX5RKQTR7', + name: 'Atomic-man', + url: '/comics/Atomic-man.rar', + fileLastModified: new Date('2022-02-08T17:46:48'), + sizeBytes: 98957727, + size: '94.4 MiB', + status: 'UNKNOWN', + mediaType: '', + pages: [], + files: [], + comment: '', + }, + { + id: '0N1BHX5RVQQ8S', + name: 'Hulk #1', + url: '/comics/Hulk 001.cbz', + fileLastModified: new Date('2022-02-08T05:31:56'), + sizeBytes: 20852325, + size: '19.9 MiB', + status: 'UNKNOWN', + mediaType: '', + pages: [], + files: [], + comment: '', + }, + { + id: '0N1BHX5RVQQ8Z', + name: 'Hulk #2', + url: '/comics/Hulk 002.cbz', + fileLastModified: new Date('2022-02-08T05:31:56'), + sizeBytes: 20852325, + size: '19.9 MiB', + status: 'UNKNOWN', + mediaType: '', + pages: [], + files: [], + comment: '', + }, +] + +const analyzed1 = { + id: '0N1BHX5RKQTR2', + name: 'Star Trek_ The Next Generation (1988)', + url: '/comics/Star Trek_ The Next Generation (1988).zip', + fileLastModified: new Date('2022-02-08T17:46:48'), + sizeBytes: 98957727, + size: '94.4 MiB', + status: 'ERROR', + mediaType: 'application/zip', + pages: [], + files: [], + comment: 'ERR_1006', +} + +const analyzed2 = { + id: '0N1BHX5RKQTR7', + name: 'Atomic-man', + url: '/comics/Atomic-man.rar', + fileLastModified: new Date('2022-02-08T17:46:48'), + sizeBytes: 98957727, + size: '94.4 MiB', + status: 'UNSUPPORTED', + mediaType: 'application/rar', + pages: [], + files: [], + comment: 'ERR_1003', +} + +const analyzed3 = { + id: '0N1BHX5RVQQ8S', + name: 'Hulk #1', + url: '/comics/Hulk 001.cbz', + fileLastModified: new Date('2022-02-08T05:31:56'), + sizeBytes: 20852325, + size: '19.9 MiB', + status: 'READY', + mediaType: 'application/zip', + pages: [ + { + number: 1, + fileName: 'P00001.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1530, + sizeBytes: 34060, + size: '33.3 KiB', + }, + { + number: 2, + fileName: 'P00002.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1530, + sizeBytes: 322521, + size: '315 KiB', + }, + { + number: 3, + fileName: 'P00003.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1538, + sizeBytes: 256096, + size: '250.1 KiB', + }, + { + number: 4, + fileName: 'P00004.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1538, + sizeBytes: 402281, + size: '392.9 KiB', + }, + { + number: 5, + fileName: 'P00005.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1532, + sizeBytes: 392566, + size: '383.4 KiB', + }, + { + number: 6, + fileName: 'P00006.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1532, + sizeBytes: 412938, + size: '403.3 KiB', + }, + { + number: 7, + fileName: 'P00007.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1550, + sizeBytes: 414895, + size: '405.2 KiB', + }, + { + number: 8, + fileName: 'P00008.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1550, + sizeBytes: 307513, + size: '300.3 KiB', + }, + { + number: 9, + fileName: 'P00009.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1524, + sizeBytes: 401226, + size: '391.8 KiB', + }, + { + number: 10, + fileName: 'P00010.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1524, + sizeBytes: 403523, + size: '394.1 KiB', + }, + { + number: 11, + fileName: 'P00011.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1524, + sizeBytes: 380739, + size: '371.8 KiB', + }, + { + number: 12, + fileName: 'P00012.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1524, + sizeBytes: 395642, + size: '386.4 KiB', + }, + { + number: 13, + fileName: 'P00013.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1524, + sizeBytes: 401360, + size: '392 KiB', + }, + { + number: 14, + fileName: 'P00014.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1524, + sizeBytes: 322577, + size: '315 KiB', + }, + { + number: 15, + fileName: 'P00015.jpg', + mediaType: 'image/jpeg', + width: 994, + height: 1540, + sizeBytes: 381077, + size: '372.1 KiB', + }, + { + number: 16, + fileName: 'P00016.jpg', + mediaType: 'image/jpeg', + width: 994, + height: 1540, + sizeBytes: 463196, + size: '452.3 KiB', + }, + { + number: 17, + fileName: 'P00017.jpg', + mediaType: 'image/jpeg', + width: 994, + height: 1525, + sizeBytes: 421919, + size: '412 KiB', + }, + { + number: 18, + fileName: 'P00018.jpg', + mediaType: 'image/jpeg', + width: 994, + height: 1525, + sizeBytes: 411660, + size: '402 KiB', + }, + { + number: 19, + fileName: 'P00019.jpg', + mediaType: 'image/jpeg', + width: 991, + height: 1540, + sizeBytes: 447589, + size: '437.1 KiB', + }, + { + number: 20, + fileName: 'P00020.jpg', + mediaType: 'image/jpeg', + width: 991, + height: 1540, + sizeBytes: 462041, + size: '451.2 KiB', + }, + { + number: 21, + fileName: 'P00021.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1540, + sizeBytes: 406649, + size: '397.1 KiB', + }, + { + number: 22, + fileName: 'P00022.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1540, + sizeBytes: 416475, + size: '406.7 KiB', + }, + { + number: 23, + fileName: 'P00023.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1548, + sizeBytes: 453504, + size: '442.9 KiB', + }, + { + number: 24, + fileName: 'P00024.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1548, + sizeBytes: 403976, + size: '394.5 KiB', + }, + { + number: 25, + fileName: 'P00025.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1548, + sizeBytes: 391051, + size: '381.9 KiB', + }, + { + number: 26, + fileName: 'P00026.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1548, + sizeBytes: 406870, + size: '397.3 KiB', + }, + { + number: 27, + fileName: 'P00027.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1537, + sizeBytes: 403579, + size: '394.1 KiB', + }, + { + number: 28, + fileName: 'P00028.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1537, + sizeBytes: 449827, + size: '439.3 KiB', + }, + { + number: 29, + fileName: 'P00029.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1537, + sizeBytes: 415734, + size: '406 KiB', + }, + { + number: 30, + fileName: 'P00030.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1537, + sizeBytes: 417397, + size: '407.6 KiB', + }, + { + number: 31, + fileName: 'P00031.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1537, + sizeBytes: 450572, + size: '440 KiB', + }, + { + number: 32, + fileName: 'P00032.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1537, + sizeBytes: 450168, + size: '439.6 KiB', + }, + { + number: 33, + fileName: 'P00033.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1525, + sizeBytes: 430274, + size: '420.2 KiB', + }, + { + number: 34, + fileName: 'P00034.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1525, + sizeBytes: 416871, + size: '407.1 KiB', + }, + { + number: 35, + fileName: 'P00035.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1537, + sizeBytes: 433753, + size: '423.6 KiB', + }, + { + number: 36, + fileName: 'P00036.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1537, + sizeBytes: 421796, + size: '411.9 KiB', + }, + { + number: 37, + fileName: 'P00037.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1537, + sizeBytes: 406567, + size: '397 KiB', + }, + { + number: 38, + fileName: 'P00038.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1537, + sizeBytes: 423467, + size: '413.5 KiB', + }, + { + number: 39, + fileName: 'P00039.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1521, + sizeBytes: 436404, + size: '426.2 KiB', + }, + { + number: 40, + fileName: 'P00040.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1521, + sizeBytes: 475884, + size: '464.7 KiB', + }, + { + number: 41, + fileName: 'P00041.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1529, + sizeBytes: 408619, + size: '399 KiB', + }, + { + number: 42, + fileName: 'P00042.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1529, + sizeBytes: 413908, + size: '404.2 KiB', + }, + { + number: 43, + fileName: 'P00043.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1508, + sizeBytes: 403738, + size: '394.3 KiB', + }, + { + number: 44, + fileName: 'P00044.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1508, + sizeBytes: 451228, + size: '440.7 KiB', + }, + { + number: 45, + fileName: 'P00045.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1523, + sizeBytes: 391371, + size: '382.2 KiB', + }, + { + number: 46, + fileName: 'P00046.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1523, + sizeBytes: 440633, + size: '430.3 KiB', + }, + { + number: 47, + fileName: 'P00047.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1529, + sizeBytes: 400025, + size: '390.6 KiB', + }, + { + number: 48, + fileName: 'P00048.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1529, + sizeBytes: 436877, + size: '426.6 KiB', + }, + { + number: 49, + fileName: 'P00049.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1517, + sizeBytes: 416104, + size: '406.4 KiB', + }, + { + number: 50, + fileName: 'P00050.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1517, + sizeBytes: 412440, + size: '402.8 KiB', + }, + { + number: 51, + fileName: 'P00051.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1531, + sizeBytes: 406855, + size: '397.3 KiB', + }, + { + number: 52, + fileName: 'P00052.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1531, + sizeBytes: 398932, + size: '389.6 KiB', + }, + { + number: 53, + fileName: 'P00053.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1523, + sizeBytes: 33847, + size: '33.1 KiB', + }, + { + number: 54, + fileName: 'P00054.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1523, + sizeBytes: 438786, + size: '428.5 KiB', + }, + ], + files: ['ComicInfo.xml'], + comment: '', + seriesId: '63', + number: 1.0, +} + +const analyzed4 = { + id: '0N1BHX5RVQQ8Z', + name: 'Hulk #2', + url: '/comics/Hulk 002.cbz', + fileLastModified: new Date('2022-02-08T05:31:56'), + sizeBytes: 20852325, + size: '19.9 MiB', + status: 'READY', + mediaType: 'application/zip', + pages: [ + { + number: 1, + fileName: 'P00001.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1530, + sizeBytes: 34060, + size: '33.3 KiB', + }, + { + number: 2, + fileName: 'P00002.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1530, + sizeBytes: 322521, + size: '315 KiB', + }, + { + number: 3, + fileName: 'P00003.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1538, + sizeBytes: 256096, + size: '250.1 KiB', + }, + { + number: 4, + fileName: 'P00004.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1538, + sizeBytes: 402281, + size: '392.9 KiB', + }, + { + number: 5, + fileName: 'P00005.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1532, + sizeBytes: 392566, + size: '383.4 KiB', + }, + { + number: 6, + fileName: 'P00006.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1532, + sizeBytes: 412938, + size: '403.3 KiB', + }, + { + number: 7, + fileName: 'P00007.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1550, + sizeBytes: 414895, + size: '405.2 KiB', + }, + { + number: 8, + fileName: 'P00008.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1550, + sizeBytes: 307513, + size: '300.3 KiB', + }, + { + number: 9, + fileName: 'P00009.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1524, + sizeBytes: 401226, + size: '391.8 KiB', + }, + { + number: 10, + fileName: 'P00010.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1524, + sizeBytes: 403523, + size: '394.1 KiB', + }, + { + number: 11, + fileName: 'P00011.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1524, + sizeBytes: 380739, + size: '371.8 KiB', + }, + { + number: 12, + fileName: 'P00012.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1524, + sizeBytes: 395642, + size: '386.4 KiB', + }, + { + number: 13, + fileName: 'P00013.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1524, + sizeBytes: 401360, + size: '392 KiB', + }, + { + number: 14, + fileName: 'P00014.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1524, + sizeBytes: 322577, + size: '315 KiB', + }, + { + number: 15, + fileName: 'P00015.jpg', + mediaType: 'image/jpeg', + width: 994, + height: 1540, + sizeBytes: 381077, + size: '372.1 KiB', + }, + { + number: 16, + fileName: 'P00016.jpg', + mediaType: 'image/jpeg', + width: 994, + height: 1540, + sizeBytes: 463196, + size: '452.3 KiB', + }, + { + number: 17, + fileName: 'P00017.jpg', + mediaType: 'image/jpeg', + width: 994, + height: 1525, + sizeBytes: 421919, + size: '412 KiB', + }, + { + number: 18, + fileName: 'P00018.jpg', + mediaType: 'image/jpeg', + width: 994, + height: 1525, + sizeBytes: 411660, + size: '402 KiB', + }, + { + number: 19, + fileName: 'P00019.jpg', + mediaType: 'image/jpeg', + width: 991, + height: 1540, + sizeBytes: 447589, + size: '437.1 KiB', + }, + { + number: 20, + fileName: 'P00020.jpg', + mediaType: 'image/jpeg', + width: 991, + height: 1540, + sizeBytes: 462041, + size: '451.2 KiB', + }, + { + number: 21, + fileName: 'P00021.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1540, + sizeBytes: 406649, + size: '397.1 KiB', + }, + { + number: 22, + fileName: 'P00022.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1540, + sizeBytes: 416475, + size: '406.7 KiB', + }, + { + number: 23, + fileName: 'P00023.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1548, + sizeBytes: 453504, + size: '442.9 KiB', + }, + { + number: 24, + fileName: 'P00024.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1548, + sizeBytes: 403976, + size: '394.5 KiB', + }, + { + number: 25, + fileName: 'P00025.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1548, + sizeBytes: 391051, + size: '381.9 KiB', + }, + { + number: 26, + fileName: 'P00026.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1548, + sizeBytes: 406870, + size: '397.3 KiB', + }, + { + number: 27, + fileName: 'P00027.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1537, + sizeBytes: 403579, + size: '394.1 KiB', + }, + { + number: 28, + fileName: 'P00028.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1537, + sizeBytes: 449827, + size: '439.3 KiB', + }, + { + number: 29, + fileName: 'P00029.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1537, + sizeBytes: 415734, + size: '406 KiB', + }, + { + number: 30, + fileName: 'P00030.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1537, + sizeBytes: 417397, + size: '407.6 KiB', + }, + { + number: 31, + fileName: 'P00031.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1537, + sizeBytes: 450572, + size: '440 KiB', + }, + { + number: 32, + fileName: 'P00032.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1537, + sizeBytes: 450168, + size: '439.6 KiB', + }, + { + number: 33, + fileName: 'P00033.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1525, + sizeBytes: 430274, + size: '420.2 KiB', + }, + { + number: 34, + fileName: 'P00034.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1525, + sizeBytes: 416871, + size: '407.1 KiB', + }, + { + number: 35, + fileName: 'P00035.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1537, + sizeBytes: 433753, + size: '423.6 KiB', + }, + { + number: 36, + fileName: 'P00036.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1537, + sizeBytes: 421796, + size: '411.9 KiB', + }, + { + number: 37, + fileName: 'P00037.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1537, + sizeBytes: 406567, + size: '397 KiB', + }, + { + number: 38, + fileName: 'P00038.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1537, + sizeBytes: 423467, + size: '413.5 KiB', + }, + { + number: 39, + fileName: 'P00039.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1521, + sizeBytes: 436404, + size: '426.2 KiB', + }, + { + number: 40, + fileName: 'P00040.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1521, + sizeBytes: 475884, + size: '464.7 KiB', + }, + { + number: 41, + fileName: 'P00041.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1529, + sizeBytes: 408619, + size: '399 KiB', + }, + { + number: 42, + fileName: 'P00042.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1529, + sizeBytes: 413908, + size: '404.2 KiB', + }, + { + number: 43, + fileName: 'P00043.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1508, + sizeBytes: 403738, + size: '394.3 KiB', + }, + { + number: 44, + fileName: 'P00044.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1508, + sizeBytes: 451228, + size: '440.7 KiB', + }, + { + number: 45, + fileName: 'P00045.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1523, + sizeBytes: 391371, + size: '382.2 KiB', + }, + { + number: 46, + fileName: 'P00046.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1523, + sizeBytes: 440633, + size: '430.3 KiB', + }, + { + number: 47, + fileName: 'P00047.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1529, + sizeBytes: 400025, + size: '390.6 KiB', + }, + { + number: 48, + fileName: 'P00048.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1529, + sizeBytes: 436877, + size: '426.6 KiB', + }, + { + number: 49, + fileName: 'P00049.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1517, + sizeBytes: 416104, + size: '406.4 KiB', + }, + { + number: 50, + fileName: 'P00050.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1517, + sizeBytes: 412440, + size: '402.8 KiB', + }, + { + number: 51, + fileName: 'P00051.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1531, + sizeBytes: 406855, + size: '397.3 KiB', + }, + { + number: 52, + fileName: 'P00052.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1531, + sizeBytes: 398932, + size: '389.6 KiB', + }, + { + number: 53, + fileName: 'P00053.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1523, + sizeBytes: 33847, + size: '33.1 KiB', + }, + { + number: 54, + fileName: 'P00054.jpg', + mediaType: 'image/jpeg', + width: 1000, + height: 1523, + sizeBytes: 438786, + size: '428.5 KiB', + }, + ], + files: ['ComicInfo.xml'], + comment: '', +} + +export const analyzed = [analyzed1, analyzed2, analyzed3, analyzed4] + +export const transientBooksHandlers = [ + httpTyped.post('/api/v1/transient-books', async ({ request, response }) => { + const path = (await request.json()).path + if (path === '/comics') return response(200).json(scanned) + return response(200).json([]) + }), + httpTyped.post('/api/v1/transient-books/{id}/analyze', ({ params, response }) => { + const data = analyzed.find((it) => it.id === params.id) + if (data) return response(200).json(data) + + return response.untyped(response404NotFound()) + }), +] diff --git a/next-ui/src/mocks/api/pageable.ts b/next-ui/src/mocks/api/pageable.ts index b5f6f4257..f30f304bf 100644 --- a/next-ui/src/mocks/api/pageable.ts +++ b/next-ui/src/mocks/api/pageable.ts @@ -23,7 +23,7 @@ export function mockPage(data: T[], pageRequest: PageRequest) { paged: !unpaged, }, last: false, - totalPages: data.length / size, + totalPages: Math.ceil(data.length / size), totalElements: data.length, first: false, size: size, diff --git a/next-ui/src/pages/import/books.mdx b/next-ui/src/pages/import/books.mdx new file mode 100644 index 000000000..1c42c1b34 --- /dev/null +++ b/next-ui/src/pages/import/books.mdx @@ -0,0 +1,13 @@ +import { Canvas, Meta } from '@storybook/addon-docs/blocks'; + +import * as Stories from './books.stories'; + + + +# Import Books + +Book import works in 2 steps: +1. Browse for or input the import directory. +2. Scanned books are shown in the table, allowing to fine-tune the import. + + diff --git a/next-ui/src/pages/import/books.stories.ts b/next-ui/src/pages/import/books.stories.ts new file mode 100644 index 000000000..23c6ccc4c --- /dev/null +++ b/next-ui/src/pages/import/books.stories.ts @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import ImportBooks from './books.vue' +import DialogConfirmEdit from '@/fragments/fragment/dialog/ConfirmEdit.vue' +import { delay, http } from 'msw' + +const meta = { + component: ImportBooks, + render: (args: object) => ({ + components: { ImportBooks, DialogConfirmEdit }, + 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: {}, +} + +export const Loading: Story = { + parameters: { + msw: { + handlers: [http.all('*', async () => await delay(2_000))], + }, + }, +} diff --git a/next-ui/src/pages/import/books.vue b/next-ui/src/pages/import/books.vue index c432a3a5f..fcdfb8a6e 100644 --- a/next-ui/src/pages/import/books.vue +++ b/next-ui/src/pages/import/books.vue @@ -1,9 +1,33 @@ diff --git a/next-ui/src/stores/app.ts b/next-ui/src/stores/app.ts index 49623c06f..683ecfb1e 100644 --- a/next-ui/src/stores/app.ts +++ b/next-ui/src/stores/app.ts @@ -7,6 +7,7 @@ export const useAppStore = defineStore('app', { drawer: !useDisplay().mobile.value.valueOf(), theme: 'system', rememberMe: false, + importBooksPath: '', }), persist: true, }) diff --git a/next-ui/src/types/MediaStatus.ts b/next-ui/src/types/MediaStatus.ts new file mode 100644 index 000000000..000367161 --- /dev/null +++ b/next-ui/src/types/MediaStatus.ts @@ -0,0 +1,37 @@ +import { defineMessages } from 'vue-intl' + +export enum MediaStatus { + UNKNOWN = 'UNKNOWN', + ERROR = 'ERROR', + READY = 'READY', + UNSUPPORTED = 'UNSUPPORTED', + OUTDATED = 'OUTDATED', +} + +export const mediaStatusMessages = defineMessages({ + [MediaStatus.UNKNOWN]: { + description: 'Media status: unknown', + defaultMessage: 'Unknown', + id: 'vBi53Y', + }, + [MediaStatus.ERROR]: { + description: 'Media status: error', + defaultMessage: 'Error', + id: 'G49aNP', + }, + [MediaStatus.READY]: { + description: 'Media status: ready', + defaultMessage: 'Ready', + id: 'k0XIsB', + }, + [MediaStatus.UNSUPPORTED]: { + description: 'Media status: unsupported', + defaultMessage: 'Unsupported', + id: '7iAvhC', + }, + [MediaStatus.OUTDATED]: { + description: 'Media status: outdated', + defaultMessage: 'Outdated', + id: 'xba3Ob', + }, +}) diff --git a/next-ui/src/utils/i18n/enum/error-codes.ts b/next-ui/src/utils/i18n/enum/error-codes.ts new file mode 100644 index 000000000..2838b23eb --- /dev/null +++ b/next-ui/src/utils/i18n/enum/error-codes.ts @@ -0,0 +1,179 @@ +import { defineMessage, type MessageDescriptor } from 'vue-intl' + +export const errorCodeMessages: Record = { + ERR_1000: defineMessage({ + description: 'Error code: ERR_1000', + defaultMessage: 'File could not be accessed during analysis', + id: 'app.error.ERR_1000', + }), + ERR_1001: defineMessage({ + description: 'Error code: ERR_1001', + defaultMessage: 'Media type is not supported', + id: 'app.error.ERR_1001', + }), + ERR_1002: defineMessage({ + description: 'Error code: ERR_1002', + defaultMessage: 'Encrypted RAR archives are not supported', + id: 'app.error.ERR_1002', + }), + ERR_1003: defineMessage({ + description: 'Error code: ERR_1003', + defaultMessage: 'Solid RAR archives are not supported', + id: 'app.error.ERR_1003', + }), + ERR_1004: defineMessage({ + description: 'Error code: ERR_1004', + defaultMessage: 'Multi-Volume RAR archives are not supported', + id: 'app.error.ERR_1004', + }), + ERR_1005: defineMessage({ + description: 'Error code: ERR_1005', + defaultMessage: 'Unknown error while analyzing book', + id: 'app.error.ERR_1005', + }), + ERR_1006: defineMessage({ + description: 'Error code: ERR_1006', + defaultMessage: 'Book does not contain any page', + id: 'app.error.ERR_1006', + }), + ERR_1007: defineMessage({ + description: 'Error code: ERR_1007', + defaultMessage: 'Some entries could not be analyzed', + id: 'app.error.ERR_1007', + }), + ERR_1008: defineMessage({ + description: 'Error code: ERR_1008', + defaultMessage: "Unknown error while getting book's entries", + id: 'app.error.ERR_1008', + }), + ERR_1009: defineMessage({ + description: 'Error code: ERR_1009', + defaultMessage: 'A read list with that name already exists', + id: 'app.error.ERR_1009', + }), + ERR_1015: defineMessage({ + description: 'Error code: ERR_1015', + defaultMessage: 'Error while deserializing ComicRack CBL', + id: 'app.error.ERR_1015', + }), + ERR_1016: defineMessage({ + description: 'Error code: ERR_1016', + defaultMessage: 'Directory not accessible or not a directory', + id: 'app.error.ERR_1016', + }), + ERR_1017: defineMessage({ + description: 'Error code: ERR_1017', + defaultMessage: 'Cannot scan folder that is part of an existing library', + id: 'app.error.ERR_1017', + }), + ERR_1018: defineMessage({ + description: 'Error code: ERR_1018', + defaultMessage: 'File not found', + id: 'app.error.ERR_1018', + }), + ERR_1019: defineMessage({ + description: 'Error code: ERR_1019', + defaultMessage: 'Cannot import file that is part of an existing library', + id: 'app.error.ERR_1019', + }), + ERR_1020: defineMessage({ + description: 'Error code: ERR_1020', + defaultMessage: 'Book to upgrade does not belong to provided series', + id: 'app.error.ERR_1020', + }), + ERR_1021: defineMessage({ + description: 'Error code: ERR_1021', + defaultMessage: 'Destination file already exists', + id: 'app.error.ERR_1021', + }), + ERR_1022: defineMessage({ + description: 'Error code: ERR_1022', + defaultMessage: 'Newly imported book could not be scanned', + id: 'app.error.ERR_1022', + }), + ERR_1023: defineMessage({ + description: 'Error code: ERR_1023', + defaultMessage: 'Book already present in ReadingList', + id: 'app.error.ERR_1023', + }), + ERR_1024: defineMessage({ + description: 'Error code: ERR_1024', + defaultMessage: 'OAuth2 login error: no email attribute', + id: 'app.error.ERR_1024', + }), + ERR_1025: defineMessage({ + description: 'Error code: ERR_1025', + defaultMessage: 'OAuth2 login error: no local user exist with that email', + id: 'app.error.ERR_1025', + }), + ERR_1026: defineMessage({ + description: 'Error code: ERR_1026', + defaultMessage: 'OpenID Connect login error: email not verified', + id: 'app.error.ERR_1026', + }), + ERR_1027: defineMessage({ + description: 'Error code: ERR_1027', + defaultMessage: 'OpenID Connect login error: no email_verified attribute', + id: 'app.error.ERR_1027', + }), + ERR_1028: defineMessage({ + description: 'Error code: ERR_1028', + defaultMessage: 'OpenID Connect login error: no email attribute', + id: 'app.error.ERR_1028', + }), + ERR_1029: defineMessage({ + description: 'Error code: ERR_1029', + defaultMessage: 'ComicRack CBL does not contain any Book element', + id: 'app.error.ERR_1029', + }), + ERR_1030: defineMessage({ + description: 'Error code: ERR_1030', + defaultMessage: 'ComicRack CBL has no Name element', + id: 'app.error.ERR_1030', + }), + ERR_1031: defineMessage({ + description: 'Error code: ERR_1031', + defaultMessage: 'ComicRack CBL Book is missing series or number', + id: 'app.error.ERR_1031', + }), + ERR_1032: defineMessage({ + description: 'Error code: ERR_1032', + defaultMessage: 'EPUB file has wrong media type', + id: 'app.error.ERR_1032', + }), + ERR_1033: defineMessage({ + description: 'Error code: ERR_1033', + defaultMessage: 'Some entries are missing', + id: 'app.error.ERR_1033', + }), + ERR_1034: defineMessage({ + description: 'Error code: ERR_1034', + defaultMessage: 'An API key with that comment already exists', + id: 'app.error.ERR_1034', + }), + ERR_1035: defineMessage({ + description: 'Error code: ERR_1035', + defaultMessage: 'Error while getting EPUB TOC', + id: 'app.error.ERR_1035', + }), + ERR_1036: defineMessage({ + description: 'Error code: ERR_1036', + defaultMessage: 'Error while getting EPUB Landmarks', + id: 'app.error.ERR_1036', + }), + ERR_1037: defineMessage({ + description: 'Error code: ERR_1037', + defaultMessage: 'Error while getting EPUB page list', + id: 'app.error.ERR_1037', + }), + ERR_1038: defineMessage({ + description: 'Error code: ERR_1038', + defaultMessage: 'Error while getting EPUB divina pages', + id: 'app.error.ERR_1038', + }), + ERR_1039: defineMessage({ + description: 'Error code: ERR_1039', + defaultMessage: 'Error while getting EPUB positions', + id: 'app.error.ERR_1039', + }), +}