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 --> <!-- 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<{

View file

@ -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<{

View file

@ -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

View file

@ -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

View file

@ -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()