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",
|
||||
"@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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
<SnackQueue />
|
||||
<DialogConfirmEditInstance />
|
||||
<DialogConfirmInstance />
|
||||
<DialogSimpleInstance />
|
||||
</v-app>
|
||||
|
||||
<PiniaColadaDevtools />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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']
|
||||
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']
|
||||
|
|
|
|||
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 { 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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
})
|
||||
}),
|
||||
]
|
||||
|
|
|
|||
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 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<T>(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<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>
|
||||
<h1>Known</h1>
|
||||
<v-container
|
||||
fluid
|
||||
class="pa-0 pa-sm-4 h-100 h-sm-auto"
|
||||
>
|
||||
<PageHashKnownTable />
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import type { DialogConfirmEditProps } from '@/components/dialog/ConfirmEdit.vue'
|
||||
import type { DialogConfirmProps } from '@/components/dialog/Confirm.vue'
|
||||
import type { DialogSimpleProps } from '@/components/dialog/DialogSimple.vue'
|
||||
|
||||
/**
|
||||
* Reusable dialogs.
|
||||
|
|
@ -14,6 +15,7 @@ export const useDialogsStore = defineStore('dialogs', {
|
|||
slot: {
|
||||
component: undefined,
|
||||
props: {},
|
||||
handlers: {},
|
||||
},
|
||||
record: undefined,
|
||||
callback: () => {},
|
||||
|
|
@ -23,9 +25,19 @@ export const useDialogsStore = defineStore('dialogs', {
|
|||
slotWarning: {
|
||||
component: undefined,
|
||||
props: {},
|
||||
handlers: {},
|
||||
},
|
||||
callback: () => {},
|
||||
} as DialogConfirmActivation,
|
||||
simple: {
|
||||
dialogProps: {},
|
||||
slot: {
|
||||
component: undefined,
|
||||
props: {},
|
||||
handlers: {},
|
||||
},
|
||||
callback: () => {},
|
||||
} as DialogSimpleActivation,
|
||||
}),
|
||||
})
|
||||
|
||||
|
|
@ -44,7 +56,12 @@ type DialogConfirmActivation = DialogActivation<DialogConfirmProps> & {
|
|||
slotWarning: ComponentWithProps
|
||||
}
|
||||
|
||||
type DialogSimpleActivation = DialogActivation<DialogSimpleProps> & {
|
||||
slot: ComponentWithProps
|
||||
}
|
||||
|
||||
type ComponentWithProps = {
|
||||
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))
|
||||
|
||||
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