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([]) const unsupported = ref(null) const status = ref('loading') const showSkeleton = ref(false) const busy = ref([]) const failures = ref>({}) const announcement = ref('') let skeletonTimer: ReturnType | null = null let loadTicket = 0 const rows = computed(() => 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, } }