web-app/src/components/ui/ProgressiveImage.vue
padreug fae19436b1 feat: introduce ProgressiveImage component for enhanced image loading
- Added a new ProgressiveImage component to handle image loading with a blur effect and loading indicators.
- Updated ProductCard and ProductDetailDialog components to utilize ProgressiveImage, improving image loading performance and user experience.
- Configured properties such as blur radius, transition duration, and loading indicators for better customization.

These changes enhance the visual presentation of images and optimize loading behavior across market components.
2025-09-27 19:08:07 +02:00

334 lines
7.3 KiB
Vue

<template>
<div class="progressive-image-container" :class="containerClass" :style="containerStyle">
<!-- Blur placeholder background -->
<div v-if="!isLoaded"
:class="['progressive-image-placeholder', { 'shimmer-active': !isLoaded && !hasError }]"
:style="placeholderStyle" />
<!-- Main image -->
<img ref="imageRef" :src="src" :alt="alt" :class="[
'progressive-image',
imageClass,
{
'progressive-image-loading': !isLoaded,
'progressive-image-loaded': isLoaded,
'progressive-image-error': hasError
}
]" :loading="loading" @load="handleLoad" @error="handleError" />
<!-- Loading indicator (optional) -->
<div v-if="showLoadingIndicator && !isLoaded && !hasError" class="progressive-image-loading-indicator">
<div class="progressive-image-spinner" />
</div>
<!-- Error state (optional) -->
<div v-if="hasError && showErrorState" class="progressive-image-error-state">
<slot name="error">
<div class="progressive-image-error-content">
<Package class="w-6 h-6 text-muted-foreground" />
<span class="text-xs text-muted-foreground">Failed to load</span>
</div>
</slot>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Package } from 'lucide-vue-next'
interface Props {
/**
* The main image source URL
*/
src: string
/**
* Alt text for the image
*/
alt: string
/**
* Optional base64 thumbnail for blur effect
* If not provided, will use CSS blur with background color
*/
thumbnail?: string
/**
* Blur radius for the placeholder (in pixels)
*/
blurRadius?: number
/**
* Background color for the placeholder when no thumbnail is provided
*/
backgroundColor?: string
/**
* Duration of the fade-in transition (in milliseconds)
*/
transitionDuration?: number
/**
* Loading strategy for the image
*/
loading?: 'lazy' | 'eager'
/**
* Additional CSS classes for the container
*/
containerClass?: string | string[]
/**
* Additional CSS classes for the image
*/
imageClass?: string | string[]
/**
* Whether to show a loading spinner
*/
showLoadingIndicator?: boolean
/**
* Whether to show error state when image fails to load
*/
showErrorState?: boolean
/**
* Custom container styles
*/
containerStyle?: Record<string, string>
}
interface Emits {
(e: 'load', event: Event): void
(e: 'error', event: Event): void
(e: 'loading-start'): void
(e: 'loading-complete'): void
}
const props = withDefaults(defineProps<Props>(), {
blurRadius: 10,
backgroundColor: '#f3f4f6',
transitionDuration: 300,
loading: 'lazy',
showLoadingIndicator: false,
showErrorState: true
})
const emit = defineEmits<Emits>()
// Reactive state
const imageRef = ref<HTMLImageElement>()
const isLoaded = ref(false)
const hasError = ref(false)
const isLoading = ref(false)
// Computed styles
const placeholderStyle = computed(() => {
const hasThumb = props.thumbnail && props.thumbnail.trim()
// Create a subtle gradient pattern when no thumbnail
const gradientBg = hasThumb
? 'none'
: `linear-gradient(135deg, #e5e7eb 0%, #f3f4f6 50%, #e5e7eb 100%)`
return {
'--blur-radius': `${props.blurRadius}px`,
'--transition-duration': `${props.transitionDuration}ms`,
'--placeholder-bg': gradientBg,
backgroundImage: hasThumb ? `url(${props.thumbnail})` : gradientBg,
backgroundColor: hasThumb ? 'transparent' : props.backgroundColor,
filter: hasThumb ? `blur(${props.blurRadius}px)` : 'none',
transform: hasThumb ? 'scale(1.1)' : 'scale(1)', // Only scale if using thumbnail
}
})
// Note: generatePlaceholder function can be added here if needed for dynamic thumbnail generation
// Event handlers
const handleLoad = (event: Event) => {
isLoaded.value = true
isLoading.value = false
emit('load', event)
emit('loading-complete')
}
const handleError = (event: Event) => {
hasError.value = true
isLoading.value = false
emit('error', event)
emit('loading-complete')
}
// Initialize loading state
onMounted(() => {
if (imageRef.value && !imageRef.value.complete) {
isLoading.value = true
emit('loading-start')
} else if (imageRef.value?.complete) {
isLoaded.value = true
}
})
// Expose methods for parent components
defineExpose({
reload: () => {
if (imageRef.value) {
isLoaded.value = false
hasError.value = false
isLoading.value = true
imageRef.value.src = props.src
emit('loading-start')
}
},
isLoaded: () => isLoaded.value,
hasError: () => hasError.value,
isLoading: () => isLoading.value
})
</script>
<style scoped>
.progressive-image-container {
position: relative;
overflow: hidden;
}
.progressive-image-placeholder {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
transition: opacity var(--transition-duration, 300ms) ease-out;
}
/* Add shimmer effect when image is loading */
.progressive-image-placeholder.shimmer-active {
background-image:
linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.3) 25%,
rgba(255, 255, 255, 0.7) 50%,
rgba(255, 255, 255, 0.3) 75%,
transparent 100%
),
linear-gradient(135deg, #e5e7eb 0%, #f3f4f6 50%, #e5e7eb 100%);
background-size: 200px 100%, 100% 100%;
background-position: -200px 0, 0 0;
background-repeat: no-repeat, no-repeat;
animation: shimmer 1.2s infinite linear;
}
.progressive-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity var(--transition-duration, 300ms) ease-out;
}
.progressive-image-loading {
opacity: 0;
}
.progressive-image-loaded {
opacity: 1;
}
.progressive-image-error {
opacity: 0.5;
}
/* Hide placeholder when image is loaded */
.progressive-image-loaded + .progressive-image-placeholder {
opacity: 0;
}
.progressive-image-loading-indicator {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, 0.8);
}
.progressive-image-spinner {
width: 1.5rem;
height: 1.5rem;
border: 2px solid;
border-color: hsl(var(--primary));
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.progressive-image-error-state {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: hsl(var(--muted));
}
.progressive-image-error-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
/* Ensure smooth transitions */
.progressive-image-container * {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Keyframe animations */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes shimmer {
0% {
background-position: -200px 0, 0 0;
}
100% {
background-position: calc(200px + 100%) 0, 0 0;
}
}
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(1.05);
}
to {
opacity: 1;
transform: scale(1);
}
}
.progressive-image-loaded {
animation: fadeInScale var(--transition-duration, 300ms) ease-out;
}
</style>