Files
RenewCMS/web/src/composables/useImageVariantRows.ts
T

266 lines
6.8 KiB
TypeScript

import { computed, onBeforeUnmount, ref } from 'vue'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError } from '@/composables/useApi'
import { MEDIA_SKELETON_DELAY_MS, classifyMediaError } from '@/composables/useMedia'
import {
useMediaVariants,
type MediaVariantItem,
type VariantCause,
} from '@/composables/useMediaVariants'
import { formatSize } from '@/lib/mediaFormat'
import { causeOfStatus, causeText } from '@/lib/variants/variantCauses'
import { toastCopyFor } from '@/lib/variants/variantErrors'
export type RowStatus = 'generated' | 'missing' | 'busy' | 'failed'
export type TabStatus = 'loading' | 'ready' | 'error'
export interface VariantRow {
item: MediaVariantItem
status: RowStatus
cause: VariantCause | null
}
const TOAST_DURATION_MS = 5000
const NOT_FOUND = 404
export function useImageVariantRows(imageId: number, onGone: () => void) {
const api = useMediaVariants()
const toast = useLocalizedToast()
const items = ref<MediaVariantItem[]>([])
const unsupported = ref<string | null>(null)
const status = ref<TabStatus>('loading')
const showSkeleton = ref(false)
const busy = ref<string[]>([])
const failures = ref<Record<string, VariantCause>>({})
const announcement = ref('')
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let loadTicket = 0
const rows = computed<VariantRow[]>(() =>
items.value.map((item) => {
if (busy.value.includes(item.name)) {
return { item, status: 'busy', cause: null }
}
const cause = failures.value[item.name]
if (cause && !item.generated) {
return { item, status: 'failed', cause }
}
return { item, status: item.generated ? 'generated' : 'missing', cause: null }
}),
)
const generatedCount = computed(() => items.value.filter((item) => item.generated).length)
const anyBusy = computed(() => busy.value.length > 0)
const allGenerated = computed(
() => items.value.length > 0 && generatedCount.value === items.value.length,
)
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
async function load() {
loadTicket += 1
const ticket = loadTicket
status.value = 'loading'
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, MEDIA_SKELETON_DELAY_MS)
try {
const response = await api.list(imageId)
if (ticket !== loadTicket) {
return
}
items.value = response.items
unsupported.value = response.unsupported
status.value = 'ready'
} catch (error) {
if (ticket !== loadTicket) {
return
}
if (classifyMediaError(error) === 'not-found') {
onGone()
return
}
status.value = 'error'
} finally {
if (ticket === loadTicket) {
clearSkeletonTimer()
showSkeleton.value = false
}
}
}
function replaceItem(updated: MediaVariantItem) {
items.value = items.value.map((item) => (item.name === updated.name ? updated : item))
}
function clearFailure(name: string) {
const next = { ...failures.value }
delete next[name]
failures.value = next
}
function markBusy(names: string[]) {
busy.value = [...busy.value, ...names]
names.forEach(clearFailure)
}
function releaseBusy(names: string[]) {
busy.value = busy.value.filter((name) => !names.includes(name))
}
function successToast(id: string, title: () => string) {
toast.add(() => ({
id,
color: 'success' as const,
icon: 'i-lucide-circle-check',
get title() {
return title()
},
duration: TOAST_DURATION_MS,
}))
}
function failureToast(error: unknown) {
toast.add(() => ({
color: 'error' as const,
icon: 'i-lucide-circle-x',
get title() {
return toastCopyFor(error).title
},
get description() {
return toastCopyFor(error).description
},
}))
}
function causeToast(title: () => string, cause: VariantCause) {
toast.add(() => ({
color: 'error' as const,
icon: 'i-lucide-circle-x',
get title() {
return title()
},
get description() {
return `${causeText(cause)}.`
},
}))
}
function announce(text: string) {
announcement.value = text
}
async function generateOne(name: string) {
markBusy([name])
announce(t('Generating variants'))
try {
replaceItem(await api.generate(imageId, name))
successToast(`media-variant-generated-${imageId}-${name}`, () =>
t('Generated {0}', [name]),
)
announce(t('Generated {0}', [name]))
} catch (error) {
announce(t("Couldn't generate {0}.", [name]))
const cause = error instanceof ApiError ? causeOfStatus(error.status) : null
if (cause) {
failures.value = { ...failures.value, [name]: cause }
causeToast(() => t("Couldn't generate {0}.", [name]), cause)
return
}
failureToast(error)
if (error instanceof ApiError && error.status === NOT_FOUND) {
releaseBusy([name])
await load()
}
} finally {
releaseBusy([name])
}
}
function generatedTitle(count: number) {
return count === 1 ? t('Generated 1 variant') : t('Generated {0} variants', [count])
}
async function generateAll() {
const names = items.value.filter((item) => !item.generated).map((item) => item.name)
if (names.length === 0) {
return
}
markBusy(names)
announce(t('Generating variants'))
try {
const result = await api.generateAll(imageId)
items.value = result.items
const next = { ...failures.value }
result.failures.forEach((failure) => {
next[failure.name] = failure.cause
})
failures.value = next
if (result.generated > 0) {
successToast(`media-variants-generated-${imageId}`, () =>
generatedTitle(result.generated),
)
announce(generatedTitle(result.generated))
} else if (result.failures.length > 0) {
causeToast(() => t("Couldn't generate the variants."), result.failures[0]!.cause)
announce(t("Couldn't generate the variants."))
}
} catch (error) {
failureToast(error)
announce(t("Couldn't generate the variants."))
if (error instanceof ApiError && error.status === NOT_FOUND) {
releaseBusy(names)
await load()
}
} finally {
releaseBusy(names)
}
}
function purgedTitle(files: number, bytes: number) {
return files === 1
? t('Purged 1 file ({0})', [formatSize(bytes)])
: t('Purged {0} files ({1})', [files, formatSize(bytes)])
}
async function purge(name: string) {
try {
const result = await api.purge(imageId, name)
items.value = items.value.map((item) =>
item.name === name ? { ...item, generated: false, size: null } : item,
)
successToast(`media-variants-purged-${imageId}-${name}`, () =>
purgedTitle(result.purged_files, result.purged_bytes),
)
} catch (error) {
failureToast(error)
}
}
onBeforeUnmount(clearSkeletonTimer)
return {
items,
unsupported,
status,
showSkeleton,
rows,
generatedCount,
anyBusy,
allGenerated,
announcement,
load,
generateOne,
generateAll,
purge,
}
}