feat: media duplicate purge, part 3 of 3: the SPA (Settings "Media cleanup" card, PurgeDuplicatesDialog, Media Duplicates toolbar button)
This commit is contained in:
1 parent
1a55a39425
commit
2303b12377
26 files changed
+3856
-83
No files matched your search
@@ -23,6 +23,7 @@ const props = withDefaults(
|
||||
stickyFooter?: boolean
|
||||
fullBleed?: boolean
|
||||
compactPadding?: boolean
|
||||
bleedOfflineBanner?: boolean
|
||||
renderWhileChecking?: boolean
|
||||
offlineMessage?: string
|
||||
offlineIcon?: string
|
||||
@@ -36,6 +37,7 @@ const props = withDefaults(
|
||||
offlineIcon: undefined,
|
||||
fullBleed: false,
|
||||
compactPadding: false,
|
||||
bleedOfflineBanner: false,
|
||||
renderWhileChecking: false,
|
||||
},
|
||||
)
|
||||
@@ -301,7 +303,8 @@ async function onLogout() {
|
||||
<OfflineBanner
|
||||
:message="offlineMessage"
|
||||
:icon="offlineIcon"
|
||||
:bleed="compactPadding"
|
||||
:bleed="compactPadding || bleedOfflineBanner"
|
||||
:roomy="!compactPadding && bleedOfflineBanner"
|
||||
/>
|
||||
|
||||
<AppBoot v-if="showBoot" />
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useOnline } from '@vueuse/core'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import PurgeDuplicatesDialog from '@/components/PurgeDuplicatesDialog.vue'
|
||||
import { useMediaCleanupSettings } from '@/composables/useMediaCleanupSettings'
|
||||
import type { PurgeResult } from '@/composables/useMediaPurge'
|
||||
import {
|
||||
DEFAULT_INTERVAL_DAYS,
|
||||
REVIEW_DUPLICATES_PATH,
|
||||
lastPurgeLine,
|
||||
nextPurgeLine,
|
||||
parseIntervalDays,
|
||||
purgedToast,
|
||||
} from '@/lib/mediaPurge'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
|
||||
const SAVE_FAILED_COPY = "Couldn't save. Try again in a moment."
|
||||
const INVALID_DAYS_COPY = 'Enter a whole number of days between 1 and 365.'
|
||||
const READ_ONLY_COPY = 'Only owners and admins can change this.'
|
||||
const SKELETON_BAR = 'rounded-none bg-accented'
|
||||
const OUTLINE_BUTTON =
|
||||
'h-11 justify-center gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 max-md:w-full md:h-8'
|
||||
|
||||
const session = useSessionStore()
|
||||
const toast = useLocalizedToast()
|
||||
const router = useRouter()
|
||||
const online = useOnline()
|
||||
|
||||
const isAdmin = computed(
|
||||
() => session.session?.role === 'owner' || session.session?.role === 'admin',
|
||||
)
|
||||
const readOnly = computed(() => !isAdmin.value)
|
||||
const offline = computed(() => !online.value)
|
||||
|
||||
const cleanup = useMediaCleanupSettings(() => {
|
||||
session.refresh()
|
||||
})
|
||||
const { settings, loadState, showSkeleton, saveStatus, saveFailure } = cleanup
|
||||
|
||||
const enabled = ref(false)
|
||||
const daysText = ref(String(DEFAULT_INTERVAL_DAYS))
|
||||
const committedDays = ref(DEFAULT_INTERVAL_DAYS)
|
||||
const daysInvalid = ref(false)
|
||||
const daysFocused = ref(false)
|
||||
const purgeOpen = ref(false)
|
||||
const purgeButton = ref<{ $el: HTMLElement } | null>(null)
|
||||
|
||||
const loading = computed(() => !readOnly.value && loadState.value === 'loading')
|
||||
const skeletonVisible = computed(() => loading.value && showSkeleton.value)
|
||||
const loadFailed = computed(() => !readOnly.value && loadState.value === 'error')
|
||||
const showBody = computed(() => readOnly.value || loadState.value === 'ready')
|
||||
const controlsDisabled = computed(() => readOnly.value || offline.value)
|
||||
const switchChecked = computed(() => !readOnly.value && enabled.value)
|
||||
const showEvery = computed(() => readOnly.value || enabled.value)
|
||||
const lastLine = computed(() =>
|
||||
readOnly.value ? lastPurgeLine(null) : lastPurgeLine(settings.value),
|
||||
)
|
||||
const nextIso = computed(() =>
|
||||
!readOnly.value && enabled.value ? (settings.value?.next_purge_at ?? null) : null,
|
||||
)
|
||||
const daysError = computed(() => (daysInvalid.value ? t(INVALID_DAYS_COPY) : ''))
|
||||
const daysSaveError = computed(() => (saveFailure.value === 'days' ? t(SAVE_FAILED_COPY) : ''))
|
||||
const switchSaveError = computed(() => (saveFailure.value === 'enabled' ? t(SAVE_FAILED_COPY) : ''))
|
||||
const fieldError = computed(() => daysError.value || daysSaveError.value)
|
||||
|
||||
watch(settings, (next) => {
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
enabled.value = next.auto_purge_enabled
|
||||
committedDays.value = next.auto_purge_interval_days
|
||||
if (!daysInvalid.value && !daysFocused.value) {
|
||||
daysText.value = String(next.auto_purge_interval_days)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
isAdmin,
|
||||
(admin) => {
|
||||
if (admin) {
|
||||
cleanup.load()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function toggle(value: boolean) {
|
||||
if (controlsDisabled.value) {
|
||||
return
|
||||
}
|
||||
const previous = enabled.value
|
||||
enabled.value = value
|
||||
const saved = await cleanup.save({ auto_purge_enabled: value }, 'enabled')
|
||||
if (!saved) {
|
||||
enabled.value = previous
|
||||
}
|
||||
}
|
||||
|
||||
async function commitDays() {
|
||||
if (controlsDisabled.value) {
|
||||
return
|
||||
}
|
||||
const days = parseIntervalDays(daysText.value)
|
||||
if (days === null) {
|
||||
daysInvalid.value = true
|
||||
return
|
||||
}
|
||||
daysInvalid.value = false
|
||||
if (days === committedDays.value) {
|
||||
daysText.value = String(days)
|
||||
return
|
||||
}
|
||||
const previous = committedDays.value
|
||||
committedDays.value = days
|
||||
const saved = await cleanup.save({ auto_purge_interval_days: days }, 'days')
|
||||
if (!saved) {
|
||||
committedDays.value = previous
|
||||
daysText.value = String(previous)
|
||||
}
|
||||
}
|
||||
|
||||
function onDaysBlur() {
|
||||
daysFocused.value = false
|
||||
commitDays()
|
||||
}
|
||||
|
||||
function openPurge() {
|
||||
if (!offline.value) {
|
||||
purgeOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function closePurge() {
|
||||
purgeOpen.value = false
|
||||
await nextTick()
|
||||
purgeButton.value?.$el?.focus()
|
||||
}
|
||||
|
||||
function onPurged(result: PurgeResult) {
|
||||
toast.add(purgedToast(result))
|
||||
cleanup.refresh()
|
||||
}
|
||||
|
||||
function reviewDuplicates() {
|
||||
purgeOpen.value = false
|
||||
router.push(REVIEW_DUPLICATES_PATH)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
aria-labelledby="media-cleanup-h"
|
||||
data-testid="media-cleanup-card"
|
||||
:aria-busy="skeletonVisible ? 'true' : undefined"
|
||||
class="flex flex-col gap-4 border border-default bg-default p-4 lg:p-5"
|
||||
>
|
||||
<template v-if="skeletonVisible">
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-4 w-35']" />
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-9/10']" />
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-11/20']" />
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-30']" />
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-2.75 w-45']" />
|
||||
</div>
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-5 w-9']" />
|
||||
</div>
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-3/5']" />
|
||||
<USeparator />
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-25']" />
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-2.75 w-55']" />
|
||||
</div>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-merge"
|
||||
disabled
|
||||
:class="OUTLINE_BUTTON"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
>
|
||||
{{ t('Purge duplicates') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<h2 id="media-cleanup-h" class="text-lg font-semibold text-highlighted">
|
||||
{{ t('Media cleanup') }}
|
||||
</h2>
|
||||
<p class="text-pretty text-muted">
|
||||
{{
|
||||
t(
|
||||
'Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
aria-live="polite"
|
||||
data-testid="cleanup-save-status"
|
||||
class="flex h-6 shrink-0 items-center"
|
||||
>
|
||||
<span
|
||||
v-if="saveStatus === 'saving'"
|
||||
class="inline-flex items-center gap-1.5 text-xs text-muted"
|
||||
>
|
||||
<UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />
|
||||
{{ t('Saving…') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="saveStatus === 'saved'"
|
||||
class="inline-flex items-center gap-1.5 text-xs text-success"
|
||||
>
|
||||
<UIcon name="i-lucide-check" class="size-3.5" />
|
||||
{{ t('Saved') }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="loadFailed"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
:description="t(UNREACHABLE_COPY)"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="cleanup.load"
|
||||
>
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<template v-else-if="showBody">
|
||||
<UAlert
|
||||
v-if="readOnly"
|
||||
role="status"
|
||||
color="info"
|
||||
variant="soft"
|
||||
:close="false"
|
||||
icon="i-lucide-info"
|
||||
:description="t(READ_ONLY_COPY)"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-stretch gap-3 md:flex-row md:items-start md:justify-between md:gap-4"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<span id="auto-purge-label" class="font-medium text-highlighted">{{
|
||||
t('Automatic purge')
|
||||
}}</span>
|
||||
<span class="text-xs/5 text-muted">{{
|
||||
t('Runs in the background. Off by default.')
|
||||
}}</span>
|
||||
<span
|
||||
v-if="switchSaveError"
|
||||
role="alert"
|
||||
data-testid="cleanup-switch-error"
|
||||
class="flex items-start gap-1.5 text-xs/5 text-error"
|
||||
>
|
||||
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
|
||||
{{ switchSaveError }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex h-11 w-11 shrink-0 items-center justify-center md:h-6 md:w-9">
|
||||
<USwitch
|
||||
:model-value="switchChecked"
|
||||
:disabled="controlsDisabled"
|
||||
aria-labelledby="auto-purge-label"
|
||||
:ui="{ base: 'data-[disabled]:opacity-40' }"
|
||||
@update:model-value="toggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showEvery"
|
||||
class="flex flex-col items-stretch gap-3 md:flex-row md:items-start md:justify-between md:gap-4"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<label for="auto-purge-days" class="font-medium text-highlighted">{{
|
||||
t('Every')
|
||||
}}</label>
|
||||
<span class="text-xs/5 text-muted">{{ t('Between 1 and 365 days.') }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5 md:items-end">
|
||||
<UInput
|
||||
id="auto-purge-days"
|
||||
v-model="daysText"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
:disabled="controlsDisabled"
|
||||
:highlight="fieldError !== ''"
|
||||
:color="fieldError ? 'error' : 'neutral'"
|
||||
:aria-invalid="fieldError ? 'true' : undefined"
|
||||
class="w-full md:w-35"
|
||||
:ui="{
|
||||
base: 'h-11 pe-14 font-mono text-lg disabled:bg-default disabled:opacity-40 disabled:ring-(--ui-border-accented) md:h-10 md:text-sm',
|
||||
trailing: 'pe-3',
|
||||
}"
|
||||
@focus="daysFocused = true"
|
||||
@blur="onDaysBlur"
|
||||
@keydown.enter="commitDays"
|
||||
>
|
||||
<template #trailing>
|
||||
<span class="font-mono text-sm text-muted">{{ t('days') }}</span>
|
||||
</template>
|
||||
</UInput>
|
||||
<span
|
||||
v-if="fieldError"
|
||||
role="alert"
|
||||
data-testid="cleanup-days-error"
|
||||
class="flex items-start gap-1.5 text-xs/5 text-error md:max-w-75 md:text-right"
|
||||
>
|
||||
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
|
||||
{{ fieldError }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 text-toned">
|
||||
<span data-testid="cleanup-last">{{ lastLine }}</span>
|
||||
<span v-if="nextIso" data-testid="cleanup-next">{{
|
||||
nextPurgeLine(nextIso)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<template v-if="!readOnly">
|
||||
<USeparator />
|
||||
<div
|
||||
class="flex flex-col items-stretch gap-3 md:flex-row md:items-start md:justify-between md:gap-4"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="font-medium text-highlighted">{{ t('Purge now') }}</span>
|
||||
<span class="text-xs/5 text-muted">{{
|
||||
t(
|
||||
'Merge every group of duplicates that has an image in use, right away.',
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex md:shrink-0">
|
||||
<UButton
|
||||
ref="purgeButton"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-merge"
|
||||
:disabled="offline"
|
||||
:class="OUTLINE_BUTTON"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
@click="openPurge"
|
||||
>
|
||||
{{ t('Purge duplicates') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<PurgeDuplicatesDialog
|
||||
v-if="purgeOpen"
|
||||
@close="closePurge"
|
||||
@purged="onPurged"
|
||||
@partial="cleanup.refresh"
|
||||
@review="reviewDuplicates"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -40,16 +40,22 @@ const VIEW_OPTIONS: { value: MediaView; icon: string; label: string }[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const props = defineProps<{
|
||||
controls: MediaControls
|
||||
counts: MediaCounts | null
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
controls: MediaControls
|
||||
counts: MediaCounts | null
|
||||
showPurge?: boolean
|
||||
purgeDisabled?: boolean
|
||||
}>(),
|
||||
{ showPurge: false, purgeDisabled: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:q': [value: string]
|
||||
'update:filter': [value: MediaFilter]
|
||||
'update:sort': [value: MediaSort]
|
||||
'update:view': [value: MediaView]
|
||||
purge: []
|
||||
}>()
|
||||
|
||||
const searchText = ref(props.controls.q)
|
||||
@@ -191,7 +197,9 @@ const sortItems = computed(() =>
|
||||
</template>
|
||||
</UInput>
|
||||
|
||||
<div class="order-3 flex gap-2 md:order-none md:mb-1.5">
|
||||
<div
|
||||
class="order-3 flex flex-wrap gap-x-2 gap-y-3 md:order-none md:mb-1.5 md:flex-nowrap md:gap-2"
|
||||
>
|
||||
<UDropdownMenu :items="sortItems" :content="{ align: 'start' }">
|
||||
<UButton
|
||||
color="neutral"
|
||||
@@ -205,6 +213,20 @@ const sortItems = computed(() =>
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
|
||||
<UButton
|
||||
v-if="showPurge"
|
||||
data-testid="purge-duplicates"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-merge"
|
||||
:disabled="purgeDisabled"
|
||||
class="order-last h-11 w-full justify-center gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 md:order-none md:h-8 md:w-auto"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
@click="emit('purge')"
|
||||
>
|
||||
{{ t('Purge duplicates') }}
|
||||
</UButton>
|
||||
|
||||
<div role="group" :aria-label="t('View')" class="flex shrink-0 border border-accented">
|
||||
<UButton
|
||||
v-for="option in VIEW_OPTIONS"
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { translate } from '@/i18n'
|
||||
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const DEFAULT_MESSAGE =
|
||||
"You're offline. You can keep writing, but saving and publishing will wait until you're back online."
|
||||
|
||||
const ROOMY_BLEED_ROOT = 'w-auto -mx-4 -mt-5 mb-5 lg:-mx-8 lg:-mt-8 lg:mb-8'
|
||||
|
||||
const BLEED_UI = {
|
||||
root: 'w-auto -mx-4 -mt-4 mb-4 lg:-mx-6 lg:-mt-6 lg:mb-6',
|
||||
container: 'h-auto min-h-10 max-w-none justify-start gap-2.5 px-4 py-2.5 sm:px-4 lg:px-4',
|
||||
@@ -14,15 +16,18 @@ const BLEED_UI = {
|
||||
title: 'whitespace-normal font-normal text-wrap',
|
||||
}
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
message?: string
|
||||
icon?: string
|
||||
bleed?: boolean
|
||||
roomy?: boolean
|
||||
}>(),
|
||||
{ message: DEFAULT_MESSAGE, icon: undefined, bleed: false },
|
||||
{ message: DEFAULT_MESSAGE, icon: undefined, bleed: false, roomy: false },
|
||||
)
|
||||
|
||||
const bleedUi = computed(() => (props.roomy ? { ...BLEED_UI, root: ROOMY_BLEED_ROOT } : BLEED_UI))
|
||||
|
||||
const isOffline = ref(!navigator.onLine)
|
||||
|
||||
function updateOnlineStatus() {
|
||||
@@ -46,6 +51,6 @@ onUnmounted(() => {
|
||||
color="neutral"
|
||||
:icon="icon"
|
||||
:title="translate(message)"
|
||||
:ui="bleed ? BLEED_UI : undefined"
|
||||
:ui="bleed ? bleedUi : undefined"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,375 @@
|
||||
<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>
|
||||
Reference in new issue
Block a user