feat: serve responsive image variants
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-08 22:09:30 -04:00
parent dd8af7c140
commit 9704c2db02
5 changed files with 33 additions and 7 deletions
@@ -1,6 +1,7 @@
import { and, eq, inArray } from 'drizzle-orm';
import {
imageDerivativeJobs,
inventoryImages,
inventoryItems,
listings,
@@ -42,16 +43,19 @@ export function createInventoryImageRepository(db) {
sortOrder: inventoryImages.sortOrder,
isPrimary: inventoryImages.isPrimary,
processingStatus: inventoryImages.processingStatus,
derivatives: imageDerivativeJobs.derivatives,
})
.from(inventoryImages)
.innerJoin(inventoryItems, eq(inventoryItems.id, inventoryImages.inventoryItemId))
.innerJoin(products, eq(products.id, inventoryItems.productId))
.innerJoin(listings, eq(listings.inventoryItemId, inventoryItems.id))
.innerJoin(imageDerivativeJobs, eq(imageDerivativeJobs.inventoryImageId, inventoryImages.id))
.where(and(
eq(inventoryImages.inventoryItemId, inventoryItemId),
eq(inventoryItems.status, 'ACTIVE'),
inArray(listings.status, ['AVAILABLE', 'PENDING']),
eq(inventoryImages.processingStatus, 'COMPLETED'),
eq(imageDerivativeJobs.status, 'COMPLETED'),
))
.orderBy(inventoryImages.sortOrder);
},
@@ -62,16 +66,19 @@ export function createInventoryImageRepository(db) {
id: inventoryImages.id,
storageKey: inventoryImages.storageKey,
mimeType: inventoryImages.mimeType,
derivatives: imageDerivativeJobs.derivatives,
})
.from(inventoryImages)
.innerJoin(inventoryItems, eq(inventoryItems.id, inventoryImages.inventoryItemId))
.innerJoin(products, eq(products.id, inventoryItems.productId))
.innerJoin(listings, eq(listings.inventoryItemId, inventoryItems.id))
.innerJoin(imageDerivativeJobs, eq(imageDerivativeJobs.inventoryImageId, inventoryImages.id))
.where(and(
eq(inventoryImages.id, imageId),
eq(inventoryItems.status, 'ACTIVE'),
inArray(listings.status, ['AVAILABLE', 'PENDING']),
eq(inventoryImages.processingStatus, 'COMPLETED'),
eq(imageDerivativeJobs.status, 'COMPLETED'),
));
return image ?? null;
@@ -2,7 +2,12 @@ export async function registerPublicImageRoutes(app, options) {
const { publicImageService } = options;
app.get('/public/images/:id', async (request, reply) => {
const image = await publicImageService.getContent(request.params.id);
const image = await publicImageService.getContent(request.params.id, 'original');
return reply.type(image.mimeType).send(image.data);
});
app.get('/public/images/:id/:variant', async (request, reply) => {
const image = await publicImageService.getContent(request.params.id, request.params.variant);
return reply.type(image.mimeType).send(image.data);
});
}
@@ -6,18 +6,31 @@ export function createPublicImageService({ inventoryImageRepository, imageStorag
const images = await inventoryImageRepository.listPublicByInventoryItemId(inventoryItemId);
return images.map((image) => ({
...image,
url: `/api/v1/public/images/${image.id}`,
url: `/api/v1/public/images/${image.id}/large`,
variants: Object.fromEntries(
(Array.isArray(image.derivatives) ? image.derivatives : []).map(({ name }) => [
name,
`/api/v1/public/images/${image.id}/${name}`,
]),
),
}));
},
async getContent(imageId) {
async getContent(imageId, variant = 'original') {
const image = await inventoryImageRepository.findPublicById(imageId);
if (!image) {
throw createApplicationError('IMAGE_NOT_FOUND', 'Image was not found.', 404);
}
return { ...image, data: await imageStorage.get(image.storageKey) };
const derivative = (Array.isArray(image.derivatives) ? image.derivatives : [])
.find((candidate) => candidate.name === variant);
const storageKey = derivative?.key ?? image.storageKey;
return {
...image,
mimeType: derivative ? 'image/webp' : image.mimeType,
data: await imageStorage.get(storageKey),
};
},
};
}
@@ -18,7 +18,8 @@ test('returns public image URLs without exposing storage keys', async () => {
id: 'image-1',
altText: 'Front of card',
sortOrder: 0,
url: '/api/v1/public/images/image-1',
variants: {},
url: '/api/v1/public/images/image-1/large',
}]);
});
@@ -15,7 +15,7 @@ function markFailed(imageId) {
<template>
<div class="image-gallery">
<div class="image-gallery__stage">
<img v-if="props.images[selectedIndex]?.src && !failedImages.has(props.images[selectedIndex].id)" :src="props.images[selectedIndex].src" :alt="props.images[selectedIndex].alt" @error="markFailed(props.images[selectedIndex].id)" />
<img v-if="(props.images[selectedIndex]?.src ?? props.images[selectedIndex]?.url) && !failedImages.has(props.images[selectedIndex].id)" :src="props.images[selectedIndex].src ?? props.images[selectedIndex].url" :alt="props.images[selectedIndex].altText ?? props.images[selectedIndex].alt" @error="markFailed(props.images[selectedIndex].id)" />
<span v-else class="image-gallery__placeholder" role="img" :aria-label="props.images[selectedIndex]?.src ? 'Collectible image unavailable' : 'No collectible image available'">{{ props.images[selectedIndex]?.src ? 'IMAGE UNAVAILABLE' : (props.images[selectedIndex]?.label ?? 'IMAGE') }}</span>
</div>
<div v-if="props.images.length > 1" class="image-gallery__controls" aria-label="Gallery images">