389 lines
11 KiB
Vue
389 lines
11 KiB
Vue
<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>
|