diff --git a/next-ui/package-lock.json b/next-ui/package-lock.json index 9fd401fda..b32d0b8e3 100644 --- a/next-ui/package-lock.json +++ b/next-ui/package-lock.json @@ -14,6 +14,7 @@ "@pinia/colada-plugin-delay": "^0.1.0", "@vueuse/core": "^13.9.0", "core-js": "^3.45.1", + "filesize": "^11.0.13", "marked": "^16.3.0", "openapi-fetch": "^0.14.1", "pinia": "^3.0.3", @@ -370,6 +371,16 @@ } } }, + "node_modules/@chromatic-com/storybook/node_modules/filesize": { + "version": "10.1.6", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.6.tgz", + "integrity": "sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 10.4.0" + } + }, "node_modules/@chromatic-com/storybook/node_modules/strip-ansi": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", @@ -6271,13 +6282,12 @@ } }, "node_modules/filesize": { - "version": "10.1.6", - "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.6.tgz", - "integrity": "sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==", - "dev": true, + "version": "11.0.13", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-11.0.13.tgz", + "integrity": "sha512-mYJ/qXKvREuO0uH8LTQJ6v7GsUvVOguqxg2VTwQUkyTPXXRRWPdjuUPVqdBrJQhvci48OHlNGRnux+Slr2Rnvw==", "license": "BSD-3-Clause", "engines": { - "node": ">= 10.4.0" + "node": ">= 10.8.0" } }, "node_modules/fill-range": { diff --git a/next-ui/package.json b/next-ui/package.json index aa9db5489..5d3155dcb 100644 --- a/next-ui/package.json +++ b/next-ui/package.json @@ -34,6 +34,7 @@ "@pinia/colada-plugin-delay": "^0.1.0", "@vueuse/core": "^13.9.0", "core-js": "^3.45.1", + "filesize": "^11.0.13", "marked": "^16.3.0", "openapi-fetch": "^0.14.1", "pinia": "^3.0.3", diff --git a/next-ui/src/App.vue b/next-ui/src/App.vue index 98e5f2b1c..b28df0bc9 100644 --- a/next-ui/src/App.vue +++ b/next-ui/src/App.vue @@ -5,6 +5,7 @@ + diff --git a/next-ui/src/api/images.ts b/next-ui/src/api/images.ts index 22431a5cc..7fb7518f2 100644 --- a/next-ui/src/api/images.ts +++ b/next-ui/src/api/images.ts @@ -10,6 +10,11 @@ export function bookThumbnailUrl(bookId?: string): string | undefined { return undefined } +export function bookPageThumbnailUrl(bookId?: string, page?: number): string | undefined { + if (bookId && page) return `${API_BASE_URL}/api/v1/books/${bookId}/pages/${page}/thumbnail` + return undefined +} + export function pageHashKnownThumbnailUrl(hash?: string): string | undefined { if (hash) return `${API_BASE_URL}/api/v1/page-hashes/${hash}/thumbnail` return undefined diff --git a/next-ui/src/assets/mock-thumbnail-landscape.jpg b/next-ui/src/assets/mock-thumbnail-landscape.jpg new file mode 100644 index 000000000..4596d28da Binary files /dev/null and b/next-ui/src/assets/mock-thumbnail-landscape.jpg differ diff --git a/next-ui/src/colada/page-hashes.ts b/next-ui/src/colada/page-hashes.ts new file mode 100644 index 000000000..cca77bfe9 --- /dev/null +++ b/next-ui/src/colada/page-hashes.ts @@ -0,0 +1,67 @@ +import { defineQueryOptions } from '@pinia/colada' +import { komgaClient } from '@/api/komga-client' +import type { PageHashAction } from '@/types/PageHashAction' + +export const pageHashesKnownQuery = defineQueryOptions( + ({ + actions, + page, + size, + sort, + }: { + actions?: string[] + page?: number + size?: number + sort?: string[] + }) => ({ + key: ['page-hashes-known', { actions: actions, page: page, size: size, sort: sort }], + query: () => + komgaClient + .GET('/api/v1/page-hashes', { + params: { + query: { + page: page, + size: size, + sort: sort, + action: actions as PageHashAction[], + }, + }, + }) + // unwrap the openapi-fetch structure on success + .then((res) => res.data), + placeholderData: (previousData: any) => previousData, // eslint-disable-line @typescript-eslint/no-explicit-any + }), +) + +export const pageHashMatchesQuery = defineQueryOptions( + ({ + pageHash, + page, + size, + sort, + }: { + pageHash: string + page?: number + size?: number + sort?: string[] + }) => ({ + key: ['page-hash-matches', pageHash, { page: page, size: size, sort: sort }], + query: () => + komgaClient + .GET('/api/v1/page-hashes/{pageHash}', { + params: { + path: { + pageHash: pageHash, + }, + query: { + page: page, + size: size, + sort: sort, + }, + }, + }) + // unwrap the openapi-fetch structure on success + .then((res) => res.data), + placeholderData: (previousData: any) => previousData, // eslint-disable-line @typescript-eslint/no-explicit-any + }), +) diff --git a/next-ui/src/components.d.ts b/next-ui/src/components.d.ts index e79c5dc62..1f0ce80cb 100644 --- a/next-ui/src/components.d.ts +++ b/next-ui/src/components.d.ts @@ -23,6 +23,8 @@ declare module 'vue' { DialogConfirmInstance: typeof import('./components/dialog/ConfirmInstance.vue')['default'] DialogFileNamePicker: typeof import('./components/dialog/FileNamePicker.vue')['default'] DialogSeriesPicker: typeof import('./components/dialog/SeriesPicker.vue')['default'] + DialogSimple: typeof import('./components/dialog/DialogSimple.vue')['default'] + DialogSimpleInstance: typeof import('./components/dialog/DialogSimpleInstance.vue')['default'] EmptyStateNetworkError: typeof import('./components/EmptyStateNetworkError.vue')['default'] FormattedMessage: typeof import('./components/FormattedMessage.ts')['default'] HelloWorld: typeof import('./components/HelloWorld.vue')['default'] @@ -47,6 +49,8 @@ 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'] LocaleSelector: typeof import('./components/LocaleSelector.vue')['default'] + PageHashKnownTable: typeof import('./components/pageHash/KnownTable.vue')['default'] + PageHashMatchTable: typeof import('./components/pageHash/MatchTable.vue')['default'] ReleaseCard: typeof import('./components/release/Card.vue')['default'] RemoteFileList: typeof import('./components/RemoteFileList.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] diff --git a/next-ui/src/components/dialog/DialogSimple.vue b/next-ui/src/components/dialog/DialogSimple.vue new file mode 100644 index 000000000..769a373b7 --- /dev/null +++ b/next-ui/src/components/dialog/DialogSimple.vue @@ -0,0 +1,67 @@ + + + diff --git a/next-ui/src/components/dialog/DialogSimpleInstance.vue b/next-ui/src/components/dialog/DialogSimpleInstance.vue new file mode 100644 index 000000000..713718762 --- /dev/null +++ b/next-ui/src/components/dialog/DialogSimpleInstance.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/next-ui/src/components/pageHash/KnownTable.stories.ts b/next-ui/src/components/pageHash/KnownTable.stories.ts new file mode 100644 index 000000000..97d8fa711 --- /dev/null +++ b/next-ui/src/components/pageHash/KnownTable.stories.ts @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import KnownTable from './KnownTable.vue' +import { delay, http } from 'msw' +import { response401Unauthorized } from '@/mocks/api/handlers' +import { httpTyped } from '@/mocks/api/httpTyped' +import { mockPage } from '@/mocks/api/pageable' +import { PageRequest } from '@/types/PageRequest' +import DialogSimpleInstance from '@/components/dialog/DialogSimpleInstance.vue' +import SnackQueue from '@/components/SnackQueue.vue' + +const meta = { + component: KnownTable, + subcomponents: { SnackQueue }, + render: (args: object) => ({ + components: { KnownTable, DialogSimpleInstance, 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: {}, +} + +export const NoData: Story = { + parameters: { + msw: { + handlers: [ + httpTyped.get('/api/v1/page-hashes', ({ response }) => + response(200).json(mockPage([], new PageRequest())), + ), + ], + }, + }, +} + +export const Loading: Story = { + parameters: { + msw: { + handlers: [http.all('*/api/*', async () => await delay(2_000))], + }, + }, +} + +export const Error: Story = { + parameters: { + msw: { + handlers: [http.all('*/api/v1/page-hashes', response401Unauthorized)], + }, + }, +} + +export const ErrorOnDeletion: Story = { + parameters: { + msw: { + handlers: [ + httpTyped.post('/api/v1/page-hashes/{pageHash}/delete-all', ({ response }) => + response.untyped(response401Unauthorized()), + ), + ], + }, + }, +} diff --git a/next-ui/src/components/pageHash/KnownTable.vue b/next-ui/src/components/pageHash/KnownTable.vue new file mode 100644 index 000000000..70a4c7e7d --- /dev/null +++ b/next-ui/src/components/pageHash/KnownTable.vue @@ -0,0 +1,442 @@ + + + + diff --git a/next-ui/src/components/pageHash/MatchTable.stories.ts b/next-ui/src/components/pageHash/MatchTable.stories.ts new file mode 100644 index 000000000..8b5474f3d --- /dev/null +++ b/next-ui/src/components/pageHash/MatchTable.stories.ts @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import MatchTable from './MatchTable.vue' +import { delay, http } from 'msw' +import { response401Unauthorized } from '@/mocks/api/handlers' +import { httpTyped } from '@/mocks/api/httpTyped' +import { mockPage } from '@/mocks/api/pageable' +import { PageRequest } from '@/types/PageRequest' + +const meta = { + component: MatchTable, + render: (args: object) => ({ + components: { MatchTable }, + setup() { + return { args } + }, + template: '', + }), + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + }, + args: { + modelValue: 'hash1', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} + +export const NoData: Story = { + parameters: { + msw: { + handlers: [ + httpTyped.get('/api/v1/page-hashes/{pageHash}', ({ response }) => + response(200).json(mockPage([], new PageRequest())), + ), + ], + }, + }, +} + +export const Loading: Story = { + parameters: { + msw: { + handlers: [http.all('*/api/*', async () => await delay(2_000))], + }, + }, +} + +export const Error: Story = { + parameters: { + msw: { + handlers: [http.all('*/api/v1/page-hashes/*', response401Unauthorized)], + }, + }, +} diff --git a/next-ui/src/components/pageHash/MatchTable.vue b/next-ui/src/components/pageHash/MatchTable.vue new file mode 100644 index 000000000..4c645ac7f --- /dev/null +++ b/next-ui/src/components/pageHash/MatchTable.vue @@ -0,0 +1,151 @@ + + + + diff --git a/next-ui/src/mocks/api/handlers.ts b/next-ui/src/mocks/api/handlers.ts index 33ad28b88..5f13cb8ca 100644 --- a/next-ui/src/mocks/api/handlers.ts +++ b/next-ui/src/mocks/api/handlers.ts @@ -13,6 +13,7 @@ import { booksHandlers } from '@/mocks/api/handlers/books' import { filesystemHandlers } from '@/mocks/api/handlers/filesystem' import { transientBooksHandlers } from '@/mocks/api/handlers/transient-books' import { readListsHandlers } from '@/mocks/api/handlers/readlists' +import { pageHashesHandlers } from '@/mocks/api/handlers/page-hashes' export const handlers = [ ...actuatorHandlers, @@ -22,6 +23,7 @@ export const handlers = [ ...filesystemHandlers, ...historyHandlers, ...librariesHandlers, + ...pageHashesHandlers, ...readListsHandlers, ...referentialHandlers, ...releasesHandlers, diff --git a/next-ui/src/mocks/api/handlers/history.ts b/next-ui/src/mocks/api/handlers/history.ts index 00b7a01b4..24074d503 100644 --- a/next-ui/src/mocks/api/handlers/history.ts +++ b/next-ui/src/mocks/api/handlers/history.ts @@ -1,8 +1,6 @@ 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' export const historyBookImported = { id: 'H1', @@ -112,14 +110,4 @@ 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(mockThumbnailUrl).then((response) => response.arrayBuffer()) - - return HttpResponse.arrayBuffer(buffer, { - headers: { - 'content-type': 'image/jpg', - }, - }) - }), ] diff --git a/next-ui/src/mocks/api/handlers/page-hashes.ts b/next-ui/src/mocks/api/handlers/page-hashes.ts new file mode 100644 index 000000000..9471ce2b4 --- /dev/null +++ b/next-ui/src/mocks/api/handlers/page-hashes.ts @@ -0,0 +1,85 @@ +import { httpTyped } from '@/mocks/api/httpTyped' +import { mockPage } from '@/mocks/api/pageable' +import { PageRequest } from '@/types/PageRequest' +import type { components } from '@/generated/openapi/komga' +import { HttpResponse } from 'msw' +import mockThumbnailUrl from '@/assets/mock-thumbnail.jpg' +import mockThumbnailLandscapeUrl from '@/assets/mock-thumbnail-landscape.jpg' + +export function mockPageHashesKnown(count: number): components['schemas']['PageHashKnownDto'][] { + return [...Array(count).keys()].map((index) => { + const created = new Date(`19${String(index).slice(-2).padStart(2, '0')}-05-10`) + return { + hash: `HASH${index}`, + size: 1234 * (index + 1), + action: index % 3 === 0 ? 'DELETE_AUTO' : index % 3 === 1 ? 'DELETE_MANUAL' : 'IGNORE', + deleteCount: index % 3 === 0 ? 5 : index % 3 === 1 ? 2 : 0, + matchCount: index * 2, + created: created, + lastModified: created, + } + }) +} + +export function mockPageHashMatches(count: number): components['schemas']['PageHashMatchDto'][] { + return [...Array(count).keys()].map((index) => { + return { + bookId: `BOOK${index + 1}`, + url: '/books/Super Duck/Super_Duck_001__MLJ___Fall_1944___c2c___titansfan_editor_.cbz', + pageNumber: 25 + index, + fileName: `Page_${25 + index}.jpg`, + fileSize: 1234 * (index + 1), + mediaType: 'image/jpeg', + } + }) +} + +export const pageHashesHandlers = [ + httpTyped.get('/api/v1/page-hashes', ({ query, response }) => { + let data = mockPageHashesKnown(50) + const actions = query.getAll('action') + if (actions.length > 0) data = data.filter((it) => actions.includes(it.action)) + return response(200).json( + mockPage( + data, + new PageRequest(Number(query.get('page')), Number(query.get('size')), query.getAll('sort')), + ), + ) + }), + httpTyped.get('/api/v1/page-hashes/{pageHash}', ({ params, query, response }) => { + const hash = params.pageHash + const data = mockPageHashMatches(Number(hash.substring(4)) * 2) + return response(200).json( + mockPage( + data, + new PageRequest(Number(query.get('page')), Number(query.get('size')), query.getAll('sort')), + ), + ) + }), + httpTyped.put('/api/v1/page-hashes', ({ response }) => { + return response(202).empty() + }), + httpTyped.post('/api/v1/page-hashes/{pageHash}/delete-all', ({ response }) => { + return response(202).empty() + }), + httpTyped.get('/api/v1/page-hashes/{pageHash}/thumbnail', async ({ params, response }) => { + const hash = params.pageHash + + // use landscape image for some images + const landscape = Number(hash.slice(-1)) % 2 === 0 + + // Get an ArrayBuffer from reading the file from disk or fetching it. + const buffer = await fetch(landscape ? mockThumbnailLandscapeUrl : mockThumbnailUrl).then( + (response) => response.arrayBuffer(), + ) + + return response.untyped( + HttpResponse.arrayBuffer(buffer, { + status: 200, + headers: { + 'content-type': 'image/jpg', + }, + }), + ) + }), +] diff --git a/next-ui/src/mocks/api/pageable.ts b/next-ui/src/mocks/api/pageable.ts index f30f304bf..76073593b 100644 --- a/next-ui/src/mocks/api/pageable.ts +++ b/next-ui/src/mocks/api/pageable.ts @@ -4,12 +4,18 @@ export function mockPage(data: T[], pageRequest: PageRequest) { const page = Number(pageRequest.page) || 0 const size = Number(pageRequest.size) || 20 const unpaged = pageRequest.unpaged || false + const sort = pageRequest.sort const start = page * size const slice = unpaged ? data : data.slice(start, start + size) + let sortedSlice = slice + if (sort) { + sortedSlice = slice.sort(orderBy(parseSort(sort))) + } + return { - content: slice, + content: sortedSlice, pageable: { pageNumber: page, pageSize: size, @@ -37,3 +43,36 @@ export function mockPage(data: T[], pageRequest: PageRequest) { empty: slice.length > 0, } } + +function parseSort(sorts: string[]): OrderBy[] { + return sorts.map((sort) => { + const components = sort.split(',') + return { + property: components[0]!, + direction: components[1] === 'desc' ? 'desc' : 'asc', + } + }) +} + +type OrderBy = { + property: string + direction?: 'desc' | 'asc' +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const orderBy = (items: OrderBy[]) => (a: any, b: any) => { + const sortDirection: Record = { asc: 1, desc: -1 } + const sortCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }) + const totalOrders = items.length + + for (let index = 0; index < totalOrders; index++) { + const { property, direction = 'desc' } = items[index]! + const directionInt = sortDirection[direction]! + const compare = sortCollator.compare(a[property], b[property]) + + if (compare < 0) return directionInt + if (compare > 0) return -directionInt + } + + return 0 +} diff --git a/next-ui/src/pages/media/duplicate-pages/known.vue b/next-ui/src/pages/media/duplicate-pages/known.vue index c5ee65c04..8cdb8926e 100644 --- a/next-ui/src/pages/media/duplicate-pages/known.vue +++ b/next-ui/src/pages/media/duplicate-pages/known.vue @@ -1,5 +1,10 @@