382 lines
10 KiB
Vue
382 lines
10 KiB
Vue
<script setup lang="ts">
|
|
import { t } from '@/i18n'
|
|
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
import MediaThumb from '@/components/MediaThumb.vue'
|
|
import { ApiError } from '@/composables/useApi'
|
|
import { useMediaPurge, type PurgePreview, type PurgeResult } from '@/composables/useMediaPurge'
|
|
import {
|
|
PURGE_BUSY_COPY,
|
|
PURGE_STALE_COPY,
|
|
REVIEW_DUPLICATES_PATH,
|
|
isForbidden,
|
|
isHandledGlobally,
|
|
keptLine,
|
|
mergedCopies,
|
|
moreGroupsLine,
|
|
pluralGroups,
|
|
previewSummary,
|
|
thumbnailSource,
|
|
} from '@/lib/mediaPurge'
|
|
import { useSessionStore } from '@/stores/session'
|
|
|
|
type Status = 'loading' | 'ready' | 'purging' | 'nothing' | 'error' | 'partial' | 'conflict'
|
|
type Notice = 'failed' | 'partial' | 'stale' | 'busy'
|
|
|
|
const SKELETON_DELAY_MS = 150
|
|
const SKELETON_ROWS = 3
|
|
const HTTP_CONFLICT = 409
|
|
const FAILED_COPY = "Couldn't purge the duplicates. Nothing was changed. Try again in a moment."
|
|
const DESCRIPTION =
|
|
"Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can't be undone."
|
|
const NOTHING_COPY = 'No duplicate is used in an article, so there is nothing to merge.'
|
|
const PARTIAL_COPY =
|
|
"Some groups couldn't be merged. {0} of {1} groups were purged. Try again to finish."
|
|
const BUTTON_BASE = 'h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3'
|
|
const MODAL_CONTENT =
|
|
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-md sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0'
|
|
|
|
const emit = defineEmits<{
|
|
close: []
|
|
purged: [result: PurgeResult]
|
|
partial: [result: PurgeResult]
|
|
review: []
|
|
}>()
|
|
|
|
const { getPreview, purge } = useMediaPurge()
|
|
const session = useSessionStore()
|
|
|
|
const open = ref(true)
|
|
const status = ref<Status>('loading')
|
|
const notice = ref<Notice | null>(null)
|
|
const preview = ref<PurgePreview | null>(null)
|
|
const partialCounts = ref({ purged: 0, total: 0 })
|
|
const refreshing = ref(false)
|
|
const showSkeleton = ref(false)
|
|
const cancelButton = ref<{ $el: HTMLElement } | null>(null)
|
|
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
const purging = computed(() => status.value === 'purging')
|
|
const busy = computed(() => purging.value || refreshing.value)
|
|
const nothing = computed(() => status.value === 'nothing')
|
|
const partial = computed(() => status.value === 'partial')
|
|
const waitingForPreview = computed(() => status.value === 'loading')
|
|
const canConfirm = computed(() => preview.value !== null && !busy.value)
|
|
const confirmLabel = computed(() => {
|
|
if (purging.value) {
|
|
return t('Purging…')
|
|
}
|
|
return preview.value
|
|
? t('Purge {0}', [pluralGroups(preview.value.groups)])
|
|
: t('Purge duplicates')
|
|
})
|
|
const noticeText = computed(() => {
|
|
if (notice.value === 'partial') {
|
|
return t(PARTIAL_COPY, [partialCounts.value.purged, partialCounts.value.total])
|
|
}
|
|
return {
|
|
failed: t(FAILED_COPY),
|
|
stale: t(PURGE_STALE_COPY),
|
|
busy: t(PURGE_BUSY_COPY),
|
|
none: '',
|
|
}[notice.value ?? 'none']
|
|
})
|
|
const noticeIsError = computed(() => notice.value === 'failed')
|
|
const ui = computed(() => ({
|
|
content: MODAL_CONTENT,
|
|
header: nothing.value
|
|
? 'min-h-0 justify-center px-6 pt-7 pb-0 text-center sm:px-6'
|
|
: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
|
|
title: 'text-lg/6 font-semibold',
|
|
description: nothing.value
|
|
? 'mx-auto mt-3 max-w-85 text-sm text-muted text-pretty'
|
|
: 'mt-1 text-sm text-muted text-pretty',
|
|
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
|
footer: 'px-6 py-5 sm:px-6',
|
|
}))
|
|
|
|
function clearSkeletonTimer() {
|
|
if (skeletonTimer !== null) {
|
|
clearTimeout(skeletonTimer)
|
|
skeletonTimer = null
|
|
}
|
|
}
|
|
|
|
function giveUpAfterAuthFailure(error: unknown) {
|
|
if (isForbidden(error)) {
|
|
session.refresh()
|
|
close()
|
|
}
|
|
}
|
|
|
|
function applyPreview(next: PurgePreview) {
|
|
preview.value = next
|
|
if (next.groups === 0) {
|
|
status.value = 'nothing'
|
|
notice.value = null
|
|
}
|
|
}
|
|
|
|
async function loadPreview() {
|
|
skeletonTimer = setTimeout(() => {
|
|
showSkeleton.value = true
|
|
}, SKELETON_DELAY_MS)
|
|
try {
|
|
const first = await getPreview()
|
|
applyPreview(first)
|
|
if (first.groups > 0) {
|
|
status.value = 'ready'
|
|
}
|
|
} catch (error) {
|
|
if (isHandledGlobally(error)) {
|
|
giveUpAfterAuthFailure(error)
|
|
return
|
|
}
|
|
status.value = 'error'
|
|
notice.value = 'failed'
|
|
} finally {
|
|
clearSkeletonTimer()
|
|
showSkeleton.value = false
|
|
}
|
|
}
|
|
|
|
async function refreshPreview() {
|
|
refreshing.value = true
|
|
try {
|
|
applyPreview(await getPreview())
|
|
} catch (error) {
|
|
if (isHandledGlobally(error)) {
|
|
giveUpAfterAuthFailure(error)
|
|
}
|
|
} finally {
|
|
refreshing.value = false
|
|
}
|
|
}
|
|
|
|
function close() {
|
|
open.value = false
|
|
emit('close')
|
|
}
|
|
|
|
async function settle(result: PurgeResult) {
|
|
if (result.failed_groups === 0) {
|
|
emit('purged', result)
|
|
close()
|
|
return
|
|
}
|
|
partialCounts.value = {
|
|
purged: result.groups_purged,
|
|
total: result.groups_total,
|
|
}
|
|
notice.value = 'partial'
|
|
status.value = 'partial'
|
|
emit('partial', result)
|
|
await refreshPreview()
|
|
}
|
|
|
|
async function fail(error: unknown) {
|
|
if (isHandledGlobally(error)) {
|
|
giveUpAfterAuthFailure(error)
|
|
return
|
|
}
|
|
if (error instanceof ApiError && error.status === HTTP_CONFLICT) {
|
|
notice.value = error.body === PURGE_BUSY_COPY ? 'busy' : 'stale'
|
|
status.value = 'conflict'
|
|
await refreshPreview()
|
|
return
|
|
}
|
|
notice.value = 'failed'
|
|
status.value = 'error'
|
|
}
|
|
|
|
async function confirm() {
|
|
if (!preview.value || busy.value) {
|
|
return
|
|
}
|
|
const { fingerprint } = preview.value
|
|
status.value = 'purging'
|
|
try {
|
|
await settle(await purge(fingerprint))
|
|
} catch (error) {
|
|
await fail(error)
|
|
}
|
|
}
|
|
|
|
function onOpenChange(value: boolean) {
|
|
if (!value && busy.value) {
|
|
return
|
|
}
|
|
if (!value) {
|
|
close()
|
|
}
|
|
}
|
|
|
|
function onCancel() {
|
|
if (!busy.value) {
|
|
close()
|
|
}
|
|
}
|
|
|
|
function onReview(event: MouseEvent) {
|
|
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
|
return
|
|
}
|
|
event.preventDefault()
|
|
emit('review')
|
|
}
|
|
|
|
onMounted(async () => {
|
|
loadPreview()
|
|
await nextTick()
|
|
cancelButton.value?.$el?.focus()
|
|
})
|
|
|
|
onBeforeUnmount(clearSkeletonTimer)
|
|
</script>
|
|
|
|
<template>
|
|
<UModal
|
|
:open="open"
|
|
:title="nothing ? t('Nothing to purge') : t('Purge duplicates?')"
|
|
:description="nothing ? t(NOTHING_COPY) : t(DESCRIPTION)"
|
|
:dismissible="!busy"
|
|
:close="false"
|
|
:content="{
|
|
role: 'alertdialog',
|
|
'aria-busy': busy ? 'true' : undefined,
|
|
}"
|
|
:ui="ui"
|
|
@update:open="onOpenChange"
|
|
>
|
|
<template v-if="nothing" #title>
|
|
<span class="flex flex-col items-center gap-3">
|
|
<span
|
|
class="flex size-11 items-center justify-center bg-(--rc-info-soft) text-info"
|
|
>
|
|
<UIcon name="i-lucide-copy" class="size-5" />
|
|
</span>
|
|
{{ t('Nothing to purge') }}
|
|
</span>
|
|
</template>
|
|
<template v-if="!nothing" #body>
|
|
<div class="flex flex-col gap-4">
|
|
<UAlert
|
|
v-if="notice && noticeText"
|
|
:color="noticeIsError ? 'error' : 'warning'"
|
|
variant="soft"
|
|
:icon="noticeIsError ? 'i-lucide-circle-x' : 'i-lucide-triangle-alert'"
|
|
:description="noticeText"
|
|
:close="false"
|
|
role="alert"
|
|
/>
|
|
|
|
<template v-if="waitingForPreview && showSkeleton">
|
|
<USkeleton
|
|
data-testid="purge-skeleton"
|
|
class="h-4 w-4/5 rounded-none bg-accented"
|
|
/>
|
|
<div class="border border-default">
|
|
<div
|
|
v-for="row in SKELETON_ROWS"
|
|
:key="row"
|
|
class="flex items-center gap-3 border-muted px-3 py-2 not-first:border-t"
|
|
>
|
|
<USkeleton class="h-7.5 w-10 rounded-none bg-accented" />
|
|
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
|
|
<USkeleton class="h-3.25 w-9/20 rounded-none bg-accented" />
|
|
<USkeleton class="h-2.75 w-3/5 rounded-none bg-accented" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-if="preview">
|
|
<span data-testid="purge-summary" class="font-medium text-highlighted">{{
|
|
previewSummary(preview)
|
|
}}</span>
|
|
<ul data-testid="purge-groups" class="border border-default">
|
|
<li
|
|
v-for="group in preview.list"
|
|
:key="group.keep_id"
|
|
data-testid="purge-group"
|
|
class="flex items-center gap-3 border-muted px-3 py-2 not-first:border-t"
|
|
>
|
|
<MediaThumb
|
|
:item="thumbnailSource(group.url, group.filename)"
|
|
size="sm"
|
|
class="h-7.5 w-10 border border-default"
|
|
/>
|
|
<div class="flex min-w-0 flex-1 flex-col">
|
|
<span class="truncate font-medium text-highlighted">{{
|
|
group.filename
|
|
}}</span>
|
|
<span class="text-xs/5 text-muted">{{
|
|
keptLine(group.used_in)
|
|
}}</span>
|
|
</div>
|
|
<span class="text-xs whitespace-nowrap text-toned">{{
|
|
mergedCopies(group.merged)
|
|
}}</span>
|
|
</li>
|
|
<li
|
|
v-if="preview.more > 0"
|
|
data-testid="purge-more"
|
|
class="border-t border-muted px-3 py-2 text-muted"
|
|
>
|
|
{{ moreGroupsLine(preview.more) }}
|
|
</li>
|
|
</ul>
|
|
<a
|
|
:href="REVIEW_DUPLICATES_PATH"
|
|
class="inline-flex min-h-11 items-center gap-1 self-start font-medium text-highlighted underline underline-offset-3 md:min-h-5"
|
|
@click="onReview"
|
|
>
|
|
{{ t('Review in Duplicates') }}
|
|
<UIcon name="i-lucide-arrow-right" class="size-3.5" />
|
|
</a>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
<template #footer>
|
|
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
|
<UButton
|
|
ref="cancelButton"
|
|
color="neutral"
|
|
variant="outline"
|
|
block
|
|
:class="[
|
|
BUTTON_BASE,
|
|
'text-highlighted disabled:text-dimmed disabled:opacity-100',
|
|
]"
|
|
:disabled="busy"
|
|
@click="onCancel"
|
|
>
|
|
{{ nothing || partial ? t('Close') : t('Cancel') }}
|
|
</UButton>
|
|
<UButton
|
|
v-if="partial"
|
|
color="error"
|
|
icon="i-lucide-rotate-cw"
|
|
block
|
|
:class="BUTTON_BASE"
|
|
:disabled="!canConfirm"
|
|
@click="confirm"
|
|
>
|
|
{{ t('Retry') }}
|
|
</UButton>
|
|
<UButton
|
|
v-else-if="!nothing"
|
|
color="error"
|
|
:loading="purging"
|
|
block
|
|
:class="BUTTON_BASE"
|
|
:disabled="!canConfirm"
|
|
@click="confirm"
|
|
>
|
|
{{ confirmLabel }}
|
|
</UButton>
|
|
</div>
|
|
</template>
|
|
</UModal>
|
|
</template>
|