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.
This commit is contained in:
parent
43c368e4e4
commit
fae19436b1
3 changed files with 356 additions and 6 deletions
334
src/components/ui/ProgressiveImage.vue
Normal file
334
src/components/ui/ProgressiveImage.vue
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
<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>
|
||||
|
|
@ -2,11 +2,16 @@
|
|||
<Card class="overflow-hidden hover:shadow-lg transition-shadow duration-200">
|
||||
<!-- Product Image -->
|
||||
<div class="relative">
|
||||
<img
|
||||
<ProgressiveImage
|
||||
:src="product.images?.[0] || '/placeholder-product.png'"
|
||||
:alt="product.name"
|
||||
class="w-full h-48 object-cover"
|
||||
container-class="w-full h-48 bg-gray-100"
|
||||
image-class="w-full h-48 object-cover"
|
||||
:background-color="'#f3f4f6'"
|
||||
:blur-radius="8"
|
||||
:transition-duration="400"
|
||||
loading="lazy"
|
||||
:show-loading-indicator="false"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
|
|
@ -105,6 +110,7 @@ import { ref } from 'vue'
|
|||
import { Card, CardContent, CardFooter, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import ProgressiveImage from '@/components/ui/ProgressiveImage.vue'
|
||||
import { ShoppingCart } from 'lucide-vue-next'
|
||||
import type { Product } from '@/modules/market/stores/market'
|
||||
|
||||
|
|
|
|||
|
|
@ -12,12 +12,17 @@
|
|||
<div class="space-y-4">
|
||||
<!-- Main Image -->
|
||||
<div class="aspect-square rounded-lg overflow-hidden bg-gray-100">
|
||||
<img
|
||||
<ProgressiveImage
|
||||
v-if="currentImage"
|
||||
:src="currentImage"
|
||||
:alt="product.name"
|
||||
class="w-full h-full object-cover"
|
||||
container-class="w-full h-full"
|
||||
image-class="w-full h-full object-cover"
|
||||
:background-color="'#f3f4f6'"
|
||||
:blur-radius="12"
|
||||
:transition-duration="500"
|
||||
loading="lazy"
|
||||
:show-loading-indicator="true"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
|
|
@ -34,10 +39,14 @@
|
|||
class="relative w-20 h-20 rounded-lg overflow-hidden border-2 transition-all"
|
||||
:class="currentImageIndex === index ? 'border-primary' : 'border-gray-200 hover:border-gray-400'"
|
||||
>
|
||||
<img
|
||||
<ProgressiveImage
|
||||
:src="image"
|
||||
:alt="`${product.name} - Image ${index + 1}`"
|
||||
class="w-full h-full object-cover"
|
||||
container-class="w-full h-full"
|
||||
image-class="w-full h-full object-cover"
|
||||
:background-color="'#f3f4f6'"
|
||||
:blur-radius="6"
|
||||
:transition-duration="300"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
|
|
@ -163,6 +172,7 @@ import { Button } from '@/components/ui/button'
|
|||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import ProgressiveImage from '@/components/ui/ProgressiveImage.vue'
|
||||
import { Package, ShoppingCart, Store, Plus, Minus, X } from 'lucide-vue-next'
|
||||
import { useToast } from '@/core/composables/useToast'
|
||||
import type { Product } from '../types/market'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue