feat: media duplicate purge, part 3 of 3: the SPA (Settings "Media cleanup" card, PurgeDuplicatesDialog, Media Duplicates toolbar button)

This commit is contained in:
fsylvain committed 2026-10-05 23:57:50 +02:00
1 parent 1a55a39425
commit 2303b12377
26 files changed
+3856 -83

No files matched your search

@@ -0,0 +1,129 @@
import { onBeforeUnmount, ref } from 'vue'
import {
useMediaPurge,
type PurgeSettings,
type PurgeSettingsPatch,
} from '@/composables/useMediaPurge'
import { isForbidden, isHandledGlobally } from '@/lib/mediaPurge'
export type CleanupLoadState = 'loading' | 'ready' | 'error'
export type SaveStatus = 'idle' | 'saving' | 'saved'
export type SaveFailure = 'enabled' | 'days'
const SKELETON_DELAY_MS = 150
const SAVED_VISIBLE_MS = 2000
export function useMediaCleanupSettings(onForbidden: () => void) {
const { getSettings, updateSettings } = useMediaPurge()
const settings = ref<PurgeSettings | null>(null)
const loadState = ref<CleanupLoadState>('loading')
const showSkeleton = ref(false)
const saveStatus = ref<SaveStatus>('idle')
const saveFailure = ref<SaveFailure | null>(null)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let savedTimer: ReturnType<typeof setTimeout> | null = null
let latestLoad = 0
let pendingSaves = 0
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
function clearSavedTimer() {
if (savedTimer !== null) {
clearTimeout(savedTimer)
savedTimer = null
}
}
async function load() {
latestLoad += 1
const ticket = latestLoad
loadState.value = 'loading'
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
try {
const loaded = await getSettings()
if (ticket === latestLoad) {
settings.value = loaded
loadState.value = 'ready'
}
} catch (error) {
if (ticket !== latestLoad) {
return
}
if (isHandledGlobally(error)) {
if (isForbidden(error)) {
onForbidden()
}
return
}
loadState.value = 'error'
} finally {
if (ticket === latestLoad) {
clearSkeletonTimer()
showSkeleton.value = false
}
}
}
async function refresh() {
try {
settings.value = await getSettings()
} catch (error) {
if (isForbidden(error)) {
onForbidden()
}
}
}
function finishSaving(succeeded: boolean) {
if (pendingSaves > 0) {
return
}
if (!succeeded) {
saveStatus.value = 'idle'
return
}
saveStatus.value = 'saved'
savedTimer = setTimeout(() => {
saveStatus.value = 'idle'
}, SAVED_VISIBLE_MS)
}
async function save(patch: PurgeSettingsPatch, field: SaveFailure) {
pendingSaves += 1
clearSavedTimer()
saveStatus.value = 'saving'
let succeeded = false
try {
settings.value = await updateSettings(patch)
saveFailure.value = null
succeeded = true
} catch (error) {
if (isForbidden(error)) {
onForbidden()
} else if (!isHandledGlobally(error)) {
saveFailure.value = field
}
} finally {
pendingSaves -= 1
finishSaving(succeeded)
}
return succeeded
}
onBeforeUnmount(() => {
clearSkeletonTimer()
clearSavedTimer()
})
return { settings, loadState, showSkeleton, saveStatus, saveFailure, load, refresh, save }
}
+78
View File
@@ -0,0 +1,78 @@
import { useApi } from '@/composables/useApi'
import { bumpMediaRevision } from '@/lib/mediaRevision'
export interface PurgeSettings {
auto_purge_enabled: boolean
auto_purge_interval_days: number
last_purge_at: string | null
last_purge_merged: number
last_purge_articles_updated: number
enabled_at: string | null
next_purge_at: string | null
}
export type PurgeSettingsPatch =
| { auto_purge_enabled: boolean }
| { auto_purge_interval_days: number }
export interface PurgePreviewGroup {
keep_id: number
filename: string
url: string
used_in: number
merged: number
}
export interface PurgePreview {
groups: number
copies: number
articles: number
list: PurgePreviewGroup[]
more: number
fingerprint: string
}
export interface PurgeResult {
groups_total: number
groups_purged: number
merged: number
articles_updated: number
failed_groups: number
}
const SETTINGS_PATH = '/v1/media/purge-settings'
const PREVIEW_PATH = '/v1/media/purge-preview'
const PURGE_PATH = '/v1/media/purge'
const JSON_HEADERS = { 'Content-Type': 'application/json' }
export function useMediaPurge() {
const { request } = useApi()
function getSettings() {
return request<PurgeSettings>(SETTINGS_PATH)
}
function updateSettings(patch: PurgeSettingsPatch) {
return request<PurgeSettings>(SETTINGS_PATH, {
method: 'PATCH',
headers: JSON_HEADERS,
body: JSON.stringify(patch),
})
}
function getPreview() {
return request<PurgePreview>(PREVIEW_PATH)
}
async function purge(fingerprint: string) {
const result = await request<PurgeResult>(PURGE_PATH, {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify({ fingerprint }),
})
bumpMediaRevision()
return result
}
return { getSettings, updateSettings, getPreview, purge }
}