komga/next-ui/src/components/pageHash/KnownTable.vue
Gauthier Roebroeck 6157fdde79 known duplicates
2025-11-28 16:05:23 +08:00

442 lines
12 KiB
Vue

<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>