@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import ConditionBadge from '@/components/atoms/ConditionBadge.vue';
|
||||||
|
import ImageGallery from '@/components/molecules/ImageGallery.vue';
|
||||||
|
import StatusBadge from '@/components/atoms/StatusBadge.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
images: { type: Array, required: true },
|
||||||
|
processingStatus: { type: String, default: 'COMPLETED' },
|
||||||
|
errorMessage: { type: String, default: '' },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(['reorder', 'primary', 'alt-text', 'retry', 'delete']);
|
||||||
|
const altText = ref(props.images.find((image) => image.isPrimary)?.altText ?? '');
|
||||||
|
const isEditingAltText = ref(false);
|
||||||
|
const orderedImages = computed(() => [...props.images].sort((left, right) => left.sortOrder - right.sortOrder));
|
||||||
|
|
||||||
|
function saveAltText() {
|
||||||
|
emit('alt-text', altText.value);
|
||||||
|
isEditingAltText.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-image-manager" aria-labelledby="image-manager-title">
|
||||||
|
<div class="admin-image-manager__header">
|
||||||
|
<div>
|
||||||
|
<p class="catalog-kicker">Inventory images</p>
|
||||||
|
<h2 id="image-manager-title">Image management</h2>
|
||||||
|
</div>
|
||||||
|
<StatusBadge :status="processingStatus" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="processingStatus === 'PROCESSING'" class="state state--loading" role="status">Processing responsive images...</p>
|
||||||
|
<div v-else-if="processingStatus === 'FAILED'" class="state state--error" role="alert">
|
||||||
|
<span>{{ errorMessage || 'Image processing failed.' }}</span>
|
||||||
|
<button class="button button--secondary" type="button" @click="emit('retry')">Retry processing</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageGallery :images="orderedImages" />
|
||||||
|
|
||||||
|
<div class="admin-image-manager__controls">
|
||||||
|
<div class="admin-image-manager__row">
|
||||||
|
<ConditionBadge :label="orderedImages.find((image) => image.isPrimary)?.altText || 'Primary image'" :selected="true" />
|
||||||
|
<button class="button button--secondary" type="button" @click="emit('primary', orderedImages[0]?.id)">Make first primary</button>
|
||||||
|
</div>
|
||||||
|
<div class="admin-image-manager__row">
|
||||||
|
<label class="fixture-label" for="admin-alt-text">Alt text</label>
|
||||||
|
<input id="admin-alt-text" v-model="altText" class="fixture-input" :disabled="!isEditingAltText" />
|
||||||
|
<button v-if="!isEditingAltText" class="button button--secondary" type="button" @click="isEditingAltText = true">Edit alt text</button>
|
||||||
|
<button v-else class="button button--primary" type="button" @click="saveAltText">Save alt text</button>
|
||||||
|
</div>
|
||||||
|
<div class="admin-image-manager__row admin-image-manager__row--order">
|
||||||
|
<button v-for="(image, index) in orderedImages" :key="image.id" class="button button--secondary" type="button" :aria-label="`Move image ${index + 1} down`" :disabled="index === orderedImages.length - 1" @click="emit('reorder', { id: image.id, sortOrder: image.sortOrder + 1 })">Move {{ index + 1 }} down</button>
|
||||||
|
</div>
|
||||||
|
<button class="button button--danger" type="button" @click="emit('delete', orderedImages[0]?.id)">Delete selected image</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.admin-image-manager { display: grid; gap: var(--space-6); max-width: 720px; border: var(--border-thin) solid var(--color-border); border-radius: var(--radius-xl); padding: var(--space-6); background: var(--color-surface); }
|
||||||
|
.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__row--order { align-items: stretch; }
|
||||||
|
</style>
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user