diff --git a/apps/web/src/components/organisms/AdminImageManager.stories.js b/apps/web/src/components/organisms/AdminImageManager.stories.js new file mode 100644 index 0000000..fe6d074 --- /dev/null +++ b/apps/web/src/components/organisms/AdminImageManager.stories.js @@ -0,0 +1,27 @@ +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 }, +]; + +export default { title: 'Organisms/AdminImageManager', component: AdminImageManager }; + +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 EditAltTextWithKeyboard = { + args: { images, processingStatus: 'COMPLETED' }, + 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.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(); + }, +}; diff --git a/apps/web/src/components/organisms/AdminImageManager.vue b/apps/web/src/components/organisms/AdminImageManager.vue new file mode 100644 index 0000000..7c11f4e --- /dev/null +++ b/apps/web/src/components/organisms/AdminImageManager.vue @@ -0,0 +1,68 @@ + + + + + + + Inventory images + Image management + + + + + Processing responsive images... + + {{ errorMessage || 'Image processing failed.' }} + Retry processing + + + + + + + + Make first primary + + + Alt text + + Edit alt text + Save alt text + + + Move {{ index + 1 }} down + + Delete selected image + + + + + diff --git a/apps/web/src/services/admin-image-service.js b/apps/web/src/services/admin-image-service.js new file mode 100644 index 0000000..b256e37 --- /dev/null +++ b/apps/web/src/services/admin-image-service.js @@ -0,0 +1,56 @@ +const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1'; + +async function request(path, options = {}) { + const response = await fetch(`${apiBaseUrl}${path}`, { + credentials: 'include', + ...options, + headers: { + 'content-type': 'application/json', + ...options.headers, + }, + }); + + if (!response.ok) { + const payload = await response.json().catch(() => null); + throw new Error(payload?.error?.message ?? 'Image update is unavailable.'); + } + + return response.status === 204 ? null : (await response.json()).data; +} + +export function reorderImage(imageId, sortOrder, csrfToken) { + return request(`/admin/images/${encodeURIComponent(imageId)}/order`, { + method: 'PATCH', + headers: { 'x-csrf-token': csrfToken }, + body: JSON.stringify({ sortOrder }), + }); +} + +export function setPrimaryImage(imageId, csrfToken) { + return request(`/admin/images/${encodeURIComponent(imageId)}/primary`, { + method: 'POST', + headers: { 'x-csrf-token': csrfToken }, + }); +} + +export function updateImageAltText(imageId, altText, csrfToken) { + return request(`/admin/images/${encodeURIComponent(imageId)}/alt-text`, { + method: 'PATCH', + headers: { 'x-csrf-token': csrfToken }, + body: JSON.stringify({ altText }), + }); +} + +export function retryImageProcessing(imageId, csrfToken) { + return request(`/admin/images/${encodeURIComponent(imageId)}/retry`, { + method: 'POST', + headers: { 'x-csrf-token': csrfToken }, + }); +} + +export function deleteImage(imageId, csrfToken) { + return request(`/admin/images/${encodeURIComponent(imageId)}`, { + method: 'DELETE', + headers: { 'x-csrf-token': csrfToken }, + }); +}
Inventory images
Processing responsive images...