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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.imageUrl}
|
||||
onChange={(e) => handleChange("imageUrl", e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder="https://..."
|
||||
<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,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{/* 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={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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user