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:
Patrick Mulligan 2026-03-27 22:59:59 -04:00
parent 667b8eebc2
commit 6e4449ac3d
6 changed files with 59 additions and 6 deletions

View 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
}
}

View file

@ -5,7 +5,7 @@
<!-- Product Image -->
<div class="flex-shrink-0">
<img
:src="item.product.images?.[0] || '/placeholder-product.png'"
:src="thumbnail(item.product.images?.[0], 128) || '/placeholder-product.png'"
:alt="item.product.name"
class="w-16 h-16 object-cover rounded-md"
loading="lazy"
@ -104,7 +104,7 @@
<!-- Product Image -->
<div class="flex-shrink-0">
<img
:src="item.product.images?.[0] || '/placeholder-product.png'"
:src="thumbnail(item.product.images?.[0], 128) || '/placeholder-product.png'"
:alt="item.product.name"
class="w-16 h-16 object-cover rounded-md"
loading="lazy"
@ -203,12 +203,14 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Minus, Plus, Trash2 } from 'lucide-vue-next'
import type { CartItem as CartItemType } from '../types/market'
import { useImageOptimizer } from '@/modules/base/composables/useImageOptimizer'
interface Props {
item: CartItemType
stallId: string
}
const { thumbnail } = useImageOptimizer()
const props = defineProps<Props>()
const emit = defineEmits<{

View file

@ -17,7 +17,7 @@
>
<div class="flex items-center space-x-3">
<img
:src="item.product.images?.[0] || '/placeholder-product.png'"
:src="thumbnail(item.product.images?.[0], 64) || '/placeholder-product.png'"
:alt="item.product.name"
class="w-8 h-8 object-cover rounded"
loading="lazy"
@ -144,6 +144,7 @@ import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { Button } from '@/components/ui/button'
import { Shield } from 'lucide-vue-next'
import { useImageOptimizer } from '@/modules/base/composables/useImageOptimizer'
import type { ShippingZone } from '@/modules/market/stores/market'
interface Props {
@ -188,6 +189,7 @@ interface Props {
}
}
const { thumbnail } = useImageOptimizer()
const props = defineProps<Props>()
const emit = defineEmits<{

View file

@ -158,6 +158,7 @@ import { Badge } from '@/components/ui/badge'
import ProgressiveImage from '@/components/ui/image/ProgressiveImage.vue'
import { ShoppingCart, Package, ChevronLeft, ChevronRight } from 'lucide-vue-next'
import type { Product } from '@/modules/market/stores/market'
import { useImageOptimizer } from '@/modules/base/composables/useImageOptimizer'
interface Props {
product: Product
@ -171,6 +172,7 @@ const emit = defineEmits<{
'view-stall': [stallId: string]
}>()
const { thumbnail } = useImageOptimizer()
const imageError = ref(false)
const currentImageIndex = ref(0)
@ -188,7 +190,7 @@ const currentImage = computed(() => {
if (productImages.value.length === 0) {
return null
}
return productImages.value[currentImageIndex.value]
return thumbnail(productImages.value[currentImageIndex.value], 400)
})
// Image cycling methods

View file

@ -56,7 +56,7 @@
<div class="w-16 h-16 bg-muted rounded-lg flex items-center justify-center">
<ProgressiveImage
v-if="item.product.images?.[0]"
:src="item.product.images[0]"
:src="thumbnail(item.product.images[0], 128)"
:alt="item.product.name"
container-class="w-full h-full"
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 { Label } from '@/components/ui/label'
import ProgressiveImage from '@/components/ui/image/ProgressiveImage.vue'
import { useImageOptimizer } from '@/modules/base/composables/useImageOptimizer'
import {
Package,
CheckCircle
} from 'lucide-vue-next'
const { thumbnail } = useImageOptimizer()
const route = useRoute()
const marketStore = useMarketStore()
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any

View file

@ -21,7 +21,7 @@
<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">
<img
:src="stall.logo"
:src="thumbnail(stall.logo, 128)"
:alt="stall.name"
class="w-full h-full object-cover"
loading="lazy"
@ -157,7 +157,9 @@ import CartButton from '../components/CartButton.vue'
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
import type { Product, Stall } from '../types/market'
import type { FuzzySearchOptions } from '@/composables/useFuzzySearch'
import { useImageOptimizer } from '@/modules/base/composables/useImageOptimizer'
const { thumbnail } = useImageOptimizer()
const route = useRoute()
const router = useRouter()
const marketStore = useMarketStore()