194 lines
4.8 KiB
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>
|