diff --git a/apps/web/src/components/organisms/AdminImageManager.stories.js b/apps/web/src/components/organisms/AdminImageManager.stories.js index 0a48db8..de29cf3 100644 --- a/apps/web/src/components/organisms/AdminImageManager.stories.js +++ b/apps/web/src/components/organisms/AdminImageManager.stories.js @@ -3,27 +3,44 @@ import { expect, userEvent, within } from '@storybook/test'; import AdminImageManager from './AdminImageManager.vue'; const images = [ - { id: 'image-1', label: 'FRONT', altText: 'Front of card', sortOrder: 0, isPrimary: true }, - { id: 'image-2', label: 'BACK', altText: 'Back of card', sortOrder: 1, isPrimary: false }, + { id: 'image-1', label: 'FRONT', altText: 'Front of card', sortOrder: 0, isPrimary: true, processingStatus: 'COMPLETED' }, + { id: 'image-2', label: 'BACK', altText: 'Back of card', sortOrder: 1, isPrimary: false, processingStatus: 'COMPLETED' }, +]; + +const failedImages = [ + { id: 'image-1', label: 'FRONT', altText: 'Front of card', sortOrder: 0, isPrimary: true, processingStatus: 'COMPLETED' }, + { id: 'image-2', label: 'BACK', altText: 'Back of card', sortOrder: 1, isPrimary: false, processingStatus: 'FAILED' }, +]; + +const processingImages = [ + { id: 'image-1', label: 'FRONT', altText: 'Front of card', sortOrder: 0, isPrimary: true, processingStatus: 'PROCESSING' }, ]; export default { title: 'Organisms/AdminImageManager', component: AdminImageManager }; -export const Completed = { args: { images, processingStatus: 'COMPLETED' } }; -export const EmptyUpload = { 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 Completed = { args: { images } }; +export const EmptyUpload = { args: { images: [] } }; +export const Processing = { args: { images: processingImages } }; +export const Failed = { args: { images: failedImages } }; +export const Saving = { args: { images, isSaving: true } }; export const EditAltTextWithKeyboard = { - args: { images, processingStatus: 'COMPLETED' }, + args: { images }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole('button', { name: 'Edit alt text' })); - const input = canvas.getByLabelText('Alt text'); + await userEvent.click(canvas.getAllByRole('button', { name: 'Edit alt text' })[0]); + const input = canvas.getAllByLabelText('Alt text')[0]; await userEvent.clear(input); await userEvent.type(input, 'Updated front image'); await userEvent.click(canvas.getByRole('button', { name: 'Save alt text' })); await expect(canvas.getByDisplayValue('Updated front image')).toBeInTheDocument(); }, }; + +export const RetryFailedImage = { + args: { images: failedImages }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole('button', { name: 'Retry processing' })).toBeInTheDocument(); + }, +}; diff --git a/apps/web/src/components/organisms/AdminImageManager.vue b/apps/web/src/components/organisms/AdminImageManager.vue index a9386b9..c69a5b2 100644 --- a/apps/web/src/components/organisms/AdminImageManager.vue +++ b/apps/web/src/components/organisms/AdminImageManager.vue @@ -1,5 +1,5 @@ @@ -76,7 +101,8 @@ function handleFileChange(event) { .admin-image-manager__header, .admin-image-manager__row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); } .admin-image-manager__header { justify-content: space-between; } .admin-image-manager__header h2 { font-size: var(--text-xl); } -.admin-image-manager__controls { display: grid; gap: var(--space-4); } -.admin-image-manager__upload { display: grid; gap: var(--space-2); } -.admin-image-manager__row--order { align-items: stretch; } +.admin-image-manager__dropzone { display: grid; gap: var(--space-2); border: var(--border-thin) dashed var(--color-border-strong); border-radius: var(--radius-lg); padding: var(--space-4); text-align: center; } +.admin-image-manager__dropzone--active { border-color: var(--color-interactive); background: var(--color-surface-muted, var(--color-surface)); } +.admin-image-manager__list { display: grid; gap: var(--space-4); list-style: none; padding: 0; margin: 0; } +.admin-image-manager__item { display: grid; gap: var(--space-3); border: var(--border-thin) solid var(--color-border); border-radius: var(--radius-lg); padding: var(--space-4); } diff --git a/apps/web/src/composables/useAdminImages.js b/apps/web/src/composables/useAdminImages.js index 1791306..565dd34 100644 --- a/apps/web/src/composables/useAdminImages.js +++ b/apps/web/src/composables/useAdminImages.js @@ -36,23 +36,48 @@ export function useAdminImages(inventoryItemId) { finally { isSaving.value = false; } } - async function upload(file) { + async function uploadOne(file, isPrimary, token) { const objectUrl = URL.createObjectURL(file); const image = new Image(); image.src = objectUrl; try { await image.decode(); - await apply((token) => uploadAdminImage(inventoryItemId, file, { + await uploadAdminImage(inventoryItemId, file, { width: image.naturalWidth, height: image.naturalHeight, altText: file.name, - isPrimary: images.value.length === 0, - }, token)); + isPrimary, + }, token); } finally { URL.revokeObjectURL(objectUrl); } } + async function upload(files) { + const fileList = Array.from(files ?? []); + if (fileList.length === 0) return; + await apply(async (token) => { + let hasExistingImages = images.value.length > 0; + for (const file of fileList) { + await uploadOne(file, !hasExistingImages, token); + hasExistingImages = true; + } + }); + } + + async function moveImage(imageId, direction) { + const ordered = [...images.value].sort((left, right) => left.sortOrder - right.sortOrder); + const index = ordered.findIndex((image) => image.id === imageId); + const neighborIndex = direction === 'up' ? index - 1 : index + 1; + if (index === -1 || neighborIndex < 0 || neighborIndex >= ordered.length) return; + const current = ordered[index]; + const neighbor = ordered[neighborIndex]; + await apply(async (token) => { + await reorderImage(current.id, neighbor.sortOrder, token); + await reorderImage(neighbor.id, current.sortOrder, token); + }); + } + return { images, isLoading, @@ -60,7 +85,7 @@ export function useAdminImages(inventoryItemId) { error, load, upload, - reorder: (imageId, sortOrder) => apply((token) => reorderImage(imageId, sortOrder, token)), + move: moveImage, setPrimary: (imageId) => apply((token) => setPrimaryImage(imageId, token)), updateAltText: (imageId, altText) => apply((token) => updateImageAltText(imageId, altText, token)), retry: (imageId) => apply((token) => retryImageProcessing(imageId, token)), diff --git a/apps/web/src/pages/AdminImagePage.vue b/apps/web/src/pages/AdminImagePage.vue index a680f86..9172061 100644 --- a/apps/web/src/pages/AdminImagePage.vue +++ b/apps/web/src/pages/AdminImagePage.vue @@ -7,7 +7,7 @@ import { useAdminImages } from '@/composables/useAdminImages.js'; const route = useRoute(); const inventoryItemId = route.params.id; -const { images, isLoading, isSaving, error, load, upload, reorder, setPrimary, updateAltText, retry, remove } = useAdminImages(inventoryItemId); +const { images, isLoading, isSaving, error, load, upload, move, setPrimary, updateAltText, retry, remove } = useAdminImages(inventoryItemId); onMounted(load); @@ -22,12 +22,12 @@ onMounted(load); diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index b25deef..932fa00 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -177,6 +177,7 @@ Remaining roadmap areas are approved external provider credentials/adapters, bro - Public catalog sorting now supports latest manual/calculated market value low-to-high and high-to-low, with null values placed last. - Public listing detail now shows market-value freshness and suppresses asking price for sold listings, with Storybook coverage for sold and valued states. - Public catalog filters now collapse behind an accessible mobile disclosure, show an active-filter count, and provide a reset action while preserving URL synchronization. +- The admin image manager now supports multi-file upload with a drag-and-drop dropzone, per-image alt-text editing, primary selection, up/down reordering, and per-image retry/delete actions targeted at the correct image instead of the first uploaded image. ## Local Commands