feat(admin): replace image URL input with ImageUpload component + fetch-from-URL
All checks were successful
CI / ci (push) Successful in 1m56s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 17s

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 22:01:39 +02:00
parent 31a9e3c1ff
commit 8b60428b3b
3 changed files with 123 additions and 19 deletions

View File

@@ -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<string | null>(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<Manufacturer[]>("/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() {
<form onSubmit={handleSave}>
{/* Image section */}
<div>
{item.imageUrl && (
<img
src={item.imageUrl}
alt={`${item.brand} ${item.model}`}
className="w-full h-48 object-contain rounded-lg bg-gray-50 mb-3"
<label className={labelClass}>Image</label>
<ImageUpload
value={form.imageFilename}
imageUrl={form.imageUrl || null}
dominantColor={form.dominantColor || null}
onChange={(filename, dominantColor) => {
setForm((prev) => ({
...prev,
imageFilename: filename ?? "",
dominantColor: dominantColor ?? "",
}));
}}
onCropChange={(crop) => {
setForm((prev) => ({
...prev,
cropZoom: crop.zoom,
cropX: crop.x,
cropY: crop.y,
}));
}}
/>
)}
<label className={labelClass}>Image URL</label>
{/* Fetch from URL alternative */}
<div className="mt-3">
<label className={`${labelClass} text-gray-500`}>
Or fetch from URL
</label>
<div className="flex gap-2">
<input
type="url"
value={form.imageUrl}
onChange={(e) => handleChange("imageUrl", e.target.value)}
className={inputClass}
placeholder="https://..."
value={fetchUrl}
onChange={(e) => setFetchUrl(e.target.value)}
className={`${inputClass} flex-1`}
placeholder="https://example.com/image.jpg"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleFetchFromUrl();
}
}}
/>
<button
type="button"
onClick={handleFetchFromUrl}
disabled={fetchingImage || !fetchUrl.trim()}
className="px-3 py-2 rounded-lg border border-gray-200 text-sm text-gray-600 hover:bg-gray-50 transition-colors disabled:opacity-50 shrink-0"
>
{fetchingImage ? "Fetching..." : "Fetch"}
</button>
</div>
{fetchError && (
<p className="mt-1 text-xs text-red-500">{fetchError}</p>
)}
</div>
</div>
{/* Brand + Model */}
@@ -355,7 +433,7 @@ function AdminItemEditPage() {
/>
</div>
<div className="mb-4">
<label className={labelClass}>Source URL</label>
<label className={labelClass}>Product Page URL</label>
<input
type="url"
value={form.sourceUrl}

View File

@@ -8,6 +8,7 @@ import {
listGlobalItemsForAdmin,
updateGlobalItemById,
} from "../services/global-item.service.ts";
import { getImageUrl } from "../services/storage.service.ts";
type Env = { Variables: { db?: any; userId?: number } };
@@ -20,6 +21,11 @@ const updateGlobalItemAdminSchema = z.object({
weightGrams: z.number().positive().nullable().optional(),
priceCents: z.number().int().nonnegative().nullable().optional(),
imageUrl: z.string().url().nullable().optional(),
imageFilename: z.string().nullable().optional(),
dominantColor: z.string().nullable().optional(),
cropZoom: z.number().nullable().optional(),
cropX: z.number().nullable().optional(),
cropY: z.number().nullable().optional(),
description: z.string().nullable().optional(),
sourceUrl: z.string().url().nullable().optional(),
imageCredit: z.string().nullable().optional(),
@@ -58,7 +64,15 @@ app.get("/:id", async (c) => {
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

View File

@@ -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;