This commit is contained in:
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import ConditionBadge from '@/components/atoms/ConditionBadge.vue';
|
||||
import ImageGallery from '@/components/molecules/ImageGallery.vue';
|
||||
@@ -7,25 +7,39 @@ import StatusBadge from '@/components/atoms/StatusBadge.vue';
|
||||
|
||||
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(['upload', '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));
|
||||
const emit = defineEmits(['upload', 'move', 'primary', 'alt-text', 'retry', 'delete']);
|
||||
|
||||
function saveAltText() {
|
||||
emit('alt-text', altText.value);
|
||||
isEditingAltText.value = false;
|
||||
const orderedImages = computed(() => [...props.images].sort((left, right) => left.sortOrder - right.sortOrder));
|
||||
const editingImageId = ref(null);
|
||||
const altTextDrafts = reactive({});
|
||||
const isDragging = ref(false);
|
||||
|
||||
function beginEditAltText(image) {
|
||||
altTextDrafts[image.id] = image.altText ?? '';
|
||||
editingImageId.value = image.id;
|
||||
}
|
||||
|
||||
function saveAltText(imageId) {
|
||||
emit('alt-text', { id: imageId, altText: altTextDrafts[imageId] ?? '' });
|
||||
editingImageId.value = null;
|
||||
}
|
||||
|
||||
function queueUpload(fileList) {
|
||||
const files = Array.from(fileList ?? []);
|
||||
if (files.length > 0) emit('upload', files);
|
||||
}
|
||||
|
||||
function handleFileChange(event) {
|
||||
const [file] = event.target.files;
|
||||
if (file) emit('upload', file);
|
||||
queueUpload(event.target.files);
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
function handleDrop(event) {
|
||||
isDragging.value = false;
|
||||
queueUpload(event.dataTransfer?.files);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -35,39 +49,50 @@ function handleFileChange(event) {
|
||||
<p class="catalog-kicker">Inventory images</p>
|
||||
<h2 id="image-manager-title">Image management</h2>
|
||||
</div>
|
||||
<StatusBadge :status="processingStatus" />
|
||||
<StatusBadge :status="orderedImages.find((image) => image.processingStatus === 'FAILED') ? 'FAILED' : (orderedImages.find((image) => image.processingStatus === 'PROCESSING') ? 'PROCESSING' : 'COMPLETED')" />
|
||||
</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>
|
||||
|
||||
<div class="admin-image-manager__upload">
|
||||
<label class="fixture-label" for="admin-image-upload">Add image</label>
|
||||
<input id="admin-image-upload" class="fixture-input" type="file" accept="image/jpeg,image/png,image/webp" :disabled="isSaving" @change="handleFileChange" />
|
||||
<div
|
||||
class="admin-image-manager__dropzone"
|
||||
:class="{ 'admin-image-manager__dropzone--active': isDragging }"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<p>Drag and drop images here, or choose files below.</p>
|
||||
<label class="fixture-label" for="admin-image-upload">Add images</label>
|
||||
<input id="admin-image-upload" class="fixture-input" type="file" accept="image/jpeg,image/png,image/webp" multiple :disabled="isSaving" @change="handleFileChange" />
|
||||
</div>
|
||||
|
||||
<ImageGallery :images="orderedImages" />
|
||||
|
||||
<p v-if="isSaving" class="state state--loading" role="status">Saving image changes...</p>
|
||||
<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>
|
||||
<p v-else-if="orderedImages.length === 0" class="state" role="status">No images uploaded yet.</p>
|
||||
|
||||
<ul v-else class="admin-image-manager__list">
|
||||
<li v-for="(image, index) in orderedImages" :key="image.id" class="admin-image-manager__item">
|
||||
<div class="admin-image-manager__row">
|
||||
<ConditionBadge :label="image.altText || `Image ${index + 1}`" :selected="image.isPrimary" />
|
||||
<StatusBadge :status="image.processingStatus" />
|
||||
</div>
|
||||
<p v-if="image.processingStatus === 'FAILED'" class="state state--error" role="alert">Image processing failed.</p>
|
||||
|
||||
<div class="admin-image-manager__row">
|
||||
<label class="fixture-label" :for="`admin-alt-text-${image.id}`">Alt text</label>
|
||||
<input :id="`admin-alt-text-${image.id}`" v-model="altTextDrafts[image.id]" class="fixture-input" :disabled="editingImageId !== image.id" />
|
||||
<button v-if="editingImageId !== image.id" class="button button--secondary" type="button" @click="beginEditAltText(image)">Edit alt text</button>
|
||||
<button v-else class="button button--primary" type="button" @click="saveAltText(image.id)">Save alt text</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-image-manager__row">
|
||||
<button class="button button--secondary" type="button" :disabled="image.isPrimary" @click="emit('primary', image.id)">Make primary</button>
|
||||
<button class="button button--secondary" type="button" :disabled="index === 0" @click="emit('move', { id: image.id, direction: 'up' })">Move up</button>
|
||||
<button class="button button--secondary" type="button" :disabled="index === orderedImages.length - 1" @click="emit('move', { id: image.id, direction: 'down' })">Move down</button>
|
||||
<button v-if="image.processingStatus === 'FAILED'" class="button button--secondary" type="button" @click="emit('retry', image.id)">Retry processing</button>
|
||||
<button class="button button--danger" type="button" @click="emit('delete', image.id)">Delete image</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -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); }
|
||||
</style>
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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);
|
||||
</script>
|
||||
@@ -22,12 +22,12 @@ onMounted(load);
|
||||
<AdminImageManager
|
||||
v-else
|
||||
:images="images"
|
||||
:processing-status="isSaving ? 'PROCESSING' : (images[0]?.processingStatus ?? 'COMPLETED')"
|
||||
:is-saving="isSaving"
|
||||
@upload="upload"
|
||||
@reorder="reorder($event.id, $event.sortOrder)"
|
||||
@move="({ id, direction }) => move(id, direction)"
|
||||
@primary="setPrimary"
|
||||
@alt-text="updateAltText(images[0]?.id, $event)"
|
||||
@retry="retry(images[0]?.id)"
|
||||
@alt-text="({ id, altText }) => updateAltText(id, altText)"
|
||||
@retry="retry"
|
||||
@delete="remove"
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user