From 9704c2db027738e0e25f4c40c1eee9bca2798464 Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Tue, 8 Sep 2026 22:09:30 -0400 Subject: [PATCH] feat: serve responsive image variants --- .../inventory-image-repository.js | 9 ++++++++- .../inventory/routes/public-image-routes.js | 7 ++++++- .../services/public-image-service.js | 19 ++++++++++++++++--- .../application/public-image-service.test.js | 3 ++- .../src/components/molecules/ImageGallery.vue | 2 +- 5 files changed, 33 insertions(+), 7 deletions(-) diff --git a/apps/api/src/modules/inventory/repositories/inventory-image-repository.js b/apps/api/src/modules/inventory/repositories/inventory-image-repository.js index dd962b2..ad0efcb 100644 --- a/apps/api/src/modules/inventory/repositories/inventory-image-repository.js +++ b/apps/api/src/modules/inventory/repositories/inventory-image-repository.js @@ -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,19 +66,22 @@ 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; }, }; -} \ No newline at end of file +} diff --git a/apps/api/src/modules/inventory/routes/public-image-routes.js b/apps/api/src/modules/inventory/routes/public-image-routes.js index 64c102d..60a4bc0 100644 --- a/apps/api/src/modules/inventory/routes/public-image-routes.js +++ b/apps/api/src/modules/inventory/routes/public-image-routes.js @@ -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); }); } \ No newline at end of file diff --git a/apps/api/src/modules/inventory/services/public-image-service.js b/apps/api/src/modules/inventory/services/public-image-service.js index 7d510fa..a8639b6 100644 --- a/apps/api/src/modules/inventory/services/public-image-service.js +++ b/apps/api/src/modules/inventory/services/public-image-service.js @@ -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), + }; }, }; } \ No newline at end of file diff --git a/apps/api/tests/application/public-image-service.test.js b/apps/api/tests/application/public-image-service.test.js index 645bba7..4619090 100644 --- a/apps/api/tests/application/public-image-service.test.js +++ b/apps/api/tests/application/public-image-service.test.js @@ -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', }]); }); diff --git a/apps/web/src/components/molecules/ImageGallery.vue b/apps/web/src/components/molecules/ImageGallery.vue index 08e7db1..e030571 100644 --- a/apps/web/src/components/molecules/ImageGallery.vue +++ b/apps/web/src/components/molecules/ImageGallery.vue @@ -15,7 +15,7 @@ function markFailed(imageId) {