121 lines
6.9 KiB
Vue
121 lines
6.9 KiB
Vue
<script setup>
|
|
import { computed, reactive, ref, watch } 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 },
|
|
disabled: { type: Boolean, default: false },
|
|
isSaving: { type: Boolean, default: false },
|
|
});
|
|
const emit = defineEmits(['upload', 'move', 'primary', 'alt-text', 'retry', 'delete']);
|
|
|
|
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) {
|
|
const altText = (altTextDrafts[imageId] ?? '').trim();
|
|
if (props.isSaving || props.disabled || altText.length > 1000) return;
|
|
altTextDrafts[imageId] = altText;
|
|
emit('alt-text', { id: imageId, altText });
|
|
}
|
|
|
|
watch(() => props.images, (images) => {
|
|
const edited = images.find((image) => image.id === editingImageId.value);
|
|
if (edited && edited.altText === altTextDrafts[edited.id]) editingImageId.value = null;
|
|
});
|
|
|
|
function queueUpload(fileList) {
|
|
if (props.isSaving || props.disabled) return;
|
|
const files = Array.from(fileList ?? []);
|
|
if (files.length > 0) emit('upload', files);
|
|
}
|
|
|
|
function handleFileChange(event) {
|
|
queueUpload(event.target.files);
|
|
event.target.value = '';
|
|
}
|
|
|
|
function handleDrop(event) {
|
|
isDragging.value = false;
|
|
queueUpload(event.dataTransfer?.files);
|
|
}
|
|
</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="orderedImages.find((image) => image.processingStatus === 'FAILED') ? 'FAILED' : (orderedImages.find((image) => image.processingStatus === 'PROCESSING') ? 'PROCESSING' : 'COMPLETED')" />
|
|
</div>
|
|
|
|
<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 || disabled" @change="handleFileChange" />
|
|
</div>
|
|
|
|
<ImageGallery :images="orderedImages" />
|
|
|
|
<p v-if="isSaving" class="state state--loading" role="status">Saving image changes...</p>
|
|
<p v-if="!isSaving && orderedImages.length === 0" class="state" role="status">No images uploaded yet.</p>
|
|
|
|
<ul v-if="orderedImages.length" 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" maxlength="1000" :aria-describedby="`admin-alt-help-${image.id}${(altTextDrafts[image.id] ?? '').trim().length > 1000 ? ` admin-alt-error-${image.id}` : ''}`" :aria-invalid="(altTextDrafts[image.id] ?? '').trim().length > 1000" :disabled="isSaving || disabled || editingImageId !== image.id" />
|
|
<button v-if="editingImageId !== image.id" class="button button--secondary" type="button" :disabled="isSaving || disabled" @click="beginEditAltText(image)">Edit alt text</button>
|
|
<button v-else class="button button--primary" type="button" :disabled="isSaving || disabled || (altTextDrafts[image.id] ?? '').trim().length > 1000" @click="saveAltText(image.id)">Save alt text</button>
|
|
</div>
|
|
|
|
<p :id="`admin-alt-help-${image.id}`">Describe the image in up to 1,000 characters. Clear the field to remove its alt text.</p>
|
|
<p :id="`admin-alt-error-${image.id}`" v-if="(altTextDrafts[image.id] ?? '').trim().length > 1000" class="state state--error" role="alert">Alt text must be 1,000 characters or fewer.</p>
|
|
|
|
<div class="admin-image-manager__row">
|
|
<button class="button button--secondary" type="button" :disabled="isSaving || disabled || image.isPrimary" @click="emit('primary', image.id)">Make primary</button>
|
|
<button class="button button--secondary" type="button" :disabled="isSaving || disabled || index === 0" @click="emit('move', { id: image.id, direction: 'up' })">Move up</button>
|
|
<button class="button button--secondary" type="button" :disabled="isSaving || 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" :disabled="isSaving || disabled" @click="emit('retry', image.id)">Retry processing</button>
|
|
<button class="button button--danger" type="button" :disabled="isSaving || disabled" @click="emit('delete', image.id)">Delete image</button>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</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__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>
|