From 8b60428b3b418571da39f27fc2f6a9d6077fe6e7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Apr 2026 22:01:39 +0200 Subject: [PATCH] feat(admin): replace image URL input with ImageUpload component + fetch-from-URL Admin item edit page now uses the same ImageUpload component as the rest of the app (file upload, preview, crop editor). Also adds a "Fetch from URL" input that uses /api/images/from-url. Renames "Source URL" to "Product Page URL". Backend updated to accept imageFilename, dominantColor, cropZoom/X/Y fields and return presignedImageUrl for display. Co-Authored-By: Claude Opus 4.6 --- src/client/routes/admin/items/$itemId.tsx | 114 +++++++++++++++++---- src/server/routes/admin-items.ts | 16 ++- src/server/services/global-item.service.ts | 12 +++ 3 files changed, 123 insertions(+), 19 deletions(-) diff --git a/src/client/routes/admin/items/$itemId.tsx b/src/client/routes/admin/items/$itemId.tsx index 22bc011..1476ee9 100644 --- a/src/client/routes/admin/items/$itemId.tsx +++ b/src/client/routes/admin/items/$itemId.tsx @@ -1,11 +1,12 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect, useRef, useState } from "react"; +import { ImageUpload } from "../../../components/ImageUpload"; import { useAdminGlobalItem, useDeleteAdminGlobalItem, useUpdateAdminGlobalItem, } from "../../../hooks/useAdminGlobalItems"; -import { apiGet } from "../../../lib/api"; +import { apiGet, apiPost } from "../../../lib/api"; export const Route = createFileRoute("/admin/items/$itemId")({ component: AdminItemEditPage, @@ -114,13 +115,21 @@ function AdminItemEditPage() { category: "", weightGrams: "", priceCents: "", + imageFilename: "", imageUrl: "", + dominantColor: "", + cropZoom: null as number | null, + cropX: null as number | null, + cropY: null as number | null, description: "", sourceUrl: "", imageCredit: "", imageSourceUrl: "", tags: [] as string[], }); + const [fetchUrl, setFetchUrl] = useState(""); + const [fetchingImage, setFetchingImage] = useState(false); + const [fetchError, setFetchError] = useState(null); // Populate form when item loads useEffect(() => { @@ -132,7 +141,12 @@ function AdminItemEditPage() { weightGrams: item.weightGrams != null ? String(item.weightGrams) : "", priceCents: item.priceCents != null ? String(item.priceCents / 100) : "", - imageUrl: item.imageUrl ?? "", + imageFilename: item.imageFilename ?? "", + imageUrl: item.presignedImageUrl ?? "", + dominantColor: item.dominantColor ?? "", + cropZoom: item.cropZoom ?? null, + cropX: item.cropX ?? null, + cropY: item.cropY ?? null, description: item.description ?? "", sourceUrl: item.sourceUrl ?? "", imageCredit: item.imageCredit ?? "", @@ -142,6 +156,28 @@ function AdminItemEditPage() { } }, [item]); + async function handleFetchFromUrl() { + if (!fetchUrl.trim()) return; + setFetchingImage(true); + setFetchError(null); + try { + const result = await apiPost<{ filename: string; sourceUrl: string }>( + "/api/images/from-url", + { url: fetchUrl.trim() }, + ); + setForm((prev) => ({ + ...prev, + imageFilename: result.filename, + imageSourceUrl: fetchUrl.trim(), + })); + setFetchUrl(""); + } catch { + setFetchError("Failed to fetch image from URL"); + } finally { + setFetchingImage(false); + } + } + // Fetch manufacturers for dropdown useEffect(() => { apiGet("/api/manufacturers") @@ -171,7 +207,11 @@ function AdminItemEditPage() { category: form.category || null, weightGrams: weightGrams, priceCents: priceCents, - imageUrl: form.imageUrl || null, + imageFilename: form.imageFilename || null, + dominantColor: form.dominantColor || null, + cropZoom: form.cropZoom, + cropX: form.cropX, + cropY: form.cropY, description: form.description || null, sourceUrl: form.sourceUrl || null, imageCredit: form.imageCredit || null, @@ -246,21 +286,59 @@ function AdminItemEditPage() {
{/* Image section */}
- {item.imageUrl && ( - {`${item.brand} - )} - - handleChange("imageUrl", e.target.value)} - className={inputClass} - placeholder="https://..." + + { + setForm((prev) => ({ + ...prev, + imageFilename: filename ?? "", + dominantColor: dominantColor ?? "", + })); + }} + onCropChange={(crop) => { + setForm((prev) => ({ + ...prev, + cropZoom: crop.zoom, + cropX: crop.x, + cropY: crop.y, + })); + }} /> + {/* Fetch from URL alternative */} +
+ +
+ setFetchUrl(e.target.value)} + className={`${inputClass} flex-1`} + placeholder="https://example.com/image.jpg" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleFetchFromUrl(); + } + }} + /> + +
+ {fetchError && ( +

{fetchError}

+ )} +
{/* Brand + Model */} @@ -355,7 +433,7 @@ function AdminItemEditPage() { />
- + { if (!id) return c.json({ error: "Invalid item ID" }, 400); const item = await getGlobalItemWithOwnerCount(db, id); if (!item) return c.json({ error: "Global item not found" }, 404); - return c.json(item); + // Resolve presigned URL for image display + const presignedImageUrl = item.imageUrl + ? await getImageUrl(item.imageUrl) + : null; + return c.json({ + ...item, + imageFilename: item.imageUrl, + presignedImageUrl, + }); }); // PUT /api/admin/items/:id — update item fields diff --git a/src/server/services/global-item.service.ts b/src/server/services/global-item.service.ts index 9947c7f..241cbf3 100644 --- a/src/server/services/global-item.service.ts +++ b/src/server/services/global-item.service.ts @@ -239,6 +239,11 @@ export async function updateGlobalItemById( weightGrams?: number | null; priceCents?: number | null; imageUrl?: string | null; + imageFilename?: string | null; + dominantColor?: string | null; + cropZoom?: number | null; + cropX?: number | null; + cropY?: number | null; description?: string | null; sourceUrl?: string | null; imageCredit?: string | null; @@ -260,6 +265,13 @@ export async function updateGlobalItemById( if ("priceCents" in fields) updateSet.priceCents = fields.priceCents ?? null; if ("imageUrl" in fields) updateSet.imageUrl = fields.imageUrl ?? null; + if ("imageFilename" in fields) + updateSet.imageUrl = fields.imageFilename ?? null; + if ("dominantColor" in fields) + updateSet.dominantColor = fields.dominantColor ?? null; + if ("cropZoom" in fields) updateSet.cropZoom = fields.cropZoom ?? null; + if ("cropX" in fields) updateSet.cropX = fields.cropX ?? null; + if ("cropY" in fields) updateSet.cropY = fields.cropY ?? null; if ("description" in fields) updateSet.description = fields.description ?? null; if ("sourceUrl" in fields) updateSet.sourceUrl = fields.sourceUrl ?? null;