From a21d1ae8542630c9f56faca1ad7ccd3de68bad03 Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Tue, 8 Sep 2026 22:16:59 -0400 Subject: [PATCH] feat: connect admin image workflow --- .../inventory-image-repository.js | 14 ++++++ .../inventory/routes/admin-image-routes.js | 7 +++ .../services/inventory-image-service.js | 4 ++ .../organisms/AdminImageManager.stories.js | 1 + .../organisms/AdminImageManager.vue | 2 + apps/web/src/composables/useAdminImages.js | 50 +++++++++++++++++++ apps/web/src/pages/AdminImagePage.vue | 33 ++++++++++++ apps/web/src/router/index.js | 2 + apps/web/src/services/admin-image-service.js | 7 +++ 9 files changed, 120 insertions(+) create mode 100644 apps/web/src/composables/useAdminImages.js create mode 100644 apps/web/src/pages/AdminImagePage.vue 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 53b3e2b..4e4ddf7 100644 --- a/apps/api/src/modules/inventory/repositories/inventory-image-repository.js +++ b/apps/api/src/modules/inventory/repositories/inventory-image-repository.js @@ -43,6 +43,20 @@ export function createInventoryImageRepository(db) { return image?.image ?? null; }, + async listByInventoryItemForSeller(inventoryItemId, sellerId) { + return db + .select({ image: inventoryImages }) + .from(inventoryImages) + .innerJoin(inventoryItems, eq(inventoryItems.id, inventoryImages.inventoryItemId)) + .innerJoin(products, eq(products.id, inventoryItems.productId)) + .where(and( + eq(inventoryImages.inventoryItemId, inventoryItemId), + eq(products.sellerId, sellerId), + )) + .orderBy(inventoryImages.sortOrder) + .then((rows) => rows.map(({ image }) => image)); + }, + async updateOrderForSeller(imageId, sortOrder, sellerId) { const owned = await this.findByIdForSeller(imageId, sellerId); if (!owned) return null; diff --git a/apps/api/src/modules/inventory/routes/admin-image-routes.js b/apps/api/src/modules/inventory/routes/admin-image-routes.js index db7ca60..4721adf 100644 --- a/apps/api/src/modules/inventory/routes/admin-image-routes.js +++ b/apps/api/src/modules/inventory/routes/admin-image-routes.js @@ -78,6 +78,13 @@ export async function registerAdminImageRoutes(app, options) { return reply.code(201).send({ data: toAdminInventoryImageDto(image) }); }); + app.get('/inventory/:id/images', async (request) => ({ + data: (await inventoryImageService.list({ + inventoryItemId: request.params.id, + sellerId: (await authenticateAdmin(request)).sellerId, + })).map(toAdminInventoryImageDto), + })); + async function authorizeAdminMutation(request) { const admin = await authenticateAdmin(request); if (!admin?.sellerId) { diff --git a/apps/api/src/modules/inventory/services/inventory-image-service.js b/apps/api/src/modules/inventory/services/inventory-image-service.js index efb2830..7275db3 100644 --- a/apps/api/src/modules/inventory/services/inventory-image-service.js +++ b/apps/api/src/modules/inventory/services/inventory-image-service.js @@ -23,6 +23,10 @@ export function createInventoryImageService({ inventoryImageRepository, imageDer return image; }, + async list({ inventoryItemId, sellerId }) { + return inventoryImageRepository.listByInventoryItemForSeller(inventoryItemId, sellerId); + }, + async upload(input) { const { data, ...metadata } = input; validateInventoryImageMetadata(metadata); diff --git a/apps/web/src/components/organisms/AdminImageManager.stories.js b/apps/web/src/components/organisms/AdminImageManager.stories.js index fe6d074..2e40ca1 100644 --- a/apps/web/src/components/organisms/AdminImageManager.stories.js +++ b/apps/web/src/components/organisms/AdminImageManager.stories.js @@ -12,6 +12,7 @@ export default { title: 'Organisms/AdminImageManager', component: AdminImageMana export const Completed = { args: { images, processingStatus: 'COMPLETED' } }; export const Processing = { args: { images, processingStatus: 'PROCESSING' } }; export const Failed = { args: { images, processingStatus: 'FAILED', errorMessage: 'Derivative generation failed.' } }; +export const Saving = { args: { images, processingStatus: 'COMPLETED', isSaving: true } }; export const EditAltTextWithKeyboard = { args: { images, processingStatus: 'COMPLETED' }, diff --git a/apps/web/src/components/organisms/AdminImageManager.vue b/apps/web/src/components/organisms/AdminImageManager.vue index 7c11f4e..77ec677 100644 --- a/apps/web/src/components/organisms/AdminImageManager.vue +++ b/apps/web/src/components/organisms/AdminImageManager.vue @@ -9,6 +9,7 @@ const props = defineProps({ images: { type: Array, required: true }, processingStatus: { type: String, default: 'COMPLETED' }, errorMessage: { type: String, default: '' }, + isSaving: { type: Boolean, default: false }, }); const emit = defineEmits(['reorder', 'primary', 'alt-text', 'retry', 'delete']); const altText = ref(props.images.find((image) => image.isPrimary)?.altText ?? ''); @@ -39,6 +40,7 @@ function saveAltText() { +

Saving image changes...

diff --git a/apps/web/src/composables/useAdminImages.js b/apps/web/src/composables/useAdminImages.js new file mode 100644 index 0000000..3d173fa --- /dev/null +++ b/apps/web/src/composables/useAdminImages.js @@ -0,0 +1,50 @@ +import { ref } from 'vue'; + +import { + deleteImage, + listAdminImages, + reorderImage, + retryImageProcessing, + setPrimaryImage, + updateImageAltText, +} from '@/services/admin-image-service.js'; + +function csrfToken() { + return document.cookie.split('; ').find((value) => value.startsWith('pokemon_inventory_csrf='))?.split('=')[1] ?? ''; +} + +export function useAdminImages(inventoryItemId) { + const images = ref([]); + const isLoading = ref(false); + const isSaving = ref(false); + const error = ref(null); + + async function load() { + isLoading.value = true; + error.value = null; + try { images.value = await listAdminImages(inventoryItemId); } + catch (requestError) { error.value = requestError; } + finally { isLoading.value = false; } + } + + async function apply(action) { + isSaving.value = true; + error.value = null; + try { await action(csrfToken()); await load(); } + catch (requestError) { error.value = requestError; } + finally { isSaving.value = false; } + } + + return { + images, + isLoading, + isSaving, + error, + load, + reorder: (imageId, sortOrder) => apply((token) => reorderImage(imageId, sortOrder, token)), + setPrimary: (imageId) => apply((token) => setPrimaryImage(imageId, token)), + updateAltText: (imageId, altText) => apply((token) => updateImageAltText(imageId, altText, token)), + retry: (imageId) => apply((token) => retryImageProcessing(imageId, token)), + remove: (imageId) => apply((token) => deleteImage(imageId, token)), + }; +} diff --git a/apps/web/src/pages/AdminImagePage.vue b/apps/web/src/pages/AdminImagePage.vue new file mode 100644 index 0000000..feecbc7 --- /dev/null +++ b/apps/web/src/pages/AdminImagePage.vue @@ -0,0 +1,33 @@ + + + diff --git a/apps/web/src/router/index.js b/apps/web/src/router/index.js index e9f63de..51377df 100644 --- a/apps/web/src/router/index.js +++ b/apps/web/src/router/index.js @@ -3,6 +3,7 @@ import { createRouter, createWebHistory } from 'vue-router'; import CatalogPage from '@/pages/CatalogPage.vue'; import ListingDetailPage from '@/pages/ListingDetailPage.vue'; import ProductDetailPage from '@/pages/ProductDetailPage.vue'; +import AdminImagePage from '@/pages/AdminImagePage.vue'; export default createRouter({ history: createWebHistory(), @@ -10,5 +11,6 @@ export default createRouter({ { path: '/', name: 'catalog', component: CatalogPage }, { path: '/listings/:id', name: 'listing-detail', component: ListingDetailPage }, { path: '/products/:slug', name: 'product-detail', component: ProductDetailPage }, + { path: '/admin/inventory/:id/images', name: 'admin-image-manager', component: AdminImagePage }, ], }); \ No newline at end of file diff --git a/apps/web/src/services/admin-image-service.js b/apps/web/src/services/admin-image-service.js index b256e37..7e80593 100644 --- a/apps/web/src/services/admin-image-service.js +++ b/apps/web/src/services/admin-image-service.js @@ -54,3 +54,10 @@ export function deleteImage(imageId, csrfToken) { headers: { 'x-csrf-token': csrfToken }, }); } + +export async function listAdminImages(inventoryItemId) { + return request(`/admin/inventory/${encodeURIComponent(inventoryItemId)}/images`, { + method: 'GET', + headers: {}, + }); +}