diff --git a/next-ui/src/api/images.ts b/next-ui/src/api/images.ts index 7fb7518f2..08811e676 100644 --- a/next-ui/src/api/images.ts +++ b/next-ui/src/api/images.ts @@ -19,3 +19,8 @@ export function pageHashKnownThumbnailUrl(hash?: string): string | undefined { if (hash) return `${API_BASE_URL}/api/v1/page-hashes/${hash}/thumbnail` return undefined } + +export function pageHashUnknownThumbnailUrl(hash?: string): string | undefined { + if (hash) return `${API_BASE_URL}/api/v1/page-hashes/unknown/${hash}/thumbnail` + return undefined +} diff --git a/next-ui/src/colada/page-hashes.ts b/next-ui/src/colada/page-hashes.ts index cca77bfe9..b2cd571e9 100644 --- a/next-ui/src/colada/page-hashes.ts +++ b/next-ui/src/colada/page-hashes.ts @@ -2,6 +2,10 @@ import { defineQueryOptions } from '@pinia/colada' import { komgaClient } from '@/api/komga-client' import type { PageHashAction } from '@/types/PageHashAction' +export const QUERY_KEYS_PAGE_HASHES = { + unknown: ['page-hashes-unknown'] as const, +} + export const pageHashesKnownQuery = defineQueryOptions( ({ actions, @@ -33,6 +37,26 @@ export const pageHashesKnownQuery = defineQueryOptions( }), ) +export const pageHashesUnknownQuery = defineQueryOptions( + ({ page, size, sort }: { page?: number; size?: number; sort?: string[] }) => ({ + key: [QUERY_KEYS_PAGE_HASHES.unknown, { page: page, size: size, sort: sort }], + query: () => + komgaClient + .GET('/api/v1/page-hashes/unknown', { + params: { + 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 + }), +) + export const pageHashMatchesQuery = defineQueryOptions( ({ pageHash, diff --git a/next-ui/src/components.d.ts b/next-ui/src/components.d.ts index 76f07599d..c7895097e 100644 --- a/next-ui/src/components.d.ts +++ b/next-ui/src/components.d.ts @@ -54,6 +54,7 @@ declare module 'vue' { LocaleSelector: typeof import('./components/LocaleSelector.vue')['default'] PageHashKnownTable: typeof import('./components/pageHash/KnownTable.vue')['default'] PageHashMatchTable: typeof import('./components/pageHash/MatchTable.vue')['default'] + PageHashUnknownTable: typeof import('./components/pageHash/UnknownTable.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/pageHash/UnknownTable.stories.ts b/next-ui/src/components/pageHash/UnknownTable.stories.ts new file mode 100644 index 000000000..577eb4259 --- /dev/null +++ b/next-ui/src/components/pageHash/UnknownTable.stories.ts @@ -0,0 +1,61 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import UnknownTable from './UnknownTable.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: UnknownTable, + subcomponents: { SnackQueue }, + render: (args: object) => ({ + components: { UnknownTable, 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/unknown', ({ 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/unknown', response401Unauthorized)], + }, + }, +} diff --git a/next-ui/src/components/pageHash/UnknownTable.vue b/next-ui/src/components/pageHash/UnknownTable.vue new file mode 100644 index 000000000..91c5ef311 --- /dev/null +++ b/next-ui/src/components/pageHash/UnknownTable.vue @@ -0,0 +1,393 @@ + + + + diff --git a/next-ui/src/mocks/api/handlers/page-hashes.ts b/next-ui/src/mocks/api/handlers/page-hashes.ts index 9471ce2b4..5fd74ecbb 100644 --- a/next-ui/src/mocks/api/handlers/page-hashes.ts +++ b/next-ui/src/mocks/api/handlers/page-hashes.ts @@ -21,6 +21,18 @@ export function mockPageHashesKnown(count: number): components['schemas']['PageH }) } +export function mockPageHashesUnknown( + count: number, +): components['schemas']['PageHashUnknownDto'][] { + return [...Array(count).keys()].map((index) => { + return { + hash: `UNKN${index}`, + size: 1234 * (index + 1), + matchCount: index * 2, + } + }) +} + export function mockPageHashMatches(count: number): components['schemas']['PageHashMatchDto'][] { return [...Array(count).keys()].map((index) => { return { @@ -34,6 +46,8 @@ export function mockPageHashMatches(count: number): components['schemas']['PageH }) } +const knownHashes: string[] = [] + export const pageHashesHandlers = [ httpTyped.get('/api/v1/page-hashes', ({ query, response }) => { let data = mockPageHashesKnown(50) @@ -46,6 +60,15 @@ export const pageHashesHandlers = [ ), ) }), + httpTyped.get('/api/v1/page-hashes/unknown', ({ query, response }) => { + const data = mockPageHashesUnknown(50).filter((it) => !knownHashes.includes(it.hash)) + 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) @@ -56,7 +79,10 @@ export const pageHashesHandlers = [ ), ) }), - httpTyped.put('/api/v1/page-hashes', ({ response }) => { + httpTyped.put('/api/v1/page-hashes', async ({ request, response }) => { + const body = await request.json() + knownHashes.push(body.hash) + return response(202).empty() }), httpTyped.post('/api/v1/page-hashes/{pageHash}/delete-all', ({ response }) => { @@ -82,4 +108,27 @@ export const pageHashesHandlers = [ }), ) }), + httpTyped.get( + '/api/v1/page-hashes/unknown/{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/pages/media/duplicate-pages/unknown.vue b/next-ui/src/pages/media/duplicate-pages/unknown.vue index 0edc4680f..ec75048db 100644 --- a/next-ui/src/pages/media/duplicate-pages/unknown.vue +++ b/next-ui/src/pages/media/duplicate-pages/unknown.vue @@ -1,5 +1,10 @@