Merge branch 'marketplace'
This commit is contained in:
commit
e062dfe2b8
26 changed files with 3651 additions and 253 deletions
351
src/components/ui/ProgressiveImage.vue
Normal file
351
src/components/ui/ProgressiveImage.vue
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
<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">
|
||||
<!-- Branded shimmer effect using semantic colors -->
|
||||
<div v-if="!isLoaded && !hasError" class="absolute inset-0 bg-gradient-to-r from-accent/40 via-primary/40 via-accent/70 to-accent/50 animate-pulse"></div>
|
||||
</div>
|
||||
|
||||
<!-- 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 or no image 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">{{ errorMessage }}</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: 'hsl(var(--muted))',
|
||||
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)
|
||||
|
||||
// Check if this is a placeholder/no-image URL
|
||||
const isNoImage = computed(() => {
|
||||
return props.src.includes('/placeholder-product.png') ||
|
||||
props.src === '' ||
|
||||
!props.src
|
||||
})
|
||||
|
||||
// Dynamic error message based on whether image exists
|
||||
const errorMessage = computed(() => {
|
||||
if (isNoImage.value) {
|
||||
return 'No image available'
|
||||
}
|
||||
return 'Failed to load'
|
||||
})
|
||||
|
||||
// Computed styles
|
||||
const placeholderStyle = computed(() => {
|
||||
const hasThumb = props.thumbnail && props.thumbnail.trim()
|
||||
|
||||
// Create a subtle gradient pattern when no thumbnail (theme-aware)
|
||||
const gradientBg = hasThumb
|
||||
? 'none'
|
||||
: `linear-gradient(135deg, hsl(var(--muted)) 0%, hsl(var(--muted) / 0.5) 50%, hsl(var(--muted)) 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 - follows Tailwind animation patterns */
|
||||
.progressive-image-placeholder.shimmer-active {
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, hsl(var(--muted)) 0%, hsl(var(--muted) / 0.5) 50%, hsl(var(--muted)) 100%) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Accessibility: Respect user's reduced motion preference */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.shimmer-overlay {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.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: linear-gradient(135deg, hsl(var(--muted) / 0.5), 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);
|
||||
}
|
||||
}
|
||||
|
||||
/* DEBUG: Enhanced keyframes for testing */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 100% 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>
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue