mirror of
https://github.com/gotson/komga.git
synced 2025-12-06 08:32:25 +01:00
known duplicates
This commit is contained in:
parent
35dbd70c2a
commit
6157fdde79
21 changed files with 1109 additions and 20 deletions
20
next-ui/package-lock.json
generated
20
next-ui/package-lock.json
generated
|
|
@ -14,6 +14,7 @@
|
||||||
"@pinia/colada-plugin-delay": "^0.1.0",
|
"@pinia/colada-plugin-delay": "^0.1.0",
|
||||||
"@vueuse/core": "^13.9.0",
|
"@vueuse/core": "^13.9.0",
|
||||||
"core-js": "^3.45.1",
|
"core-js": "^3.45.1",
|
||||||
|
"filesize": "^11.0.13",
|
||||||
"marked": "^16.3.0",
|
"marked": "^16.3.0",
|
||||||
"openapi-fetch": "^0.14.1",
|
"openapi-fetch": "^0.14.1",
|
||||||
"pinia": "^3.0.3",
|
"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": {
|
"node_modules/@chromatic-com/storybook/node_modules/strip-ansi": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
|
||||||
|
|
@ -6271,13 +6282,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/filesize": {
|
"node_modules/filesize": {
|
||||||
"version": "10.1.6",
|
"version": "11.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/filesize/-/filesize-11.0.13.tgz",
|
||||||
"integrity": "sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==",
|
"integrity": "sha512-mYJ/qXKvREuO0uH8LTQJ6v7GsUvVOguqxg2VTwQUkyTPXXRRWPdjuUPVqdBrJQhvci48OHlNGRnux+Slr2Rnvw==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.4.0"
|
"node": ">= 10.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
"@pinia/colada-plugin-delay": "^0.1.0",
|
"@pinia/colada-plugin-delay": "^0.1.0",
|
||||||
"@vueuse/core": "^13.9.0",
|
"@vueuse/core": "^13.9.0",
|
||||||
"core-js": "^3.45.1",
|
"core-js": "^3.45.1",
|
||||||
|
"filesize": "^11.0.13",
|
||||||
"marked": "^16.3.0",
|
"marked": "^16.3.0",
|
||||||
"openapi-fetch": "^0.14.1",
|
"openapi-fetch": "^0.14.1",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
<SnackQueue />
|
<SnackQueue />
|
||||||
<DialogConfirmEditInstance />
|
<DialogConfirmEditInstance />
|
||||||
<DialogConfirmInstance />
|
<DialogConfirmInstance />
|
||||||
|
<DialogSimpleInstance />
|
||||||
</v-app>
|
</v-app>
|
||||||
|
|
||||||
<PiniaColadaDevtools />
|
<PiniaColadaDevtools />
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,11 @@ export function bookThumbnailUrl(bookId?: string): string | undefined {
|
||||||
return 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 {
|
export function pageHashKnownThumbnailUrl(hash?: string): string | undefined {
|
||||||
if (hash) return `${API_BASE_URL}/api/v1/page-hashes/${hash}/thumbnail`
|
if (hash) return `${API_BASE_URL}/api/v1/page-hashes/${hash}/thumbnail`
|
||||||
return undefined
|
return undefined
|
||||||
|
|
|
||||||
BIN
next-ui/src/assets/mock-thumbnail-landscape.jpg
Normal file
BIN
next-ui/src/assets/mock-thumbnail-landscape.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
67
next-ui/src/colada/page-hashes.ts
Normal file
67
next-ui/src/colada/page-hashes.ts
Normal file
|
|
@ -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
|
||||||
|
}),
|
||||||
|
)
|
||||||
4
next-ui/src/components.d.ts
vendored
4
next-ui/src/components.d.ts
vendored
|
|
@ -23,6 +23,8 @@ declare module 'vue' {
|
||||||
DialogConfirmInstance: typeof import('./components/dialog/ConfirmInstance.vue')['default']
|
DialogConfirmInstance: typeof import('./components/dialog/ConfirmInstance.vue')['default']
|
||||||
DialogFileNamePicker: typeof import('./components/dialog/FileNamePicker.vue')['default']
|
DialogFileNamePicker: typeof import('./components/dialog/FileNamePicker.vue')['default']
|
||||||
DialogSeriesPicker: typeof import('./components/dialog/SeriesPicker.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']
|
EmptyStateNetworkError: typeof import('./components/EmptyStateNetworkError.vue')['default']
|
||||||
FormattedMessage: typeof import('./components/FormattedMessage.ts')['default']
|
FormattedMessage: typeof import('./components/FormattedMessage.ts')['default']
|
||||||
HelloWorld: typeof import('./components/HelloWorld.vue')['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']
|
LayoutAppDrawerMenuMedia: typeof import('./components/layout/app/drawer/menu/Media.vue')['default']
|
||||||
LayoutAppDrawerMenuServer: typeof import('./components/layout/app/drawer/menu/Server.vue')['default']
|
LayoutAppDrawerMenuServer: typeof import('./components/layout/app/drawer/menu/Server.vue')['default']
|
||||||
LocaleSelector: typeof import('./components/LocaleSelector.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']
|
ReleaseCard: typeof import('./components/release/Card.vue')['default']
|
||||||
RemoteFileList: typeof import('./components/RemoteFileList.vue')['default']
|
RemoteFileList: typeof import('./components/RemoteFileList.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
|
|
|
||||||
67
next-ui/src/components/dialog/DialogSimple.vue
Normal file
67
next-ui/src/components/dialog/DialogSimple.vue
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
<template>
|
||||||
|
<v-dialog
|
||||||
|
v-model="showDialog"
|
||||||
|
:activator="activator"
|
||||||
|
:max-width="maxWidth"
|
||||||
|
:fullscreen="fullscreen"
|
||||||
|
:transition="fullscreen ? 'dialog-bottom-transition' : undefined"
|
||||||
|
:scrollable="scrollable"
|
||||||
|
:aria-label="title"
|
||||||
|
>
|
||||||
|
<template #default="{ isActive }">
|
||||||
|
<v-card
|
||||||
|
:title="title"
|
||||||
|
:subtitle="subtitle"
|
||||||
|
:loading="loading"
|
||||||
|
>
|
||||||
|
<template #text>
|
||||||
|
<slot name="text" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
:text="
|
||||||
|
$formatMessage({
|
||||||
|
description: 'Simple dialog: Close button',
|
||||||
|
defaultMessage: 'Close',
|
||||||
|
id: 'Wivz5J',
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@click="isActive.value = false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const showDialog = defineModel<boolean>('dialog', { required: false, default: false })
|
||||||
|
|
||||||
|
export type DialogSimpleProps = {
|
||||||
|
/**
|
||||||
|
* Dialog title
|
||||||
|
* @type string
|
||||||
|
*/
|
||||||
|
title?: string
|
||||||
|
subtitle?: string
|
||||||
|
maxWidth?: string | number
|
||||||
|
activator?: Element | string
|
||||||
|
loading?: boolean
|
||||||
|
fullscreen?: boolean
|
||||||
|
scrollable?: boolean
|
||||||
|
shown?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<DialogSimpleProps>()
|
||||||
|
const {
|
||||||
|
title = undefined,
|
||||||
|
subtitle = undefined,
|
||||||
|
maxWidth = undefined,
|
||||||
|
activator = undefined,
|
||||||
|
loading = false,
|
||||||
|
fullscreen = undefined,
|
||||||
|
scrollable = undefined,
|
||||||
|
} = toRefs(props)
|
||||||
|
</script>
|
||||||
39
next-ui/src/components/dialog/DialogSimpleInstance.vue
Normal file
39
next-ui/src/components/dialog/DialogSimpleInstance.vue
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<template>
|
||||||
|
<DialogSimple
|
||||||
|
v-model="showDialog"
|
||||||
|
v-bind="simple.dialogProps"
|
||||||
|
:loading="loading"
|
||||||
|
:activator="simple.activator"
|
||||||
|
>
|
||||||
|
<template #text>
|
||||||
|
<component
|
||||||
|
:is="simple.slot.component"
|
||||||
|
v-bind="simple.slot.props"
|
||||||
|
class="mt-1"
|
||||||
|
v-on="simple.slot.handlers"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</DialogSimple>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Single instance of DialogSimple, mounted under App.
|
||||||
|
* Communication from other components is done via useDialogsStore.simple
|
||||||
|
*/
|
||||||
|
import { useDialogsStore } from '@/stores/dialogs'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { syncRefs } from '@vueuse/core'
|
||||||
|
|
||||||
|
const showDialog = ref<boolean>(false)
|
||||||
|
const loading = ref<boolean>(false)
|
||||||
|
|
||||||
|
const { simple } = storeToRefs(useDialogsStore())
|
||||||
|
|
||||||
|
syncRefs(
|
||||||
|
toRef(() => simple.value.dialogProps.shown),
|
||||||
|
showDialog,
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
73
next-ui/src/components/pageHash/KnownTable.stories.ts
Normal file
73
next-ui/src/components/pageHash/KnownTable.stories.ts
Normal file
|
|
@ -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: '<KnownTable v-bind="args" /><DialogSimpleInstance/><SnackQueue/>',
|
||||||
|
}),
|
||||||
|
parameters: {
|
||||||
|
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
|
||||||
|
},
|
||||||
|
args: {},
|
||||||
|
} satisfies Meta<typeof KnownTable>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
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()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
442
next-ui/src/components/pageHash/KnownTable.vue
Normal file
442
next-ui/src/components/pageHash/KnownTable.vue
Normal file
|
|
@ -0,0 +1,442 @@
|
||||||
|
<template>
|
||||||
|
<v-data-table-server
|
||||||
|
v-model:sort-by="sortBy"
|
||||||
|
:loading="isLoading"
|
||||||
|
:items="data?.content"
|
||||||
|
:items-length="data?.totalElements || 0"
|
||||||
|
:items-per-page-options="[
|
||||||
|
{ value: 10, title: '10' },
|
||||||
|
{ value: 25, title: '25' },
|
||||||
|
{ value: 50, title: '50' },
|
||||||
|
{ value: 100, title: '100' },
|
||||||
|
]"
|
||||||
|
:headers="headers"
|
||||||
|
fixed-header
|
||||||
|
fixed-footer
|
||||||
|
select-strategy="page"
|
||||||
|
multi-sort
|
||||||
|
mobile-breakpoint="md"
|
||||||
|
@update:options="updateOptions"
|
||||||
|
>
|
||||||
|
<template #top>
|
||||||
|
<v-toolbar
|
||||||
|
v-if="display.smAndUp.value"
|
||||||
|
flat
|
||||||
|
>
|
||||||
|
<v-toolbar-title>
|
||||||
|
<v-icon
|
||||||
|
color="medium-emphasis"
|
||||||
|
icon="i-mdi:content-copy"
|
||||||
|
size="x-small"
|
||||||
|
start
|
||||||
|
/>
|
||||||
|
{{
|
||||||
|
$formatMessage({
|
||||||
|
description: 'Known Duplicate Page Table global header',
|
||||||
|
defaultMessage: 'Known Duplicates',
|
||||||
|
id: 'I9ub/l',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</v-toolbar-title>
|
||||||
|
|
||||||
|
<v-spacer />
|
||||||
|
<v-chip-group
|
||||||
|
v-model="filterSelect"
|
||||||
|
multiple
|
||||||
|
class="ms-2"
|
||||||
|
>
|
||||||
|
<v-chip
|
||||||
|
v-for="f in filterOptions"
|
||||||
|
:key="f.value"
|
||||||
|
:value="f.value"
|
||||||
|
:text="f.title"
|
||||||
|
filter
|
||||||
|
rounded
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</v-chip-group>
|
||||||
|
</v-toolbar>
|
||||||
|
|
||||||
|
<v-select
|
||||||
|
v-else
|
||||||
|
v-model="filterSelect"
|
||||||
|
label="Filter"
|
||||||
|
multiple
|
||||||
|
:items="filterOptions"
|
||||||
|
chips
|
||||||
|
closable-chips
|
||||||
|
variant="underlined"
|
||||||
|
class="px-4"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #no-data>
|
||||||
|
<EmptyStateNetworkError v-if="error" />
|
||||||
|
<template v-else>
|
||||||
|
{{
|
||||||
|
$formatMessage({
|
||||||
|
description: 'Known Duplicate Page Table: shown when table has no data',
|
||||||
|
defaultMessage: 'No data found',
|
||||||
|
id: '+6YDzS',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #[`item.hash`]="{ value }">
|
||||||
|
<div>
|
||||||
|
<v-img
|
||||||
|
width="200"
|
||||||
|
height="200"
|
||||||
|
contain
|
||||||
|
style="cursor: zoom-in"
|
||||||
|
:src="pageHashKnownThumbnailUrl(value)"
|
||||||
|
lazy-src="@/assets/cover.svg"
|
||||||
|
class="my-1"
|
||||||
|
:alt="
|
||||||
|
$formatMessage({
|
||||||
|
description: 'Known Duplicate Page Table: alt description for thumbnail',
|
||||||
|
defaultMessage: 'Duplicate page',
|
||||||
|
id: 'P+WI/K',
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@mouseenter="dialogSimple.activator = $event.currentTarget"
|
||||||
|
@click="showDialogImage(value)"
|
||||||
|
>
|
||||||
|
<template #placeholder>
|
||||||
|
<div class="d-flex align-center justify-center fill-height">
|
||||||
|
<v-progress-circular
|
||||||
|
color="grey"
|
||||||
|
indeterminate
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-img>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #[`item.action`]="{ item, value }">
|
||||||
|
<v-btn-toggle
|
||||||
|
:model-value="value"
|
||||||
|
variant="outlined"
|
||||||
|
divided
|
||||||
|
color="primary"
|
||||||
|
rounded="lg"
|
||||||
|
mandatory
|
||||||
|
@update:model-value="(v) => updateHashAction(item, v)"
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
size="small"
|
||||||
|
icon="i-mdi:robot"
|
||||||
|
:value="PageHashActionEnum.DELETE_AUTO"
|
||||||
|
color="success"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
size="small"
|
||||||
|
icon="i-mdi:hand-back-right"
|
||||||
|
:value="PageHashActionEnum.DELETE_MANUAL"
|
||||||
|
color="warning"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
size="small"
|
||||||
|
icon="i-mdi:cancel"
|
||||||
|
:value="PageHashActionEnum.IGNORE"
|
||||||
|
color=""
|
||||||
|
/>
|
||||||
|
</v-btn-toggle>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #[`item.size`]="{ value }">
|
||||||
|
{{ getFileSize(value) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #[`item.deleteSize`]="{ value }">
|
||||||
|
{{ getFileSize(value) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #[`item.matchCount`]="{ item, value }">
|
||||||
|
<v-btn-group
|
||||||
|
rounded="lg"
|
||||||
|
divided
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
:text="value"
|
||||||
|
append-icon="i-mdi:image-multiple-outline"
|
||||||
|
color=""
|
||||||
|
size="small"
|
||||||
|
:disabled="value == 0"
|
||||||
|
@mouseenter="dialogSimple.activator = $event.currentTarget"
|
||||||
|
@click="showDialogMatches(item.hash)"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
:icon="deletionRequests[item.hash]?.success ? 'i-mdi:timer-sand-empty' : 'i-mdi:delete'"
|
||||||
|
:color="deletionRequests[item.hash]?.success ? 'info' : 'error'"
|
||||||
|
size="small"
|
||||||
|
:disabled="
|
||||||
|
value == 0 ||
|
||||||
|
getPageHashAction(item) !== PageHashActionEnum.DELETE_MANUAL ||
|
||||||
|
deletionRequests[item.hash]?.success
|
||||||
|
"
|
||||||
|
:loading="deletionRequests[item.hash]?.isLoading"
|
||||||
|
@click="deletePageHashMatches(item.hash)"
|
||||||
|
/>
|
||||||
|
</v-btn-group>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #[`item.created`]="{ value }">
|
||||||
|
<div>{{ $formatDate(value, { dateStyle: 'short' }) }}</div>
|
||||||
|
<div>{{ $formatDate(value, { timeStyle: 'short' }) }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #[`item.lastModified`]="{ value }">
|
||||||
|
<div>{{ $formatDate(value, { dateStyle: 'short' }) }}</div>
|
||||||
|
<div>{{ $formatDate(value, { timeStyle: 'short' }) }}</div>
|
||||||
|
</template>
|
||||||
|
</v-data-table-server>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useIntl } from 'vue-intl'
|
||||||
|
import { PageRequest, type SortItem } from '@/types/PageRequest'
|
||||||
|
import { useMutation, useQuery } from '@pinia/colada'
|
||||||
|
import { pageHashesKnownQuery } from '@/colada/page-hashes'
|
||||||
|
import type { components } from '@/generated/openapi/komga'
|
||||||
|
import { getFileSize } from '@/utils/utils'
|
||||||
|
import { pageHashKnownThumbnailUrl } from '@/api/images'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useDialogsStore } from '@/stores/dialogs'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
import { VImg } from 'vuetify/components'
|
||||||
|
import MatchTable from '@/components/pageHash/MatchTable.vue'
|
||||||
|
import { type ErrorCause, komgaClient } from '@/api/komga-client'
|
||||||
|
import {
|
||||||
|
type PageHashAction,
|
||||||
|
PageHashActionEnum,
|
||||||
|
pageHashActionMessages,
|
||||||
|
} from '@/types/PageHashAction'
|
||||||
|
import { useMessagesStore } from '@/stores/messages'
|
||||||
|
import { commonMessages } from '@/utils/i18n/common-messages'
|
||||||
|
|
||||||
|
const intl = useIntl()
|
||||||
|
const display = useDisplay()
|
||||||
|
const messagesStore = useMessagesStore()
|
||||||
|
const { simple: dialogSimple } = storeToRefs(useDialogsStore())
|
||||||
|
|
||||||
|
const sortBy = ref<SortItem[]>([{ key: 'deleteSize', order: 'desc' }])
|
||||||
|
|
||||||
|
//region headers
|
||||||
|
const headers = [
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
description: 'Known Duplicate Page Table header: thumbnail',
|
||||||
|
defaultMessage: 'Thumbnail',
|
||||||
|
id: 'm9h2wk',
|
||||||
|
}),
|
||||||
|
key: 'hash',
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
description: 'Known Duplicate Page Table header: action',
|
||||||
|
defaultMessage: 'Action',
|
||||||
|
id: 'XM8VQH',
|
||||||
|
}),
|
||||||
|
key: 'action',
|
||||||
|
value: (item: components['schemas']['PageHashKnownDto']) => {
|
||||||
|
return getPageHashAction(item)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
description: 'Known Duplicate Page Table header: match count',
|
||||||
|
defaultMessage: 'Matches',
|
||||||
|
id: 'xfThoX',
|
||||||
|
}),
|
||||||
|
key: 'matchCount',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
description: 'Known Duplicate Page Table header: size',
|
||||||
|
defaultMessage: 'Size',
|
||||||
|
id: 'syF0Ap',
|
||||||
|
}),
|
||||||
|
key: 'size',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
description: 'Known Duplicate Page Table header: delete count',
|
||||||
|
defaultMessage: 'Delete count',
|
||||||
|
id: 'QM5+gW',
|
||||||
|
}),
|
||||||
|
key: 'deleteCount',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
description: 'Known Duplicate Page Table header: space saved',
|
||||||
|
defaultMessage: 'Space saved',
|
||||||
|
id: 'WY7aQf',
|
||||||
|
}),
|
||||||
|
key: 'deleteSize',
|
||||||
|
value: (item: components['schemas']['PageHashKnownDto']) => (item.size || 0) * item.deleteCount,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
description: 'Known Duplicate Page Table header: creation date',
|
||||||
|
defaultMessage: 'Created',
|
||||||
|
id: 'EzOmuX',
|
||||||
|
}),
|
||||||
|
key: 'created',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
description: 'Known Duplicate Page Table header: modified date',
|
||||||
|
defaultMessage: 'Modified',
|
||||||
|
id: 'REmxIM',
|
||||||
|
}),
|
||||||
|
key: 'lastModified',
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region Filtering
|
||||||
|
const filterSelect = ref<string[]>([
|
||||||
|
PageHashActionEnum.DELETE_AUTO,
|
||||||
|
PageHashActionEnum.DELETE_MANUAL,
|
||||||
|
])
|
||||||
|
const filterOptions = [
|
||||||
|
{
|
||||||
|
title: intl.formatMessage(pageHashActionMessages[PageHashActionEnum.DELETE_AUTO]),
|
||||||
|
value: PageHashActionEnum.DELETE_AUTO,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage(pageHashActionMessages[PageHashActionEnum.DELETE_MANUAL]),
|
||||||
|
value: PageHashActionEnum.DELETE_MANUAL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage(pageHashActionMessages[PageHashActionEnum.IGNORE]),
|
||||||
|
value: PageHashActionEnum.IGNORE,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
const pageRequest = ref<PageRequest>(new PageRequest())
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery(pageHashesKnownQuery, () => ({
|
||||||
|
...pageRequest.value,
|
||||||
|
actions: filterSelect.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
function updateOptions({
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
sortBy,
|
||||||
|
}: {
|
||||||
|
page: number
|
||||||
|
itemsPerPage: number
|
||||||
|
sortBy: SortItem[]
|
||||||
|
}) {
|
||||||
|
pageRequest.value = PageRequest.FromVuetify(page - 1, itemsPerPage, sortBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDialogImage(hash: string) {
|
||||||
|
dialogSimple.value.dialogProps = {
|
||||||
|
fullscreen: display.xs.value,
|
||||||
|
scrollable: true,
|
||||||
|
}
|
||||||
|
dialogSimple.value.slot = {
|
||||||
|
component: markRaw(VImg),
|
||||||
|
props: {
|
||||||
|
src: pageHashKnownThumbnailUrl(hash),
|
||||||
|
contain: true,
|
||||||
|
style: 'cursor: zoom-out;',
|
||||||
|
},
|
||||||
|
handlers: {
|
||||||
|
click: () => {
|
||||||
|
dialogSimple.value.dialogProps.shown = false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDialogMatches(hash: string) {
|
||||||
|
dialogSimple.value.dialogProps = {
|
||||||
|
fullscreen: display.xs.value,
|
||||||
|
scrollable: true,
|
||||||
|
}
|
||||||
|
dialogSimple.value.slot = {
|
||||||
|
component: markRaw(MatchTable),
|
||||||
|
props: {
|
||||||
|
modelValue: hash,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//region Manual deletion
|
||||||
|
const deletionRequests = ref<Record<string, { success?: boolean; isLoading: boolean }>>({})
|
||||||
|
function deletePageHashMatches(hash: string) {
|
||||||
|
if (hash in deletionRequests.value && deletionRequests.value[hash]!.success) return
|
||||||
|
|
||||||
|
deletionRequests.value[hash] = {
|
||||||
|
isLoading: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
useMutation({
|
||||||
|
mutation: () =>
|
||||||
|
komgaClient.POST('/api/v1/page-hashes/{pageHash}/delete-all', {
|
||||||
|
params: { path: { pageHash: hash } },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.mutateAsync()
|
||||||
|
.then(
|
||||||
|
() =>
|
||||||
|
(deletionRequests.value[hash] = {
|
||||||
|
success: true,
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch((error) => {
|
||||||
|
deletionRequests.value[hash] = {
|
||||||
|
success: false,
|
||||||
|
isLoading: false,
|
||||||
|
}
|
||||||
|
messagesStore.messages.push({
|
||||||
|
text:
|
||||||
|
(error?.cause as ErrorCause)?.message || intl.formatMessage(commonMessages.networkError),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region Update action
|
||||||
|
const updateRequests = ref<Record<string, PageHashAction>>({})
|
||||||
|
|
||||||
|
function getPageHashAction(pageHash: components['schemas']['PageHashKnownDto']): PageHashAction {
|
||||||
|
return updateRequests.value[pageHash.hash] || pageHash.action
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHashAction(
|
||||||
|
pageHash: components['schemas']['PageHashKnownDto'],
|
||||||
|
newAction: PageHashAction,
|
||||||
|
) {
|
||||||
|
useMutation({
|
||||||
|
mutation: () =>
|
||||||
|
komgaClient.PUT('/api/v1/page-hashes', {
|
||||||
|
body: {
|
||||||
|
...pageHash,
|
||||||
|
action: newAction,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.mutateAsync()
|
||||||
|
.then(() => (updateRequests.value[pageHash.hash] = newAction))
|
||||||
|
.catch((error) =>
|
||||||
|
messagesStore.messages.push({
|
||||||
|
text:
|
||||||
|
(error?.cause as ErrorCause)?.message || intl.formatMessage(commonMessages.networkError),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
</script>
|
||||||
|
<script setup lang="ts"></script>
|
||||||
58
next-ui/src/components/pageHash/MatchTable.stories.ts
Normal file
58
next-ui/src/components/pageHash/MatchTable.stories.ts
Normal file
|
|
@ -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: '<MatchTable v-bind="args" />',
|
||||||
|
}),
|
||||||
|
parameters: {
|
||||||
|
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
modelValue: 'hash1',
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof MatchTable>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
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)],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
151
next-ui/src/components/pageHash/MatchTable.vue
Normal file
151
next-ui/src/components/pageHash/MatchTable.vue
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
<template>
|
||||||
|
<v-data-table-server
|
||||||
|
v-model:sort-by="sortBy"
|
||||||
|
:loading="isLoading"
|
||||||
|
:items="data?.content"
|
||||||
|
:items-length="data?.totalElements || 0"
|
||||||
|
:items-per-page-options="[
|
||||||
|
{ value: 10, title: '10' },
|
||||||
|
{ value: 25, title: '25' },
|
||||||
|
{ value: 50, title: '50' },
|
||||||
|
{ value: 100, title: '100' },
|
||||||
|
]"
|
||||||
|
:headers="headers"
|
||||||
|
fixed-header
|
||||||
|
fixed-footer
|
||||||
|
select-strategy="page"
|
||||||
|
multi-sort
|
||||||
|
mobile-breakpoint="md"
|
||||||
|
@update:options="updateOptions"
|
||||||
|
>
|
||||||
|
<template #no-data>
|
||||||
|
<EmptyStateNetworkError v-if="error" />
|
||||||
|
<template v-else>
|
||||||
|
{{
|
||||||
|
$formatMessage({
|
||||||
|
description: 'Duplicate Page Hash Matches Table: shown when table has no data',
|
||||||
|
defaultMessage: 'No data found',
|
||||||
|
id: 'ggaOkE',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #[`item.url`]="{ value }">
|
||||||
|
<div
|
||||||
|
v-if="display.xs.value"
|
||||||
|
v-tooltip="value"
|
||||||
|
class="text-truncate"
|
||||||
|
style="max-width: 200px"
|
||||||
|
>
|
||||||
|
{{ value }}
|
||||||
|
</div>
|
||||||
|
<template v-else>{{ value }}</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #[`item.bookId`]="{ item }">
|
||||||
|
<div>
|
||||||
|
<v-img
|
||||||
|
width="200"
|
||||||
|
height="200"
|
||||||
|
contain
|
||||||
|
style="cursor: zoom-in"
|
||||||
|
:src="bookPageThumbnailUrl(item.bookId, item.pageNumber)"
|
||||||
|
lazy-src="@/assets/cover.svg"
|
||||||
|
class="my-1"
|
||||||
|
:alt="
|
||||||
|
$formatMessage({
|
||||||
|
description: 'Duplicate Page Hash Matches Table: alt description for thumbnail',
|
||||||
|
defaultMessage: 'Duplicate page',
|
||||||
|
id: 'V3wgcu',
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template #placeholder>
|
||||||
|
<div class="d-flex align-center justify-center fill-height">
|
||||||
|
<v-progress-circular
|
||||||
|
color="grey"
|
||||||
|
indeterminate
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-img>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-data-table-server>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useIntl } from 'vue-intl'
|
||||||
|
import { PageRequest, type SortItem } from '@/types/PageRequest'
|
||||||
|
import { useQuery } from '@pinia/colada'
|
||||||
|
import { pageHashMatchesQuery } from '@/colada/page-hashes'
|
||||||
|
import { bookPageThumbnailUrl } from '@/api/images'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
|
||||||
|
const pageHash = defineModel<string>({ required: true })
|
||||||
|
|
||||||
|
const intl = useIntl()
|
||||||
|
const display = useDisplay()
|
||||||
|
|
||||||
|
//region headers
|
||||||
|
const headers = [
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
description: 'Duplicate Page Hash Matches Table header: book file name',
|
||||||
|
defaultMessage: 'Book',
|
||||||
|
id: 'bOUy3X',
|
||||||
|
}),
|
||||||
|
key: 'url',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
description: 'Duplicate Page Hash Matches Table header: File name',
|
||||||
|
defaultMessage: 'File name',
|
||||||
|
id: 'o+h0F+',
|
||||||
|
}),
|
||||||
|
key: 'fileName',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
description: 'Duplicate Page Hash Matches Table header: page number',
|
||||||
|
defaultMessage: 'Page number',
|
||||||
|
id: 'm+yz6Z',
|
||||||
|
}),
|
||||||
|
key: 'pageNumber',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
description: 'Duplicate Page Hash Matches Table header: page',
|
||||||
|
defaultMessage: 'Page',
|
||||||
|
id: '2Sx0J4',
|
||||||
|
}),
|
||||||
|
key: 'bookId',
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region Data loading
|
||||||
|
const sortBy = ref<SortItem[]>([])
|
||||||
|
const pageRequest = ref<PageRequest>(new PageRequest())
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery(pageHashMatchesQuery, () => ({
|
||||||
|
pageHash: pageHash.value,
|
||||||
|
...pageRequest.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
function updateOptions({
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
sortBy,
|
||||||
|
}: {
|
||||||
|
page: number
|
||||||
|
itemsPerPage: number
|
||||||
|
sortBy: SortItem[]
|
||||||
|
}) {
|
||||||
|
pageRequest.value = PageRequest.FromVuetify(page - 1, itemsPerPage, sortBy)
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
</script>
|
||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
@ -13,6 +13,7 @@ import { booksHandlers } from '@/mocks/api/handlers/books'
|
||||||
import { filesystemHandlers } from '@/mocks/api/handlers/filesystem'
|
import { filesystemHandlers } from '@/mocks/api/handlers/filesystem'
|
||||||
import { transientBooksHandlers } from '@/mocks/api/handlers/transient-books'
|
import { transientBooksHandlers } from '@/mocks/api/handlers/transient-books'
|
||||||
import { readListsHandlers } from '@/mocks/api/handlers/readlists'
|
import { readListsHandlers } from '@/mocks/api/handlers/readlists'
|
||||||
|
import { pageHashesHandlers } from '@/mocks/api/handlers/page-hashes'
|
||||||
|
|
||||||
export const handlers = [
|
export const handlers = [
|
||||||
...actuatorHandlers,
|
...actuatorHandlers,
|
||||||
|
|
@ -22,6 +23,7 @@ export const handlers = [
|
||||||
...filesystemHandlers,
|
...filesystemHandlers,
|
||||||
...historyHandlers,
|
...historyHandlers,
|
||||||
...librariesHandlers,
|
...librariesHandlers,
|
||||||
|
...pageHashesHandlers,
|
||||||
...readListsHandlers,
|
...readListsHandlers,
|
||||||
...referentialHandlers,
|
...referentialHandlers,
|
||||||
...releasesHandlers,
|
...releasesHandlers,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { httpTyped } from '@/mocks/api/httpTyped'
|
import { httpTyped } from '@/mocks/api/httpTyped'
|
||||||
import { mockPage } from '@/mocks/api/pageable'
|
import { mockPage } from '@/mocks/api/pageable'
|
||||||
import { PageRequest } from '@/types/PageRequest'
|
import { PageRequest } from '@/types/PageRequest'
|
||||||
import { http, HttpResponse } from 'msw'
|
|
||||||
import mockThumbnailUrl from '@/assets/mock-thumbnail.jpg'
|
|
||||||
|
|
||||||
export const historyBookImported = {
|
export const historyBookImported = {
|
||||||
id: 'H1',
|
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',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
85
next-ui/src/mocks/api/handlers/page-hashes.ts
Normal file
85
next-ui/src/mocks/api/handlers/page-hashes.ts
Normal file
|
|
@ -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',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
@ -4,12 +4,18 @@ export function mockPage<T>(data: T[], pageRequest: PageRequest) {
|
||||||
const page = Number(pageRequest.page) || 0
|
const page = Number(pageRequest.page) || 0
|
||||||
const size = Number(pageRequest.size) || 20
|
const size = Number(pageRequest.size) || 20
|
||||||
const unpaged = pageRequest.unpaged || false
|
const unpaged = pageRequest.unpaged || false
|
||||||
|
const sort = pageRequest.sort
|
||||||
|
|
||||||
const start = page * size
|
const start = page * size
|
||||||
const slice = unpaged ? data : data.slice(start, start + size)
|
const slice = unpaged ? data : data.slice(start, start + size)
|
||||||
|
|
||||||
|
let sortedSlice = slice
|
||||||
|
if (sort) {
|
||||||
|
sortedSlice = slice.sort(orderBy(parseSort(sort)))
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: slice,
|
content: sortedSlice,
|
||||||
pageable: {
|
pageable: {
|
||||||
pageNumber: page,
|
pageNumber: page,
|
||||||
pageSize: size,
|
pageSize: size,
|
||||||
|
|
@ -37,3 +43,36 @@ export function mockPage<T>(data: T[], pageRequest: PageRequest) {
|
||||||
empty: slice.length > 0,
|
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<string, number> = { 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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<h1>Known</h1>
|
<v-container
|
||||||
|
fluid
|
||||||
|
class="pa-0 pa-sm-4 h-100 h-sm-auto"
|
||||||
|
>
|
||||||
|
<PageHashKnownTable />
|
||||||
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import type { DialogConfirmEditProps } from '@/components/dialog/ConfirmEdit.vue'
|
import type { DialogConfirmEditProps } from '@/components/dialog/ConfirmEdit.vue'
|
||||||
import type { DialogConfirmProps } from '@/components/dialog/Confirm.vue'
|
import type { DialogConfirmProps } from '@/components/dialog/Confirm.vue'
|
||||||
|
import type { DialogSimpleProps } from '@/components/dialog/DialogSimple.vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reusable dialogs.
|
* Reusable dialogs.
|
||||||
|
|
@ -14,6 +15,7 @@ export const useDialogsStore = defineStore('dialogs', {
|
||||||
slot: {
|
slot: {
|
||||||
component: undefined,
|
component: undefined,
|
||||||
props: {},
|
props: {},
|
||||||
|
handlers: {},
|
||||||
},
|
},
|
||||||
record: undefined,
|
record: undefined,
|
||||||
callback: () => {},
|
callback: () => {},
|
||||||
|
|
@ -23,9 +25,19 @@ export const useDialogsStore = defineStore('dialogs', {
|
||||||
slotWarning: {
|
slotWarning: {
|
||||||
component: undefined,
|
component: undefined,
|
||||||
props: {},
|
props: {},
|
||||||
|
handlers: {},
|
||||||
},
|
},
|
||||||
callback: () => {},
|
callback: () => {},
|
||||||
} as DialogConfirmActivation,
|
} as DialogConfirmActivation,
|
||||||
|
simple: {
|
||||||
|
dialogProps: {},
|
||||||
|
slot: {
|
||||||
|
component: undefined,
|
||||||
|
props: {},
|
||||||
|
handlers: {},
|
||||||
|
},
|
||||||
|
callback: () => {},
|
||||||
|
} as DialogSimpleActivation,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -44,7 +56,12 @@ type DialogConfirmActivation = DialogActivation<DialogConfirmProps> & {
|
||||||
slotWarning: ComponentWithProps
|
slotWarning: ComponentWithProps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DialogSimpleActivation = DialogActivation<DialogSimpleProps> & {
|
||||||
|
slot: ComponentWithProps
|
||||||
|
}
|
||||||
|
|
||||||
type ComponentWithProps = {
|
type ComponentWithProps = {
|
||||||
component?: Component
|
component?: Component
|
||||||
props: object
|
props?: object
|
||||||
|
handlers?: object
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
next-ui/src/types/PageHashAction.ts
Normal file
27
next-ui/src/types/PageHashAction.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { defineMessages } from 'vue-intl'
|
||||||
|
|
||||||
|
export type PageHashAction = 'DELETE_AUTO' | 'DELETE_MANUAL' | 'IGNORE'
|
||||||
|
|
||||||
|
export enum PageHashActionEnum {
|
||||||
|
DELETE_AUTO = 'DELETE_AUTO',
|
||||||
|
DELETE_MANUAL = 'DELETE_MANUAL',
|
||||||
|
IGNORE = 'IGNORE',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pageHashActionMessages = defineMessages({
|
||||||
|
[PageHashActionEnum.DELETE_AUTO]: {
|
||||||
|
description: 'Page Hash Action: DELETE_AUTO',
|
||||||
|
defaultMessage: 'Auto delete',
|
||||||
|
id: 'enum.pageHashAction.DELETE_AUTO',
|
||||||
|
},
|
||||||
|
[PageHashActionEnum.DELETE_MANUAL]: {
|
||||||
|
description: 'Page Hash Action: DELETE_MANUAL',
|
||||||
|
defaultMessage: 'Manual delete',
|
||||||
|
id: 'enum.pageHashAction.DELETE_MANUAL',
|
||||||
|
},
|
||||||
|
[PageHashActionEnum.IGNORE]: {
|
||||||
|
description: 'Page Hash Action: IGNORE',
|
||||||
|
defaultMessage: 'Ignore',
|
||||||
|
id: 'enum.pageHashAction.IGNORE',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -1 +1,9 @@
|
||||||
|
import { partial } from 'filesize'
|
||||||
|
|
||||||
export const delay = (ms: number) => new Promise((res) => setTimeout(res, ms))
|
export const delay = (ms: number) => new Promise((res) => setTimeout(res, ms))
|
||||||
|
|
||||||
|
const filesizePartial = partial({ round: 1 })
|
||||||
|
export function getFileSize(n?: number): string | undefined {
|
||||||
|
if (!n) return undefined
|
||||||
|
return filesizePartial(n)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue