This commit is contained in:
@@ -43,6 +43,20 @@ export function createInventoryImageRepository(db) {
|
||||
return image?.image ?? null;
|
||||
},
|
||||
|
||||
async listByInventoryItemForSeller(inventoryItemId, sellerId) {
|
||||
return db
|
||||
.select({ image: inventoryImages })
|
||||
.from(inventoryImages)
|
||||
.innerJoin(inventoryItems, eq(inventoryItems.id, inventoryImages.inventoryItemId))
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(
|
||||
eq(inventoryImages.inventoryItemId, inventoryItemId),
|
||||
eq(products.sellerId, sellerId),
|
||||
))
|
||||
.orderBy(inventoryImages.sortOrder)
|
||||
.then((rows) => rows.map(({ image }) => image));
|
||||
},
|
||||
|
||||
async updateOrderForSeller(imageId, sortOrder, sellerId) {
|
||||
const owned = await this.findByIdForSeller(imageId, sellerId);
|
||||
if (!owned) return null;
|
||||
|
||||
@@ -78,6 +78,13 @@ export async function registerAdminImageRoutes(app, options) {
|
||||
return reply.code(201).send({ data: toAdminInventoryImageDto(image) });
|
||||
});
|
||||
|
||||
app.get('/inventory/:id/images', async (request) => ({
|
||||
data: (await inventoryImageService.list({
|
||||
inventoryItemId: request.params.id,
|
||||
sellerId: (await authenticateAdmin(request)).sellerId,
|
||||
})).map(toAdminInventoryImageDto),
|
||||
}));
|
||||
|
||||
async function authorizeAdminMutation(request) {
|
||||
const admin = await authenticateAdmin(request);
|
||||
if (!admin?.sellerId) {
|
||||
|
||||
@@ -23,6 +23,10 @@ export function createInventoryImageService({ inventoryImageRepository, imageDer
|
||||
return image;
|
||||
},
|
||||
|
||||
async list({ inventoryItemId, sellerId }) {
|
||||
return inventoryImageRepository.listByInventoryItemForSeller(inventoryItemId, sellerId);
|
||||
},
|
||||
|
||||
async upload(input) {
|
||||
const { data, ...metadata } = input;
|
||||
validateInventoryImageMetadata(metadata);
|
||||
|
||||
@@ -12,6 +12,7 @@ export default { title: 'Organisms/AdminImageManager', component: AdminImageMana
|
||||
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 Saving = { args: { images, processingStatus: 'COMPLETED', isSaving: true } };
|
||||
|
||||
export const EditAltTextWithKeyboard = {
|
||||
args: { images, processingStatus: 'COMPLETED' },
|
||||
|
||||
@@ -9,6 +9,7 @@ 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(['reorder', 'primary', 'alt-text', 'retry', 'delete']);
|
||||
const altText = ref(props.images.find((image) => image.isPrimary)?.altText ?? '');
|
||||
@@ -39,6 +40,7 @@ function saveAltText() {
|
||||
|
||||
<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" />
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
import {
|
||||
deleteImage,
|
||||
listAdminImages,
|
||||
reorderImage,
|
||||
retryImageProcessing,
|
||||
setPrimaryImage,
|
||||
updateImageAltText,
|
||||
} from '@/services/admin-image-service.js';
|
||||
|
||||
function csrfToken() {
|
||||
return document.cookie.split('; ').find((value) => value.startsWith('pokemon_inventory_csrf='))?.split('=')[1] ?? '';
|
||||
}
|
||||
|
||||
export function useAdminImages(inventoryItemId) {
|
||||
const images = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
async function load() {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
try { images.value = await listAdminImages(inventoryItemId); }
|
||||
catch (requestError) { error.value = requestError; }
|
||||
finally { isLoading.value = false; }
|
||||
}
|
||||
|
||||
async function apply(action) {
|
||||
isSaving.value = true;
|
||||
error.value = null;
|
||||
try { await action(csrfToken()); await load(); }
|
||||
catch (requestError) { error.value = requestError; }
|
||||
finally { isSaving.value = false; }
|
||||
}
|
||||
|
||||
return {
|
||||
images,
|
||||
isLoading,
|
||||
isSaving,
|
||||
error,
|
||||
load,
|
||||
reorder: (imageId, sortOrder) => apply((token) => reorderImage(imageId, sortOrder, token)),
|
||||
setPrimary: (imageId) => apply((token) => setPrimaryImage(imageId, token)),
|
||||
updateAltText: (imageId, altText) => apply((token) => updateImageAltText(imageId, altText, token)),
|
||||
retry: (imageId) => apply((token) => retryImageProcessing(imageId, token)),
|
||||
remove: (imageId) => apply((token) => deleteImage(imageId, token)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { RouterLink, useRoute } from 'vue-router';
|
||||
|
||||
import AdminImageManager from '@/components/organisms/AdminImageManager.vue';
|
||||
import { useAdminImages } from '@/composables/useAdminImages.js';
|
||||
|
||||
const route = useRoute();
|
||||
const inventoryItemId = route.params.id;
|
||||
const { images, isLoading, isSaving, error, load, reorder, setPrimary, updateAltText, retry, remove } = useAdminImages(inventoryItemId);
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="detail-page">
|
||||
<RouterLink class="detail-back" :to="{ name: 'catalog' }">Back to catalog</RouterLink>
|
||||
<p class="catalog-kicker">Admin inventory</p>
|
||||
<h1>Manage images</h1>
|
||||
<p v-if="isLoading" class="catalog-state" role="status">Loading images...</p>
|
||||
<p v-else-if="error" class="catalog-state catalog-state--error" role="alert">{{ error.message }}</p>
|
||||
<AdminImageManager
|
||||
v-else
|
||||
:images="images"
|
||||
:processing-status="isSaving ? 'PROCESSING' : (images[0]?.processingStatus ?? 'COMPLETED')"
|
||||
@reorder="reorder($event.id, $event.sortOrder)"
|
||||
@primary="setPrimary"
|
||||
@alt-text="updateAltText(images[0]?.id, $event)"
|
||||
@retry="retry(images[0]?.id)"
|
||||
@delete="remove"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
@@ -3,6 +3,7 @@ import { createRouter, createWebHistory } from 'vue-router';
|
||||
import CatalogPage from '@/pages/CatalogPage.vue';
|
||||
import ListingDetailPage from '@/pages/ListingDetailPage.vue';
|
||||
import ProductDetailPage from '@/pages/ProductDetailPage.vue';
|
||||
import AdminImagePage from '@/pages/AdminImagePage.vue';
|
||||
|
||||
export default createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -10,5 +11,6 @@ export default createRouter({
|
||||
{ path: '/', name: 'catalog', component: CatalogPage },
|
||||
{ path: '/listings/:id', name: 'listing-detail', component: ListingDetailPage },
|
||||
{ path: '/products/:slug', name: 'product-detail', component: ProductDetailPage },
|
||||
{ path: '/admin/inventory/:id/images', name: 'admin-image-manager', component: AdminImagePage },
|
||||
],
|
||||
});
|
||||
@@ -54,3 +54,10 @@ export function deleteImage(imageId, csrfToken) {
|
||||
headers: { 'x-csrf-token': csrfToken },
|
||||
});
|
||||
}
|
||||
|
||||
export async function listAdminImages(inventoryItemId) {
|
||||
return request(`/admin/inventory/${encodeURIComponent(inventoryItemId)}/images`, {
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user