Optimize market images with pict-rs thumbnails
Market components were serving full-resolution images regardless of display size. Now uses pict-rs on-the-fly processing to serve WebP thumbnails at appropriate sizes: - ProductCard: 400px thumbnails (was full-res for 192px cards) - CartItem: 128px thumbnails (was full-res for 64px display) - CartSummary: 64px thumbnails (was full-res for 32px display) - CheckoutPage: 128px thumbnails (was full-res for 64px display) - StallView logo: 128px thumbnails (was full-res for 56px display) Adds useImageOptimizer composable wrapping ImageUploadService. Closes #8 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
667b8eebc2
commit
6e4449ac3d
6 changed files with 59 additions and 6 deletions
43
src/modules/base/composables/useImageOptimizer.ts
Normal file
43
src/modules/base/composables/useImageOptimizer.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import type { ImageUploadService } from '../services/ImageUploadService'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for generating optimized image URLs via pict-rs
|
||||||
|
* Handles both file aliases and full URLs
|
||||||
|
*/
|
||||||
|
export function useImageOptimizer() {
|
||||||
|
const imageService = tryInjectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a thumbnail URL (fast, lower quality - good for cards/lists)
|
||||||
|
*/
|
||||||
|
const thumbnail = (url: string | undefined, size = 256): string => {
|
||||||
|
if (!url) return ''
|
||||||
|
if (!imageService) return url
|
||||||
|
return imageService.getThumbnailUrl(url, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a resized URL (Lanczos2 filter - better quality for larger displays)
|
||||||
|
*/
|
||||||
|
const resized = (url: string | undefined, size = 800): string => {
|
||||||
|
if (!url) return ''
|
||||||
|
if (!imageService) return url
|
||||||
|
return imageService.getResizedUrl(url, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a blurred placeholder URL (for loading states)
|
||||||
|
*/
|
||||||
|
const blurred = (url: string | undefined, blur = 5): string => {
|
||||||
|
if (!url) return ''
|
||||||
|
if (!imageService) return url
|
||||||
|
return imageService.getBlurredUrl(url, blur)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
thumbnail,
|
||||||
|
resized,
|
||||||
|
blurred
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<!-- Product Image -->
|
<!-- Product Image -->
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<img
|
<img
|
||||||
:src="item.product.images?.[0] || '/placeholder-product.png'"
|
:src="thumbnail(item.product.images?.[0], 128) || '/placeholder-product.png'"
|
||||||
:alt="item.product.name"
|
:alt="item.product.name"
|
||||||
class="w-16 h-16 object-cover rounded-md"
|
class="w-16 h-16 object-cover rounded-md"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|
@ -104,7 +104,7 @@
|
||||||
<!-- Product Image -->
|
<!-- Product Image -->
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<img
|
<img
|
||||||
:src="item.product.images?.[0] || '/placeholder-product.png'"
|
:src="thumbnail(item.product.images?.[0], 128) || '/placeholder-product.png'"
|
||||||
:alt="item.product.name"
|
:alt="item.product.name"
|
||||||
class="w-16 h-16 object-cover rounded-md"
|
class="w-16 h-16 object-cover rounded-md"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|
@ -203,12 +203,14 @@ import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Minus, Plus, Trash2 } from 'lucide-vue-next'
|
import { Minus, Plus, Trash2 } from 'lucide-vue-next'
|
||||||
import type { CartItem as CartItemType } from '../types/market'
|
import type { CartItem as CartItemType } from '../types/market'
|
||||||
|
import { useImageOptimizer } from '@/modules/base/composables/useImageOptimizer'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: CartItemType
|
item: CartItemType
|
||||||
stallId: string
|
stallId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { thumbnail } = useImageOptimizer()
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
>
|
>
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
<img
|
<img
|
||||||
:src="item.product.images?.[0] || '/placeholder-product.png'"
|
:src="thumbnail(item.product.images?.[0], 64) || '/placeholder-product.png'"
|
||||||
:alt="item.product.name"
|
:alt="item.product.name"
|
||||||
class="w-8 h-8 object-cover rounded"
|
class="w-8 h-8 object-cover rounded"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|
@ -144,6 +144,7 @@ import { ref, computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Shield } from 'lucide-vue-next'
|
import { Shield } from 'lucide-vue-next'
|
||||||
|
import { useImageOptimizer } from '@/modules/base/composables/useImageOptimizer'
|
||||||
import type { ShippingZone } from '@/modules/market/stores/market'
|
import type { ShippingZone } from '@/modules/market/stores/market'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -188,6 +189,7 @@ interface Props {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { thumbnail } = useImageOptimizer()
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,7 @@ import { Badge } from '@/components/ui/badge'
|
||||||
import ProgressiveImage from '@/components/ui/image/ProgressiveImage.vue'
|
import ProgressiveImage from '@/components/ui/image/ProgressiveImage.vue'
|
||||||
import { ShoppingCart, Package, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
import { ShoppingCart, Package, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||||
import type { Product } from '@/modules/market/stores/market'
|
import type { Product } from '@/modules/market/stores/market'
|
||||||
|
import { useImageOptimizer } from '@/modules/base/composables/useImageOptimizer'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
product: Product
|
product: Product
|
||||||
|
|
@ -171,6 +172,7 @@ const emit = defineEmits<{
|
||||||
'view-stall': [stallId: string]
|
'view-stall': [stallId: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { thumbnail } = useImageOptimizer()
|
||||||
const imageError = ref(false)
|
const imageError = ref(false)
|
||||||
const currentImageIndex = ref(0)
|
const currentImageIndex = ref(0)
|
||||||
|
|
||||||
|
|
@ -188,7 +190,7 @@ const currentImage = computed(() => {
|
||||||
if (productImages.value.length === 0) {
|
if (productImages.value.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return productImages.value[currentImageIndex.value]
|
return thumbnail(productImages.value[currentImageIndex.value], 400)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Image cycling methods
|
// Image cycling methods
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@
|
||||||
<div class="w-16 h-16 bg-muted rounded-lg flex items-center justify-center">
|
<div class="w-16 h-16 bg-muted rounded-lg flex items-center justify-center">
|
||||||
<ProgressiveImage
|
<ProgressiveImage
|
||||||
v-if="item.product.images?.[0]"
|
v-if="item.product.images?.[0]"
|
||||||
:src="item.product.images[0]"
|
:src="thumbnail(item.product.images[0], 128)"
|
||||||
:alt="item.product.name"
|
:alt="item.product.name"
|
||||||
container-class="w-full h-full"
|
container-class="w-full h-full"
|
||||||
image-class="w-full h-full object-cover rounded-lg"
|
image-class="w-full h-full object-cover rounded-lg"
|
||||||
|
|
@ -291,11 +291,13 @@ import { Input } from '@/components/ui/input'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import ProgressiveImage from '@/components/ui/image/ProgressiveImage.vue'
|
import ProgressiveImage from '@/components/ui/image/ProgressiveImage.vue'
|
||||||
|
import { useImageOptimizer } from '@/modules/base/composables/useImageOptimizer'
|
||||||
import {
|
import {
|
||||||
Package,
|
Package,
|
||||||
CheckCircle
|
CheckCircle
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const { thumbnail } = useImageOptimizer()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const marketStore = useMarketStore()
|
const marketStore = useMarketStore()
|
||||||
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any
|
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<div v-if="stall?.logo" class="w-12 h-12 sm:w-14 sm:h-14 rounded-xl bg-gradient-to-br from-primary/20 to-accent/20 border-2 border-primary/20 shadow-lg overflow-hidden ring-2 ring-primary/10">
|
<div v-if="stall?.logo" class="w-12 h-12 sm:w-14 sm:h-14 rounded-xl bg-gradient-to-br from-primary/20 to-accent/20 border-2 border-primary/20 shadow-lg overflow-hidden ring-2 ring-primary/10">
|
||||||
<img
|
<img
|
||||||
:src="stall.logo"
|
:src="thumbnail(stall.logo, 128)"
|
||||||
:alt="stall.name"
|
:alt="stall.name"
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|
@ -157,7 +157,9 @@ import CartButton from '../components/CartButton.vue'
|
||||||
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
||||||
import type { Product, Stall } from '../types/market'
|
import type { Product, Stall } from '../types/market'
|
||||||
import type { FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
import type { FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
||||||
|
import { useImageOptimizer } from '@/modules/base/composables/useImageOptimizer'
|
||||||
|
|
||||||
|
const { thumbnail } = useImageOptimizer()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const marketStore = useMarketStore()
|
const marketStore = useMarketStore()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue