266 lines
6.8 KiB
TypeScript
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,
|
|
}
|
|
}
|