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 { and, eq, inArray } from 'drizzle-orm';
import { import {
imageDerivativeJobs,
inventoryImages, inventoryImages,
inventoryItems, inventoryItems,
listings, listings,
@@ -42,16 +43,19 @@ export function createInventoryImageRepository(db) {
sortOrder: inventoryImages.sortOrder, sortOrder: inventoryImages.sortOrder,
isPrimary: inventoryImages.isPrimary, isPrimary: inventoryImages.isPrimary,
processingStatus: inventoryImages.processingStatus, processingStatus: inventoryImages.processingStatus,
derivatives: imageDerivativeJobs.derivatives,
}) })
.from(inventoryImages) .from(inventoryImages)
.innerJoin(inventoryItems, eq(inventoryItems.id, inventoryImages.inventoryItemId)) .innerJoin(inventoryItems, eq(inventoryItems.id, inventoryImages.inventoryItemId))
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.innerJoin(listings, eq(listings.inventoryItemId, inventoryItems.id)) .innerJoin(listings, eq(listings.inventoryItemId, inventoryItems.id))
.innerJoin(imageDerivativeJobs, eq(imageDerivativeJobs.inventoryImageId, inventoryImages.id))
.where(and( .where(and(
eq(inventoryImages.inventoryItemId, inventoryItemId), eq(inventoryImages.inventoryItemId, inventoryItemId),
eq(inventoryItems.status, 'ACTIVE'), eq(inventoryItems.status, 'ACTIVE'),
inArray(listings.status, ['AVAILABLE', 'PENDING']), inArray(listings.status, ['AVAILABLE', 'PENDING']),
eq(inventoryImages.processingStatus, 'COMPLETED'), eq(inventoryImages.processingStatus, 'COMPLETED'),
eq(imageDerivativeJobs.status, 'COMPLETED'),
)) ))
.orderBy(inventoryImages.sortOrder); .orderBy(inventoryImages.sortOrder);
}, },
@@ -62,19 +66,22 @@ export function createInventoryImageRepository(db) {
id: inventoryImages.id, id: inventoryImages.id,
storageKey: inventoryImages.storageKey, storageKey: inventoryImages.storageKey,
mimeType: inventoryImages.mimeType, mimeType: inventoryImages.mimeType,
derivatives: imageDerivativeJobs.derivatives,
}) })
.from(inventoryImages) .from(inventoryImages)
.innerJoin(inventoryItems, eq(inventoryItems.id, inventoryImages.inventoryItemId)) .innerJoin(inventoryItems, eq(inventoryItems.id, inventoryImages.inventoryItemId))
.innerJoin(products, eq(products.id, inventoryItems.productId)) .innerJoin(products, eq(products.id, inventoryItems.productId))
.innerJoin(listings, eq(listings.inventoryItemId, inventoryItems.id)) .innerJoin(listings, eq(listings.inventoryItemId, inventoryItems.id))
.innerJoin(imageDerivativeJobs, eq(imageDerivativeJobs.inventoryImageId, inventoryImages.id))
.where(and( .where(and(
eq(inventoryImages.id, imageId), eq(inventoryImages.id, imageId),
eq(inventoryItems.status, 'ACTIVE'), eq(inventoryItems.status, 'ACTIVE'),
inArray(listings.status, ['AVAILABLE', 'PENDING']), inArray(listings.status, ['AVAILABLE', 'PENDING']),
eq(inventoryImages.processingStatus, 'COMPLETED'), eq(inventoryImages.processingStatus, 'COMPLETED'),
eq(imageDerivativeJobs.status, 'COMPLETED'),
)); ));
return image ?? null; return image ?? null;
}, },
}; };
} }
@@ -2,7 +2,12 @@ export async function registerPublicImageRoutes(app, options) {
const { publicImageService } = options; const { publicImageService } = options;
app.get('/public/images/:id', async (request, reply) => { 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); return reply.type(image.mimeType).send(image.data);
}); });
} }
@@ -6,18 +6,31 @@ export function createPublicImageService({ inventoryImageRepository, imageStorag
const images = await inventoryImageRepository.listPublicByInventoryItemId(inventoryItemId); const images = await inventoryImageRepository.listPublicByInventoryItemId(inventoryItemId);
return images.map((image) => ({ return images.map((image) => ({
...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); const image = await inventoryImageRepository.findPublicById(imageId);
if (!image) { if (!image) {
throw createApplicationError('IMAGE_NOT_FOUND', 'Image was not found.', 404); 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', id: 'image-1',
altText: 'Front of card', altText: 'Front of card',
sortOrder: 0, 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> <template>
<div class="image-gallery"> <div class="image-gallery">
<div class="image-gallery__stage"> <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> <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>
<div v-if="props.images.length > 1" class="image-gallery__controls" aria-label="Gallery images"> <div v-if="props.images.length > 1" class="image-gallery__controls" aria-label="Gallery images">