port-nimara-client-portal/components/LazyReceiptImage.vue

194 lines
4.8 KiB
Vue

<template>
<div class="lazy-receipt-image" ref="containerRef">
<!-- Loading state -->
<div v-if="!loaded && !error" class="loading-state">
<span class="loading loading-spinner loading-sm"></span>
<span class="text-xs text-gray-500">Loading...</span>
</div>
<!-- Error state -->
<div v-else-if="error" class="error-state" @click="retry">
<Icon name="mdi:image-broken" class="w-8 h-8 text-red-400" />
<span class="text-xs text-red-500">Failed to load</span>
<span class="text-xs text-gray-500">Click to retry</span>
</div>
<!-- Loaded image -->
<img
v-else-if="loaded && imageUrl"
:src="imageUrl"
:alt="alt"
:class="['receipt-img', imageClass]"
@error="handleImageError"
@load="handleImageLoad"
/>
<!-- Fallback if no URL -->
<div v-else class="no-image-state">
<Icon name="mdi:receipt-outline" class="w-8 h-8 text-gray-400" />
<span class="text-xs text-gray-500">No image</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import type { ExpenseReceipt } from '@/utils/types';
// Props
interface Props {
receipt: ExpenseReceipt;
alt?: string;
useCardCover?: boolean;
useSmall?: boolean;
useTiny?: boolean;
imageClass?: string;
}
const props = withDefaults(defineProps<Props>(), {
alt: 'Receipt image',
useCardCover: true,
useSmall: false,
useTiny: false,
imageClass: ''
});
// Reactive state
const containerRef = ref<HTMLElement>();
const loaded = ref(false);
const error = ref(false);
const isVisible = ref(false);
// Computed
const imageUrl = computed(() => {
if (!props.receipt) return null;
// Choose the appropriate image size based on props
if (props.useTiny && props.receipt.thumbnails?.tiny?.signedUrl) {
return props.receipt.thumbnails.tiny.signedUrl;
}
if (props.useSmall && props.receipt.thumbnails?.small?.signedUrl) {
return props.receipt.thumbnails.small.signedUrl;
}
if (props.useCardCover && props.receipt.thumbnails?.card_cover?.signedUrl) {
return props.receipt.thumbnails.card_cover.signedUrl;
}
// Fallback to full-size image
return props.receipt.signedUrl || props.receipt.url;
});
// Intersection Observer for lazy loading
let observer: IntersectionObserver | null = null;
const initIntersectionObserver = () => {
if (!containerRef.value || typeof IntersectionObserver === 'undefined') {
// Fallback for environments without IntersectionObserver
isVisible.value = true;
loadImage();
return;
}
observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry.isIntersecting && !isVisible.value) {
isVisible.value = true;
loadImage();
// Stop observing once visible
if (observer && containerRef.value) {
observer.unobserve(containerRef.value);
}
}
},
{
rootMargin: '50px', // Start loading 50px before the image comes into view
threshold: 0.1
}
);
observer.observe(containerRef.value);
};
const loadImage = () => {
if (!imageUrl.value || loaded.value) return;
console.log('[LazyReceiptImage] Loading image:', imageUrl.value);
// Pre-load the image to handle errors properly
const img = new Image();
img.onload = () => {
console.log('[LazyReceiptImage] Image loaded successfully');
loaded.value = true;
error.value = false;
};
img.onerror = () => {
console.error('[LazyReceiptImage] Failed to load image:', imageUrl.value);
error.value = true;
loaded.value = false;
};
img.src = imageUrl.value;
};
const handleImageLoad = () => {
console.log('[LazyReceiptImage] Image displayed successfully');
};
const handleImageError = () => {
console.error('[LazyReceiptImage] Image display error:', imageUrl.value);
error.value = true;
loaded.value = false;
};
const retry = () => {
console.log('[LazyReceiptImage] Retrying image load');
error.value = false;
loaded.value = false;
loadImage();
};
// Lifecycle
onMounted(() => {
initIntersectionObserver();
});
onUnmounted(() => {
if (observer && containerRef.value) {
observer.unobserve(containerRef.value);
observer = null;
}
});
</script>
<style scoped>
.lazy-receipt-image {
@apply relative w-full h-full flex items-center justify-center bg-gray-50 dark:bg-gray-700;
}
.loading-state {
@apply flex flex-col items-center justify-center gap-2 text-gray-500;
}
.error-state {
@apply flex flex-col items-center justify-center gap-1 text-red-500 cursor-pointer hover:bg-red-50 dark:hover:bg-red-950 transition-colors rounded p-2;
}
.no-image-state {
@apply flex flex-col items-center justify-center gap-1 text-gray-400;
}
.receipt-img {
@apply w-full h-full object-cover transition-opacity duration-300;
}
.receipt-img:hover {
@apply opacity-90;
}
</style>